Terraform で API Gateway(REST API)を構築する

意外とTerraformでも出来るもんですね
2020.09.10

今回は Terraform で API Gateway を構築する機会がありましたので、ハマりポイントなどを含めて紹介いたします。

Terraform で作る API Gateway 環境

今回の環境ですが、以下のような構成を想定しています。API Gateway のバックエンドは NLB を経由して ECS タスクへとリクエストが流れます。本記事で作成するのは、枠で囲んだ部分です。

もう少し詳細にご紹介すると、この記事をとおして Terraform で構築する API Gateway は以下のとおり。認証なども特に設定していない簡易的なものです。

  • API Gateway
    • REST API
    • メソッドおよびインテグレーション(OpenAPI でインポート)
    • ステージ
    • デプロイ
    • リソースポリシー(OpenAPI でインポート)
    • VPC リンク
    • カスタムドメイン
    • ベースパスマッピング

一方、以下のリソースはリソース参照部分の記載がありますが、既にあるものとして仮定しています。(参照先のリソース作成部分のコードは記載していません)

  • NLB
  • Route53

実行環境

今回、検証で利用した実行環境は下記のとおりです。

$ terraform version
Terraform v0.12.29
+ provider.aws v3.5.0

Terraform で API Gateway を構築するぞ!

VPC リンクの作成

はじめに、今回は VPC 内の ECS コンテナにリクエストを送るため、VPC リンクを作成します。REST API で VPC リンクに利用できる ELB は NLB のみです。

resource "aws_api_gateway_vpc_link" "vpclink" {
  name        = "vpc_link"
  target_arns = [aws_lb.nlb.arn]
}

OpenAPI ファイルの準備(メソッドおよびインテグレーションの定義)

メソッドやインテグレーションの作成は aws_api_gateway_method および aws_api_gateway_integration など Terraform(HCL) で書くこともできますが、今回は OpenAPI を事前に作成しておきインポートする方法で構築します。

以下は / に対して GET メソッドおよび、VPC リンク先へのプロキシを定義した、とても単純な OpenAPI の定義ファイルです。OpenAPI 仕様に対する API Gateway 拡張は x-amazon-apigateway-xxx で記述します。詳細は公式ガイドを参照ください。

openapi: "3.0.1"
info:
  title: "test_api"
  version: "2020-09-09T06:11:13Z"
paths:
  /:
    get:
      responses:
        200:
          description: "200 response"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Empty"
      x-amazon-apigateway-integration:
        uri: "${nlb_uri}"
        responses:
          default:
            statusCode: "200"
        passthroughBehavior: "when_no_match"
        connectionType: "VPC_LINK"
        connectionId: "${vpc_link}"
        httpMethod: "GET"
        type: "http_proxy"

上記 .yaml ファイルを aws_api_gateway_rest_api が参照するためデータリソースとして定義します。vars で環境変数を渡すことも可能です。今回は VPC リンク ID および、NLB の URI を環境変数に指定しました。

data "template_file" "openapi" {
  template = "${file("./OpenAPI/test_api-dev-apigateway.yaml")}"

  vars = {
    vpc_link = aws_api_gateway_vpc_link.vpclink.id
    nlb_uri  = "http://${aws_lb.nlb.dns_name}"
  }
}

構築後の API Gateway からエクスポートした .yaml ファイルを再利用する場合は ${} 部分が実際の値に置き換わっているのでご注意ください。

REST API 定義

先程の template_file を指定して、REST API を定義します。name は OpenAPI 内の title に置き換えられますが差があると毎回 terraform plan の差分になってしまうので揃えます。今回は REGIONAL エンドポイントで作成しています。

resource "aws_api_gateway_rest_api" "api" {
  name = "test_api"
  body = data.template_file.openapi.rendered

  endpoint_configuration {
    types = ["REGIONAL"]
  }

  lifecycle {
    ignore_changes = [
      policy
    ]
  }
}

本記事ではリソースポリシーを後ほど OpenAPI 内で定義する手順となっています。Terraform(HCL)では記述しないため、ポリシー定義後に設定差分として検出されないように ignore_changes に指定しておきます。

ステージとデプロイメント定義

ステージの定義は aws_api_gateway_stage リソースがありますが、aws_api_gateway_deployment だけの指定でも stage_name に基づいてステージが作成されます。

アクセスログ、キャッシュ、X-Ray トレーシングなどなど、ステージの詳細設定が必要であれば aws_api_gateway_stage を個別に定義してください。今回は簡易設定として、aws_api_gateway_deployment のみで定義しています。

resource "aws_api_gateway_deployment" "deployment" {
  depends_on  = [aws_api_gateway_rest_api.api]
  rest_api_id = aws_api_gateway_rest_api.api.id
  stage_name  = "dev"

  triggers = {
    redeployment = "v0.1"
  }

  lifecycle {
    create_before_destroy = true
  }
}

今回のキモは triggerscreate_before_destroy です。

triggers

OpenAPI の内容や、その他 REST API 設定を変更し terraform apply すると定義情報は更新されますが再デプロイはされません。再デプロイをトリガーするための設定が triggers = {redeployment = "xxx"} になります。

上記の例では単純に v0.1 のような値を与えていますので、v0.2に変更すると再デプロイがトリガーされます。

いちいち値を変更するのが面倒だしスマートじゃない、という場合は .tf や OpenAPI ファイルのハッシュ値を指定することもできます。ファイル内容の変更によってハッシュ値が変わりますので、わざわざ値を変更せずとも再デプロイをトリガーします。

  triggers = {
    redeployment = sha1(file("./OpenAPI/xxx.yaml"))
  }

create_before_destroy

create_before_destroy は既存のリソースがあった場合に、先に削除してから作り直すことを指示します。

ベースパスマッピングを利用していない場合、この指定がなくても再デプロイはうまく動作しますが、ベースパスマッピングがある場合、create_before_destroy の指定がないと、以下のようなエラーになります。

Error: error deleting API Gateway Deployment (xxxxxx): BadRequestException: Active stages pointing to this deployment must be moved or deleted

リソースポリシー定義

リソースポリシーは data "aws_iam_policy_document" を使って定義することも出来ますが、今回は OpenAPI 内で x-amazon-apigateway-policy を追加し、再デプロイで設定することにしました。(参考:x-amazon-apigateway-policy

初回のデプロイ後、execution_arn が確定しますので、以下のような記述で API Gateway の呼び出し元を制限することができます。

(最下行に追加)
x-amazon-apigateway-policy:
  Version: "2012-10-17"
  Statement:
  - Sid: ""
    Effect: "Allow"
    Principal:
      AWS: "*"
    Action: "execute-api:Invoke"
    Resource: "arn:aws:execute-api:ap-northeast-1:123456789012:7c7dxz6gh7/*"
    Condition:
      IpAddress:
        aws:SourceIp:
        - "58.xx.xx.xx/32"

カスタムドメイン定義

カスタムドメインに使用するドメインおよび、ACM 証明書は別途、設定されていることを前提とします。ちなみに、今回は REGIONAL ですので ACM 証明書は対象リージョンの東京に作成済みです。EDGE の場合は CloudFront 同様にバージニアで作成してください。

resource "aws_api_gateway_domain_name" "domain" {
  domain_name              = "api.marumo.classmethod.info"
  regional_certificate_arn = aws_acm_certificate_validation.retrieval.certificate_arn

  endpoint_configuration {
    types = ["REGIONAL"]
  }
}

Terraform で ACM 無料証明書発行される場合は、以下の記事を参照ください。

ベースパスマッピング定義

aws_api_gateway_domain_name ではカスタムドメインを定義しただけです。次にベースパスマッピングを定義し、カスタムドメインと呼び出し先のステージを紐付けます。

resource "aws_api_gateway_base_path_mapping" "base_path" {
  depends_on  = [aws_api_gateway_deployment.deployment]
  api_id      = aws_api_gateway_rest_api.api.id
  stage_name  = aws_api_gateway_deployment.deployment.stage_name
  domain_name = aws_api_gateway_domain_name.domain.domain_name
}

動作確認

上記のとおり Terraform で作成した API Gateway をコールして動作確認してみましょう。まずは、リソースポリシーで許可されたアクセス元からの API コール。

$ curl https://checkip.amazonaws.com/
58.xx.xx.xx
$ curl https://api.marumo.classmethod.info/
Hello,Classmethod!

次に、許可されていないアクセス元からの API コール。

$ curl https://checkip.amazonaws.com/
54.xx.xx.xx
$ curl https://api.marumo.classmethod.info/
{"Message":"User: anonymous is not authorized to perform: execute-api:Invoke on resource: arn:aws:execute-api:ap-northeast-1:********3583:7c7dxz6gh7/dev/GET/"}

期待したとおりの動きですね。これでアクセス元を制限した REST API を Terraform でデプロイできるこを確認できました。

さいごに

API Gateway の IaC には AWS SAM や Serverless Framework、最近だと CDK あたりを利用されることが多いとは思います。適材適所で IaC のツールを使い分けるのが正解だとは思いますが、とはいえ、これから IaC をはじめる方にとって幾つものツールをリソース毎に使い分けるには学習コストもそれなりに高くなるでしょう。

正直、Terraform で API Gateway を構築、運用する情報はあまり多くないのですが、「ようやく手に馴染んできた Terraform で出来る限り管理したいんや!」という方も少なくないかと思いましたので試してみました。

今回はかなりシンプルな API Gateway 定義しかしていないため、判断するには難しいですが触ってみた感じでは Terraform でも十分に管理できるんじゃないか、という気がいたします。もちろん先に述べたようなツールと比較するとコード量は多いのですが。。

(「いやいや、やってみたけどココが辛かった」「おい、やめとけ。そっちは修羅の国やぞ」という知見をお持ちの方がおられましたらフィードバックください!)

以上!大阪オフィスの丸毛(@marumo1981)でした!

サンプル

./OpenAPI/rest_api-dev-apigateway.yaml
openapi: "3.0.1"
info:
  title: "test_api"
  version: "2020-09-09T06:11:13Z"
paths:
  /:
    get:
      responses:
        200:
          description: "200 response"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Empty"
      x-amazon-apigateway-integration:
        uri: "${nlb_uri}"
        responses:
          default:
            statusCode: "200"
        passthroughBehavior: "when_no_match"
        connectionType: "VPC_LINK"
        connectionId: "${vpc_link}"
        httpMethod: "GET"
        type: "http_proxy"
x-amazon-apigateway-policy:
  Version: "2012-10-17"
  Statement:
  - Sid: ""
    Effect: "Allow"
    Principal:
      AWS: "*"
    Action: "execute-api:Invoke"
    Resource: "arn:aws:execute-api:ap-northeast-1:123456789012:7c7dxz6gh7/*"
    Condition:
      IpAddress:
        aws:SourceIp:
        - "58.xx.xx.xx/32"
./api-gateway.tf
resource "aws_api_gateway_vpc_link" "vpclink" {
  name        = "vpc_link"
  target_arns = [aws_lb.nlb.arn]
}

data "template_file" "openapi" {
  template = "${file("./OpenAPI/test_api-dev-apigateway.yaml")}"
 
  vars = {
    vpc_link = aws_api_gateway_vpc_link.vpclink.id
    nlb_uri  = "http://${aws_lb.nlb.dns_name}"
  }
}
  
resource "aws_api_gateway_rest_api" "api" {
  name = "test_api"
  body = data.template_file.openapi.rendered
 
  endpoint_configuration {
    types = ["REGIONAL"]
  }
 
  lifecycle {
    ignore_changes = [
      policy
    ]
  }
}

resource "aws_api_gateway_deployment" "deployment" {
  depends_on  = [aws_api_gateway_rest_api.api]
  rest_api_id = aws_api_gateway_rest_api.api.id
  stage_name  = "dev"
 
  triggers = {
    redeployment = "v0.1"
  }
 
  lifecycle {
    create_before_destroy = true
  }
}
  
resource "aws_api_gateway_domain_name" "domain" {
  domain_name              = "api.marumo.classmethod.info"
  regional_certificate_arn = aws_acm_certificate_validation.retrieval.certificate_arn
 
  endpoint_configuration {
    types = ["REGIONAL"]
  }
}
  
resource "aws_api_gateway_base_path_mapping" "base_path" {
  depends_on  = [aws_api_gateway_deployment.deployment]
  api_id      = aws_api_gateway_rest_api.api.id
  stage_name  = aws_api_gateway_deployment.deployment.stage_name
  domain_name = aws_api_gateway_domain_name.domain.domain_name
}