1. この記事について
AWS WAF を CloudFront に関連付けて保護しているシステムでは、WAF がリクエストをブロックした際のユーザー体験を設計する必要がある。デフォルトでは AWS が返す素の 403 Forbidden テキストがユーザーに届くが、本番運用では「アクセスが制限されています。詳細はサポートへお問い合わせください」といった案内付きのカスタム Sorry ページが必要だ。
Sorryページの実装方法は1つではない。CloudFront の Custom Error Response で S3 上の静的 HTML を配信する方式、WAF 自身がブロック時に直接カスタムレスポンスを返す方式、Lambda@Edge で動的に Sorry を生成する方式の3つが代表的だ。どれを選ぶかはコスト・動的性・WAF Block 専用対応の要否によって異なり、本記事では3方式を比較した上で Terraform 完全実装まで解説する。
また、計画メンテナンス時と攻撃検知時では Sorry ページの内容だけでなく HTTP status も変える必要がある。503 + Retry-After で Google のクロールを継続させるか、403/451 で明確にアクセス制限を示すかは、SEO 影響と運用設計の両面から判断する問題だ。本記事はその判断指針も含めて提供する。

- AWS WAF がリクエストをブロックした瞬間、デフォルトではブラウザに素っ気ない「403 Forbidden」テキストのみが表示される。ブランドロゴも案内文も補足情報もない標準エラー画面は、ユーザーのパニックと問い合わせ急増を招き、ブランドイメージを直接毀損する。大規模なセキュリティインシデント時に素のエラー画面が拡散されることは、技術力への信頼にも影響を与える。
- 「計画メンテナンス中」の Sorry と「セキュリティ上の理由でブロック」の Sorry は、メッセージも HTTP status も異なる。503 + Retry-After が必要な場面と、403/451 が適切な場面を区別して運用できる体制を最初から設計しておく必要がある。この切替が手動で数分かかるか、自動化で秒単位で完了するかはシステムの成熟度を左右する。
- 本記事は WAF多層防御ガイド の「Block後の出口」を完成させる補完記事だ。多層防御でブロックする技術と、ブロック後のユーザー体験・SEO保護を設計する技術の両方が揃って初めて、本番品質の WAF 運用が完成する。
- 3方式の選定判断: CloudFront Custom Error Response / WAF Custom Response / Lambda@Edge の3方式をコスト・動的性・SEO影響の8軸で比較し、自分のシステム要件に合った方式を選べる。
- HTTP status と SEO 影響の把握: 403/503/451 の各 status が Google 検索クロールに与える影響を理解し、メンテ Sorry と攻撃 Sorry で適切な status を使い分けられる。
- Terraform 完全実装: aws_cloudfront_distribution / aws_wafv2_web_acl / aws_lambda_function の完全な HCL をコピーして本番に投入できる。
- メンテ/攻撃切替運用フロー: CloudWatch Alarm → SNS → Lambda による切替自動化の設計指針と手動チェックリストを使った運用設計ができる。
1-1. 本記事のゴール
本記事を読み終えると、4つのことが実現できる状態になる。
1つ目: 3方式の選定判断
WAF + CloudFront 構成で Sorryページを実装する3方式の特性を理解し、自分のシステム要件に合った方式を選べる。コスト最優先・追加設定レスなら方式A (Custom Error Response + S3)、WAF ネイティブで Block 専用応答が必要なら方式B (WAF Custom Response)、Cookie・UA 別の動的ページや A/B テストが必要なら方式C (Lambda@Edge) という判断軸を持てる。3方式を組み合わせる選択肢についても §2 の比較マトリクスで整理する。
2つ目: Terraform 3方式完全実装
3方式それぞれの Terraform HCL をコピーして本番環境に投入できる。aws_cloudfront_distribution の custom_error_response ブロック (方式A)、aws_wafv2_web_acl の custom_response_bodies と rule_action_override (方式B)、aws_lambda_function と lambda_function_association (方式C) の完全な実装例を提供する。AWS CLI・コンソール手順も合わせて解説するため、IaC 移行前の検証や既存環境への部分適用も可能だ。
3つ目: HTTP status と SEO 影響の理解
計画メンテナンス時に返すべき status (503 + Retry-After) と、攻撃ブロック時に返すべき status (403/451) の違いを Google Search Central のガイドラインに基づいて整理する。403 を返し続けることのリスクと、503 + Retry-After でクロールを継続させる仕組みを理解した上で、自システムの HTTP status 設計を見直せる。
4つ目: メンテ/攻撃 Sorry 切替運用フローの設計
計画メンテナンスと攻撃検知時それぞれの切替フローを設計できる。CloudWatch Alarm → SNS → Lambda による切替自動化の Terraform 実装指針と、手動切替のチェックリストを §7 で提供する。切替後の Sorry ページキャッシュ TTL 設計と、切替完了確認の手順も合わせて解説する。
1-2. 読者像
想定読者は、CloudFront と AWS WAF を本番環境で既に活用しており、Terraform でインフラを管理している中級以上の AWS エンジニアだ。
具体的には以下の知識・経験を前提としている。
- CloudFront ディストリビューションの作成・設定変更、および OAC (Origin Access Control) を使った S3 オリジン構成の経験がある
- AWS WAF Web ACL の作成・マネージドルールグループ追加・ルール優先度設定の経験がある
- Terraform で AWS リソースを管理しており、aws_cloudfront_distribution / aws_wafv2_web_acl の HCL を読み書きできる基礎がある
- Lambda@Edge の概念 (viewer-request / viewer-response / origin-request / origin-response のイベント種別) を把握している、または理解したい
- Sorryページの設計・運用に課題感があり、メンテ Sorry と攻撃 Sorry の切替を体系化したいと考えている
本記事でカバーしない範囲は以下の通りだ。CloudFront ディストリビューションや WAF Web ACL の基本的なセットアップ手順は前提知識として扱い、ゼロから構築する手順は解説しない。Lambda のデプロイ基礎知識 (zip パッケージ作成・IAM ロール設定) も前提として進める。SEO 影響の実測データや特定サイトへの適用事例は本記事の範囲外だ。
本記事の内容はエンジニアが実際に手を動かして試せるレベルで書いている。概念解説だけでなく、すぐに terraform apply できる HCL や aws wafv2 CLI コマンドを随所に組み込んでいる。特に Sorryページの 4096 bytes 上限 (方式B) や Lambda@Edge のコールドスタート回避策 (方式C) など、公式ドキュメントで見落としがちな実運用上の制約を重点的に解説する。
1-3. なぜ今これを書くか
国内の技術ブログで「WAF Sorryページ」を検索すると、CloudFront のカスタムエラーページ設定や WAF の基本設定を断片的に紹介する記事は見つかるが、3方式を横断比較してメンテ Sorry と攻撃 Sorry の切替運用まで踏み込んだ記事はほとんど存在しない。
本記事が埋めようとしているギャップは3つある。
1. 方式選定の判断軸がない問題
「Sorryページを実装したい」と調べると、多くの記事が CloudFront Custom Error Response を紹介する。しかしこの方式は WAF Block 以外の 4xx/5xx エラー (オリジンサーバーの障害・404 Not Found 等) も巻き込む点が見落とされがちだ。WAF Native の Custom Response を使えば、WAF Block 時だけ応答を差し替えられ、オリジンの正常レスポンスや 5xx エラーには一切影響しない。この差分を比較表で整理した記事が国内に不足している。
2. HTTP status の SEO 影響が見落とされがちな問題
計画メンテ中に 403 Forbidden を返し続けると、Google の再クロール頻度が低下して検索順位に影響するリスクがある。正解は 503 Service Unavailable + Retry-After ヘッダだが、これを知らずに全ブロックで 403 を使うサイトは多い。一方、攻撃ブロック時は 403 や地域制限には 451 が適切で、常に 503 を返すのも誤りだ。この使い分けを整理した実践的な記事が国内で稀少だ。
3. Terraform 実装が断片的な問題
aws_wafv2_web_acl の custom_response_bodies ブロック設計や、Lambda@Edge を aws_cloudfront_distribution の lambda_function_association と組み合わせた完全な HCL 例はなかなか見当たらない。コンソール手順の紹介はあっても、IaC で再現可能な形でまとめた記事が不足している。コピーしてすぐ使える形で提供することに価値がある。
本記事は WAF多層防御ガイド (cloudfront-waf-shield-defense-in-depth) の「Block後の出口」を完成させる位置付けでもある。多層防御で Block する技術と、Block 後の UX・SEO を守る技術を合わせて持つことが本番品質の WAF 運用の要件だ。
2026年時点で AWS WAF の CLOUDFRONT スコープ Custom Response 機能の使い方を日本語で詳述した記事は非常に少ない。本記事は公式ドキュメントとプロダクション実装の両方を参照した一次情報として、実務で繰り返し参照できるリファレンスになることを目指している。
1-4. 本記事の構成と読み方
本記事は §1〜§8 の構成で、前から順に読むことで Sorryページ実装の全体像から詳細実装・運用設計まで一気通貫で習得できる。
- §2 (本章): 3方式の全体比較と前提環境。どの方式を選ぶか迷っている場合はここを先に読む
- §3: 方式A (Custom Error Response + S3) の詳細実装。コスト最優先の構成向け
- §4: 方式B (WAF Custom Response) の詳細実装。WAF Block 専用応答を最速で実現したい場合
- §5: 方式C (Lambda@Edge) の詳細実装。動的な Cookie/UA 別判定が必要な高度なユースケース向け
- §6: 3方式すべての Terraform 完全 HCL。IaC で管理したい場合のリファレンス
- §7: 運用設計。メンテ/攻撃 Sorry 切替フロー・HTTP status SEO影響・Retry-After 設計
- §8: まとめ・落とし穴10選・3方式選択チートシート
特定方式のみ実装したい場合は、§2 の比較マトリクスで選定後に対応する §3〜§5 に直接ジャンプしても構わない。§6 の Terraform HCL は §3〜§5 の解説を読んだ後に参照することを推奨する。
本記事に関連する既存記事との関係は以下の通りだ。
- WAF多層防御ガイド (cloudfront-waf-shield-defense-in-depth): 多層防御の構成 (Shield Advanced/WAF マネージドルール/レートリミット) を解説。本記事はその「Block後の出口」補完として位置付け。先に読むと本記事の文脈が掴みやすい。
- CloudFront WAF Bot Control (cloudfront-waf-bot-control): Bot 検知・ブロックの実装を解説。Bot Block 時の Custom Response (方式B) の活用例は §4 で参照する。
apigateway-lambda-authorizer-production: API Gateway の Authorizer Deny 時のエラーレスポンス設計。Lambda@Edge の Sorry 実装 (方式C) と合わせて読むと応用範囲が広がる。
1-5. 本記事の差別化ポイント
国内の WAF・CloudFront 関連記事との差分を明示する。
Sorryページ単独深掘り記事の希少性: WAF の設定方法やルールチューニングを解説する記事は多いが、「Block 後にユーザーに何を見せるか」を1記事で深掘りした記事は国内で稀少だ。ブロック時の UX 設計を独立したテーマとして扱うことで、セキュリティエンジニアとフロントエンド/UX 担当者の橋渡し役になれる。
3方式並列比較の網羅性: Custom Error Response のみを解説する記事は多いが、WAF Custom Response と Lambda@Edge を含む3方式を同一記事内で実装例付きで並列比較した記事は国内でほぼ見当たらない。用途別の選定判断が1記事で完結する。
SEO 影響まで踏み込む実践性: HTTP status コードの選択が Google Search Central のガイドラインとどう整合するかを、AWS エンジニア向けに整理した記事は国内で稀少だ。インフラ観点と SEO 観点の両方を持つエンジニアでないと書けない切り口だ。
IaC 完結の即投入性: 全方式の Terraform HCL が揃っており、コンソールやドキュメントを横断しなくても本記事だけで実装を完結できる。CI/CD パイプラインとの統合まで視野に入れた設計例を提供する。
メンテ/攻撃切替の運用設計視点: 技術実装だけでなく、インシデント発生時の切替フロー・Retry-After 設計・HTTP status 選択の意思決定プロセスまで含めた「運用者視点の設計ガイド」を提供する。この視点で書かれた記事は国内で稀少だ。
WAF + CloudFront の Sorry ページは「実装して終わり」ではなく、メンテナンス運用・インシデント対応・SEO 保護の3つの観点を統合した継続的な設計課題だ。本記事はその全てをカバーする一次解説記事として、エンジニアが迷わず意思決定できる指針を提供することを目指している。
既存の関連記事である WAF多層防御ガイドでは「どのようにブロックするか」を解説したが、本記事は「ブロックした後にどう応答するか」を解説する。この2記事を合わせることで、WAF + CloudFront セキュリティ構成の攻撃対応フローが前後両端から揃う。1本の記事として独立して読んでも価値があるが、多層防御ガイドと合わせて読むことでより深い理解が得られる。
Sorry ページの設計は一度作って終わりではない。トラフィックパターンの変化・新しい攻撃手法の登場・メンテナンス頻度の増加に合わせて、方式の見直しや切替自動化の改善が継続的に求められる。本記事が提供するフレームワークを土台に、自チームの運用実態に合った Sorry ページ運用設計を構築してほしい。
公式ドキュメント: CloudFront Custom Error Response
2. 全体像 + 3方式比較マトリクス

2-1. 前提環境
本記事のハンズオンを実施するには以下の環境を準備する。
AWS アカウントと権限
- AWS アカウント (本番環境とは別の検証アカウントを推奨)
- 操作に必要な IAM 権限:
cloudfront:*/wafv2:*/lambda:*/s3:*/iam:PassRole/logs:* - WAF for CloudFront は 必ず us-east-1 (バージニア北部) リージョン で作成する。CLOUDFRONT スコープの Web ACL は us-east-1 グローバルサービスであるため、他リージョンには作成できない
ツールバージョン
- Terraform: 1.9.x 以上 (aws プロバイダー 5.x 以上を推奨)
- AWS CLI: v2 系 (
aws --versionで確認。v1 系は一部コマンド構文が異なる) - Node.js: 18.x 以上 (方式C Lambda@Edge を実装する場合)
既存リソースの前提
- CloudFront ディストリビューションが作成済みであること
- WAF Web ACL (スコープ: CLOUDFRONT) が CloudFront に関連付け済みであること
- 方式A を使う場合は S3 バケットへのアクセス設定 (OAC) が設定済みであること
事前コスト確認
方式別の月次コスト概算 (リクエスト 100万件/月の想定) を参考にしておく。
- 方式A: S3 GET コスト約 $0.04/月 + CloudFront リクエスト費用。Sorry ページの配信頻度が低い通常運用では実質的に追加コストはほぼゼロだ。
- 方式B: WAF Custom Response は WAF の既存料金 ($5/月/Web ACL + $1/100万リクエスト) 内で動作する。Custom Response の設定自体に追加課金はない。
- 方式C: Lambda@Edge は $0.60/100万リクエスト (呼出) + $0.00005001/GB秒 (実行時間)。全リクエストに発火するため、高トラフィックサイトでは月額コストが方式A/B の数十倍になる可能性がある。事前に AWS Pricing Calculator で試算することを強く推奨する。
動作確認コマンド
# Terraform バージョン確認
terraform version
# AWS CLI バージョン確認
aws --version
# us-east-1 の WAF Web ACL 一覧 (CLOUDFRONT スコープ)
aws wafv2 list-web-acls \
--scope CLOUDFRONT \
--region us-east-1 \
--query 'WebACLs[*].{Name:Name,Id:Id}' \
--output table
# CloudFront ディストリビューション一覧
aws cloudfront list-distributions \
--query 'DistributionList.Items[*].{Id:Id,DomainName:DomainName,Status:Status}' \
--output table
2-2. WAF Block→ユーザー応答までの選択肢マップ
リクエストが WAF にブロックされてから Sorryページがユーザーに届くまでの経路は、3方式で完全に異なる。
クライアント
↓ HTTPS リクエスト
CloudFront エッジ
↓ WAF 評価
├─ [WAF Block: 方式B] ──────────────────────────────→ WAF Custom Response (response_body_key)
│WAF が直接 403/503/451 + Sorry HTML を返す
│CloudFront オリジンには到達しない
│
├─ [WAF Allow → オリジンエラー or 方式A] ──────────→ CloudFront Custom Error Response
│オリジン (S3/ALB) からの 4xx/5xx をインターセプト S3 から静的 Sorry HTML を配信
│※ WAF Block 以外の 4xx/5xx も対象になる error_caching_min_ttl で TTL 設定
│
└─ [WAF Allow → 方式C: Lambda@Edge 発火] ──────────→ viewer-response で動的 Sorry 生成
Lambda@Edge が WAF のカスタムヘッダやCookie/UA/Geo 別の動的 Sorry
CloudFront 変数を評価してレスポンスを差し替え A/B テスト・多言語対応も可能
選択の基本指針: 方式B は「WAF Block のみを差し替えたい」ときの第一選択肢。方式A は「コスト最優先・追加設定レス」のシンプル構成向け。方式C は「動的な判定・個別メッセージ・多言語対応」が必要な高度なユースケース向け。
WAF Block 時の経路を方式別に詳しく見ると次の通りだ。
方式A (CloudFront Custom Error Response + S3)
リクエストは WAF を通過後にオリジンへ転送される。オリジンが 4xx または 5xx を返した場合、または WAF Block の応答 (WAF Default Block は 403) を CloudFront がキャッチした場合、CloudFront が custom_error_response の設定を見て S3 バケット上の Sorry HTML を取得してクライアントに返す。
この方式の課題は「WAF Block 以外のエラー (オリジンの 503 や 504) も巻き込む点」だ。メンテ中のオリジン 503 と攻撃ブロックの 403 を同じ Sorry ページにしたくない場合は、response_code の差異で Sorry HTML を分けるか、方式B を組み合わせる。
方式B (WAF Custom Response)
WAF がルール評価でブロックアクションを実行した瞬間、WAF 自身がカスタム HTTP レスポンスをクライアントに返す。CloudFront オリジン (S3/ALB/EC2) にはリクエストが届かないため、オリジン側の設定変更は一切不要だ。§4 で詳しく解説する。
方式C (Lambda@Edge)
Lambda@Edge の viewer-response イベントを使い、WAF が付与したカスタムリクエストヘッダやレスポンスのステータスコードを評価して、動的に Sorry レスポンスを生成する。Cookie 別の多言語 Sorry、UA 別のスマートフォン/PC 出し分け、A/B テストによる Sorry ページ改善など、静的な方式では対応できないユースケースに使う。§5 で詳しく解説する。
2-3. 3方式比較マトリクス
| 比較軸 | 方式A: CFE + S3静的Sorry | 方式B: WAF Custom Response | 方式C: Lambda@Edge 動的Sorry |
|---|---|---|---|
| コスト | 最低コスト S3 GET のみ (数十円/月) | 追加コストなし WAF 料金内で完結 | 高コスト Lambda@Edge 呼出課金 (全リクエスト) |
| レイテンシ | S3 取得分 +数ms error_caching_min_ttl でキャッシュ可 | 最速 WAF 内で完結・オリジン到達なし | コールドスタート時 +数百ms provisioned_concurrency 不可 |
| 動的性 | なし 全ユーザー同一 HTML | なし 全ユーザー同一 HTML (4096B 上限あり) | 高い Cookie/UA/Geo 別・A-B テスト可 |
| メンテ Sorry 対応 | 〇 503 custom_error_response で実現 | △ WAF ルール追加が必要 (全パスに Block) | 〇 viewer-response で全リクエスト差し替え可 |
| 攻撃 Sorry 対応 | △ WAF Block + CFE の組合せが必要 | ◎ WAF Block 時のみ応答を差し替え・最適 | 〇 WAF のカスタムヘッダを評価して動的判定 |
| SEO 影響 | 503 + Retry-After が設定しやすい | HTTP status を 403/503/451 から選択可 | レスポンスを自由に制御・最も柔軟 |
| Terraform 実装難易度 | 低 custom_error_response ブロックのみ | 中 custom_response_bodies + rule_action_override | 高 Lambda関数 + IAM + lambda_function_association |
| WAF Block 専用対応 | × 他の 4xx/5xx も巻き込む | ◎ Block アクション時のみ発火 | △ WAF カスタムヘッダで判定が必要 |
選定ガイドライン:
- コスト最優先・動的判定不要: 方式A
- WAF Block 時のみ差し替え・追加コスト不可: 方式B
- Cookie/UA/言語別の動的 Sorry・A-Bテスト: 方式C
- 実際の本番構成では方式A + 方式B の組み合わせが多い (WAF Block→方式B / オリジン 5xx→方式A)
HTTP status と SEO 影響については §7 で詳しく解説する。メンテ Sorry に 503 + Retry-After を使う理由、攻撃 Sorry に 403/451 を使うべきシーンは、Google Search Central のクロールエラーガイドラインに基づいて整理する。
2-4. 方式組み合わせパターンと判断フロー
3方式は排他的な選択ではなく、組み合わせて使うことが多い。典型的な組み合わせパターンを整理する。
パターン1: 方式B + 方式A の組み合わせ (最も多い本番構成)
WAF Block 時のみ方式B で Custom Response を返し、オリジン側の 5xx エラー (アプリケーションの障害・メンテナンス) は方式A の S3 静的 Sorry でカバーする。この組み合わせにより「WAF Block時: 403 Sorry」「オリジン障害時: 503 Sorry」と HTTP status を使い分けられる。追加コストは方式A の S3 分のみで最小化できる。
パターン2: 方式B のみ (シンプル構成)
WAF Block 時だけ Sorry を表示し、オリジンエラーはデフォルト動作に任せる最小構成だ。管理するリソース数が最少で、スタートアップや小規模サービスに向いている。オリジンの 5xx に対しては CloudFront のデフォルトエラーページが表示される。
パターン3: 方式C + 方式B の組み合わせ (高度な構成)
方式C で Cookie/言語/UA 別の動的 Sorry を実現しつつ、方式B で WAF Block 専用の Sorry を並走させる。Lambda@Edge のコスト増を最小化しながら動的判定が必要なシーンに絞った実装ができる。構成の複雑度は高いが、大規模な EC サービスや多言語サービスで実績がある。
方式選定フロー
以下の順で質問に答えると方式が絞り込める。
- Cookie/UA/Geo 別に Sorry ページを出し分けたいか? → Yes なら 方式C 確定
- WAF Block 時のみ応答を差し替えたい (オリジンエラーは別扱いにしたい) か? → Yes なら 方式B を中心に検討
- コストを最小化したい・追加設定を最少にしたい → 方式A が最適候補
- 1〜3 の全てに Yes → 方式B + 方式C の組み合わせを検討する
迷った場合は方式B から始めることを推奨する。方式B は WAF 料金内で追加コストゼロ、実装も比較的シンプルで、後から方式A や方式C を追加する拡張も容易だ。
3. 方式A: CloudFront カスタムエラーレスポンス + S3静的Sorry
3-1. 方式A の仕組みと位置付け
方式A は CloudFront の Custom Error Response 機能を使い、CloudFront がエラーコードを検知した瞬間に S3 バケットに置いた静的 Sorry HTML を代わりに返す方式だ。Lambda も WAF カスタム設定も不要で、CloudFront ディストリビューションの設定変更と S3 への HTML ファイル配置だけで実装が完了する、3 方式の中で最もシンプルな構成である。
動作フローは以下の通りだ。
- クライアントのリクエストが CloudFront エッジに到達する
- WAF が 403 を返すか、オリジン (ALB/EC2) が 503/504 を返す
- CloudFront は
custom_error_responseブロックで定義したエラーコードに一致することを検知する - CloudFront は
/sorry.htmlパスに対応するキャッシュビヘイビアを参照し、S3 OAC オリジンから Sorry HTML を取得する - CloudFront は指定した
response_code(例: 503) でユーザーに Sorry ページを返す - エッジは
error_caching_min_ttlで指定した秒数だけこのエラーレスポンスをキャッシュする
WAF Block (403) だけでなくオリジン障害 (502/503/504) も同一の custom_error_response ブロックで統一的に捕捉できる点が方式A の特徴だ。メンテナンス窓口の一元化や予期しないオリジンダウン時のフォールバックとしても機能する。

3-2. custom_error_response パラメータ設計
aws_cloudfront_distribution の custom_error_response ブロックには 4 つのパラメータがある。エラーコードごとにブロックを複数定義できるため、WAF Block とオリジン障害を別々に設定することも、まとめて同一 Sorry ページに誘導することも可能だ。
| パラメータ | 推奨値 | 設計根拠 | 注意点 |
|---|---|---|---|
error_code | 403 / 503 (最低 2 エントリ) | WAF Block=403、オリジン障害=503。ALB タイムアウト (504) を追加すると網羅性が上がる | 404 を追加すると存在しないパスも Sorry になる。意図的な場合のみ設定する |
response_page_path | /sorry.html | CloudFront キャッシュビヘイビアのパスと一致させる。先頭スラッシュ必須 | パスが存在しないとエラーページ取得自体が失敗し白画面になる |
response_code | 503 | 503 + Retry-After で Googlebot がクロールを継続する。403 を返し続けると再クロール頻度が低下するリスクがある | 攻撃 Block (403) も 503 に変換することでユーザーへの情報開示を避けられる |
error_caching_min_ttl | 10〜30 秒 | 0 = エッジキャッシュなし。攻撃トラフィックが集中すると S3 に大量リクエストが流れるため 10〜30 秒を推奨する | 長すぎると復旧後もエッジが Sorry を配信し続ける。30 秒以下で素早い切替を可能にする |
404 を custom_error_response に追加すると意図しない Not Found ページも Sorry になる。WAF Block の 403 とオリジン障害の 503/504 に絞るのが安全だ。
3-3. S3 バケット + OAC 設計
Sorry HTML の格納先 S3 バケットは パブリックアクセスを完全ブロックし、CloudFront の OAC (Origin Access Control) 経由でのみ読み取りを許可する構成にする。S3 静的ウェブサイトホスティング機能は不要で、CloudFront REST API エンドポイント経由のアクセスで十分だ。
Sorry HTML の設計指針:
- 外部 CSS/JS・外部フォント・画像は使わない。S3 リクエスト数を削減し、オリジン障害中でも確実にページが表示される
- インライン CSS でスタイルを完結させる。Google Fonts を使う場合は
<link>タグのみにとどめる - ファイルサイズは 50KB 以下を目標にする
<title>と<h1>には「ただいまメンテナンス中」など日本語を使うとユーザー混乱が減る
3-4. Terraform 完全実装
# -------------------------------------------------------
# 方式A: CloudFront Custom Error Response + S3 静的 Sorry
# -------------------------------------------------------
# --- S3 バケット (Sorry HTML 格納) ---
resource "aws_s3_bucket" "sorry" {
bucket = "my-cloudfront-sorry-page"
}
resource "aws_s3_bucket_public_access_block" "sorry" {
bucket= aws_s3_bucket.sorry.id
block_public_acls = true
block_public_policy = true
ignore_public_acls= true
restrict_public_buckets = true
}
# --- Sorry HTML のアップロード ---
resource "aws_s3_object" "sorry_html" {
bucket = aws_s3_bucket.sorry.id
key = "sorry.html"
content_type = "text/html; charset=UTF-8"
content= <<-HTML
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>ただいまメンテナンス中です</title>
<style>
body{margin:0;font-family:sans-serif;background:#f5f5f5;
display:flex;align-items:center;justify-content:center;min-height:100vh}
.box{background:#fff;border-radius:8px;padding:40px;max-width:480px;
text-align:center;box-shadow:0 2px 8px rgba(0,0,0,.1)}
h1{color:#2c3e50;font-size:1.4rem}p{color:#555;line-height:1.6}
</style>
</head>
<body>
<div class="box">
<h1>ただいまメンテナンス中です</h1>
<p>現在サービスのメンテナンスを実施しています。<br>
しばらくしてから再度アクセスしてください。</p>
</div></body></html>
HTML
}
# --- OAC (Origin Access Control) ---
resource "aws_cloudfront_origin_access_control" "sorry" {
name= "sorry-page-oac"
description = "OAC for sorry page S3 bucket"
origin_access_control_origin_type = "s3"
signing_behavior= "always"
signing_protocol= "sigv4"
}
# --- S3 バケットポリシー (CloudFront OAC からの GetObject のみ許可) ---
data "aws_iam_policy_document" "sorry_bucket_policy" {
statement {
principals {
type = "Service"
identifiers = ["cloudfront.amazonaws.com"]
}
actions= ["s3:GetObject"]
resources = ["${aws_s3_bucket.sorry.arn}/*"]
condition {
test = "StringEquals"
variable = "AWS:SourceArn"
values= [aws_cloudfront_distribution.main.arn]
}
}
}
resource "aws_s3_bucket_policy" "sorry" {
bucket = aws_s3_bucket.sorry.id
policy = data.aws_iam_policy_document.sorry_bucket_policy.json
}
# --- CloudFront Distribution ---
resource "aws_cloudfront_distribution" "main" {
enabled = true
default_root_object = "index.html"
# メインオリジン (ALB)
origin {
domain_name = "my-alb.ap-northeast-1.elb.amazonaws.com"
origin_id= "alb-origin"
custom_origin_config {
http_port = 80
https_port = 443
origin_protocol_policy = "https-only"
origin_ssl_protocols= ["TLSv1.2"]
}
}
# Sorry ページオリジン (S3 + OAC)
origin {
domain_name = aws_s3_bucket.sorry.bucket_regional_domain_name
origin_id = "sorry-s3-origin"
origin_access_control_id = aws_cloudfront_origin_access_control.sorry.id
}
# デフォルトキャッシュビヘイビア → ALB
default_cache_behavior {
target_origin_id = "alb-origin"
viewer_protocol_policy = "redirect-to-https"
allowed_methods = ["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"]
cached_methods= ["GET", "HEAD"]
forwarded_values {
query_string = true
cookies { forward = "all" }
}
min_ttl = 0
default_ttl = 0
max_ttl = 0
}
# /sorry.html 専用キャッシュビヘイビア → S3 OAC
# custom_error_response が response_page_path を取得する際にこのビヘイビアを参照する
ordered_cache_behavior {
path_pattern = "/sorry.html"
target_origin_id = "sorry-s3-origin"
viewer_protocol_policy = "redirect-to-https"
allowed_methods = ["GET", "HEAD"]
cached_methods= ["GET", "HEAD"]
forwarded_values {
query_string = false
cookies { forward = "none" }
}
min_ttl = 0
default_ttl = 300
max_ttl = 300
}
# -------------------------------------------------------
# custom_error_response: エラーコードを Sorry ページに差し替える
# WAF Block (403) + オリジン障害 (503/504) の 3 エントリが推奨構成
# -------------------------------------------------------
custom_error_response {
error_code= 403
response_code= 503
response_page_path = "/sorry.html"
error_caching_min_ttl = 10
}
custom_error_response {
error_code= 503
response_code= 503
response_page_path = "/sorry.html"
error_caching_min_ttl = 10
}
custom_error_response {
error_code= 504
response_code= 503
response_page_path = "/sorry.html"
error_caching_min_ttl = 10
}
restrictions {
geo_restriction { restriction_type = "none" }
}
viewer_certificate {
cloudfront_default_certificate = true
}
web_acl_id = aws_wafv2_web_acl.main.arn
}
/sorry.html 用の ordered_cache_behavior を追加している点が重要だ。custom_error_response が response_page_path = "/sorry.html" を取得する際、CloudFront はキャッシュビヘイビアのパスマッチングに従ってオリジンを決定する。このパスビヘイビアなしで ALB をデフォルトオリジンにしていると、ALB 自体が停止している障害時に Sorry ページの取得も失敗して白画面になるリスクがある。
3-5. AWS CLI / コンソール操作
AWS CLI (aws cloudfront update-distribution):
DIST_ID="E1XXXXXXXXXX"
# 現在の設定と ETag を取得する
aws cloudfront get-distribution-config \
--id "$DIST_ID" \
--query 'DistributionConfig' \
--output json > dist-config.json
ETAG=$(aws cloudfront get-distribution-config \
--id "$DIST_ID" \
--query 'ETag' \
--output text)
# CustomErrorResponses に 403 エントリを追加して更新する
jq '.CustomErrorResponses.Quantity += 1 |
.CustomErrorResponses.Items += [{
"ErrorCode": 403,
"ResponsePagePath": "/sorry.html",
"ResponseCode": "503",
"ErrorCachingMinTTL": 10
}]' dist-config.json > dist-config-updated.json
aws cloudfront update-distribution \
--id "$DIST_ID" \
--if-match "$ETAG" \
--distribution-config file://dist-config-updated.json
コンソール操作手順:
- AWS マネジメントコンソール → CloudFront → 対象ディストリビューションを選択する
- Error pages タブを開く
- Create custom error response をクリックする
- HTTP error code:
403を選択する - Customize error response: Yes を選択する
- Response page path:
/sorry.htmlと入力する - HTTP response code: 503 Service Unavailable を選択する
- Error caching minimum TTL:
10と入力する (単位: 秒) - Create custom error response をクリックして保存する
- 503/504 についても同様の手順を繰り返す
- ディストリビューションのステータスが Deployed になるまで待機する (約 5〜10 分)
3-6. 利点と注意点
| 区分 | ポイント | 詳細 |
|---|---|---|
| 利点 | 最低コスト | S3 静的ホスティング費用のみ。Lambda 実行料ゼロ・WAF Custom Response 追加設定不要 |
| 利点 | 実装が最もシンプル | CloudFront の custom_error_response 設定と S3 HTML 配置のみ。Lambda・追加 WAF ルール不要 |
| 利点 | S3 OAC でダイレクトアクセス不可 | バケットポリシーで CloudFront OAC 以外の GetObject を拒否。Sorry HTML への直接 URL アクセスを防ぐ |
| 利点 | WAF Block 以外も捕捉 | オリジン障害 (502/503/504) も同じ Sorry ページに誘導できる。メンテ窓口を一元化できる |
| 注意点 | 動的判定不可 | Cookie / User-Agent / 地域別の Sorry ページ出し分けは不可。動的要件は方式C (§5) を選択する |
| 注意点 | error_code 選定に注意 | 404/500 を追加すると意図しないエラーも Sorry になる。403 と 503/504 に絞るのが安全だ |
| 注意点 | Sorry 専用キャッシュビヘイビアが必要 | /sorry.html を S3 OAC オリジンから提供するには path_pattern=/sorry.html のビヘイビア定義が必須。なしだと ALB 障害時に白画面になるリスクがある |
AWS 公式: CloudFront カスタムエラーレスポンス
4. 方式B: WAF Custom Response (Block時にWAF直接応答)
4-1. 方式B の仕組みと位置付け
方式B は AWS WAF の Custom Response 機能を使い、WAF がリクエストをブロックする瞬間に WAF 自身がカスタム HTTP レスポンスを返す方式だ。CloudFront の設定変更も S3 も Lambda も不要な WAF ネイティブ統合 が最大の強みである。
cmd_084 (WAF多層防御 WP:2016) で構築した多層防御ルールは「脅威を Block する」役割を担う。しかし Block 時のデフォルトレスポンスは味気ない 403 Forbidden のみだ。本記事の §4 はその「Block後の出口」を完成させる — 方式B が cmd_084 補完の主軸である理由がここにある。
WAF Block 時に Custom Response を返すまでの流れは以下の通りだ。
- クライアントのリクエストが CloudFront に到達する
- WAF がリクエストを評価し、ルールに一致して Block アクションを実行する
- WAF は
custom_response_bodiesで定義した HTML/テキスト/JSON を HTTP レスポンスとして直接返却する - CloudFront オリジン (S3/ALB/EC2) へは転送されず、Sorry ページがユーザーに表示される
他のレスポンス (2xx/3xx/5xx) には一切影響しない点が方式A (Custom Error Response) との大きな差異だ。方式A は CloudFront レベルでエラーコードをインターセプトするため、WAF Block 以外の 4xx/5xx も巻き込む可能性があるのに対し、方式B は Block アクションが発火した瞬間のみ動作する。

4-2. custom_response_bodies ブロックの設計
Terraform で方式B を実装する際の核心は aws_wafv2_web_acl リソース内の custom_response_bodies ブロックと、各ルールの action.block.custom_response における response_body_key による紐付け だ。
custom_response_bodies ブロックは Web ACL レベルで一度定義し、複数ルールから key で参照できる。同じ Sorry HTML を複数ルール (AWSManagedRulesCommonRuleSet / IP セット / レートリミット) で共有できるため、管理コストが大幅に下がる。
response_body_key 設計原則:
keyはスコープ内でユニークにする (英数字・ハイフン・アンダースコアのみ)- 用途別に複数のキーを用意する例:
sorry_block(汎用ブロック) /sorry_ratelimit(レートリミット) /sorry_bot(Bot ブロック) - Key 名は機能名ベースで命名し、環境名や担当チーム名などは含めない
4-3. content_type 選定と 4096B 上限戦略
WAF Custom Response の content_type は 3択 のみで、それ以外の MIME タイプは指定不可だ。
| content_type | 用途 | 4096B 上限戦略 | 推奨シーン |
|---|---|---|---|
text/html | ブラウザ向け Sorry ページ | インライン CSS 削減・外部 CDN 画像参照・不要タグ除去 | エンドユーザー向け WAF Block (Bot/SQLi/XSS ブロック時) |
text/plain | 軽量テキストメッセージ | 数十バイトで収まる。上限問題なし | 内部 API 同士の簡易エラー通知・スクリプトからのアクセス |
application/json | SPA / API クライアント向けエラー | JSON フィールドを最小化 (code/message の 2 フィールドのみ) | REST API の WAF 保護・フロントエンドでエラー処理したい場合 |
4096B 上限への対応策:
- HTML 最小化: style タグをインラインに集約し、外部 CSS/JS は CDN URL 参照にとどめる
- 画像は
<img src="https://cdn.example.com/sorry.png">で外部参照 (Base64 data URI は避ける) - フォント埋め込み禁止 — Google Fonts の
<link rel="stylesheet">で代替する - 4096B に収まらない場合は
text/plainに切り替えて「503 Service Unavailable」などの 1 行メッセージに留める - apply 時に “body exceeds 4096 bytes” エラーが出たら即座に HTML を削減する
response_body_key 紐付けフロー:
- Web ACL レベルで
custom_response_bodies { key = "sorry_block" content_type = "text/html" content = ... }を定義する - 各ルールの
action.block.custom_response { response_body_key = "sorry_block" }で参照する - マネージドルールグループは
rule_action_overrideブロックで個別ルールに紐付ける
4-4. Terraform 完全実装
# -------------------------------------------------------
# WAF Custom Response: custom_response_bodies + ルール定義
# -------------------------------------------------------
locals {
# Sorry HTML (4096 bytes 以内・最小化必須)
sorry_html = <<-HTML
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>アクセスを制限しています</title>
<style>
body{margin:0;font-family:sans-serif;background:#f5f5f5;
display:flex;align-items:center;justify-content:center;min-height:100vh}
.box{background:#fff;border-radius:8px;padding:40px;max-width:480px;
text-align:center;box-shadow:0 2px 8px rgba(0,0,0,.1)}
h1{color:#c0392b;font-size:1.4rem}p{color:#555;line-height:1.6}
</style>
</head>
<body>
<div class="box">
<h1>アクセスを制限しています</h1>
<p>セキュリティポリシーにより、このリクエストはブロックされました。<br>
心当たりがない場合はお問い合わせください。</p>
</div></body></html>
HTML
}
resource "aws_wafv2_web_acl" "main" {
name = "cloudfront-waf-acl"
scope = "CLOUDFRONT"
# -------------------------------------------------------
# custom_response_bodies: Web ACL レベルで一元定義
# 複数ルールから key で参照できる
# -------------------------------------------------------
custom_response_bodies {
key = "sorry_block"
content_type = "text/html"
content= local.sorry_html# 4096 bytes 以内
}
custom_response_bodies {
key = "sorry_ratelimit"
content_type = "application/json"
content= jsonencode({
code = 429
message = "Too many requests. Please try again later."
})
}
default_action {
allow {}
}
# -------------------------------------------------------
# ルール例1: カスタム IP ブロック (Custom Response 適用)
# -------------------------------------------------------
rule {
name = "block-bad-ips"
priority = 10
action {
block {
custom_response {
response_code = 403
response_body_key = "sorry_block"
response_header {
name = "X-Block-Reason"
value = "IP-Blocklist"
}
}
}
}
statement {
ip_set_reference_statement {
arn = aws_wafv2_ip_set.blocklist.arn
}
}
visibility_config {
cloudwatch_metrics_enabled = true
metric_name = "block-bad-ips"
sampled_requests_enabled= true
}
}
# -------------------------------------------------------
# ルール例2: レートリミット (JSON エラーを返す)
# -------------------------------------------------------
rule {
name = "rate-limit"
priority = 20
action {
block {
custom_response {
response_code = 429
response_body_key = "sorry_ratelimit"
response_header {
name = "Retry-After"
value = "60"
}
}
}
}
statement {
rate_based_statement {
limit = 2000
aggregate_key_type = "IP"
}
}
visibility_config {
cloudwatch_metrics_enabled = true
metric_name = "rate-limit"
sampled_requests_enabled= true
}
}
# -------------------------------------------------------
# ルール例3: マネージドルールグループ + rule_action_override
# AWSManagedRulesCommonRuleSet の特定ルールにも Custom Response を適用
# -------------------------------------------------------
rule {
name = "managed-common-rule-set"
priority = 30
override_action {
none {}
}
statement {
managed_rule_group_statement {
name = "AWSManagedRulesCommonRuleSet"
vendor_name = "AWS"
rule_action_override {
name = "SizeRestrictions_BODY"
action_to_use {
block {
custom_response {
response_code = 403
response_body_key = "sorry_block"
}
}
}
}
rule_action_override {
name = "CrossSiteScripting_BODY"
action_to_use {
block {
custom_response {
response_code = 403
response_body_key = "sorry_block"
}
}
}
}
}
}
visibility_config {
cloudwatch_metrics_enabled = true
metric_name = "managed-common-rule-set"
sampled_requests_enabled= true
}
}
visibility_config {
cloudwatch_metrics_enabled = true
metric_name = "cloudfront-waf-acl"
sampled_requests_enabled= true
}
}
content フィールドが 4096 bytes を超えると terraform apply 時に ValidationException が発生する。事前に wc -c sorry.html でバイト数を確認すること。
4-5. AWS CLI / コンソール操作
AWS CLI (aws wafv2 update-web-acl):
# LockToken を取得 (update-web-acl に必須)
WEB_ACL_ID="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
LOCK_TOKEN=$(aws wafv2 get-web-acl \
--name "cloudfront-waf-acl" \
--id "$WEB_ACL_ID" \
--scope CLOUDFRONT \
--region us-east-1 \
--query 'LockToken' \
--output text)
# Custom Response Body を含む Web ACL を更新
# (WAF は部分更新 API を持たないため、既存設定を GET → マージ → PUT する)
aws wafv2 update-web-acl \
--name "cloudfront-waf-acl" \
--id "$WEB_ACL_ID" \
--scope CLOUDFRONT \
--region us-east-1 \
--lock-token "$LOCK_TOKEN" \
--default-action '{"Allow":{}}' \
--custom-response-bodies '{
"sorry_block": {
"ContentType": "TEXT_HTML",
"Content": "<html><body><h1>Access Denied</h1></body></html>"
}
}' \
--rules file://waf-rules.json \
--visibility-config \
'SampledRequestsEnabled=true,CloudWatchMetricsEnabled=true,MetricName=cloudfront-waf-acl'
コンソール操作手順:
- AWS マネジメントコンソール → WAF & Shield → Web ACLs を開く
- 対象の Web ACL (スコープ: CloudFront) を選択 → Rules タブを開く
- 対象ルールの Edit をクリック → Action セクションで Block を選択する
- Blocked request セクションを展開 → Customize the HTTP response チェックボックスをオンにする
- Response code: 403 / 503 / 451 から選択する
- Response body: Add a response body をクリック → キー名・content_type・本文を入力する
- Save rule → Web ACL 画面に戻り Save をクリックして反映する
4-6. HTTP status コード選択指針と利点/注意点
HTTP status の選択指針:
- 403 Forbidden: 不正アクセス・Bot・SQLi/XSS ブロック時。Google は 403 を受け続けると再クロール頻度を下げるリスクがある。正当ユーザーが誤 Block される可能性がある場合は 503 を検討する
- 503 Service Unavailable: 一時的な制限 (レートリミット・メンテ) に使用。
Retry-Afterヘッダと組み合わせることで Googlebot がクロールを継続する - 451 Unavailable for Legal Reasons: 地域制限・著作権・法的理由によるブロック時。Google は国別インデックスから除外する可能性があるため、通常の攻撃 Block では使用しない
Bot Control 実装記事 (WP:1392) では Bot Control によるブロック時に Custom Response を組み合わせてユーザーに分かりやすいメッセージを返す実装例を解説している。方式B は Bot Block にも適用できる。
利点:
- WAF ネイティブ統合 — CloudFront / S3 / Lambda の追加設定不要
- Block 時のみ Custom Response を返す — 正常レスポンス (2xx/3xx) に一切影響しない
- マネージドルールグループの個別ルールにも
rule_action_overrideで適用できる response_headerを追加してX-Block-ReasonやRetry-Afterを返せる- 同一の
response_body_keyを複数ルールで共有できるため Sorry HTML の一元管理が可能
注意点:
- 4096 bytes 上限: Sorry HTML は最小化必須。超過するとプロビジョニングエラーで apply が失敗する
- 動的判定不可: Cookie / User-Agent 別の出し分けは不可。動的な Sorry ページが必要な場合は方式C (Lambda@Edge §5) を選択する
- ルール数が増えると管理コスト増: ルールごとに
custom_response { response_body_key = ... }を設定する必要があり、ルール数が多い場合は Terraform モジュール化で管理しやすくする - CLOUDFRONT スコープで us-east-1 必須: WAF for CloudFront は必ず
us-east-1リージョンで作成する。REGIONAL スコープとは別管理になるため注意する
5. 方式C: Lambda@Edge で動的Sorry (Cookie/UA別/A-Bテスト可)

5-1. 仕組みと動作原理
Lambda@Edge は CloudFront のエッジロケーション上で Lambda 関数を実行する仕組みで、4つのトリガーポイントを持つ。
| トリガー | 実行タイミング | Sorry用途 |
|---|---|---|
| viewer-request | クライアントリクエスト受信直後 (WAF評価前) | 不向き: WAF Block前なので403を捕捉できない |
| origin-request | CloudFrontがオリジンへリクエストを転送する前 | キャッシュミス時のみ実行 |
| origin-response | オリジンからCloudFrontがレスポンスを受け取った後 | オリジン5xxのみ対応 |
| viewer-response | CloudFrontがクライアントへレスポンスを返す直前 | WAF Block (403) 捕捉に最適 |
WAF Sorryページに viewer-response を使う理由は、WAF Block が CloudFront ディストリビューションレベルで評価されるため、viewer-response が唯一 403 ステータスを受け取りボディを書き換えられるトリガーだからだ。viewer-request は WAF 評価前に実行されるため、Block されたリクエストの Sorry には使えない。
5-2. Lambda@Edge 制約一覧
主な制約:
- デプロイリージョン: us-east-1 のみ (CloudFrontはグローバルだが関数はus-east-1を参照)
- タイムアウト: viewer-response=5秒 / origin-response=30秒
- レスポンスボディ: 最大 1 MB (方式Bの4,096bytes制限の250倍)
- 環境変数: 非対応 (設定値はコードにハードコードかSSMから取得)
- VPC接続: 非対応
- provisioned concurrency: 非対応 (コールドスタート対策不可)
- IAM実行ロール: edgelambda.amazonaws.comのtrust policyが必要
- publish=true: CloudFrontはバージョンARNを参照するため必須
コールドスタートは初回実行時に約 100〜500ms の遅延が発生する。Sorry ページはトラフィック急増時に大量アクセスが来るため、コールドスタートが重なるとユーザー体験が悪化する。us-east-1 への配置とウォームアップリクエストの定期実行が対策として有効だ。
5-3. Node.js 18 ハンドラ実装例
基本実装 (WAF Block 時のみ Sorry 差し替え):
// sorry-handler.js
'use strict';
const SORRY_HTML = `<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>アクセスできません</title>
<style>
body{margin:0;font-family:sans-serif;background:#f5f5f5;
display:flex;align-items:center;justify-content:center;min-height:100vh}
.box{background:#fff;border-radius:8px;padding:40px;max-width:480px;
text-align:center;box-shadow:0 2px 8px rgba(0,0,0,.1)}
h1{color:#c0392b}p{color:#555;line-height:1.6}
</style>
</head>
<body>
<div class="box">
<h1>アクセスが制限されています</h1>
<p>セキュリティポリシーによりアクセスをブロックしました。<br>
心当たりがない場合はサポートまでお問い合わせください。</p>
</div>
</body>
</html>`;
exports.handler = async (event) => {
const { response } = event.Records[0].cf;
if (response.status === '403') {
response.status = '403';
response.statusDescription = 'Forbidden';
response.body = SORRY_HTML;
response.bodyEncoding = 'text';
response.headers['content-type'] = [{ key: 'Content-Type', value: 'text/html; charset=UTF-8' }];
response.headers['cache-control'] = [{ key: 'Cache-Control', value: 'no-store, no-cache' }];
}
return response;
};
Cookie別動的判定 (ログイン済みユーザーへ別メッセージ):
exports.handler = async (event) => {
const { request, response } = event.Records[0].cf;
if (response.status !== '403') {
return response;
}
const cookieHeader = request.headers['cookie'];
const isLoggedIn = cookieHeader &&
cookieHeader.some(c => c.value.includes('session='));
response.body = isLoggedIn
? '<html><body style="text-align:center;padding:60px"><h1>一時的にご利用いただけません</h1><p>セキュリティチェックが完了するまでお待ちください。</p></body></html>'
: '<html><body style="text-align:center;padding:60px"><h1>アクセスが制限されています</h1><p>不審なアクセスとして検知されました。</p></body></html>';
response.bodyEncoding = 'text';
response.headers['content-type'] = [{ key: 'Content-Type', value: 'text/html; charset=UTF-8' }];
response.headers['cache-control'] = [{ key: 'Cache-Control', value: 'no-store' }];
return response;
};
Geo別多言語Sorry (CloudFront-Viewer-Country ヘッダ利用):
exports.handler = async (event) => {
const { request, response } = event.Records[0].cf;
if (response.status !== '403') {
return response;
}
const countryHeader = request.headers['cloudfront-viewer-country'];
const country = countryHeader ? countryHeader[0].value : 'US';
const messages = {
JP: '<h1>アクセスが制限されています</h1><p>セキュリティポリシーによりブロックされました。</p>',
CN: '<h1>访问受限</h1><p>由于安全策略,您的访问已被阻止。</p>',
KR: '<h1>접근이 제한되었습니다</h1><p>보안 정책에 의해 차단되었습니다.</p>',
};
const body = messages[country] ||
'<h1>Access Denied</h1><p>Blocked by security policy.</p>';
response.body = `<!DOCTYPE html><html><body style="text-align:center;padding:60px">${body}</body></html>`;
response.bodyEncoding = 'text';
response.headers['content-type'] = [{ key: 'Content-Type', value: 'text/html; charset=UTF-8' }];
response.headers['cache-control'] = [{ key: 'Cache-Control', value: 'no-store' }];
return response;
};
A-Bテスト (Sorryページデザイン乱数切替):
exports.handler = async (event) => {
const { response } = event.Records[0].cf;
if (response.status !== '403') {
return response;
}
const variant = Math.random() < 0.5 ? 'A' : 'B';
const designs = {
A: '<html><body style="background:#fff;text-align:center;padding:60px"><h1 style="color:#e53e3e">アクセス制限</h1><p>セキュリティポリシーによりブロックされました。</p></body></html>',
B: '<html><body style="background:#1a202c;color:#fff;text-align:center;padding:60px"><h1>Access Restricted</h1><p>Blocked by security policy.</p></body></html>',
};
response.body = designs[variant];
response.bodyEncoding = 'text';
response.headers['content-type'] = [{ key: 'Content-Type', value: 'text/html; charset=UTF-8' }];
response.headers['cache-control'] = [{ key: 'Cache-Control', value: 'no-store' }];
response.headers['x-sorry-variant'] = [{ key: 'X-Sorry-Variant', value: variant }];
return response;
};
5-4. User-Agent 別応答 (bot/スクレイパーへの text/plain)
一般ユーザーには HTML Sorry、bot やスクレイパーには軽量な text/plain を返す設計はサーバー負荷削減に有効だ。
exports.handler = async (event) => {
const { request, response } = event.Records[0].cf;
if (response.status !== '403') {
return response;
}
const ua = (request.headers['user-agent'] || [{ value: '' }])[0].value.toLowerCase();
const isBot = /bot|crawler|spider|scraper|curl|wget|python-requests/.test(ua);
if (isBot) {
response.body = 'Access denied by security policy.';
response.bodyEncoding = 'text';
response.headers['content-type'] = [{ key: 'Content-Type', value: 'text/plain; charset=UTF-8' }];
} else {
response.body = '<html><body style="text-align:center;padding:60px"><h1>アクセスが制限されています</h1></body></html>';
response.bodyEncoding = 'text';
response.headers['content-type'] = [{ key: 'Content-Type', value: 'text/html; charset=UTF-8' }];
}
response.headers['cache-control'] = [{ key: 'Cache-Control', value: 'no-store' }];
return response;
};
5-5. 3点セット
Terraform HCL (§6 で完全実装を示す):
# cloudfront.tf の default_cache_behavior に追加
lambda_function_association {
event_type= "viewer-response"
lambda_arn= aws_lambda_function.sorry_edge.qualified_arn # バージョンARN必須
include_body = false
}
AWS CLI (Lambda バージョン発行 + CloudFront 紐付け確認):
VERSION=$(aws lambda publish-version \
--function-name sorry-page-edge \
--region us-east-1 \
--query 'Version' \
--output text)
echo "Published version: $VERSION"
DIST_ID="E1234567890ABCD"
aws cloudfront get-distribution-config \
--id "$DIST_ID" \
--query 'DistributionConfig.DefaultCacheBehavior.LambdaFunctionAssociations'
コンソール (Lambda@Edge デプロイ手順):
1. AWS コンソール → Lambda → 関数「sorry-page-edge」を選択
2. [アクション] → [Lambda@Edge へのデプロイ] をクリック
3. 設定:
- CloudFront ディストリビューション: 対象を選択
- キャッシュ動作: デフォルト (*)
- CloudFront イベント: ビューワーレスポンス
- [関数のバージョンを確認してデプロイ] チェックを入れて [デプロイ]
4. ステータスが「Deployed」になるまで待機 (5〜10 分)
5. ブラウザで 403 応答に Sorry HTML が返ることを確認
5-6. 利点・欠点まとめ
| 項目 | 内容 |
|---|---|
| 利点 | Cookie / UA / Geo 別の動的判定・A-Bテスト・多言語 Sorry・1 MB ボディ上限 (方式B の 250 倍) |
| 欠点 | Lambda@Edge 実行課金・コールドスタート遅延 (100〜500ms)・us-east-1 限定・provisioned concurrency 非対応 |
| 向いているケース | ログイン済み/未ログインで Sorry を変えたい・多言語サイト・A-B テストでデザイン最適化したい |
| 向いていないケース | 静的 Sorry で十分・コスト最小化優先・実装工数を最小にしたい |
6. Terraform実装 (3方式すべて完全HCL)
6-1. ディレクトリ構成と共通ファイル
3方式を同一リポジトリで管理するためのディレクトリ構成を示す。
terraform/
├── provider.tf # provider設定 (us-east-1 alias 必須)
├── variables.tf# 入力変数
├── locals.tf# Sorry HTML / JSON ローカル変数
├── s3.tf # 方式A: S3 + OAC
├── cloudfront.tf # 方式A+C: CloudFront
├── waf.tf# 方式B: WAF Web ACL
├── lambda.tf# 方式C: Lambda@Edge
├── iam.tf# IAM ロール / ポリシー
├── outputs.tf # 出力値
└── src/
└── sorry-handler.js
provider.tf (us-east-1 alias 必須):
terraform {
required_version = ">= 1.9"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
archive = {
source = "hashicorp/archive"
version = "~> 2.0"
}
}
}
provider "aws" {
region = var.region
}
# WAF CLOUDFRONT scope と Lambda@Edge は us-east-1 必須
provider "aws" {
alias = "us_east_1"
region = "us-east-1"
}
variables.tf:
variable "region" {
type = string
default = "ap-northeast-1"
}
variable "project" {
type = string
default = "sorry-page"
}
variable "domain" {
type = string
}
variable "acm_certificate_arn" {
type = string
}
variable "sorry_html_path" {
type = string
default = "files/sorry.html"
}
locals.tf (Sorry コンテンツ一元管理):
locals {
# 方式B: WAF Custom Response (4096 bytes 以内必須)
waf_sorry_html = <<-HTML
<!DOCTYPE html><html lang="ja">
<head><meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>アクセスを制限しています</title>
<style>
body{margin:0;font-family:sans-serif;background:#f5f5f5;
display:flex;align-items:center;justify-content:center;min-height:100vh}
.box{background:#fff;border-radius:8px;padding:40px;max-width:480px;
text-align:center;box-shadow:0 2px 8px rgba(0,0,0,.1)}
h1{color:#c0392b;font-size:1.4rem}p{color:#555;line-height:1.6}
</style></head>
<body><div class="box">
<h1>アクセスを制限しています</h1>
<p>セキュリティポリシーにより、このリクエストはブロックされました。<br>
心当たりがない場合はお問い合わせください。</p>
</div></body></html>
HTML
waf_ratelimit_json = jsonencode({
code = 429
message = "Too many requests. Please try again later."
})
}
6-2. 方式A Terraform (S3静的Sorry + Custom Error Response)
s3.tf:
resource "aws_s3_bucket" "sorry" {
bucket = "${var.project}-sorry-static"
}
resource "aws_s3_bucket_public_access_block" "sorry" {
bucket= aws_s3_bucket.sorry.id
block_public_acls = true
block_public_policy = true
ignore_public_acls= true
restrict_public_buckets = true
}
resource "aws_cloudfront_origin_access_control" "sorry" {
name= "${var.project}-sorry-oac"
origin_access_control_origin_type = "s3"
signing_behavior= "always"
signing_protocol= "sigv4"
}
resource "aws_s3_bucket_policy" "sorry" {
bucket = aws_s3_bucket.sorry.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Sid = "AllowCloudFrontOAC"
Effect = "Allow"
Principal = { Service = "cloudfront.amazonaws.com" }
Action = "s3:GetObject"
Resource = "${aws_s3_bucket.sorry.arn}/*"
Condition = {
StringEquals = {
"AWS:SourceArn" = aws_cloudfront_distribution.main.arn
}
}
}]
})
}
resource "aws_s3_object" "sorry_html" {
bucket = aws_s3_bucket.sorry.id
key = "sorry.html"
source = var.sorry_html_path
content_type = "text/html"
}
cloudfront.tf (方式A custom_error_response + 方式C lambda_function_association):
resource "aws_cloudfront_distribution" "main" {
enabled = true
default_root_object = "index.html"
aliases = [var.domain]
origin {
domain_name = "alb.example.com"
origin_id= "primary-alb"
custom_origin_config {
http_port = 80
https_port = 443
origin_protocol_policy = "https-only"
origin_ssl_protocols= ["TLSv1.2"]
}
}
# 方式A: S3 Sorry オリジン
origin {
domain_name = aws_s3_bucket.sorry.bucket_regional_domain_name
origin_id = "sorry-s3"
origin_access_control_id = aws_cloudfront_origin_access_control.sorry.id
s3_origin_config {
origin_access_identity = ""
}
}
# 方式A: カスタムエラーレスポンス
# error_caching_min_ttl で大量Block時のS3負荷を吸収
custom_error_response {
error_code= 403
response_code= 403
response_page_path = "/sorry.html"
error_caching_min_ttl = 10
}
custom_error_response {
error_code= 503
response_code= 503
response_page_path = "/sorry.html"
error_caching_min_ttl = 10
}
default_cache_behavior {
target_origin_id = "primary-alb"
viewer_protocol_policy = "redirect-to-https"
allowed_methods = ["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"]
cached_methods= ["GET", "HEAD"]
forwarded_values {
query_string = true
cookies { forward = "all" }
}
# 方式C: Lambda@Edge viewer-response で動的 Sorry 差し替え
lambda_function_association {
event_type= "viewer-response"
lambda_arn= aws_lambda_function.sorry_edge.qualified_arn
include_body = false
}
}
web_acl_id = aws_wafv2_web_acl.main.arn
restrictions {
geo_restriction { restriction_type = "none" }
}
viewer_certificate {
cloudfront_default_certificate = false
acm_certificate_arn= var.acm_certificate_arn
ssl_support_method = "sni-only"
minimum_protocol_version = "TLSv1.2_2021"
}
}
6-3. 方式B Terraform (WAF custom_response_bodies)
waf.tf:
# WAF CLOUDFRONT scope は us-east-1 必須
resource "aws_wafv2_web_acl" "main" {
provider = aws.us_east_1
name = "${var.project}-waf-acl"
scope = "CLOUDFRONT"
# custom_response_bodies: Web ACL レベルで一元定義
# 複数ルールから response_body_key で参照
custom_response_bodies {
key = "sorry_block"
content_type = "text/html"
content= local.waf_sorry_html# 4096 bytes 以内必須
}
custom_response_bodies {
key = "sorry_ratelimit"
content_type = "application/json"
content= local.waf_ratelimit_json
}
default_action { allow {} }
rule {
name = "block-bad-ips"
priority = 10
action {
block {
custom_response {
response_code = 403
response_body_key = "sorry_block"
response_header {
name = "X-Block-Reason"
value = "IP-Blocklist"
}
}
}
}
statement {
ip_set_reference_statement {
arn = aws_wafv2_ip_set.blocklist.arn
}
}
visibility_config {
cloudwatch_metrics_enabled = true
metric_name = "block-bad-ips"
sampled_requests_enabled= true
}
}
rule {
name = "rate-limit"
priority = 20
action {
block {
custom_response {
response_code = 429
response_body_key = "sorry_ratelimit"
response_header {
name = "Retry-After"
value = "60"
}
}
}
}
statement {
rate_based_statement {
limit = 2000
aggregate_key_type = "IP"
}
}
visibility_config {
cloudwatch_metrics_enabled = true
metric_name = "rate-limit"
sampled_requests_enabled= true
}
}
# マネージドルールグループ + rule_action_override
rule {
name = "managed-common-rule-set"
priority = 30
override_action { none {} }
statement {
managed_rule_group_statement {
name = "AWSManagedRulesCommonRuleSet"
vendor_name = "AWS"
rule_action_override {
name = "CrossSiteScripting_BODY"
action_to_use {
block {
custom_response {
response_code = 403
response_body_key = "sorry_block"
}
}
}
}
}
}
visibility_config {
cloudwatch_metrics_enabled = true
metric_name = "managed-common-rule-set"
sampled_requests_enabled= true
}
}
visibility_config {
cloudwatch_metrics_enabled = true
metric_name = "${var.project}-waf-acl"
sampled_requests_enabled= true
}
}
6-4. 方式C Terraform (Lambda@Edge 完全実装)
iam.tf:
resource "aws_iam_role" "lambda_edge" {
provider = aws.us_east_1
name = "${var.project}-lambda-edge-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = {
Service = [
"lambda.amazonaws.com",
"edgelambda.amazonaws.com"# Lambda@Edge には必須
]
}
Action = "sts:AssumeRole"
}]
})
}
resource "aws_iam_role_policy_attachment" "lambda_edge_basic" {
provider= aws.us_east_1
role = aws_iam_role.lambda_edge.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}
lambda.tf:
data "archive_file" "sorry_handler" {
type = "zip"
source_file = "${path.module}/src/sorry-handler.js"
output_path = "${path.module}/dist/sorry-handler.zip"
}
resource "aws_lambda_function" "sorry_edge" {
provider= aws.us_east_1# us-east-1 必須
function_name = "${var.project}-sorry-edge"
role = aws_iam_role.lambda_edge.arn
handler = "sorry-handler.handler"
runtime = "nodejs18.x"
filename= data.archive_file.sorry_handler.output_path
source_code_hash = data.archive_file.sorry_handler.output_base64sha256
publish = true# qualified_arn 参照のため必須
timeout = 5# viewer-response 上限は 5 秒
}
6-5. outputs.tf
output "cloudfront_domain" {
value = aws_cloudfront_distribution.main.domain_name
}
output "cloudfront_distribution_id" {
value = aws_cloudfront_distribution.main.id
}
output "waf_web_acl_arn" {
value = aws_wafv2_web_acl.main.arn
}
output "lambda_edge_arn" {
description = "Lambda@Edge function ARN (versioned)"
value = aws_lambda_function.sorry_edge.qualified_arn
}
output "sorry_s3_bucket" {
description = "S3 bucket for static Sorry page (方式A)"
value = aws_s3_bucket.sorry.bucket
}
| ポイント | 詳細 | 落とし穴 |
|---|---|---|
custom_response_bodies 定義 | Web ACL レベルで一元定義し、複数ルールが response_body_key で参照。Sorry HTML の一元管理が可能 | content が 4096 bytes 超過で apply 時に ValidationException。事前に wc -c sorry.html で確認必須 |
rule_action_override | マネージドルールグループの個別ルール (CrossSiteScripting_BODY など) にも Custom Response を適用できる | override_action { none {} } が必須。count {} にすると Custom Response が無効化される |
provider alias us_east_1 | WAF CLOUDFRONT scope は us-east-1 必須。provider = aws.us_east_1 を aws_wafv2_web_acl と aws_lambda_function に付与 | alias 忘れで「CLOUDFRONT scope は us-east-1 でのみ使用可能」エラーが発生する |
Lambda@Edge publish = true | CloudFront は qualified_arn (バージョン付き ARN) を参照するため必須 | publish=false のまま .arn を渡すと「$LATEST は使用不可」エラーが返る |
方式A error_caching_min_ttl | 10 秒以上に設定し、大量 Block 時の S3 Sorry ページ再取得を抑制する | 0 設定でキャッシュ無効化すると大量 Block 時に S3 へのリクエストが殺到してコスト増になる |
7. 運用設計 (メンテSorry/攻撃Sorry切替・HTTP status SEO影響・Retry-After)
SorryページはWAF Block時だけでなく、計画メンテナンス停止時にも活用できる。この2つのユースケース——メンテSorry(計画停止)と攻撃Sorry(WAF Block時)——は、返すHTTPステータスコード・対象リクエスト・SEO影響がまったく異なる。混同すると検索順位低下や運用事故につながる。本節では切替フロー・HTTP status の SEO 影響・Retry-After ヘッダの設定方法を整理し、本番で迷わない運用設計を確立する。
7-1. メンテSorry vs 攻撃Sorry: 根本的な違い
メンテSorry(計画停止)は、すべてのリクエストに対して 503 を返し、Google に「一時的な停止」を伝えるための Sorry だ。Retry-After ヘッダでメンテ終了予定時刻を通知することで、Google クローラーは「指定時間後に再クロールすれば良い」と判断し、インデックスからの除外を回避できる。
攻撃Sorry(WAF Block時)は、悪意あるリクエストや WAF ルール違反リクエストにのみ 403 / 451 を返す。正常なユーザーのリクエストは通常どおりのレスポンスが返り、SEO 影響は最小限に抑えられる。
| 観点 | メンテSorry(計画停止) | 攻撃Sorry(WAF Block時) |
|---|---|---|
| 対象リクエスト | 全リクエスト | WAFルール違反リクエストのみ |
| HTTP status | 503 + Retry-After | 403 / 451 |
| SEO影響 | 一時的停止として認識・クロール継続 | 再クロール頻度低下リスク |
| 正常ユーザー影響 | 全員Sorry画面 | 通常レスポンスのまま |
| 主な操作 | Custom Error Response 503有効化 | WAFルール action を block に設定 |
| 自動化優先度 | 高(切替忘れがSEO損失に直結) | 中(攻撃終息後に手動でも対応可) |
7-2. 切替実装方式(A/B/C)
3方式それぞれでメンテSorry/攻撃Sorryの切替実装が異なる。本番運用前に手順を把握しておくこと。
方式A: CloudFront Custom Error Response の error_code 切替
メンテSorry を有効にするには error_code = 503 を持つ custom_error_response ブロックを追加し、terraform apply で反映する。
# メンテSorry 有効化: 503 → /sorry.html に転換
custom_error_response {
error_code= 503
response_page_path = "/sorry.html"
response_code= 503
error_caching_min_ttl = 10
}
# メンテ終了時: 上記ブロックを削除して terraform apply
503 設定をすると、オリジンが返す 502/504 も Sorryページに転換される点に注意(落とし穴1)。
方式B: WAF ルールの action を block ⇄ allow に切替
メンテ時は「全 IP ブロック + 503 Custom Response」ルールを最高優先度で追加する。攻撃Sorry専用のブロックルールは引き続き 403 を返し、両者が共存する。
# メンテ終了後: LockToken 取得 → 全遮断ルール削除
WEB_ACL_ID="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
LOCK_TOKEN=$(aws wafv2 get-web-acl--name "cloudfront-waf-acl" --id "${WEB_ACL_ID}"--scope CLOUDFRONT --region us-east-1--query 'LockToken' --output text)
aws wafv2 update-web-acl--name "cloudfront-waf-acl" --id "${WEB_ACL_ID}"--scope CLOUDFRONT --region us-east-1--default-action '{"Allow":{}}'--rules file://rules_without_maintenance.json--lock-token "${LOCK_TOKEN}"
方式C: Lambda@Edge のバージョン管理でモード切替
Lambda@Edge は環境変数をサポートしないため、コード定数 MAINTENANCE_MODE を変更して新バージョンを発行し、CloudFront の lambda_function_association を更新する。
// viewer-request: MAINTENANCE_MODE=true で全リクエストに 503 を返す
const MAINTENANCE_MODE = true;
exports.handler = async (event) => {
if (MAINTENANCE_MODE) {
return {
status: '503', statusDescription: 'Service Unavailable',
headers: {
'retry-after': [{ key: 'Retry-After', value: '3600' }],
'content-type': [{ key: 'Content-Type', value: 'text/html; charset=utf-8' }],
'cache-control': [{ key: 'Cache-Control', value: 'no-store' }],
},
body: '<html lang="ja"><body><h1>メンテナンス中</h1></body></html>',
};
}
return event.Records[0].cf.request;
};
7-3. 自動切替アーキテクチャ
手動での切替は「切替忘れ」「深夜対応ミス」を招く。CloudWatch Alarm → SNS → Lambda の組み合わせが本番運用の自動化基盤だ。
resource "aws_cloudwatch_metric_alarm" "high_5xx_rate" {
alarm_name = "high-5xx-rate"
comparison_operator = "GreaterThanThreshold"
evaluation_periods = 2
metric_name= "5xxErrorRate"
namespace = "AWS/CloudFront"
period = 60
statistic = "Average"
threshold = 50
alarm_actions = [aws_sns_topic.ops_alert.arn]
ok_actions = [aws_sns_topic.ops_alert.arn]
dimensions = {
DistributionId = aws_cloudfront_distribution.main.id
Region= "Global"
}
}
resource "aws_sns_topic" "ops_alert" { name = "ops-alert" }
resource "aws_sns_topic_subscription" "ops_alert_lambda" {
topic_arn = aws_sns_topic.ops_alert.arn
protocol = "lambda"
endpoint = aws_lambda_function.maintenance_switcher.arn
}
自動切替 Lambda は SNS の AlarmName と NewStateValue を判定し、ALARM なら 503 Custom Error Response を有効化、OK なら解除する。切替 Lambda には cloudfront:UpdateDistribution と wafv2:UpdateWebACL の IAM 権限が必要だ。
Sorryページに使う HTTP ステータスコードは SEO に直接影響する。Google は各ステータスコードを以下のように解釈する。
| HTTP status | Googleの解釈 | 再クロール | インデックス影響 | 推奨用途 |
|---|---|---|---|---|
| 503 Service Unavailable | 一時的な停止(サーバー側の問題) | Retry-After 後に再クロール | 数日はインデックス維持 | ✅ メンテSorry(計画停止) |
| 403 Forbidden | 永続的なアクセス拒否 | 頻度低下・長期除外リスク | 徐々にインデックス削除 | ⚠️ 攻撃Sorry(WAF Block)のみ |
| 451 Unavailable For Legal Reasons | 法的制約による利用不可 | 該当地域からのクロール停止 | 地域別インデックス除外 | 🚫 地域制限(GDPR等)専用 |
選定指針まとめ:
- 計画メンテナンス → 503 + Retry-After(Google に「一時的停止」と明示し、インデックス維持)
- WAF Block / 攻撃遮断 → 403(正常ユーザーには影響なし。攻撃元のみ永続拒否)
- 地域制限 / GDPR 対応 → 451(国別除外目的のみに限定使用)
公式参考: Google 検索セントラル: HTTP ネットワーク エラーのトラブルシューティング
メンテ/攻撃 切替フロー(方式別):
| フェーズ | 方式A 操作 | 方式B 操作 | 方式C 操作 |
|---|---|---|---|
| メンテ開始 | 503 custom_error_response 追加・terraform apply | 全遮断ルール追加(503 Custom Response) | MAINTENANCE_MODE=true バージョン発行・apply |
| メンテ終了 | 503 custom_error_response 削除・terraform apply | 全遮断ルール削除 | 通常バージョンに切替・apply |
| 攻撃発生時 | WAF block ルール追加(403 Custom Response) | 攻撃元パターンのルール action = block | viewer-request で攻撃パターンを 403 返却 |
| 攻撃終息後 | WAF block ルール削除 | block ルール削除 / action = allow | 攻撃パターン判定ロジックを削除 |
7-4. Retry-After ヘッダの設定方法(方式別)
Retry-After ヘッダは 503 返却時に 必須 だ。未設定の場合、Google は再クロール間隔を延長し、メンテ終了後もインデックス再取得が遅延する(落とし穴5)。設定値は秒数(例: 3600 = 1時間)で指定する。
方式B: WAF Custom Response の response_header でセット
rule {
name = "maintenance-block-all"
priority = 0
action {
block {
custom_response {
response_code= 503
custom_response_body_key = "maintenance_sorry"
response_header { name = "Retry-After"; value = "3600" }
response_header { name = "Cache-Control"; value = "no-store" }
}
}
}
statement {
byte_match_statement {
search_string= "/"
field_to_match { uri_path {} }
text_transformation { priority = 0; type = "NONE" }
positional_constraint = "STARTS_WITH"
}
}
visibility_config {
cloudwatch_metrics_enabled = true
metric_name = "maintenance-block-all"
sampled_requests_enabled= true
}
}
方式C: Lambda@Edge viewer-response でのヘッダ追加
exports.handler = async (event) => {
const response = event.Records[0].cf.response;
if (response.status === '503') {
response.headers['retry-after'] = [{ key: 'Retry-After', value: '3600' }];
response.headers['cache-control'] = [{ key: 'Cache-Control', value: 'no-store' }];
}
return response;
};
コンソール/CLI での動作確認
# CloudFront が Retry-After を正しく返しているか確認
curl -sI https://your-domain.example.com/ | grep -E "HTTP|retry-after|cache-control"
# HTTP/2 503
# retry-after: 3600
# cache-control: no-store
# WAF Custom Response ヘッダ設定を確認
aws wafv2 get-web-acl--name cloudfront-waf-acl --scope CLOUDFRONT--id "${WEB_ACL_ID}" --region us-east-1--query 'WebACL.Rules[?Name==`maintenance-block-all`].Action.Block.CustomResponse.ResponseHeaders'

8. まとめ + よくある落とし穴10選 + 次回予告
8-1. 本記事のまとめ
本記事では WAF Block 後の Sorryページを「Block後の出口」として体系的に設計するための全要素を解説した。
3方式の特性と選定軸
方式A(CloudFront Custom Error Response + S3 静的 Sorry)は、最低コストでシンプルな実装が特徴だ。error_caching_min_ttl で大量トラフィックをキャッシュで吸収できる。一方、WAF Block 以外の 4xx/5xx も巻き込むリスクがあるため、本番では error_code 別に response_page_path を分けて設計することが重要だ。
方式B(WAF Custom Response)は、WAF Block の瞬間に WAF 自身が Sorry を返す WAF ネイティブ統合だ。CloudFront・S3・Lambda への依存がなく、Block 時のみ発火するため正常レスポンスへの影響がゼロだ。custom_response_bodies の 4096 bytes 上限が実装の壁になるため、HTML の最小化設計を先行させること。
方式C(Lambda@Edge)は、Cookie・User-Agent・Geo 情報を組み合わせた動的判定と多言語対応が可能な最も柔軟な方式だ。コールドスタート遅延とリクエスト単位の Lambda コストが課題のため、コスト試算と SLO 要件の確認を先行させること。
実際の本番構成では 方式A + 方式B の組み合わせ(WAF Block → 方式B / オリジン 5xx → 方式A)が最もバランスの取れた選択肢だ。
HTTP status と SEO 維持の関係
計画メンテ時は必ず 503 + Retry-After、攻撃ブロック時は 403、地域制限時は 451 を使い分けることが SEO 維持の要件だ。503 + Retry-After によって Google クローラーは「一時的停止」と認識し、指定時間後に再クロールするため、インデックスを数日間維持できる。403 を計画停止に誤用すると「永続的なアクセス拒否」と解釈され、インデックス削除が進む。チームの運用手順書に使い分けルールを明記しておくこと。
切替運用の自動化
CloudWatch Alarm → SNS → Lambda の連鎖で 5xx 率急増時に自動でメンテ Sorry を有効化し、回復後は自動解除できる。Terraform apply を起点とした切替管理により、「戻し忘れ」リスクを排除できる。切替後は CloudFront の 503 率と Google Search Console のクロールエラーを確認してインデックスへの影響がないことを検証すること。
本番投入前のセルフチェック
Terraform apply の前に以下を自己点検しておくと、本番障害を未然に防げる。
– error_caching_min_ttl を明示設定し、デフォルト 300s のキャッシュ滞留を防いでいる
– WAF Custom Response の response_body バイト数を wc -c で 4096B 以内と確認している
– S3 OAC バケットポリシーに CloudFront 以外の許可エントリが残っていない
– メンテ Sorry 解除の自動化 Lambda に ok_actions でロールバックトリガーを設定している
– CloudWatch ダッシュボードで 503 率・5xx 内訳・Lambda エラー率を監視できる状態にある
– 切替操作のたびに Google Search Console でクロールエラーがないことを確認している
– Lambda@Edge のトリガー設定が意図したイベントタイプのみになっていることを確認している
8-2. 3方式選択チートシート
| 方式 | ベストな選択シナリオ | コスト感 | 動的判定 | 主な制約 |
|---|---|---|---|---|
| 方式A Custom Error Response | シンプル最優先・最低コスト・S3 OAC 静的Sorry | $ | ✗ | 5xx全体巻き込みリスク |
| 方式B WAF Custom Response | WAF Block専用・Lambda不要・WAF native統合 | $$ | △ | 4096 bytes上限 |
| 方式C Lambda@Edge | 動的判定・多言語・A-Bテスト・Cookie/UA/Geo別 | $$$ | ✅ | コールドスタート遅延 |
- シンプル・最低コスト → 方式A(CloudFront Custom Error Response + S3)
- WAF native・Block時のみ → 方式B(WAF Custom Response)
- 動的判定・多言語・A-Bテスト → 方式C(Lambda@Edge)
- 最も現実的な本番構成 → 方式A + 方式B の組み合わせ
- Custom Error Response が WAF Block 以外(5xx オリジンエラー等)も巻き込む
error_codeを 403/503 に絞り込んで設定する。503 を設定するとオリジンの 502/504 も Sorryページに転換される。
本番では意図しないエラーが Sorry に吸収されないよう、error_code ごとに response_page_path を分けて設計すること。aws cloudfront get-distribution-configでカスタムエラーレスポンスの設定を確認し、監視ダッシュボードで 5xx の内訳を定期監査すること。 - WAF Custom Response の 4096 bytes 上限超過
response body が 4096 bytes を超えるとterraform applyが ValidationException で失敗する。
事前にwc -c sorry.htmlでバイト数を確認し、超える場合は HTML を最小化するかtext/plainの簡素なテキスト応答に切り替えること。
画像は外部 CDN URL で参照し、Base64 data URI は使わないこと。 - Lambda@Edge viewer-response の再帰呼出リスク(CloudFront キャッシュ設定との不整合)
viewer-request と viewer-response の両方に同一 Lambda をトリガーすると無限ループになるケースがある。
トリガーは1つのイベントタイプに絞り、CloudFront のキャッシュ設定との整合を事前確認すること。
デプロイ後は少量のテストリクエストで動作確認してから本番トラフィックを流すこと。 - Lambda@Edge コールドスタート遅延(~100ms)をユーザーが感じる
Lambda@Edge では Provisioned Concurrency が利用不可。関数を軽量化(依存ライブラリゼロ・バンドルサイズ最小化)し、us-east-1 に配置して遅延を最小化すること。
定期的な EventBridge スケジュールでウォームアップリクエストを送ることも有効だ。コールドスタートが許容できない場合は方式B への切り替えを検討すること。
グローバル変数へのオブジェクトキャッシュを活用すると、2回目以降のウォームリクエストの初期化コストを削減できる。 - 503 Sorry 時に Retry-After ヘッダ未設定 → SEO 悪化
Retry-After なしの 503 は Google が「復旧時期不明」と判断し、インデックス除外を前倒しする。
503 を返す全方式で必ずRetry-Afterヘッダを付加すること。curl -I https://example.com/で応答ヘッダーを確認し、Retry-Afterが含まれていることをデプロイ後に必ずテストすること。 - メンテSorry 時に S3 OAC 設定漏れ(直接アクセス可能になる)
S3 バケットポリシーが CloudFront OAC のみを許可しているか事前確認。
メンテ中に S3 直接 URL へアクセスされると Sorryページが表示されずオリジンのコンテンツが露出する。aws s3api get-bucket-policyでポリシーを確認し、定期的な S3 バケットポリシー監査を実施すること。 - CloudFront キャッシュで Sorry が滞留(error_caching_min_ttl 設定漏れ)
error_caching_min_ttlのデフォルトは 300 秒。明示的に 0〜10 秒に設定しないと、メンテ終了後も CloudFront エッジに Sorryページがキャッシュされ続ける。
テスト環境での動作確認でキャッシュ TTL を忘れずに検証すること。
メンテ終了後にaws cloudfront create-invalidationでキャッシュをパージする手順も運用手順書に含めること。 - HTTP status 403/503 混同(メンテに 403 を使うと SEO に悪影響)
計画停止で 403 を返すと Google は「永続的なアクセス拒否」と解釈してインデックス削除を進める。
メンテ時は必ず 503 + Retry-After を使用すること。
チームの運用手順書に「メンテ = 503 / 攻撃Block = 403」のルールを明記して認識齟齬を防ぐこと。 - WAF ルールを block→allow に戻し忘れてメンテSorry が残存
手動コンソール操作による切替は「戻し忘れ」が発生しやすい。
Terraform apply を起点に切替し、切替後は必ずモニタリングダッシュボードで 503 率がゼロであることを確認すること。
自動切替 Lambda を使う場合は CloudWatch Alarm のok_actionsで必ず解除トリガーを設定し、切替操作ごとに Google Search Console でクロールエラーを確認すること。 - Lambda@Edge ログが us-east-1 以外のリージョンに分散(CloudFront エッジ別)
Lambda@Edge の実行ログはエッジリージョン(ap-northeast-1 など)の CloudWatch に書き込まれる。
us-east-1 にすべてのログが集まるわけではないため、ログ収集には CloudWatch Logs Insights のクロスリージョンクエリを活用すること。
アラート設定もエッジリージョンごとに必要な点を設計段階で考慮し、aws logs describe-log-groups --region ap-northeast-1でエッジリージョンのロググループを洗い出すこと。
8-3. 次回予告
本記事では WAF Block 後の Sorryページ 3方式(Custom Error Response / WAF Custom Response / Lambda@Edge)を比較・Terraform 実装・メンテ/攻撃切替運用まで一気通貫で解説した。次回は CloudFront + WAF の実運用監視——WAF ログ集約・CloudWatch Metrics Dashboard 設計・コスト最適化——を深掘りする予定だ。
Sorryページを「場当たり実装」でなく「設計された出口」として本番に定着させるために、本記事のチートシートと落とし穴リストを運用手順書に転記し、Terraform コードを IaC レビューの対象として管理してほしい。
WAF多層防御(Shield Advanced + WAF + CloudFront)完全実装ガイド
CloudFront + WAF Bot Control 実装ガイド
API Gateway Lambda オーソライザー 本番運用ガイド