運用中の Web サービス群を、Docker + Traefik で立てたオリジンに直接 DNS を向けている状態から、CloudFront + AWS WAF を間に挟む構成へ移行しました。本記事ではそのとき採用したパターンと、想定しなかった落とし穴を汎用化してまとめます。

類似の構成を移行する方が、同じ落とし穴を回避できることを目的としています。

移行前の構成

ブラウザ ──► DNS ──► オリジン IP(reverse proxy: Traefik on VPS)
                          ├── service-a (cultural.jp 相当)
                          ├── service-b (api.cultural.jp 相当)
                          └── service-c (webcatplus.jp 相当)
  • 各サービスは Docker コンテナ
  • Traefik が Host ヘッダで振り分け、Let’s Encrypt(HTTP-01)で TLS 終端
  • 攻撃検知に CrowdSec バウンサープラグイン

移行後の構成

ブラウザ ──► DNS ──► CloudFront ──► オリジン用ドメイン ──► Traefik
                       │              (origin.example.com)
                       └── WAF(OWASP / 既知悪性入力 / IP評判 / レート制限)

ポイントは 3 つ:

  1. オリジン用に別サブドメインを立てるorigin.<service>.example.com
  2. CloudFront → オリジン間にカスタムヘッダで「合言葉」を仕込む
  3. 既存ワイルドカード ACM 証明書を CloudFront でも再利用

1. オリジン用サブドメインを立てる理由

この方式が必要な前提

このパターンが必要になるのは、おおむね次の条件のどれか:

  • 既存ドメインに直接アクセスが来ている状態から CloudFront を後付けする(移行)
  • オリジンが VPS / 非 AWS で、独自の TLS 証明書しか持っていない
  • AWS ALB だが、ALB の cert が *.elb.amazonaws.comではない(例: *.example.com のカスタム cert)

新規構築で S3 や、ALB の native DNS で十分な cert を持つ構成なら、別サブドメインを立てずに直接オリジン指定で済みます。

なぜ別名が要るのか

CloudFront の Alternate Domain Name(CNAME)に example.com を設定すると、CloudFront のオリジンにも同じ example.com を指定するわけにはいきません(DNS ループ)。 オリジン側を別名で受けられるようにする必要があります。

ALB を origin にする場合も、ALB の HTTPS listener が返す cert(多くのケースで *.example.com)と CloudFront が送る SNI(ALB native DNS = dualstack.*.elb.amazonaws.com)が一致せず、TLS 検証で弾かれます。*.elb.amazonaws.com の cert は ACM で発行不可なので、結局オリジン用サブドメインを立てるのが標準解になります。

推奨: origin.<service>.<domain> を新設

example.com         ALIAS → CloudFront
origin.example.com  A     → 既存オリジン IP

CloudFront は origin.example.com を origin として叩き、Traefik 側はそのホスト名のルータで受ける。

この方式の良いところ:

  • 既存のドメインと並行稼働できる(DNS 切替前に動作確認可能)
  • ワイルドカード証明書 *.example.com が origin 側でも使える(Let’s Encrypt でも ACM でも)
  • オリジン側を後で別サーバに移したい時、CNAME を差し替えるだけで CloudFront 設定は無変更

避けたほうが良い案:

  • CloudFront のオリジンにオリジン IP を直書き → CloudFront が SNI を送れず TLS 検証で失敗
  • ALB の dualstack.*.elb.amazonaws.com を origin 名にする → ELB の cert と SNI が一致せず失敗(cert は通常 *.example.com で発行されている)

サブドメインのレベルに注意

ワイルドカード証明書 *.example.com1 階層 しかカバーしません。

  • origin.example.com → ✅ カバーされる
  • origin.api.example.com → ❌ カバーされない(2 階層)

なので、api.example.com のオリジンを作るときは origin.api.example.com ではなく、origin-api.example.com のように 1 階層に収めると、既存ワイルドカード cert がそのまま使えます。

私のケースでは API ドメインだけは origin.api.example.com にして、ワイルドカードでカバーしようとして TLS エラーになり、結局 origin-api.example.com 形式に揃え直しました(時間ロス)。


2. オリジン保護: secret header

CloudFront のオリジン保護として、カスタムヘッダ + リバースプロキシ側でのヘッダ検証 がシンプルかつ効果的です。

CloudFront 側

custom_header {
  name  = "X-Origin-Secret"
  value = var.origin_secret  # openssl rand -hex 32
}

Traefik 側(ルータの rule に Headers マッチを足すだけ)

labels:
  traefik.http.routers.svc-origin.rule: >-
    Host(`origin.example.com`) &&
    Headers(`X-Origin-Secret`, `${ORIGIN_SECRET}`)

ヘッダがマッチしないリクエストは 404 が返る(ルータ自体が反応しない)。これで、攻撃者がオリジン IP を特定して直接アクセスしても、無関係な 404 しか返りません。

Nginx の場合

server {
    server_name origin.example.com;

    if ($http_x_origin_secret != "${ORIGIN_SECRET}") {
        return 404;
    }
    ...
}

より堅くしたい場合

  • ALB なら EC2 SG を com.amazonaws.global.cloudfront.origin-facing プレフィックスリストのみ許可 に絞れる(後述)
  • VPS の場合はファイアウォールで CloudFront IP レンジを許可する手もあるが、レンジが膨大かつ更新があるので、secret header 単独でも十分実用的

3. ALB ⇄ EC2 の SG 分離(落とし穴)

ここが今回最大の事故ポイント

ALB と背後の EC2 が 同じ Security Group を共有していたため、ALB の inbound を CloudFront プレフィックスリスト限定に絞ろうとして 0.0.0.0/0 からの 80/443 を revoke したところ、ALB → EC2 の内部通信まで巻き添えで遮断されるという事象が発生しました。

推奨される SG 構成

[ALB SG]                    [EC2 SG]
inbound:                    inbound:
  443 from CF prefix list    80 from ALB SG     (← 自己/他参照で許可)
                             22 from admin IPs

ALB SG と EC2 SG を別物として作ること。同じだと「片方を絞ると両方絞られる」事故が起きます。

同じ SG になっている既存環境を分離する手順(無停止)

  1. 新しい EC2 用 SG を作成(ALB SG からの inbound 80 を許可)
  2. EC2 の ENI に両方の SG をアタッチ
  3. 動作確認
  4. 古い SG を ENI から外す
  5. 古い SG(旧共有 SG)を ALB 専用ルールに整理

途中段階で必ず「両方アタッチ → 確認 → 片方剥がす」を挟むと無停止で切り替わります。


4. キャッシュキー設計

基本ポリシー

項目採用値理由
Path当然
Query string (全て)API/SPARQL のパラメータごとに別キャッシュ
Cookieヒット率が極端に低下する
Authorization同上、認証ありなら別ポリシーに分離
Accept-Encoding (gzip/br)✅ (CF が自動)圧縮形式ごとに別キャッシュ
Accept-LanguageURL に /ja/, /en/ が入る i18n なら不要

i18n が URL パスベース(/ja/foo, /en/foo)なら、Accept-Language をキーに含めずに済みます。これは大きな差で、含めると BOT/ブラウザごとにキャッシュが分散して効率が落ちます。

TTL の決め方

  • Default TTL = 24h
  • Max TTL = 7 days
  • Min TTL = 0(オリジンの Cache-Control を尊重)

オリジンが Cache-Control を返さないケースが多い API に対しても、CloudFront は Default TTL でキャッシュする。

更新頻度が低いサイトであれば 1〜7 日でも実害は少なく、デプロイ時の Invalidation で即時反映できます。

静的アセット用に追加ポリシー

/_next/static/*/img/* 等のハッシュ付きパスは別ポリシー:

  • Min/Default/Max = 1 year
  • 永続キャッシュ(ファイル名にハッシュが入るので invalidation 不要)

SPARQL や検索 API への効果

GET リクエスト+クエリ文字列付きのリクエストは、URL がバイト一致すれば 24h ヒットします。

  • 重い SPARQL DESCRIBE クエリ: 初回 1.1 秒 → warm 50ms (約 20 倍の高速化
  • ファセット検索 API: 初回 480ms → warm 45ms (約 10 倍
  • 同一クエリが何度も発行される UI(無限スクロールやファセット連打)で効果が大きい

注意点:

  • POST リクエストはキャッシュされない(HTTP 仕様上の RFC 準拠挙動)
  • SPARQL エンドポイントはクライアント側で「短いクエリは GET、長いクエリは POST」と切り替えていることがあるので、GET 化できると恩恵が増える

5. WAF は必ず COUNT モードで開始する

「CrowdSec で誤 BAN がよく発生していた」というのが今回の WAF 導入動機の一つです。同じ轍を踏まないために、WAF も最初の 1 週間は全ルール COUNT モードで運用しました。

COUNT モードとは

ルールにマッチしても block しない、ログだけ残す 状態。CloudWatch Metrics と Sampled Requests で「もし block していたら何件ブロックされていたか」が可視化されます。

採用ルール

ルール用途
AWSManagedRulesCommonRuleSetXSS / SQLi / 一般的攻撃
AWSManagedRulesKnownBadInputsRuleSet既知の悪性入力(log4shell 等)
AWSManagedRulesAmazonIpReputationList悪性 IP(IP評判)
Rate-based rule5 分間に 1000req/IP(DoS 緩和)

避けたルール:

  • AWSManagedRulesBotControlRuleSet — 別料金かつ偽陽性多めなので最初は外す

Terraform でのモード切替

override_action を変数で切り替えできるように書いておくと、運用初期の count から本番の none(block 有効)への移行がコマンド一発で済みます。

variable "waf_rule_action" {
  type    = string
  default = "count"
  validation {
    condition     = contains(["count", "block"], var.waf_rule_action)
    error_message = "Must be 'count' or 'block'."
  }
}

locals {
  managed_override = var.waf_rule_action == "block" ? "none" : "count"
}

resource "aws_wafv2_web_acl" "cf" {
  rule {
    name = "AWSManagedRulesCommonRuleSet"

    override_action {
      dynamic "count" {
        for_each = local.managed_override == "count" ? [1] : []
        content {}
      }
      dynamic "none" {
        for_each = local.managed_override == "none" ? [1] : []
        content {}
      }
    }
    ...
  }
}

1 週間の COUNT 運用で誤検知のあるルールを特定し、必要なルールだけ block に切り替えるのが安全です。

CrowdSec を捨てて WAF にした判断基準

CrowdSec は「振る舞い分析」ベースで、http-crawl-non_statics のようなシナリオは「短時間に多数の動的リソース要求」を BAN します。これは:

  • 静的サイト・低トラフィック → ◎ 効く
  • インタラクティブ Web App + 検索 API → △ 普通のユーザが BAN される

私の構成は後者(ファセット検索や無限スクロール)が多く、CrowdSec とは相性が悪かった。WAF Managed Rules は「リクエストのパターン」で判定するので、リクエスト数自体には基本反応せず、誤検知が出にくい構造です。


6. 段階的な切り替え手順

無停止で本番切り替えするには、次の順がおすすめ:

1. オリジン用 DNS(origin.example.com)追加
2. リバースプロキシに「オリジン用ルータ」追加(既存と並行稼働)
3. CloudFront 構築(DNS は触らない)
4. CF dist-domain (xxxx.cloudfront.net) で疎通確認 (curl --resolve / /etc/hosts)
5. ブラウザでも /etc/hosts でテスト(あなたの環境だけ CF 経由)
6. DNS 切替(ALIAS を CloudFront に向ける)
7. 旧ルータ削除(DNS TTL 切れを待ってから)

重要なのは 5 と 6 の間に検証を挟めること。CloudFront のディストリビューション名(xxxx.cloudfront.net)はディストリビューション作成と同時に発行されるので、本物の DNS を切り替える前に curl と /etc/hosts で本番相当の動作を検証できます。

# /etc/hosts
18.65.x.x   example.com
18.64.y.y   api.example.com

このとき DNS resolver の挙動に注意。私の環境は dig のローカルリゾルバが cloudfront.net を REFUSE する状態だったため、dig @8.8.8.8 ... で明示しないと CloudFront IP が引けませんでした。


7. CI に Invalidation を組み込む

デプロイ後にキャッシュをクリアする一手間が必要です。

IAM 用のミニマムポリシー

{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Action": "cloudfront:CreateInvalidation",
    "Resource": "arn:aws:cloudfront::<account>:distribution/*"
  }]
}

GitHub Actions ステップ

- name: Invalidate CloudFront
  env:
    AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
    AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
    AWS_DEFAULT_REGION: us-east-1
  run: |
    aws cloudfront create-invalidation \
      --distribution-id ${{ secrets.CF_DIST_ID }} \
      --paths "/*"

CMS 経由で記事更新があるサービスなら、CMS の webhook → Lambda → invalidation でも同等のことができます。


8. 速度向上の実測

東京クライアント → 東京オリジン(VPS)の場合の実測値:

経路TTFB
オリジン直接~70ms
CloudFront cold(初回 cache miss)~125ms(初回のみ)
CloudFront warm(cache hit)~45ms

東京同士でもキャッシュヒット時は 35〜60% 速くなる。これは、

  • オリジンとの SSL ハンドシェイクが edge で再利用される
  • TCP/TLS のラウンドトリップ短縮
  • 圧縮(brotli)が edge で実施される

ことが効いています。海外ユーザにはさらに大きく効きます(オリジンへの太平洋越えが消える)。

cold miss は確かに遅いですが、TTL 24h であれば「最初の 1 リクエストだけ遅い、以降 24h は高速応答」という分布になります。


9. 月額コスト

項目概算
WAF Web ACL$5/月
WAF Managed Rules(3 個)$3/月
WAF リクエスト課金$0.60/100 万リクエスト
CloudFront データ転送(無料枠 1TB/月あり)~$0–5/月
ACM 証明書無料
Route53 ALIAS無料(Hosted Zone $0.50/月のみ)

合計 月 $10〜15 程度 で、複数ドメインの dist を 1 つの WAF ACL に紐づけて共有できるので意外と安価です。


10. 移行で「やってよかった」副次効果

  • ES(Elasticsearch)の外部公開を停止できた。攻撃面を縮小する良い機会になった。
  • docker-compose.yml の Traefik ラベル整理で、ルータごとの責務が明確になった。
  • Terraform で IaC 化したことで、新ドメインを追加する際は sites = { ... } map に 1 エントリ追加するだけで済む。
  • CrowdSec の運用負荷から解放された(誤検知対応・whitelist メンテナンスが不要に)。

11. 反省・落とし穴まとめ

場面失敗教訓
ALB SG共有 SG から 0.0.0.0/0 を削除して内部通信も切断ALB SG と EC2 SG は最初から分離する
オリジン名origin.api.example.com(2階層)にして wildcard cert カバー外1階層に収める。例: origin-api.example.com
Let’s EncryptHTTP-01 で port 80 必須 → 後で CF 専用にすると更新詰まる可能性移行と同時に DNS-01 へ切替検討
DNS リゾルバローカル DNS が cloudfront.net を REFUSEdig @8.8.8.8 で明示
OGP 描画API の description 配列の先頭が「無し」「不明」を使ってしまう空値フィルタ+複数値結合
CrowdSecAPI 多用ユーザに対して誤 BAN 多発振る舞い系より WAF Managed Rules が無難

まとめ

CloudFront + WAF を後付けで挟むパターンは、

  1. オリジン用ドメインを別名で立てる(並行稼働可、後の移行も楽)
  2. secret header でオリジンを実質非公開化
  3. WAF を COUNT モードで 1 週間運用してから block 化
  4. ALB と EC2 の SG は最初から分離
  5. Terraform で sites map を for_each(拡張が容易)
  6. デプロイに invalidation を CI で自動化

を踏めば、無停止で安全にセキュリティと速度の両方を底上げできます。

特に「ALB と EC2 の SG 分離」は 初期設計の段階で必ず押さえておくべきポイントです。同様の事故を回避できるよう、本記事として共有しました。