CloudFront /api/* で 1 ドメイン化 — dynamic block と Origin Request Policy

API Gateway は https://xxx.execute-api.region.amazonaws.com という ugly な URL を払い出します。本記事ではこれを https://lab.iigtn.com/api/contact という同一ドメインに統合する設定を解説します。フロント呼び出しの CORS も自動で解決される副次効果があります。

なぜ 1 ドメインに統合するか

観点別ドメイン (api.lab.iigtn.com)同一ドメイン (lab.iigtn.com/api/*)
URL の見た目整理されているシンプル
CORS必要不要(同一オリジン)
cookie 共有困難容易
追加 DNS / 証明書必要不要
運用2 系統1 系統

本シリーズは個人サイト規模なので 1 ドメイン統合の方がシンプル。CORS preflight も同一オリジンなので発生しません。

CloudFront に「2 つ目の origin」を追加

CloudFront Distribution は複数の origin を持てます。本シリーズでは:

  1. origin 1: S3 バケット(既存・default behavior)
  2. origin 2: API Gateway(新規・/api/* behavior)

これを Terraform の dynamic block で条件付きで定義します(frontend_cdn モジュールに後付けする形で実装)。

frontend_cdn モジュールに API origin 引数を追加

# modules/frontend_cdn/variables.tf に追加
variable "api_origin_domain_name" {
  description = "/api/* を委譲する API Gateway のホスト名 (例: xxx.execute-api.ap-northeast-1.amazonaws.com)。空文字なら API behavior を作らない"
  type        = string
  default     = ""
}

variable "api_path_pattern" {
  description = "API behavior にマッチさせるパスパターン"
  type        = string
  default     = "/api/*"
}

デフォルト値を空にしておくことで、API を持たない静的サイトでもこのモジュールを使い回せます。

dynamic block で条件付き origin 追加

resource "aws_cloudfront_distribution" "this" {
  # ... 既存の S3 origin はそのまま

  # API GW origin: api_origin_domain_name が空でない時だけ追加
  dynamic "origin" {
    for_each = var.api_origin_domain_name == "" ? [] : [1]
    content {
      domain_name = var.api_origin_domain_name
      origin_id   = "apigw-origin"

      custom_origin_config {
        http_port              = 80
        https_port             = 443
        origin_protocol_policy = "https-only"   # API GW は HTTPS のみ
        origin_ssl_protocols   = ["TLSv1.2"]
      }
    }
  }
}

dynamic は Terraform の便利構文。for_each に空配列を渡せばブロック生成 0、要素 1 個渡せば 1 個生成。条件付きでブロックを足す王道。

/api/* behavior を dynamic block で追加

dynamic "ordered_cache_behavior" {
  for_each = var.api_origin_domain_name == "" ? [] : [1]
  content {
    path_pattern             = var.api_path_pattern              # /api/*
    target_origin_id         = "apigw-origin"
    viewer_protocol_policy   = "redirect-to-https"
    allowed_methods          = ["DELETE","GET","HEAD","OPTIONS","PATCH","POST","PUT"]
    cached_methods           = ["GET","HEAD"]
    compress                 = true

    # API レスポンスはキャッシュしない
    cache_policy_id          = "4135ea2d-6df8-44a3-9df3-4b5a84be39ad"  # CachingDisabled

    # Host 以外のヘッダ・クエリ・cookie をオリジンに転送
    origin_request_policy_id = "b689b0a8-53d0-40ab-baf2-68738e2966ac"  # AllViewerExceptHostHeader
  }
}

AllViewerExceptHostHeader が必要な理由

これがハマりポイント。普通に AllViewer を使うと、リクエストの Host ヘッダが lab.iigtn.com のまま API Gateway に届きます。すると API GW は「自分宛じゃないリクエスト」と判断してエラー。

正解は 「Host ヘッダだけは転送せず、API GW 側で xxx.execute-api... を Host とする」。これを実現するのが AllViewerExceptHostHeader マネージドポリシー。

CloudFront → API Gateway の連携で {"message":"Forbidden"} が返る場合、ほぼ確実に Host ヘッダ問題です。AllViewerExceptHostHeader を使うか、Host ヘッダを除外するカスタムポリシーを書く。

envs/prod/main.tf でモジュール呼出

module "frontend_cdn" {
  source = "../../modules/frontend_cdn"

  domain_name     = var.domain_name
  certificate_arn = module.network_dns.certificate_arn
  bucket_name     = local.web_bucket_name

  # /api/* を API Gateway に向ける
  api_origin_domain_name = module.backend_api.api_endpoint_host

  providers = {
    aws           = aws
    aws.us_east_1 = aws.us_east_1
  }
}

module.backend_api.api_endpoint_host は API Gateway モジュールが output しているホスト名(xxx.execute-api...)。Terraform が apply 順を自動解決してくれる。

backend_api モジュール側の output

# modules/backend_api/outputs.tf
output "api_endpoint" {
  value = aws_apigatewayv2_api.this.api_endpoint
  # https://xxx.execute-api.ap-northeast-1.amazonaws.com
}

output "api_endpoint_host" {
  value = replace(aws_apigatewayv2_api.this.api_endpoint, "https://", "")
  # xxx.execute-api.ap-northeast-1.amazonaws.com  (CloudFront origin に直で使える)
}

CloudFront の origin は ホスト名のみhttps:// 不要)。replace() で先頭の https:// を削るのが一手間。

apply の順序

Terraform は依存関係を自動解決するので、特に意識せず terraform apply で OK:

  1. backend_api モジュールが先に apply される(API GW 等が作られて api_endpoint_host が確定)
  2. その値を frontend_cdn モジュールが受け取って Distribution を更新
  3. CloudFront Distribution の更新は 5〜15 分かかる

動作確認

# 直接 API GW を叩く
curl -X POST "https://xxx.execute-api.ap-northeast-1.amazonaws.com/api/contact" \
  -H "Content-Type: application/json" \
  -d '{"name":"Test","email":"a@b.com","message":"From direct"}'
# {"ok":true}

# CloudFront 経由 (本番経路)
curl -X POST "https://lab.iigtn.com/api/contact" \
  -H "Content-Type: application/json" \
  -d '{"name":"Test","email":"a@b.com","message":"From CDN"}'
# {"ok":true}

両方 200 が返れば成功。ブラウザの DevTools で確認すると、レスポンスヘッダに Via: cloudfront.net (CloudFront)X-Amz-Cf-Pop: NRT12-P4(東京 Edge)が見えます。

フロント側の fetch コード

同一ドメインなので相対パスで叩けます:

// frontend/index.html の script タグ内
const res = await fetch('/api/contact', {
  method:  'POST',
  headers: { 'Content-Type': 'application/json' },
  body:    JSON.stringify({ name, email, message }),
});

ハードコードされた API URL が無いので、テスト・デプロイ環境に依存しないクリーンなコード。

Behavior の優先順位

CloudFront は behavior を「より具体的なものから順に」マッチさせます:

  1. /api/* ← まずこれをチェック → API GW へ
  2. default_cache_behavior ← マッチしなければここ → S3 へ

つまり /api/contact は API GW、/index.html/static/main.js は S3。明示的に書かなくても自動でこの順序になる。

次の記事

API + フロント が同一ドメインで動くようになったら、次は 運用フェーズの観測 に入ります。次の記事では CloudWatch Alarms + SNS で「壊れたら気付ける」仕組みを作ります。

📚 用語集

dynamic block (Terraform)
Terraform の構文で、ネストされたブロック(origin {} 等)を for_each で動的に生成する。条件付きブロック追加にも使える(空配列 vs 1 要素配列で 0 or 1 ブロック生成)。
ordered_cache_behavior
CloudFront の追加 behavior。default_cache_behavior 以外のパスパターン用。path_pattern でパスを指定する。
path_pattern
CloudFront behavior でマッチさせる URL パスのパターン。wildcard(* / ?)が使える。
custom_origin_config
S3 以外の origin(API Gateway、外部サーバ等)の追加設定。HTTP/HTTPS ポート、TLS バージョン等を指定。
origin_protocol_policy
CloudFront → origin への通信プロトコル指定。https-only / http-only / match-viewer。API GW は HTTPS のみなので https-only
AllViewerExceptHostHeader
AWS マネージドの Origin Request Policy。Host 以外のすべてをオリジンに転送。API Gateway を origin にする時の定番。
Host ヘッダ
HTTP リクエストの Host を示すヘッダ。複数のドメインが同じサーバを使う時の振り分けに使われる。CloudFront → API GW では Host を上書き必須。
CachingDisabled
AWS マネージドの Cache Policy。「キャッシュしない」設定。動的 API 用。
allowed_methods
CloudFront behavior で受け付ける HTTP メソッドの集合。POST を許可する場合は ["DELETE","GET","HEAD","OPTIONS","PATCH","POST","PUT"] 一択。
cached_methods
CloudFront でキャッシュ対象とするメソッド。通常は ["GET","HEAD"] のみ(POST 等の結果はキャッシュしない)。
同一オリジン (Same-Origin)
scheme + host + port が一致するリソース。同一オリジンなら CORS preflight 不要・cookie 共有可能。
CORS preflight
クロスオリジン POST 等の前にブラウザが自動で送る OPTIONS リクエスト。同一オリジン化すれば不要に。
Behavior の優先順位
CloudFront はより具体的な path_pattern を優先する。/api/*/* (default)より先にマッチする。
fetch (相対パス)
同一ドメイン内なら fetch('/api/contact') のように https://... を省略できる。環境ごとの URL 切替が不要になる利点。