こしあん
2024-12-17

API Gatewayのカナリアリリースを試す


14{icon} {views}

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.examplevariablesがステージ変数です。ここでは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の中の人が運営しているサークル「じゅ~しぃ~すくりぷと」の本のご案内

技術書コーナー

北海道の駅巡りコーナー


Add a Comment

メールアドレスが公開されることはありません。 が付いている欄は必須項目です