運用中の 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 つ:
- オリジン用に別サブドメインを立てる(
origin.<service>.example.com) - CloudFront → オリジン間にカスタムヘッダで「合言葉」を仕込む
- 既存ワイルドカード 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.com は 1 階層 しかカバーしません。
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 になっている既存環境を分離する手順(無停止)
- 新しい EC2 用 SG を作成(ALB SG からの inbound 80 を許可)
- EC2 の ENI に両方の SG をアタッチ
- 動作確認
- 古い SG を ENI から外す
- 古い SG(旧共有 SG)を ALB 専用ルールに整理
途中段階で必ず「両方アタッチ → 確認 → 片方剥がす」を挟むと無停止で切り替わります。
4. キャッシュキー設計
基本ポリシー
| 項目 | 採用値 | 理由 |
|---|---|---|
| Path | ✅ | 当然 |
| Query string (全て) | ✅ | API/SPARQL のパラメータごとに別キャッシュ |
| Cookie | ❌ | ヒット率が極端に低下する |
Authorization | ❌ | 同上、認証ありなら別ポリシーに分離 |
Accept-Encoding (gzip/br) | ✅ (CF が自動) | 圧縮形式ごとに別キャッシュ |
Accept-Language | ❓ | URL に /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 していたら何件ブロックされていたか」が可視化されます。
採用ルール
| ルール | 用途 |
|---|---|
AWSManagedRulesCommonRuleSet | XSS / SQLi / 一般的攻撃 |
AWSManagedRulesKnownBadInputsRuleSet | 既知の悪性入力(log4shell 等) |
AWSManagedRulesAmazonIpReputationList | 悪性 IP(IP評判) |
| Rate-based rule | 5 分間に 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 Encrypt | HTTP-01 で port 80 必須 → 後で CF 専用にすると更新詰まる可能性 | 移行と同時に DNS-01 へ切替検討 |
| DNS リゾルバ | ローカル DNS が cloudfront.net を REFUSE | dig @8.8.8.8 で明示 |
| OGP 描画 | API の description 配列の先頭が「無し」「不明」を使ってしまう | 空値フィルタ+複数値結合 |
| CrowdSec | API 多用ユーザに対して誤 BAN 多発 | 振る舞い系より WAF Managed Rules が無難 |
まとめ
CloudFront + WAF を後付けで挟むパターンは、
- オリジン用ドメインを別名で立てる(並行稼働可、後の移行も楽)
- secret header でオリジンを実質非公開化
- WAF を COUNT モードで 1 週間運用してから block 化
- ALB と EC2 の SG は最初から分離
- Terraform で sites map を for_each(拡張が容易)
- デプロイに invalidation を CI で自動化
を踏めば、無停止で安全にセキュリティと速度の両方を底上げできます。
特に「ALB と EC2 の SG 分離」は 初期設計の段階で必ず押さえておくべきポイントです。同様の事故を回避できるよう、本記事として共有しました。