TerraformでAmazon API Gatewayを構築する(基本編)


はじめに

これまで何度かAmazon API Gatewayに関して書いてきたが、直近の記事で、「結局色々な機能を統合したAPI GatewayってSAMに組み込めないし、そうなるとSAMにするメリットってIaCの記述量がちょっと少ないくらいだよね」な結論になったので、そうであれば業界でややデファクト化しているTerraformでも作れた方が良いのだろう、という感じで書いてみた。

前提知識

  • 今回作るTerraformは直近の記事のSAMテンプレートをリファクタしているだけなので、中身については該当記事を読んでもらうことを前提とする。
  • Terraformについてはある程度書き方を理解していて、自力でリファレンスを見ながら書くことができる程度の知識量を期待する

Swagger版で作ってみる

慣れている人にとってはSwaggerの方が簡単な気がする。
あとは、一旦手でポチポチ作ったものをエクスポートして使うこともできるので、大量のHCLなんて書いてられねーぜ!という人はこっちの手法がオススメ。

構成
root
├── apigateway.tf
├── data_sources.tf
├── swagger.yml
└── variables.tf

variables.tf
variables.tf
variable "prefix" {
  default = "ApigwTest"
}

data_sources.tf
data_sources.tf
################################################################################
# Basic Settings                                                               #
################################################################################

data "aws_region" "current" {}

################################################################################
# Lambda                                                                       #
################################################################################

data "aws_lambda_alias" "get_function_prod" {
  function_name = "ApigwTest-GetName-LambdaFunction"
  name          = "Prod"
}

data "aws_lambda_alias" "set_function_prod" {
  function_name = "ApigwTest-SetName-LambdaFunction"
  name          = "Prod"
}

apigateway.tf
apigateway.tf
resource "aws_api_gateway_rest_api" "rest_api" {
  name        = "${var.prefix}-RestAPI"
  description = "APIGateway Test for Terraform"
  body        = "${data.template_file.swagger.rendered}"

  endpoint_configuration {
    types = ["REGIONAL"]
  }
}

data "template_file" "swagger" {
  template = "${file("${path.module}/swagger.yml")}"

  vars = {
    title                   = "${var.prefix}-RestAPI"
    aws_region_name         = "${data.aws_region.current.name}"
    get_lambda_function_arn = "${data.aws_lambda_alias.get_function_prod.arn}"
    set_lambda_function_arn = "${data.aws_lambda_alias.set_function_prod.arn}"
  }
}

resource "aws_api_gateway_stage" "prod" {
  stage_name    = "prod"
  rest_api_id   = "${aws_api_gateway_rest_api.rest_api.id}"
  deployment_id = "${aws_api_gateway_deployment.default.id}"
}

resource "aws_api_gateway_deployment" "default" {
  rest_api_id = "${aws_api_gateway_rest_api.rest_api.id}"

  triggers = {
    redeployment = sha1(jsonencode([
      aws_api_gateway_rest_api.rest_api.body,
    ]))
  }

  lifecycle {
    create_before_destroy = true
  }
}

swagger.yml
swagger.yml
swagger: "2.0"
info:
  description: "Created by Terraform"
  version: "1.0.0"
  title: "${title}"
basePath: "/default"
schemes:
  - "https"
paths:
  /names:
    get:
      produces:
      - "application/json"
      responses:
        "200":
          description: "200 response"
          schema:
            $ref: "#/definitions/Empty"
      x-amazon-apigateway-integration:
        uri: arn:aws:apigateway:${aws_region_name}:lambda:path/2015-03-31/functions/${get_lambda_function_arn}/invocations
        passthroughBehavior: when_no_templates
        httpMethod: POST
        type: aws_proxy
  /name:
    put:
      consumes:
      - "application/json"
      produces:
      - "application/json"
      parameters:
      - name: "name"
        in: "query"
        required: false
        type: "string"
      - name: "id"
        in: "query"
        required: true
        type: "string"
      - in: "body"
        name: "Test"
        required: true
        schema:
          $ref: "#/definitions/Test"
      responses:
        "200":
          description: "200 response"
          schema:
            $ref: "#/definitions/Empty"
      x-amazon-apigateway-integration:
        uri: arn:aws:apigateway:${aws_region_name}:lambda:path/2015-03-31/functions/${set_lambda_function_arn}/invocations
        responses:
          default:
            statusCode: "200"
        passthroughBehavior: when_no_match
        httpMethod: POST
        contentHandling: CONVERT_TO_TEXT
        type: aws_proxy
definitions:
  Empty:
    type: "object"
    title: "Empty Schema"
  Test:
    type: "object"
    required:
    - "id"
    - "name"
    properties:
      id:
        type: "string"
        minLength: 5
        maxLength: 5
        pattern: "^[0-9]*$"
      name:
        type: "string"
        minLength: 1
        maxLength: 20
        pattern: "^[a-zA-Z]*$"
    title: "Test"
x-amazon-apigateway-request-validators:
  本文、クエリ文字列パラメータ、およびヘッダーの検証:
    validateRequestParameters: true
    validateRequestBody: true

あまりトリッキーな部分は無い。強いて挙げるなら、

data "template_file" "swagger" {
  template = "${file("${path.module}/swagger.yml")}"

  vars = {
    title                   = "${var.prefix}-RestAPI"
    aws_region_name         = "${data.aws_region.current.name}"
    get_lambda_function_arn = "${data.aws_lambda_alias.get_function_prod.arn}"
    set_lambda_function_arn = "${data.aws_lambda_alias.set_function_prod.arn}"
  }
}

の部分でSwaggerをテンプレートとして定義して、それを変数置換してレンダリングしたものを

  body        = "${data.template_file.swagger.rendered}"

で食わせている部分だろうか。
応用できるとJSONBODYとかも扱えるようになると思うので、汎用性が高そうだ。

また、aws_api_gateway_deployment のリソースについては、Terraformの公式ドキュメントに記載されている通り、以下の2つを入れておく。

  triggers = {
    redeployment = sha1(jsonencode([
      aws_api_gateway_rest_api.rest_api.body,
    ]))
  }

これは、YAML が更新された際にステージへの際デプロイが必要になるため、そのトリガーを作成している。
aws_api_gateway_rest_api.rest_api.body の JSON のハッシュ値を確認して、
前回と異なる値になっていたらデプロイを行うという意味だ。

  lifecycle {
    create_before_destroy = true
  }

こちらは、API Gateway のステージは、最低1つ以上のステージと紐づいていなければならないという制約に対応したものだ。
古いステージを destroy する前に新しいステージを作ることで、destroy がエラーになることを防止している。

API Gateway のデプロイの概念は、

  • デプロイを作ると、その時の API Gateway の設定状態のスナップショットを作る
  • ステージに対して、上記で作成したスナップショットを紐付ける

といった考え方で混乱しやすいので、自分でもステージとデプロイを作成してみてしっかりと概念を理解しよう。
※デプロイにステージ名を渡した際に、ステージが存在しないと自動で作ってくれるというところが余計に混乱を助長するので、aws_api_gateway_deployment にはステージ名を設定しないことをオススメする。

HCL単独版で書いてみる

Swagger記法なんて今更覚えてられねーぜ!という人はこっちの方法で。
apigateway.tf を以下のように書き換える。それ以外は特に変更しなくても良い。

基本は、aws_api_gateway_resourceでリソース情報を定義し、aws_api_gateway_methodでメソッド情報を定義し、aws_api_gateway_integrationで統合リクエストの定義をする。

Swagger版で書いたパラメータチェック等はaws_api_gateway_modelあたりで定義できそうだし、プロキシ統合を使わない場合は必要になってくる部分ではあるが、ひとまず今回は、プロキシ統合する前提としてこの辺りはあまり触れない。

apigateway.tf
apigateway.tf
################################################################################
# API Gateway Base                                                             #
################################################################################
resource "aws_api_gateway_rest_api" "rest_api" {
  name        = "${var.prefix}-RestAPI"
  description = "APIGateway Test for Terraform"

  endpoint_configuration {
    types = ["REGIONAL"]
  }
}

################################################################################
# Stage and Deploy                                                             #
################################################################################
resource "aws_api_gateway_stage" "prod" {
  stage_name    = "prod"
  rest_api_id   = "${aws_api_gateway_rest_api.rest_api.id}"
  deployment_id = "${aws_api_gateway_deployment.default.id}"
}

resource "aws_api_gateway_deployment" "default" {
  rest_api_id = "${aws_api_gateway_rest_api.rest_api.id}"

  triggers = {
    redeployment = sha1(jsonencode([
      aws_api_gateway_rest_api.rest_api.body,
      aws_api_gateway_resource.names,
      aws_api_gateway_method.names_get,
      aws_api_gateway_integration.names_get,
      aws_api_gateway_resource.name,
      aws_api_gateway_method.name_put,
      aws_api_gateway_integration.name_put,
    ]))
  }

  lifecycle {
    create_before_destroy = true
  }
}

################################################################################
# Path  : /names                                                               #
# Method: GET                                                                  #
################################################################################
resource "aws_api_gateway_resource" "names" {
  path_part   = "names"
  parent_id   = "${aws_api_gateway_rest_api.rest_api.root_resource_id}"
  rest_api_id = "${aws_api_gateway_rest_api.rest_api.id}"
}

resource "aws_api_gateway_method" "names_get" {
  rest_api_id   = "${aws_api_gateway_rest_api.rest_api.id}"
  resource_id   = "${aws_api_gateway_resource.names.id}"
  http_method   = "GET"
  authorization = "NONE"
}

resource "aws_api_gateway_integration" "names_get" {
  rest_api_id             = "${aws_api_gateway_rest_api.rest_api.id}"
  resource_id             = "${aws_api_gateway_resource.names.id}"
  http_method             = "${aws_api_gateway_method.names_get.http_method}"
  integration_http_method = "POST"
  type                    = "AWS_PROXY"
  uri                     = "${data.aws_lambda_alias.get_function_prod.invoke_arn}"
}

################################################################################
# Path  : /name                                                                #
# Method: PUT                                                                  #
################################################################################
resource "aws_api_gateway_resource" "name" {
  path_part   = "name"
  parent_id   = "${aws_api_gateway_rest_api.rest_api.root_resource_id}"
  rest_api_id = "${aws_api_gateway_rest_api.rest_api.id}"
}

resource "aws_api_gateway_method" "name_put" {
  rest_api_id   = "${aws_api_gateway_rest_api.rest_api.id}"
  resource_id   = "${aws_api_gateway_resource.name.id}"
  http_method   = "PUT"
  authorization = "NONE"
}

resource "aws_api_gateway_integration" "name_put" {
  rest_api_id             = "${aws_api_gateway_rest_api.rest_api.id}"
  resource_id             = "${aws_api_gateway_resource.name.id}"
  http_method             = "${aws_api_gateway_method.name_put.http_method}"
  integration_http_method = "POST"
  type                    = "AWS_PROXY"
  uri                     = "${data.aws_lambda_alias.set_function_prod.invoke_arn}"
}

気を付けなければいけないのは、aws_api_gateway_deploymentに記載する依存関係で、以下の部分。
統合リクエストの定義がリソース的にはデプロイと紐付かないが、実際にはこれがないとAPI Gatewayが作れないため、明示的に依存関係を書いておかないとエラーになってしまう。

  triggers = {
    redeployment = sha1(jsonencode([
      (中略)
      aws_api_gateway_integration.names_get,
      (中略)
      aws_api_gateway_integration.name_put,
    ]))

VPCリンクはどう書くの?

ちょっとだけオマケ。Lambda統合だけでなく、VPCリンクでのHTTP統合もちゃんと書ける。

VPCリンクするためのinternalなNLBについては、適当に作っておいてタグのName属性でデータリソースとして取得する前提とする。ということで、data_sources.tfに以下を追記する

################################################################################
# NLB(for VPCLink)                                                             #
################################################################################
data "aws_alb" "nlb" {
  tags = {
    Name = "あらかじめ用意してあるNLBのタグ名"
  }
}

VPCリンクでも、上記したリソース、メソッド、統合の基本は変わらない。
Lambda統合がintegration_http_methodをポスト固定にしなければならないのに対して、こちらではその縛りがないのがポイントか。

あと、URIはデータリソースの参照だけでは通らず、スキーマの記載が必要なので注意しよう。

################################################################################
# Path  : /test                                                                #
# Method: GET                                                                  #
################################################################################
resource "aws_api_gateway_vpc_link" "test" {
  name        = "${var.prefix}-VPCLink"
  target_arns = ["${data.aws_alb.nlb.arn}"]
}

resource "aws_api_gateway_resource" "test" {
  path_part   = "test"
  parent_id   = "${aws_api_gateway_rest_api.rest_api.root_resource_id}"
  rest_api_id = "${aws_api_gateway_rest_api.rest_api.id}"
}

resource "aws_api_gateway_method" "test_get" {
  rest_api_id   = "${aws_api_gateway_rest_api.rest_api.id}"
  resource_id   = "${aws_api_gateway_resource.test.id}"
  http_method   = "GET"
  authorization = "NONE"
}

resource "aws_api_gateway_integration" "test_get" {
  rest_api_id             = "${aws_api_gateway_rest_api.rest_api.id}"
  resource_id             = "${aws_api_gateway_resource.test.id}"
  http_method             = "${aws_api_gateway_method.test_get.http_method}"
  integration_http_method = "GET"
  type                    = "HTTP_PROXY"
  uri                     = "http://${data.aws_alb.nlb.dns_name}"

  connection_type = "VPC_LINK"
  connection_id   = "${aws_api_gateway_vpc_link.test.id}"
}

これで、簡単な統合系のAPI Gatewayも作ることができた。