naoki.dev

記事検索

タイトル・本文・タグから検索します

ホームに戻る
バックエンド

API Gateway(REST) + Lambda を「追加が冗長にならない」Terraform 構成にする

目次

AWS の API Gateway(REST API v1) を Terraform で素直に書くと、ルートを1本足すたびに aws_api_gateway_resourcemethodintegrationdeployment の再デプロイ → lambda_permission… とリソースが増えていきます。Lambda を分ければ実行ロールやロググループも増える。**「エンドポイントを1個足すのにコピペが5〜6リソース」**になりがちです。

この記事では、それを 「エンドポイント追加は openapi.yml に1ブロック、Lambda 追加は locals に1行」 に収める構成をまとめます。キモは3つ。

  • OpenAPI を body に import して、ルート定義を1ファイルに集約する
  • Lambda は local.lambdas マップ + module の for_each で1回だけ定義する
  • api へ渡すマップは for 内包表記で派生生成して、手で二重管理しない

サンプルのプロジェクト名は myapp、環境は dev / prod の2つとします。

なぜ discrete リソースだと冗長になるのか

REST v1 を「リソースを1個ずつ」書くと、/items/items/{id} のような階層が曲者です。aws_api_gateway_resourceparent_id を要求するツリー構造なので、素朴に for_each で回すとこうなります。

resource "aws_api_gateway_resource" "this" {
  for_each  = local.resources
  parent_id = each.value.parent == "" ? aws_api_gateway_rest_api.this.root_resource_id : aws_api_gateway_resource.this[each.value.parent].id
  #                                                                                       ^^^ 同じ resource の別インスタンスを参照
}

これは self-reference(自己参照)でエラーになります。Terraform は同一 for_each ブロック内での相互参照を許しません。つまり「ネストしたパスを1つの for_each で回す」ができない。ここが REST v1 を discrete に書いたときの一番の壁です。

回避策として深さごとにブロックを分けることもできますが、結局ルートが増えるほど手数が増えます。そこで OpenAPI import に振り切るのが、増える前提のテンプレートでは一番きれいでした。

ルートは OpenAPI(body) に集約する

aws_api_gateway_rest_apibody に OpenAPI(YAML) を渡すと、リソースツリーは API Gateway 側が自動構築します。discrete な resource/method/integration は一切書きません(書くと二重定義で壊れる)。

modules/api/openapi.yml(templatefile のテンプレート):

openapi: "3.0.1"
info:
  title: "${name}"
  version: "1.0"
paths:
  /health:
    get:
      x-amazon-apigateway-integration:
        type: aws_proxy
        httpMethod: POST # Lambda 統合は常に POST(REST v1 も)
        uri: ${invoke_arns["health"]}
        passthroughBehavior: when_no_match
      responses:
        "200":
          description: OK

ポイントは uri: ${invoke_arns["health"]} の部分。invoke_arn のマップを丸ごとテンプレートに渡し、パスごとにキーで引くようにしておくと、ルート追加時に Terraform 側(main.tf)を触らずに済みます。

modules/api/main.tf:

resource "aws_api_gateway_rest_api" "this" {
  name = local.name
 
  body = templatefile("${path.module}/openapi.yml", {
    name        = local.name
    invoke_arns = var.lambda_invoke_arns # ← マップごと渡す
  })
 
  endpoint_configuration {
    types = ["REGIONAL"]
  }
}
 
resource "aws_api_gateway_deployment" "this" {
  rest_api_id = aws_api_gateway_rest_api.this.id
 
  # body(=OpenAPI)が変われば再デプロイ
  triggers = {
    redeploy = sha1(jsonencode(aws_api_gateway_rest_api.this.body))
  }
 
  lifecycle {
    create_before_destroy = true
  }
}
 
resource "aws_api_gateway_stage" "this" {
  rest_api_id   = aws_api_gateway_rest_api.this.id
  deployment_id = aws_api_gateway_deployment.this.id
  stage_name    = var.stage_name
}
 
# Lambda ごとに invoke 許可(増えても for_each で自動追従)
resource "aws_lambda_permission" "this" {
  for_each      = var.lambda_function_names
  statement_id  = "AllowAPIGatewayInvoke-${each.key}"
  action        = "lambda:InvokeFunction"
  function_name = each.value
  principal     = "apigateway.amazonaws.com"
  source_arn    = "${aws_api_gateway_rest_api.this.execution_arn}/*/*"
}

REST v1 特有の落とし穴を3つ潰しておきます。

  • triggers に body のハッシュを入れる。これが無いと OpenAPI を編集しても再デプロイされない。
  • httpMethod: POST は固定get: のルートでも、Lambda 統合の内部呼び出しは常に POST。
  • lambda_permissionfor_each で全 Lambda に自動付与。source_arn.../*/* でステージ/メソッドをワイルドカード。

Lambda は1回だけ定義する(locals + for_each + 派生マップ)

ここが「冗長にならない」の本丸です。env 側(envs/dev/main.tf など)で Lambda を locals のマップに1回だけ書き、module を for_each で回します。

envs/dev/locals.tf:

locals {
  # Lambda はここだけに増やす(キー=論理名, handler=エントリ)
  lambdas = {
    health = { handler = "health.handler" }
    # items  = { handler = "items.handler" }  ← 増やすときはここに1行
  }
}

envs/dev/main.tf:

module "lambda" {
  source      = "../../modules/lambda"
  for_each    = local.lambdas
  project     = var.project
  env         = var.env
  zip_path    = "${path.root}/../../../backend/dist/function.zip"
  handler     = each.value.handler
  name_suffix = each.key
}
 
module "api" {
  source     = "../../modules/api"
  project    = var.project
  env        = var.env
  stage_name = var.env
 
  # module.lambda(for_each)から自動生成する。手で増やさない
  lambda_invoke_arns    = { for k, m in module.lambda : k => m.invoke_arn }
  lambda_function_names = { for k, m in module.lambda : k => m.function_name }
}

{ for k, m in module.lambda : k => m.invoke_arn } が肝で、Lambda を増やしても2つのマップは自動で追従します。local.lambdas に1行足すだけで、module.lambda のインスタンスが増え、派生マップも増え、api の lambda_permissionfor_each も自動で1個増える。手で書き足すのは locals の1行と、openapi.yml の path 1ブロックだけです。

Lambda module 側は、複数関数を区別できるよう name_suffix 変数を持たせて命名に使います。

# modules/lambda/locals.tf
locals {
  function_name = "${var.project}-${var.env}-${var.name_suffix}"
  log_group     = "/aws/lambda/${var.project}-${var.env}-${var.name_suffix}"
}

こうしておくと myapp-dev-health / myapp-dev-items のように関数ごとに一意な名前・ロググループ・IAM ロールになります(ロール名も ${local.function_name}-role に)。

パッケージング:zip は Makefile で作り、Terraform は参照するだけ

Python Lambda の zip は Terraform 内で作らず、Makefile(や CI)で決定論的に作って、Terraform は出来た zip を source_code_hash で参照します。

Makefile:

back-build: ## backend を zip 化(backend/dist/function.zip)
	rm -rf backend/build backend/dist
	mkdir -p backend/build backend/dist
	pip install -r backend/requirements.txt -t backend/build
	cp backend/src/*.py backend/build/
	cd backend/build && zip -qr ../dist/function.zip .

modules/lambda/main.tf はこの zip を参照:

resource "aws_lambda_function" "this" {
  function_name    = local.function_name
  role             = aws_iam_role.this.arn
  runtime          = var.runtime
  handler          = var.handler
  architectures    = [var.architecture]
  filename         = var.zip_path
  source_code_hash = filebase64sha256(var.zip_path) # zip が変われば更新
}

複数 Lambda でも zip は1つでよく、backend/src/*.py を全部同梱して関数ごとに handler(例 health.handler / items.handler)を変えるだけです。

注意: source_code_hash = filebase64sha256(var.zip_path) は plan 時に zip を読むので、make back-build を plan/apply より先に実行しておく必要があります。

GitHub Actions:apply の前に zip を作る

デプロイ workflow では、terraform apply前に backend の zip を作るステップが要ります(でないと filebase64sha256 が落ちる)。

.github/workflows/deploy.yml(抜粋):

- uses: actions/setup-python@v5
  with:
    python-version: "3.13"
 
# 0) backend を zip 化(terraform が source_code_hash で参照するので apply の前に必須)
- name: Build backend
  run: make back-build
 
# 1) インフラ(Lambda / API Gateway / S3 / CloudFront ...)
- name: Terraform apply
  env:
    ENV: ${{ steps.env.outputs.name }}
  run: |
    terraform -chdir=infra/envs/$ENV init
    terraform -chdir=infra/envs/$ENV apply -auto-approve

さらに、PR 段階で backend が壊れていないかだけ見る dry-run の CI を別途置いておくと安心です(デプロイはしない)。

.github/workflows/backend.yml(抜粋):

on:
  pull_request:
    paths:
      - "backend/**"
      - ".github/workflows/backend.yml"
 
jobs:
  check:
    runs-on: ubuntu-latest
    defaults:
      run:
        working-directory: backend
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-python@v5
        with:
          python-version: "3.13"
      - run: pip install ruff pytest
      - run: ruff check .
      - run: ruff format --check .
      - run: pytest -q

ruff(lint + format を1本化)と pytest の3点。フロントの「eslint / tsc / build」に対応させる形です。整形は husky + lint-staged(backend/**/*.pyruff check --fixruff format)でコミット時に自動でかかるようにしておけば、この ruff format --check が PR で赤くなることもありません。

結果:追加時の手数

この構成にすると、拡張の手数はこうなります。

追加内容 触るファイル
新パス(既存 Lambda で捌く) openapi.yml に1ブロック + ハンドラにパス分岐
新 Lambda を分離 locals.lambdas に1行 + openapi.yml に1ブロック + ハンドラ新規
lambda_permission / 再デプロイ 自動追従(手を入れない)

resource/method/integration のコピペも、2つのマップの二重管理も消えます。REST v1 の冗長さは「OpenAPI に集約 + Lambda を1回定義 + マップは派生生成」で十分に飼い慣らせる、という話でした。

まとめ

  • REST v1 を discrete に書くと、階層リソースの for_each 自己参照の壁もあって、ルート追加が高コストになる
  • ルートは OpenAPI(body)に集約し、uri は invoke_arn マップをキー引きする
  • Lambda は locals のマップ + module の for_each、api へ渡すマップは for 内包表記で派生して二重管理を避ける
  • zip は Makefile で作って source_code_hash で参照、GHA は apply の前に build、PR は ruff/pytest の dry-run
  • 拡張は「locals に1行」「openapi.yml に1ブロック」で完結する

参考ソース

関連記事