Terraform を使った API Gateway の継続的デリバリーのプラクティス

API Gateway (REST API)の構成管理、デプロイメントは設定が複雑で、コンソールでやっても、IaCでやっても面倒です。
ユースケースによっては管理が楽なHTTP APIの方を使ったり、APEXなどの別のツールを導入して管理しやすくすることもできると思いますが、今回、小規模な構成のケースで、シンプルにCodePipelineとTerraformで一定の運用プラクティスができたので紹介します。

下記が運用のイメージです。

* masterの状態を常にAPI Gatewayのdevエンドポイントに反映する
* APIバージョンの更新、パブリッシュをtfファイルの定義追加のサイクルで回す

![運用イメージ](https://cacoo.com/diagrams/rX4cIlepxDGQSbge-7BA86.png)

もちろんですが、これがベストプラクティスというわけではなく、まだまだ改善の余地があるものですので、ご承知おきください。

以下は このリポジトリを使ってポイントを順に説明していきます。

https://github.com/sabmeua/terraform_apigateway_example

## 初回の環境構築

### Terraform バックエンドを S3 にする

僕はこのS3バックエンドが死ぬほど使いづらいと思っているのですが、他にtfstateを共有する方法もないので使っています。
予めバケットを作成しておき、以下のように設定します。作成時にバージョニングを有効にしておくことで、変更を辿れるようにします。
バックエンドのバケット名、キー名には変数が使えないため値が入っていますが、ここは適宜置き換えてください。

```bash:terraform/main.tf
terraform {
  backend "s3" {
    bucket = "tfstate-test-1587434605699"
    key    = "test/terraform.tfstate"
    region = "ap-northeast-1"
  }
}
```

### API Gateway リソースを作成する

API Gatewayリソースを作成する際に `dev` というステージ名の `aws_api_gateway_deployment` も作成します。

ここでのポイントは3つあります。1つめは、ステージ変数で `alias = dev` を指定することです。
このaliasを使って、後ほどLambda関数の$LATESTバージョンを指すエイリアスを呼び出すよう設定します。

2つめは、同じく変数にこのAPI Gatewayの設定を行う tf ファイルのサム値を指定することです。
これは、API Gateway の設定変更時に必ずデプロイが行われるようにするためのTipsで、指定しない場合、他のAPI Gatewayリソースに変更を施していても、Terraformは `aws_api_gateway_deployment` についての変更は検知できないため、デプロイが行われません。

3つめは、関連する `aws_api_gateway_integration` リソースを `depends_on` で指定しておくことです。
明示的に依存関係を指定しておくことでterraform applyの際の実行順序による失敗を防ぐことができます。

```bash:terraform/apigateway.tf
resource "aws_api_gateway_rest_api" "test" {
  name = "test"
  endpoint_configuration {
    types = ["REGIONAL"]
  }
}

resource "aws_api_gateway_deployment" "test-dev" {
  rest_api_id = aws_api_gateway_rest_api.test.id
  stage_name  = "dev"
  variables = {
    "alias"      = "dev"
    "check_hash" = md5(file("terraform/apigateway.tf"))
  }
  depends_on = [
    aws_api_gateway_integration.options-foo,
    aws_api_gateway_integration.get-foo
  ]
}
```

### Lambda 関数を作成する

Lambda関数を定義する際のポイントは2つあります。
1つめは `$LATEST` バージョンのエイリアス `dev` を作成すること。
2つめは `aws_lambda_permission` で `qualifier = dev` を指定することです。
API Gatewayにエイリアスでの呼び出しを許可する場合は、エイリアス毎に許可する必要があるためです。

ソースコードの更新方法は、ここでは1ファイルだけなのでarchiveプラグインでzipにして渡していますが、
規模によっては他の方法の方が良いでしょう。

```bash:terraform/lambda_func.tf
resource "aws_lambda_alias" "foofunc-dev" {
  name             = "dev"
  function_name    = aws_lambda_function.foofunc.function_name
  function_version = "$LATEST"
}

resource "aws_lambda_permission" "allow-get-foo-dev" {
  action        = "lambda:InvokeFunction"
  function_name = aws_lambda_function.foofunc.function_name
  principal     = "apigateway.amazonaws.com"
  source_arn    = "${aws_api_gateway_rest_api.test.execution_arn}/*/*/*"
  depends_on    = [aws_lambda_alias.foofunc-dev]
  qualifier     = "dev"
}
```

### Integration の設定をする

1つのRESTリソースをCORSを有効にして設定するには、これだけの設定が必要になります。
レスポンスのステータスコードが他にもある場合は、さらに `aws_api_gateway_method_response`,
`aws_api_gateway_integration_response` がその数分だけ必要になります。
定義の煩雑さは、変数と `for_each` などを使ってもう少しすっきりするよう改善の余地があると思っています。

```bash:terraform/apigateway.tf
resource "aws_api_gateway_resource" "foo" {
  rest_api_id = aws_api_gateway_rest_api.test.id
  parent_id   = aws_api_gateway_rest_api.test.root_resource_id
  path_part   = "foo"
}

resource "aws_api_gateway_method" "options-foo" {
  rest_api_id   = aws_api_gateway_rest_api.test.id
  resource_id   = aws_api_gateway_resource.foo.id
  http_method   = "OPTIONS"
  authorization = "NONE"
}

resource "aws_api_gateway_method_response" "options-foo" {
  rest_api_id = aws_api_gateway_rest_api.test.id
  resource_id = aws_api_gateway_resource.foo.id
  http_method = aws_api_gateway_method.options-foo.http_method
  status_code = "200"
  response_models = {
    "application/json" = "Empty"
  }
  response_parameters = {
    "method.response.header.Access-Control-Allow-Headers" = true
    "method.response.header.Access-Control-Allow-Methods" = true
    "method.response.header.Access-Control-Allow-Origin"  = true
  }
}

resource "aws_api_gateway_method" "get-foo" {
  rest_api_id   = aws_api_gateway_rest_api.test.id
  resource_id   = aws_api_gateway_resource.foo.id
  http_method   = "GET"
  authorization = "NONE"
}

resource "aws_api_gateway_method_response" "get-foo" {
  rest_api_id = aws_api_gateway_rest_api.test.id
  resource_id = aws_api_gateway_resource.foo.id
  http_method = aws_api_gateway_method.get-foo.http_method
  status_code = "200"
  response_models = {
    "application/json" = "Empty"
  }
  response_parameters = {
    "method.response.header.Access-Control-Allow-Origin" = true
  }
}

resource "aws_api_gateway_integration" "options-foo" {
  rest_api_id = aws_api_gateway_rest_api.test.id
  resource_id = aws_api_gateway_resource.foo.id
  http_method = aws_api_gateway_method.options-foo.http_method
  type        = "MOCK"
  request_templates = {
    "application/json" = "{\"statusCode\": 200}"
  }
}

resource "aws_api_gateway_integration_response" "options-foo" {
  rest_api_id = aws_api_gateway_rest_api.test.id
  resource_id = aws_api_gateway_resource.foo.id
  http_method = aws_api_gateway_method.options-foo.http_method
  status_code = aws_api_gateway_method_response.options-foo.status_code
  response_parameters = {
    "method.response.header.Access-Control-Allow-Headers" = "'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token'",
    "method.response.header.Access-Control-Allow-Methods" = "'GET,OPTIONS'",
    "method.response.header.Access-Control-Allow-Origin"  = "'*'"
  }
}

resource "aws_api_gateway_integration" "get-foo" {
  content_handling        = "CONVERT_TO_TEXT"
  rest_api_id             = aws_api_gateway_rest_api.test.id
  resource_id             = aws_api_gateway_resource.foo.id
  http_method             = aws_api_gateway_method.get-foo.http_method
  integration_http_method = "POST"
  type                    = "AWS_PROXY"
  uri                     = replace(aws_lambda_function.foofunc.invoke_arn, "//invocations$/", ":$${stageVariables.alias}/invocations")
}
```

ここでもいくつかのポイントがあります。まず、上のようにCORSヘッダを定義に含めること。
注意しないといけないのは、Lambdaプロキシ統合のメソッドでは、ヘッダーの統合ができないため、Lambda関数の実装側で、`Access-Control-Allow-Origin` ヘッダを含めてレスポンスする必要があることです。

```javascript:src/index.js
'use strict';

exports.handler = async (event) => {
    const response = {
        statusCode: 200,
        body: JSON.stringify('Hello world.'),
        headers: {
            'Access-Control-Allow-Origin': '*',
        },
    }

    return response
}
```

そして、次が重要なポイントですが、 `aws_api_gateway_integration` で Lambda 関数統合を設定する際に、
uri に、ステージ変数 `alias` を使って、 Lambda関数エイリアスを呼び出すよう設定することです。
これはリソースのattributeでは参照できないので、 `replace` を使って生成しています。
生成された uri は以下のような形式になります。

```
arn:aws:apigateway:ap-northeast-1:lambda:path/2015-03-31/functions/arn:aws:lambda:ap-northeast-1:XXXXXXXXXXXX:function:foofunc:${stageVariables.alias}/invocations
```

### terraform apply する

terraform init -> planして問題なければterraform applyします。
作成されたAPI Gatewayの `dev` エンドポイントにリクエストすると、OPTIONS、GETともCORSヘッダ含めて設定できていることが確認できます。

```
$ terraform init terraform/
$ terraform plan terraform/
$ terraform apply terraform/
(略)

$ curl -XOPTIONS -i ${APIGW_ENDPOINT_DEV}/foo
HTTP/2 200 
date: Wed, 22 Apr 2020 04:40:40 GMT
content-type: application/json
content-length: 0
x-amzn-requestid: f7ebb8d1-65ef-45d2-b1b6-c55d6acc27d7
access-control-allow-origin: *
access-control-allow-headers: Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token
x-amz-apigw-id: LXg6SFsRNjMFrLw=
access-control-allow-methods: GET,OPTIONS

$ curl -i ${APIGW_ENDPOINT_DEV}/foo
HTTP/2 200 
date: Wed, 22 Apr 2020 04:40:27 GMT
content-type: application/json
content-length: 14
x-amzn-requestid: 9f6ef936-6912-4b02-9563-f5a44757d86c
access-control-allow-origin: *
x-amz-apigw-id: LXg4PFjgtjMFnNw=
x-amzn-trace-id: Root=1-5e9fae9b-37984c782b39acc001a0e7a0;Sampled=0

"Hello world."
```

## Code PipelineでCDの設定をする

以上で、API GatewayとLambdaの初期構成が整いましたので、CDの構成を作成して、以降の開発に進めるようにします。
Code PipelineからTerraformを使用するための設定方法については、こちらで詳細に解説されていますので参考にさせていただきました。

https://dev.classmethod.jp/articles/simple-terraform-cicd-pipeline-with-aws-codepipeline/

参照先と違うのはソースに、Code Commitではなくgithubを使用している点、フックするブランチがmasterである点くらいです。
Code Buildのサービスロールは適切に権限を付与しないとデプロイできません。
Terraformのバージョンを指定する環境変数 `TF_VERSION` は `0.12.24` を指定しています。

うまく設定できれば、今のところリソースは最新の状態ですのでCode Buildのログは以下のようになるでしょう。

```

Apply complete! Resources: 0 added, 0 changed, 0 destroyed.

[Container] 2020/04/22 05:53:39 Phase complete: BUILD State: SUCCEEDED
[Container] 2020/04/22 05:53:39 Phase context status code:  Message: 
[Container] 2020/04/22 05:53:39 Entering phase POST_BUILD
[Container] 2020/04/22 05:53:39 Running command echo terraform apply completed on `date`
terraform apply completed on Wed Apr 22 05:53:39 UTC 2020

[Container] 2020/04/22 05:53:39 Phase complete: POST_BUILD State: SUCCEEDED
[Container] 2020/04/22 05:53:39 Phase context status code:  Message: 
```

## 以降の開発サイクル

### APIバージョン1のパブリッシュ

開発は進み、APIバージョン1のリリースができるようになりました。最終的にLambda関数はこのようになりました。

```javascript:src/index.js
'use strict';

exports.handler = async (event) => {
    const response = {
        statusCode: 200,
        body: JSON.stringify('Hello world. version: 1'),
        headers: {
            'Access-Control-Allow-Origin': '*',
        },
    }

    return response
}
```

この変更が入った状態で、 API `v1` としてパブリッシュします。その際に必要となる定義の変更は以下の通りです。

ここでのポイントは、まず、Lambda関数の定義に `publish = true` を追加して、以降のLmabda関数の変更がバージョニングされるよう設定します。

```diff:terraform/lambda_func.tf
 resource "aws_lambda_function" "foofunc" {
   filename         = "dist/foofunc.zip"
   source_code_hash = data.archive_file.foofunc-zip.output_base64sha256
   function_name    = "foofunc"
   role             = aws_iam_role.foofunc-executer.arn
   handler          = "index.handler"
   runtime          = "nodejs12.x"
   memory_size      = 128
   timeout          = 10
+  publish          = true
 }
```

次に、エイリアス `v1` をバージョン1固定になるよう定義し、API Gatewayに `v1` の呼び出し権限を付与します。
ですので、ここで指定する `qualifier` は `v1` です。

API Gateway のステージ `v1` のデプロイメントを定義します。ステージ変数の alias は `v1` にします。
ここでのポイントは、`dev` のデプロイメント定義で入れた tf ファイルのサム値をトリガにする設定は行わず、
代わりに、パブリッシュの日時を固定で入れておくことです。
以降のステージ `v1` のデプロイメントを、この日時の変数をトリガに行うためです。

```bash:terraform/v1_publish.tf
resource "aws_lambda_alias" "foofunc-v1" {
  name             = "v1"
  function_name    = aws_lambda_function.foofunc.function_name
  function_version = "1"
  depends_on       = [aws_lambda_function.foofunc]
}

resource "aws_lambda_permission" "allow-get-foo-v1" {
  action        = "lambda:InvokeFunction"
  function_name = aws_lambda_function.foofunc.function_name
  principal     = "apigateway.amazonaws.com"
  source_arn    = "${aws_api_gateway_rest_api.test.execution_arn}/*/*/*"
  depends_on    = [aws_lambda_alias.foofunc-v1]
  qualifier     = "v1"
}

resource "aws_api_gateway_deployment" "test-v1" {
  rest_api_id = aws_api_gateway_rest_api.test.id
  stage_name  = "v1"
  variables = {
    "alias"        = "v1"
    "published_at" = "2020-04-22 16:50:00"
  }
  depends_on = [
    aws_lambda_permission.allow-get-foo-v1
  ]
}
```

これをマスターにマージした結果、API Gatewayへのリクエストはこのように振る舞うようになりました。

```bash
$ curl ${APIGW_ENDPOINT_DEV}/foo
"Hello world. ver.1!
$ curl ${APIGW_ENDPOINT_V1}/foo
"Hello world. ver.1!"
```

この時点ではdevエンドポイント、v1エンドポイントとも同じ挙動です。
しかし、v1エンドポイントへのリクエストはバージョン1でスナップショットされたLambda関数を呼び出すため、
以降のフィーチャーブランチのmasterへのマージの影響を受けません。

v1のパブリッシュが終わったら、上で述べたようにLambdaの定義を下記のように変更してmasterにマージします。

```diff:terraform/lambda_func.tf
 resource "aws_lambda_function" "foofunc" {
   filename         = "dist/foofunc.zip"
   source_code_hash = data.archive_file.foofunc-zip.output_base64sha256
   function_name    = "foofunc"
   role             = aws_iam_role.foofunc-executer.arn
   handler          = "index.handler"
   runtime          = "nodejs12.x"
   memory_size      = 128
   timeout          = 10
-  publish          = true
 }
```

### APIバージョン1のバグフィックス

現在の v1 には2つのバグがありました。それぞれ修正していきます。

#### 1つめ

```diff
045ad11 Fix wording
diff --git a/src/index.js b/src/index.js
index ce2bf19..5ce7d1c 100644
--- a/src/index.js
+++ b/src/index.js
@@ -3,7 +3,7 @@
 exports.handler = async (event) => {
     const response = {
         statusCode: 200,
-        body: JSON.stringify('Hello world. ver.1!'),
+        body: JSON.stringify('こんにちは 世界. ver.1!'),
         headers: {
             'Access-Control-Allow-Origin': '*',
         },
```

このバグフィックスブランチがマージされました。確認してみましょう。v1 は影響を受けていません。

```
$ curl ${APIGW_ENDPOINT_DEV}/foo
"こんにちは 世界. ver.1!"
$
$ curl ${APIGW_ENDPOINT_V1}/foo
"Hello world. ver.1!"
```

#### 2つめ

2つめの修正は、API Gatewayの定義にも変更がありました。

```diff
34b2c8a Update CORS domain restriction
diff --git a/src/index.js b/src/index.js
index 5ce7d1c..4916bb3 100644
--- a/src/index.js
+++ b/src/index.js
@@ -5,7 +5,7 @@ exports.handler = async (event) => {
         statusCode: 200,
         body: JSON.stringify('こんにちは 世界. ver.1!'),
         headers: {
-            'Access-Control-Allow-Origin': '*',
+            'Access-Control-Allow-Origin': 'example.com',
         },
     }
 
diff --git a/terraform/apigateway.tf b/terraform/apigateway.tf
index 6ab057d..1144e9b 100644
--- a/terraform/apigateway.tf
+++ b/terraform/apigateway.tf
@@ -84,7 +84,7 @@ resource "aws_api_gateway_integration_response" "options-foo" {
   response_parameters = {
     "method.response.header.Access-Control-Allow-Headers" = "'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token'",
     "method.response.header.Access-Control-Allow-Methods" = "'GET,OPTIONS'",
-    "method.response.header.Access-Control-Allow-Origin"  = "'*'"
+    "method.response.header.Access-Control-Allow-Origin"  = "'example.com'"
   }
 }
```

このバグフィックスブランチがマージされました。同じく v1 は影響を受けていません。

```bash
$ curl -i ${APIGW_ENDPOINT_V1}/foo
HTTP/2 200
date: Wed, 22 Apr 2020 08:56:00 GMT
content-type: application/json
content-length: 21
x-amzn-requestid: 92719999-ddaa-4e64-99d0-ceedad8e1aa3
access-control-allow-origin: *
x-amz-apigw-id: LYX5KG2lNjMFvbA=
x-amzn-trace-id: Root=1-5ea006a0-8d620700c5f44100491af800;Sampled=0

"Hello world. ver.1!"
$
$ curl -i ${APIGW_ENDPOINT_DEV}/foo
HTTP/2 200
date: Wed, 22 Apr 2020 08:56:08 GMT
content-type: application/json
content-length: 32
x-amzn-requestid: a90ee8ef-c7dd-4113-93ce-f8093702d518
access-control-allow-origin: example.com
x-amz-apigw-id: LYX6PGS4NjMFRfw=
x-amzn-trace-id: Root=1-5ea006a7-ace240ff1a0ef1f813fcf59b;Sampled=0

"こんにちは 世界. ver.1!"
```

#### v1 の更新

2つのバグフィックスが入った `v1` をパブリッシュします。変更は下記のように行います。
Lambdaのエイリアスv1の指すバージョンを、これまでの変更で更新されたバージョン 3 に更新します。
ステージのデプロイが行われるよう "aws_api_gateway_deployment" の変数 `published_at` の日時を更新します。

```diff
8102217 Deploy v1 stage with new version
diff --git a/terraform/v1_publish.tf b/terraform/v1_publish.tf
index 25ef30a..51484b1 100644
--- a/terraform/v1_publish.tf
+++ b/terraform/v1_publish.tf
@@ -1,7 +1,7 @@
 resource "aws_lambda_alias" "foofunc-v1" {
   name             = "v1"
   function_name    = aws_lambda_function.foofunc.function_name
-  function_version = "1"
+  function_version = "3"
   depends_on       = [aws_lambda_function.foofunc]
 }
 
@@ -19,7 +19,7 @@ resource "aws_api_gateway_deployment" "test-v1" {
   stage_name  = "v1"
   variables = {
     "alias"        = "v1"
-    "published_at" = "2020-04-22 16:50:00"
+    "published_at" = "2020-04-22 18:00:00"
   }
   depends_on = [
     aws_lambda_permission.allow-get-foo-v1

```

この変更が master にマージされた結果、以下のように v1 エンドポイントの挙動も更新されました。

```bash
$ curl -i ${APIGW_ENDPOINT_V1}/foo
HTTP/2 200 
date: Wed, 22 Apr 2020 09:23:30 GMT
content-type: application/json
content-length: 32
x-amzn-requestid: 2e67802e-08e9-4eea-8158-ba0d499806db
access-control-allow-origin: example.com
x-amz-apigw-id: LYktVGugtjMFrLw=
x-amzn-trace-id: Root=1-5ea01b22-5b6d8568fcf936618d4fcd32;Sampled=0

"こんにちは 世界. ver.1!"
```

## まとめ

以上のように、API GatewayのリリースサイクルをTerraformで回すことができるようになりました。
まだ、シンプルな構成にしか試したことがないので、問題は出てくると思いますが、随時更新していけたらと思います。

git flowだったり、Code Commitを使ったりという場合はCode Pipelineの設定の範囲で容易にできると思います。
また、Code Pipelineを使っていますが、すべてTerraformでデプロイするので、他のCI/CDツールでも全く問題ないと思います。

今回の設定についておさらいすると、以下がポイントになっています。

* Terraform
  * バックエンドは S3 にする
  * バケットはバージョニングを有効にする
* Lambda
  * $LATESTのエイリアスdevを作成する
  * 更新時のバージョンの発行を有効にする(publish = true)
* API Gateway
  * ステージ変数 alias を設定し、ステージ毎に呼び出すLambda関数のエイリアスを切り替える
  * Lambda統合を設定する際に、uri に `${stageVariables.alias}` を含めてエイリアスを呼び出すよう指定する
  * Lambda呼び出し権限の付与は、エイリアス毎の uri で行う必要がある
  * dev ステージのデプロイメントは tf ファイルのサム値を変数に設定し、変更の都度デプロイされるようにする
  * v1 ステージのデプロイメントは、サム値を変数に設定する代わりに、日時を指定して手動でデプロイをトリガできるようにする
  * CORSの設定は面倒だがCORSヘッダをもれなく設定する。OPTIONS以外はLambda側のレスポンスに `Access-Control-Allow-Origin` を含める
* 運用サイクル
  * 新しいAPIバージョンの発行は、バージョンを固定したLambdaエイリアスと、そのエイリアス名をステージ変数aliasに持つステージを作成して行う
  * APIバージョン内でのマイナー修正は、Lambdaエイリアスが指すバージョンを変更して行う

以上です。ありがとうございました。