API Gatewayのカナリアリリースを試す
Terraformを使用してAPI Gatewayのカナリアリリースを実装し、ステージ変数を活用してトラフィックを動的に異なるLambda関数へルーティングする方法を解説します。これにより、新しいバージョンのコードを既存のステージに段階的にデプロイし、一部のトラフィックでテストを行うことが可能になります。
目次
はじめに
- API Gatewayで既存のステージに新たなコードをデプロイする戦略としてカナリア(Canary)リリースがあります
- これは既存のステージに対し、異なるバージョンや異なる関数を割り当ててトラフィックの一部をルーティングするものです
- 具体的にはFunction1のv1が提供されているものとし、v2の関数を既存のトラフィックの10%に割り当ててテストするなどが想定例です
- 今回はバージョン(エイリアス)ではなく、異なる関数にルーティングするものとしてTerraformで実装してみました
具体的には以下の図です
- Lambda v1とLambda v2の異なるLambdaを用意し、トラフィックの90%をv1、トラフィック10%をv2に流します
- ログの出力はCloudWatch Logsに吐き出されます
- API Gatewayのルートに関数を作ります
考え方
これをTerraformで実装するには次のように考えます
- API Gatewayのステージごとにステージ変数が用意されています
- 今回は割り当てる関数名をステージ変数に定義し、呼び出し先をステージ変数を使って動的に設定します
- デフォルトのステージ変数に対し、カナリア(新しいバージョン)が呼び出されたときはステージ変数を上書きすることができます。これで動的なルーティングが可能になります。
具体的には以下のTerraformのコードに集約されます(コードの全体は後ほど示します)
data "aws_region" "current" {}
data "aws_caller_identity" "current" {}
resource "aws_api_gateway_integration" "root_integration" {
rest_api_id = aws_api_gateway_rest_api.example_api.id
resource_id = aws_api_gateway_rest_api.example_api.root_resource_id
http_method = aws_api_gateway_method.root_method.http_method
integration_http_method = "POST"
type = "AWS_PROXY"
# ステージ変数を使用するようにURIを設定
uri = join("", [
"arn:aws:apigateway:${data.aws_region.current.name}:lambda:path/2015-03-31/functions/",
"arn:aws:lambda:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:function:",
"$${stageVariables.lambda_function_name}",
"/invocations"
])
}
resource "aws_api_gateway_stage" "example" {
deployment_id = aws_api_gateway_deployment.example_deployment.id
rest_api_id = aws_api_gateway_rest_api.example_api.id
stage_name = "dev"
variables = {
lambda_function_name = aws_lambda_function.lambda_v1.function_name
}
canary_settings {
percent_traffic = 10
deployment_id = aws_api_gateway_deployment.canary_deployment.id
stage_variable_overrides = {
lambda_function_name = aws_lambda_function.lambda_v2.function_name
}
}
}
リソースaws_api_gateway_stage.example
のvariables
がステージ変数です。ここではLambdaの関数名を指定しています。
canary_settings
がカナリアの設定です。percent_traffic
は10%のトラフィックを流すもの、stage_variable_overrides
はカナリアによって上書きされるステージ変数です。
リソースaws_api_gateway_integration.root_integration
がステージ変数を使って動的に呼び出すURLです。ここはめちゃくちゃ長いですが、Terraform特有の理由があります。
ハマりどころ:URLの動的指定
ステージ変数に呼び出す関数のARNを指定するということも考えられます(ChatGPTはこう言ってきます)が、これはうまくいきません。こんなエラーが出ます。
creating API Gateway Integration: operation error API Gateway: PutIntegration, https response error StatusCode: 400, RequestID: xxx, BadRequestException: Invalid lambda function
│
│ with aws_api_gateway_integration.root_integration,
│ on apigw.tf line 16, in resource “aws_api_gateway_integration” “root_integration”:
│ 16: resource “aws_api_gateway_integration” “root_integration” {
これはTerraformにIssueがあり解決法が示されています。
Cannot create api-gateway integration with stage variable for lambda function name #6463
URLを
- Before :
arn:aws:apigateway:${var.aws_region}:lambda:path/2015-03-31/functions/$${stageVariables.lambdaFunction}/invocations
- After :
arn:aws:apigateway:${var.aws_region}:lambda:path/2015-03-31/functions/arn:aws:lambda:${var.aws_region}:${var.account_id}:function:$${stageVariables.lambdaFunction}/invocations
のように変えるとうまくいくよというもの。おそらく裏で何らかのバリデーションが働いていて、Afterのように書くとすり抜けることができるとのことでしょう。
結果
以下のようにしてAPI Gatewayのエンドポイントに対しリクエストを定期的に送ってみましょう
import requests
import time
# ターゲットURL
url = "<your-api-gateway-endpoint>"
# リクエストを送り続ける関数
def send_requests(interval=3, max_cnt=200):
cnt = 0
try:
while True:
response = requests.get(url)
print(f"Status Code: {response.status_code}")
print(f"Response: {response.text}")
cnt += 1
time.sleep(interval) # 5秒待機
if cnt >= max_cnt:
break
except KeyboardInterrupt:
print("リクエストの送信を終了します。")
# 実行
if __name__ == "__main__":
print(f"{url} に5秒おきにリクエストを送信します。Ctrl+Cで終了します。")
send_requests()
とすると以下のようにv1とv2が混じって出力されます。これはカナリアの期待された結果です。
Response: "Hi, This is ver 1 Lambda!"
Status Code: 200
Response: "Hi, This is ver 2 Lambda!"
Status Code: 200
Response: "Hi, This is ver 1 Lambda!"
Status Code: 200
Response: "Hi, This is ver 1 Lambda!"
Status Code: 200
Response: "Hi, This is ver 2 Lambda!"
Status Code: 200
Response: "Hi, This is ver 2 Lambda!"
Status Code: 200
Response: "Hi, This is ver 1 Lambda!"
Status Code: 200
Response: "Hi, This is ver 1 Lambda!"
Status Code: 200
Response: "Hi, This is ver 1 Lambda!"
Status Code: 200
Response: "Hi, This is ver 1 Lambda!"
Status Code: 200
Response: "Hi, This is ver 1 Lambda!"
Status Code: 200
Response: "Hi, This is ver 1 Lambda!"
Status Code: 200
Response: "Hi, This is ver 1 Lambda!"
全体のコード
ディレクトリ構造
.
├── apigw.tf
├── lambda.tf
├── lambda_function_v1.py
└── lambda_function_v2.py
apigw.tf
# API Gateway
resource "aws_api_gateway_rest_api" "example_api" {
name = "canary-sample-lambda"
description = "API for canary release"
}
resource "aws_api_gateway_method" "root_method" {
rest_api_id = aws_api_gateway_rest_api.example_api.id
resource_id = aws_api_gateway_rest_api.example_api.root_resource_id
http_method = "GET"
authorization = "NONE"
}
data "aws_region" "current" {}
data "aws_caller_identity" "current" {}
resource "aws_api_gateway_integration" "root_integration" {
rest_api_id = aws_api_gateway_rest_api.example_api.id
resource_id = aws_api_gateway_rest_api.example_api.root_resource_id
http_method = aws_api_gateway_method.root_method.http_method
integration_http_method = "POST"
type = "AWS_PROXY"
# ステージ変数を使用するようにURIを設定
uri = join("", [
"arn:aws:apigateway:${data.aws_region.current.name}:lambda:path/2015-03-31/functions/",
"arn:aws:lambda:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:function:",
"$${stageVariables.lambda_function_name}",
"/invocations"
])
}
# メインのデプロイメント(lambda_v1)
resource "aws_api_gateway_deployment" "example_deployment" {
depends_on = [
aws_api_gateway_integration.root_integration,
aws_lambda_permission.api_gateway_permission,
aws_lambda_permission.api_gateway_permission_v2
]
rest_api_id = aws_api_gateway_rest_api.example_api.id
triggers = {
redeployment_integration = sha1(jsonencode(aws_api_gateway_integration.root_integration))
}
lifecycle {
create_before_destroy = true
}
}
# カナリーデプロイメント用のデプロイメント(lambda_v2)
resource "aws_api_gateway_deployment" "canary_deployment" {
rest_api_id = aws_api_gateway_rest_api.example_api.id
triggers = {
redeployment_canary = "${sha1(jsonencode(aws_api_gateway_integration.root_integration))}-canary"
}
lifecycle {
create_before_destroy = true
}
depends_on = [
aws_api_gateway_integration.root_integration,
aws_lambda_permission.api_gateway_permission_v2
]
}
resource "aws_api_gateway_stage" "example" {
deployment_id = aws_api_gateway_deployment.example_deployment.id
rest_api_id = aws_api_gateway_rest_api.example_api.id
stage_name = "dev"
variables = {
lambda_function_name = aws_lambda_function.lambda_v1.function_name
}
canary_settings {
percent_traffic = 10
deployment_id = aws_api_gateway_deployment.canary_deployment.id
stage_variable_overrides = {
lambda_function_name = aws_lambda_function.lambda_v2.function_name
}
}
}
# Lambda Permission for lambda_v1
resource "aws_lambda_permission" "api_gateway_permission" {
statement_id = "AllowAPIGatewayInvoke"
action = "lambda:InvokeFunction"
function_name = aws_lambda_function.lambda_v1.function_name
principal = "apigateway.amazonaws.com"
# API GatewayのリソースARN
source_arn = "${aws_api_gateway_rest_api.example_api.execution_arn}/*"
}
# Lambda Permission for lambda_v2
resource "aws_lambda_permission" "api_gateway_permission_v2" {
statement_id = "AllowAPIGatewayInvokeV2"
action = "lambda:InvokeFunction"
function_name = aws_lambda_function.lambda_v2.function_name
principal = "apigateway.amazonaws.com"
# API GatewayのリソースARN
source_arn = "${aws_api_gateway_rest_api.example_api.execution_arn}/*"
}
# Endpoint
output "api_endpoint" {
value = aws_api_gateway_stage.example.invoke_url
}
lambda.tf
# ロールを作成
resource "aws_iam_role" "lambda_role" {
name = "LambdaExecutionRole"
assume_role_policy = jsonencode({
Version = "2012-10-17",
Statement = [
{
Action = "sts:AssumeRole",
Effect = "Allow",
Principal = {
Service = "lambda.amazonaws.com"
}
}
]
})
}
# AWSLambdaBasicExecutionRoleマネージドポリシー
resource "aws_iam_role_policy_attachment" "managed_policy" {
role = aws_iam_role.lambda_role.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}
# Lambdaの作成
data "archive_file" "lambda_v1" {
type = "zip"
source_file = "lambda_function_v1.py"
output_path = ".cache/lambda_function_v1.zip"
}
# Lambdaの作成
resource "aws_lambda_function" "lambda_v1" {
filename = data.archive_file.lambda_v1.output_path
function_name = "version1_lambda"
role = aws_iam_role.lambda_role.arn
handler = "lambda_function_v1.lambda_handler"
runtime = "python3.12"
source_code_hash = data.archive_file.lambda_v1.output_base64sha256
memory_size = 128
timeout = 5
}
# Lambdaの作成v2
data "archive_file" "lambda_v2" {
type = "zip"
source_file = "lambda_function_v2.py"
output_path = ".cache/lambda_function_v2.zip"
}
# Lambdaの作成v2
resource "aws_lambda_function" "lambda_v2" {
filename = data.archive_file.lambda_v2.output_path
function_name = "version2_lambda"
role = aws_iam_role.lambda_role.arn
handler = "lambda_function_v2.lambda_handler"
runtime = "python3.12"
source_code_hash = data.archive_file.lambda_v2.output_base64sha256
memory_size = 128
timeout = 5
}
lambda_function_v1.py
import json
def lambda_handler(event, context):
print("Lambda ver1 is called!")
return {
'statusCode': 200,
'body': json.dumps('Hi, This is ver 1 Lambda!')
}
lambda_function_v2.py
import json
def lambda_handler(event, context):
print("Lambda ver2 is called!")
return {
'statusCode': 200,
'body': json.dumps('Hi, This is ver 2 Lambda!')
}
おわりに
カナリアってなんか難しそうに思ってたけど、ステージ変数との組み合わせは汎用的に使えそう
Shikoan's ML Blogの中の人が運営しているサークル「じゅ~しぃ~すくりぷと」の本のご案内
技術書コーナー
北海道の駅巡りコーナー