コンテンツにスキップ

AWS との対比

ひとことで

同じサイトを AWS では「プライベートなバケット + 前段に CDN」に変えます。S3 はまったく公開せず、CloudFront だけに読ませます。独自ドメイン、HTTPS、404 ページ、セキュリティ関連のレスポンス・ヘッダー、キャッシュはすべて CloudFront が担当します。 大量アクセスで課金されるリスクは、CloudFront の定額料金プランで上限を設けます(超えても課金されない)。

このページは Phase 10 の実験をまとめたものです。公開の流れ(OCI のビルドから AWS にどうアップロードするか)は CI/CD とサイトの公開、IAM ユーザーとキーのローテーションは セキュリティ、費用の内訳は コストのガードレール を参照してください。

1. まず構成要素を知る

AWS このリポジトリの OCI 側で対応するもの ひとこと
Route 53 (OCI 版は DNS を使っていない) DNS サービス。ドメインの登録もできる。今回はここでドメインを購入
ACM(AWS Certificate Manager) (OCI 版は oraclecloud.com の証明書) HTTPS 証明書を無料で発行。DNS 検証のあとは自動更新
CloudFront (OCI 版には CDN がない) CDN:世界中のエッジ・ロケーションでサイトをキャッシュし、HTTPS、独自ドメイン、レスポンス・ヘッダーも担当
S3 オブジェクト・ストレージ(Object Storage) オブジェクト・ストレージ。今回はバケットを公開しない
OAC(Origin Access Control) — CloudFront が署名(SigV4)付きで S3 を読み、S3 はこの署名だけを受け付ける
WAF(Web Application Firewall) — ルールに従って悪意のあるリクエストを止める(レート制限、既知の攻撃文字列など)
CloudFront Functions — エッジで実行する短い JavaScript。今回は www → ルート・ドメインへのリダイレクトに使用
IAM Identity Center (OCI の IAM ユーザー + API キー) 統一されたログインの入口。ローカル PC の CLI が受け取るのは一時的な認証情報
IAM ユーザー + インライン・ポリシー 動的グループ(Dynamic Group)+ ポリシー 今回は OCI のビルド用(第 7 節)
S3 backend + use_lockfile OCI backend(オブジェクト・ストレージ) Terraform の state を S3 に置く。ロックはバケット内の .tflock オブジェクト

2. 仕組み:リクエストはどう流れるか

flowchart TB
  user["ブラウザ"] -->|"https://<ドメイン>"| r53["Route 53<br/>DNS"]
  r53 --> cf["CloudFront エッジ<br/>ACM 証明書<br/>WAF / キャッシュ"]
  cf -->|"キャッシュがないとき<br/>OAC の署名付きで取得"| s3["S3(プライベート)<br/>この CloudFront<br/>だけが読める"]
  user -.->|"CloudFront を通さず<br/>S3 に直接アクセス"| x["403 拒否"]

OCI 版との一番大きな違い:

OCI 版 AWS 版
誰がバケットを読めるか 誰でも(バケット自体を公開、ObjectReadWithoutList) この 1 つの CloudFront ディストリビューションだけ
HTTPS / ドメイン oraclecloud.com の URL と証明書で、変えられない 自分のドメイン + ACM 証明書
存在しないページ オブジェクト・ストレージの JSON MkDocs の 404 ページ(ステータス・コード 404)
セキュリティ関連のレスポンス・ヘッダー 付けられない HSTS、X-Content-Type-Options、X-Frame-Options など
キャッシュ なし CloudFront が世界中でキャッシュ

なぜバケットをプライベートにするのか:バケットを公開すると、訪問者は CDN を通らずに直接読めてしまい、CDN 側で行う HTTPS の強制、WAF、レート制限がすべて迂回されます。プライベート + OAC にすることで、CloudFront を唯一の入口にします。

3. アカウントの初期設定

やること 理由
root で MFA を有効にし、アクセス・キーがないことを確認して、以後 root は使わない root の権限は制限できず、漏れたらすべてを失う
予算(月 20 USD。実績 / 予測がしきい値を超えたらメール) 従量課金には上限がないので、せめて早く気づけるようにする
AWS Organizations を有効化(単一アカウント)→ IAM Identity Center → ユーザー + 許可セット AdministratorAccess 普段はアクセス・ポータルからログイン。ローカル PC の CLI は aws configure sso → 一時的な認証情報なので、ディスクに長期間有効なキーが残らない

AdministratorAccess ≒ root だが、完全に同じではない

root にしかできないこと:root のメール・アドレス / パスワードの変更、アカウントの解約、サポート・プランの変更、一部の請求設定、「全員を締め出した」バケット・ポリシーの解除など。 Organizations の管理アカウントは SCP の制限を受けない → 守りは MFA + 長期キーを保存しないことに頼ります。

Identity Center のユーザーは一般のコンソールのログイン・ページ(root と IAM ユーザー用)からはログインできず、アクセス・ポータルから入ります。

4. ドメインと証明書

  • ドメインは Route 53 で登録(コンソールで手動):1 年、自動更新オフ、WHOIS のプライバシー保護オン。登録が完了するとホストゾーンが自動で作られます
  • ホストゾーンは data source で読み、import しない:Terraform に import すると、destroy のときにゾーンごと削除されてしまいます
  • ACM 証明書は us-east-1 に作る必要がある:CloudFront はグローバル・サービスで、このリージョンの証明書しか認めません → provider に別名を付けます
# 10-aws-site/site/provider.tf(抜粋):2 つ目の provider。us-east-1 のリソース専用
provider "aws" {
  alias   = "us_east_1"
  profile = var.aws_profile
  region  = "us-east-1"
}

# 10-aws-site/site/dns.tf(抜粋)
resource "aws_acm_certificate" "site" {
  provider                  = aws.us_east_1
  domain_name               = var.domain_name
  subject_alternative_names = ["www.${var.domain_name}"]
  validation_method         = "DNS"   # ゾーンに CNAME を追加してドメインの所有を証明。CNAME があれば自動更新
}
  • ルート・ドメイン(apex)は CNAME で CloudFront を指せない → Route 53 の エイリアス・レコードを使います(A と AAAA の両方を作成。AWS リソースを指すエイリアスへの問い合わせは無料)

5. CloudFront + プライベートな S3(OAC)

S3 のバケット・ポリシーは「このディストリビューション」だけに読み取りを許可します:

# 10-aws-site/site/s3.tf(抜粋)
statement {
  sid       = "AllowCloudFrontRead"
  actions   = ["s3:GetObject"]
  resources = ["${aws_s3_bucket.site.arn}/*"]
  principals {
    type        = "Service"
    identifiers = ["cloudfront.amazonaws.com"]
  }
  condition {
    test     = "StringEquals"
    variable = "AWS:SourceArn"
    values   = [aws_cloudfront_distribution.site.arn]   # ほかの人のディストリビューションからは読めない
  }
}

さらに Deny のルールを 1 つ追加し、HTTPS でないリクエスト(aws:SecureTransport = false)はすべて拒否します。

CloudFront 側の設計:

設定 内容 理由
custom_error_response 403 / 404 → /404.html、ステータス・コード 404 OAC に s3:ListBucket を与えていない → 存在しないファイルに S3 は 403 を返す。まとめて 404 ページに置き換える
default_root_object = "index.html" ルートでトップ・ページを返す サブディレクトリでは効かない → OCI 版と同じく use_directory_urls: false
viewer_protocol_policy = "redirect-to-https" HTTP → 301 → HTTPS —
allowed_methods = ["GET", "HEAD"] 読み取りだけを許可 静的サイトに POST は不要
マネージドのキャッシュ・ポリシー / レスポンス・ヘッダー・ポリシー data source で名前から ID を引く コードに UUID を直書きしない
CloudFront Function www.<ドメイン> → 301 → https://<ドメイン> 正式な URL を 1 つにする
minimum_protocol_version = "TLSv1.2_2021" TLS 1.0 / 1.1 を受け付けない —

6. Terraform の state:S3 backend + use_lockfile

OCI(Terraform) AWS
state の置き場所 オブジェクト・ストレージ(backend "oci"、Terraform 1.12 以降) S3(backend "s3")
ロック backend に組み込み use_lockfile = true(Terraform 1.10 以降)→ バケット内の .tflock オブジェクト。以前は DynamoDB テーブルを別に作る必要があった
state バケット自身の state bootstrap/ ディレクトリ、ローカル state 同左
バケット名 ネームスペース内で一意 世界中で一意 → bucket_prefix + ランダムな接尾辞

state バケットの設定:バージョニング、デフォルト暗号化、Block Public Access をすべて有効、ACL 無効、古いバージョンは 90 日で削除、HTTPS のみ許可。

AWS provider v4 以降、S3 バケットのバージョニング / 暗号化 / ライフサイクルなどは別のリソース(aws_s3_bucket_versioning など)になり、aws_s3_bucket の中に書く方法は deprecated です。

7. OCI のビルドから AWS にアップロード:アクセス・キーしか使えない

AWS で「プログラムが自分の ID を使う」標準のやり方は IAM ロールですが、OCI のビルド runner は AWS のリソースではないので使えません → IAM ユーザーのアクセス・キー(長期間有効)を発行するしかありません。

# 10-aws-site/site/deploy.tf(抜粋):できることを最小限に絞る
statement {
  sid       = "WriteSiteObjects"
  actions   = ["s3:PutObject", "s3:DeleteObject"]    # GetObject はない
  resources = ["${aws_s3_bucket.site.arn}/*"]
}
statement {
  sid       = "InvalidateSiteCache"
  actions   = ["cloudfront:CreateInvalidation", "cloudfront:GetInvalidation"]
  resources = [aws_cloudfront_distribution.site.arn]  # このディストリビューションだけ
}

キーを OCI のボールト(Vault)に入れる、Terraform を通さない、定期的にローテーションする、といった補う手段は CI/CD とサイトの公開 と セキュリティ を参照してください。

8. 費用が暴走したときの歯止め:定額料金プランの Free 枠 + WAF

従量課金には上限がなく、大量アクセスを受けると費用も膨らみます。OCI 版の対策は「アラーム → ファンクションでバケットをプライベートにする」を自作することでした(コストのガードレール)。AWS は 2025 年 11 月に CloudFront の定額料金プランを始めました:

従量課金 定額料金プランの Free 枠($0/月)
毎月の枠 恒久的な無料枠 1TB / 1,000 万リクエスト 100GB / 100 万リクエスト(このサイトでは使い切らない)
超えたとき そのまま課金、上限なし 課金されない。超過が続くと配信性能が下がる
攻撃を受けたとき 攻撃のトラフィックにも課金される WAF / DDoS 対策で止めたリクエストは使用量に数えない
WAF 別料金 含まれる(ルールは最大 5 つ)
ホストゾーン $0.50/月 含まれる
  • 条件:ディストリビューションに WAF の web ACL を関連付ける必要がある(プラン中は外せない)。アカウントが Free Tier でないこと。Free 枠は 1 アカウントあたり最大 3 つ
  • WAF の 4 つのルール:同じ IP から 5 分間に 500 回以上でブロック + AWS マネージド・ルール・グループ 3 つ(悪意のある IP、既知の脆弱性を狙う攻撃文字列、汎用ルール)
  • price_class は PriceClass_All でなければならない:ドキュメントには書かれておらず、API は not eligible for this subscription tier としか返しません。コンソールで一度サブスクライブしてから terraform plan で drift を見て、コンソールが黙ってこの値を変えていたことに気づきました(第 13 節)

9. OCI ↔ AWS サービス対応表

このリポジトリ全体で使ったサービスです。名前が対応していても機能がまったく同じとは限らず、かっこ内は主な違いです。

分野 OCI AWS
ネットワーク VCN / Subnet / Route Table VPC / Subnet / Route Table
Internet Gateway / NAT Gateway Internet Gateway / NAT Gateway
Service Gateway VPC Endpoint(Gateway / Interface)
Security List(サブネット単位、stateful) NACL(サブネット単位、stateless)
NSG Security Group
コンピュート Compute(Shape / OCPU) EC2(Instance Type / vCPU)
Block Volume / Boot Volume EBS
Load Balancer(flexible) ALB / NLB
Bastion SSM Session Manager
ストレージ Object Storage(ネームスペース内で一意) S3(世界中で一意)
データベース Autonomous Database RDS / Aurora(近いもの)
ID IAM ユーザー / グループ / ポリシー(テキストの構文) IAM ユーザー / グループ / ポリシー(JSON)
Compartment (近いもの)アカウントの分割 / Resource Group
Dynamic Group + Instance Principal / Resource Principal IAM ロール(EC2 / Lambda / CodeBuild など)
機密情報 / 暗号化 Vault(マスター暗号化キー + Secret) KMS + Secrets Manager
ログ / 監査 Logging / Audit / Flow Logs CloudWatch Logs / CloudTrail / VPC Flow Logs
Connector Hub EventBridge / Firehose
監視 / 自動化 Monitoring Alarm / Notifications / Events / Functions CloudWatch Alarm / SNS / EventBridge / Lambda
費用 Budgets / Usage API AWS Budgets / Cost Explorer
コンテナ OKE / OCIR EKS / ECR
CI/CD DevOps(Code Repository / Build Pipeline) CodeCommit / CodeBuild / CodePipeline
IaC Resource Manager(マネージドな Terraform) CloudFormation(Terraform ではない)
運用ターミナル Cloud Shell CloudShell
サイト (CDN なし) CloudFront / ACM / Route 53 / WAF

10. 両者の設計の考え方の違い

テーマ OCI 版のやり方 AWS 版のやり方 違いの理由
サイトの入口 バケット自体を公開 プライベートなバケット + CDN が唯一の入口 OCI 版には CDN がなく、直接公開するしかない
認証情報 ビルドはリソース・プリンシパル(Resource Principal)でキーなし クラウドをまたぐのでアクセス・キーしか使えない → 最小権限 + ボールト + ローテーション ID は同じクラウドの中でしか通用しない
大量アクセス 毎分リクエスト数をチェック → ファンクションでバケットをプライベートに(自作) 定額料金プラン:超えても課金されない + WAF AWS には出来合いの「費用の上限」がある
緊急停止 site_public = false(バケットをプライベートに) site_enabled = false(ディストリビューションを無効化、反映に数分) —
リージョンの制約 Always Free はホーム・リージョンのみ ACM 証明書と CloudFront 用の WAF は us-east-1 に作る必要がある CloudFront はグローバル・サービス
名前の一意性 バケット名はネームスペース内で一意 S3 のバケット名は世界中で一意 —
費用 0(Always Free) ドメイン $16/年 + 消費税。ほかは Free 枠の中 ドメインにはお金がかかる

11. 設計判断の一覧

決定 選んだもの 選ばなかったもの 理由
ドメインの登録 Route 53 ほかのレジストラ DNS / 証明書 / CloudFront と直接つながり、DNS レコードを Terraform で管理できる
バケットの公開方法 プライベート + OAC 公開バケット CloudFront が唯一の入口になり、HTTPS / WAF を迂回できない
CloudFront → S3 の認証 OAC OAI(旧方式) OAI は非推奨
証明書 ACM(DNS 検証) メール検証 DNS 検証は CNAME があれば自動更新
ホストゾーン data source で読む import import すると destroy でゾーンが削除される
ローカル PC の認証情報 Identity Center の一時的な認証情報 IAM ユーザーのアクセス・キー ディスクに長期間有効なキーが残らない
state のロック use_lockfile DynamoDB テーブル リソースが 1 つ減る
費用の防御 定額料金プランの Free 枠 + WAF 従量課金 + アラート 超えても課金されない。攻撃のトラフィックは使用量に数えない
アップロード・ツール 自作スクリプト(MD5 比較) aws s3 sync sync は Content-Type を推測で決め、「変わったかどうか」を時刻で判断する(ビルドのたびに全部送り直し)

12. 検証

確かめたいこと やり方 結果
サイトが正常 トップ・ページ、日本語ページ、Phase のページ 200、Content-Type も正しい
404 ページ 存在しないページにアクセス 404 + MkDocs の 404 ページ
HTTPS の強制 http:// にアクセス 301 → https://
www のリダイレクト www.<ドメイン>/xxx にアクセス 301 → https://<ドメイン>/xxx
キャッシュが効いている 2 回目のリクエスト X-Cache: Hit from cloudfront
CloudFront を迂回できない S3 の URL に直接アクセス 403
読み取りだけ POST 拒否
state バケットは HTTPS のみ 認証情報付きで HTTP でアクセス AccessDenied ... explicit deny
WAF が効いている ?q=<script>...(XSS) WAF が BLOCK(訪問者には 404 ページ)。比較用の ?q=hello → 200
deployer のキーの権限が最小 実際に呼び出す サイト用バケットへの書き込み、キャッシュ削除は成功。オブジェクトの読み取り、state バケットへのアクセス、ポリシーの変更、バケットの一覧、IAM の参照はすべて AccessDenied

13. ハマりどころ

現象 原因 対策
CloudFront で東京の ACM 証明書が使えない CloudFront は us-east-1 の証明書しか認めない provider に alias = "us_east_1" を追加
存在しないファイルに 403 が返る OAC に s3:ListBucket を与えていない custom_error_response:403 / 404 → /404.html(404)
API で定額料金プランをサブスクライブ → not eligible for this subscription tier プランには PriceClass_All が必要(ドキュメントに記載なし) コンソールで一度実行 → terraform plan の drift で原因を見つける → コードを修正 + import
サブスクリプションを import したら plan に forces replacement approval_mode は作成時にしか使われず、読み戻せない 書かない(Free 枠のデフォルトは IMMEDIATE)
WAF の description で ValidationException かっこが使えない 許可された文字だけを使う
aws_s3_bucket の中の versioning などのブロックが deprecated provider v4 以降、別のリソースに分かれた aws_s3_bucket_versioning など
CLI は使えるのに Terraform は failed to refresh cached SSO token SSO トークンの期限切れ(CLI はキャッシュされたロールの認証情報を使っていた) aws sso login --profile <profile>
アクセス・ポータルのブックマークが翌日開けない ログイン途中の使い捨て URL をブックマークしていた https://d-xxxxxxxxxx.awsapps.com/start をブックマーク
アクセス・ポータルでパスワードを入れてもログインできない 入力するのはユーザー名(メール・アドレスではない) コンソールでパスワードをリセット → ワンタイム・パスワード
新しく登録したドメインが利用中のネットワークで開けない フィルターが新しいドメインを高リスクと判定 回避はしない。xxxx.cloudfront.net + Host ヘッダーで確認するか、スマートフォンで確認
.gitignore の site/ が 10-aws-site/site/ まで除外していた site/ はどの階層の同名ディレクトリにもマッチする /site/(ルートだけを除外)に変更

14. 確認問題

1. なぜ S3 バケットを直接公開せず、CloudFront だけに読ませるのですか?

公開すると、訪問者が CloudFront を通らずに S3 を直接読めてしまい、CDN 側の HTTPS の強制、WAF、レート制限がすべて迂回されます。プライベート + OAC にすることで、CloudFront を唯一の入口にします。

2. 東京リージョンで作った ACM 証明書を、CloudFront はなぜ使えないのですか?

CloudFront はグローバル・サービスで、us-east-1 の ACM 証明書しか認めません。CloudFront で使う WAF の web ACL も同じく us-east-1 に作る必要があります。

3. 存在しないページにアクセスすると、S3 はなぜ 404 ではなく 403 を返すのですか?

CloudFront の OAC には s3:GetObject しかなく、s3:ListBucket がありません。一覧を取る権限がないと、S3 は「ファイルが存在しない」ことを教えず、一律に 403 を返します。custom_error_response で 404 ページに置き換えます。

4. ホストゾーンをなぜ data source で読み、Terraform に import しないのですか?

ホストゾーンはドメイン登録時に AWS が自動で作ったものです。import すると Terraform の管理下に入り、destroy のときに一緒に削除されて、ドメインの名前解決ができなくなります。

5. OCI のビルドから S3 にアップロードするのに、なぜ IAM ロールを使えないのですか?

IAM ロールは AWS 自身のリソース(EC2、Lambda、CodeBuild など)のためのものです。OCI のビルド runner は AWS のリソースではないので、IAM ユーザーのアクセス・キーを使うしかなく、最小権限、ボールトでの保管、ローテーションで補います。

6. 大量アクセスを受けたとき、定額料金プランの Free 枠と従量課金では何が違いますか?

従量課金には上限がなく、攻撃のトラフィックにも課金されます。Free 枠は枠を超えても課金されず(超過が続くと配信性能が下がる)、WAF / DDoS 対策で止めたリクエストも使用量に数えません。

7. API のエラーが「not eligible」だけで、ドキュメントにも原因が見つからないとき、どうしますか?

コンソールで同じ操作を一度行い、terraform plan で drift を見ます。コンソールが自動で変えた設定が、隠れた条件です。今回は price_class が PriceClass_All に変えられていました。

8. ローカル PC の AWS CLI に、なぜ IAM ユーザーのアクセス・キーではなく Identity Center を使うのですか?

Identity Center が渡すのは一時的な認証情報(期限が切れる)なので、ローカル PC のディスクに長期間有効なキーが残りません。IAM ユーザーのアクセス・キーはずっと有効なので、漏れたときのリスクが大きくなります。

コード全文

10-aws-site/bootstrap/main.tf(state バケット)
# ------------------------------------------------------------
# Phase 10-1:Terraform state を置く S3 bucket(03-modules/bootstrap に相当)
#   鶏と卵の問題は OCI 版と同じ:このディレクトリ自身の state はローカルに残し、bucket の作成だけを担当する
#   → 10-aws-site のほかのディレクトリは backend "s3" + use_lockfile で state をこの bucket に置く
# ------------------------------------------------------------

resource "aws_s3_bucket" "tfstate" {
  # S3 の bucket 名は全世界で一意(OCI は namespace 内で一意であればよい)
  # → prefix を使い、AWS に後ろへランダムなサフィックスを付けさせる。アカウント ID はサフィックスに使わない:bucket 名は backend.hcl やログに出る
  bucket_prefix = "tflab-tfstate-"

  # bucket の誤削除防止(destroy 時にエラーになる)。本当に削除するときは先にこの行を false にする
  lifecycle {
    prevent_destroy = true
  }
}

# state が壊れたときに旧バージョンへ戻せる
resource "aws_s3_bucket_versioning" "tfstate" {
  bucket = aws_s3_bucket.tfstate.id
  versioning_configuration {
    status = "Enabled"
  }
}

# 旧バージョンが無限にたまるとストレージ料金がかかり続ける → 90 日後に削除(apply のたびに新しいバージョンができる)
resource "aws_s3_bucket_lifecycle_configuration" "tfstate" {
  bucket = aws_s3_bucket.tfstate.id

  rule {
    id     = "expire-old-state-versions"
    status = "Enabled"
    filter {}

    noncurrent_version_expiration {
      noncurrent_days = 90
    }
    abort_incomplete_multipart_upload {
      days_after_initiation = 1
    }
  }

  # ルールはバージョニングを有効にしてからでないと意味がない
  depends_on = [aws_s3_bucket_versioning.tfstate]
}

# state にはリソースの詳細や機密情報まで入る。絶対に公開しない(OCI の NoPublicAccess に相当)
# 2023 年以降に作成した bucket はデフォルトでこうなるが、ここでは明示しておく。誰かが Console で無効にしても気づかれない事態を防ぐ(drift になる)
resource "aws_s3_bucket_public_access_block" "tfstate" {
  bucket                  = aws_s3_bucket.tfstate.id
  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}

# ACL を無効化:アクセス制御は IAM / bucket policy だけで行う(AWS が現在推奨するやり方で、新しい bucket のデフォルト値でもある)
resource "aws_s3_bucket_ownership_controls" "tfstate" {
  bucket = aws_s3_bucket.tfstate.id
  rule {
    object_ownership = "BucketOwnerEnforced"
  }
}

# 保存時の暗号化:SSE-S3(AWS 管理のキー、無料)
#   SSE-KMS の CMK は 1 本あたり 1 USD/月。ここでは「キーを自分で管理する」必要がない → 使わない(Phase 9 の OCI Vault と比較)
resource "aws_s3_bucket_server_side_encryption_configuration" "tfstate" {
  bucket = aws_s3_bucket.tfstate.id
  rule {
    apply_server_side_encryption_by_default {
      sse_algorithm = "AES256"
    }
  }
}

# HTTPS を使わないリクエストを拒否(転送中の暗号化)
data "aws_iam_policy_document" "tfstate" {
  statement {
    sid       = "DenyInsecureTransport"
    effect    = "Deny"
    actions   = ["s3:*"]
    resources = [aws_s3_bucket.tfstate.arn, "${aws_s3_bucket.tfstate.arn}/*"]

    principals {
      type        = "*"
      identifiers = ["*"]
    }
    condition {
      test     = "Bool"
      variable = "aws:SecureTransport"
      values   = ["false"]
    }
  }
}

resource "aws_s3_bucket_policy" "tfstate" {
  bucket = aws_s3_bucket.tfstate.id
  policy = data.aws_iam_policy_document.tfstate.json

  # 先に Block Public Access を効かせ、policy が公開と判定されて失敗するのを避ける
  depends_on = [aws_s3_bucket_public_access_block.tfstate]
}
10-aws-site/site/dns.tf(ホストゾーン / ACM / DNS レコード)
# ------------------------------------------------------------
# Phase 10-3:Route 53(DNS)+ ACM(証明書)
# ------------------------------------------------------------

# Hosted zone はドメイン登録時に AWS が自動で作成したもの → data source で読むだけ、import しない
#   import すると destroy 時に zone ごと削除される(08-guard の Budget と同じ理屈)
data "aws_route53_zone" "site" {
  name         = var.domain_name
  private_zone = false
}

# 証明書:apex + www。DNS 検証 → zone に CNAME を追加し、ACM がドメインの所有を確認する
#   DNS 検証の証明書は CNAME が残っている限り自動更新される(メール検証のように毎年リンクをクリックする必要はない)
#   パブリック証明書は無料
resource "aws_acm_certificate" "site" {
  provider = aws.us_east_1

  domain_name               = var.domain_name
  subject_alternative_names = ["www.${var.domain_name}"]
  validation_method         = "DNS"

  # 証明書の差し替え時(例:ドメイン追加)は新しいものを先に作成し、CloudFront を切り替えてから古いものを削除 → サイトが止まらない
  lifecycle {
    create_before_destroy = true
  }
}

resource "aws_route53_record" "cert_validation" {
  for_each = {
    for o in aws_acm_certificate.site.domain_validation_options : o.domain_name => {
      name   = o.resource_record_name
      type   = o.resource_record_type
      record = o.resource_record_value
    }
  }

  zone_id         = data.aws_route53_zone.site.zone_id
  name            = each.value.name
  type            = each.value.type
  records         = [each.value.record]
  ttl             = 300
  allow_overwrite = true # 証明書の再作成時に CNAME の値が変わらないことがある → 同名レコードの上書きを許可
}

# ACM の検証完了(証明書が ISSUED になる)を待つ。CloudFront は ISSUED の証明書しか使えない
resource "aws_acm_certificate_validation" "site" {
  provider = aws.us_east_1

  certificate_arn         = aws_acm_certificate.site.arn
  validation_record_fqdns = [for r in aws_route53_record.cert_validation : r.fqdn]
}

# ドメイン → CloudFront。alias レコード:Route 53 自身が CloudFront の IP を解決する(apex には CNAME を使えない)
#   AWS リソースを指す alias のクエリは無料
#   A(IPv4)と AAAA(IPv6)の両方を作成する。distribution は IPv6 を有効にしている
locals {
  site_hosts = toset([var.domain_name, "www.${var.domain_name}"])
  site_records = {
    for pair in setproduct(local.site_hosts, ["A", "AAAA"]) : "${pair[0]}_${pair[1]}" => {
      name = pair[0]
      type = pair[1]
    }
  }
}

resource "aws_route53_record" "site" {
  for_each = local.site_records

  zone_id = data.aws_route53_zone.site.zone_id
  name    = each.value.name
  type    = each.value.type

  alias {
    name                   = aws_cloudfront_distribution.site.domain_name
    zone_id                = aws_cloudfront_distribution.site.hosted_zone_id
    evaluate_target_health = false
  }
}
10-aws-site/site/s3.tf(プライベートなバケット + バケット・ポリシー)
# ------------------------------------------------------------
# Phase 10-3:サイトのファイルを置く S3 bucket(プライベート)
#   07-site との比較:OCI は bucket 自体を公開(ObjectReadWithoutList)
#   AWS のここでは bucket は完全にプライベートで、「この 1 つの CloudFront distribution」だけに読み取りを許可(OAC)
#   → CloudFront を経由せず S3 の URL に直接アクセスすると 403。HTTPS / 独自ドメイン / キャッシュはすべて CloudFront が担当
# ------------------------------------------------------------

resource "aws_s3_bucket" "site" {
  bucket_prefix = "tflab-site-"

  # 中身はいつでも Git から build し直せる → バージョニングは有効にしない。destroy 時は中のファイルごと削除
  force_destroy = true
}

resource "aws_s3_bucket_public_access_block" "site" {
  bucket                  = aws_s3_bucket.site.id
  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}

resource "aws_s3_bucket_ownership_controls" "site" {
  bucket = aws_s3_bucket.site.id
  rule {
    object_ownership = "BucketOwnerEnforced"
  }
}

resource "aws_s3_bucket_server_side_encryption_configuration" "site" {
  bucket = aws_s3_bucket.site.id
  rule {
    apply_server_side_encryption_by_default {
      sse_algorithm = "AES256" # SSE-KMS だと CloudFront(OAC)に kms:Decrypt 権限も必要 → 公開サイトには不要
    }
  }
}

data "aws_iam_policy_document" "site" {
  # この distribution だけにオブジェクトの読み取りを許可(AWS:SourceArn で distribution の ARN に限定)
  #   s3:ListBucket がない → 存在しないファイルに S3 は 403 を返す(404 ではない)→ cloudfront.tf で 403 を 404 ページに置き換える
  statement {
    sid       = "AllowCloudFrontRead"
    actions   = ["s3:GetObject"]
    resources = ["${aws_s3_bucket.site.arn}/*"]

    principals {
      type        = "Service"
      identifiers = ["cloudfront.amazonaws.com"]
    }
    condition {
      test     = "StringEquals"
      variable = "AWS:SourceArn"
      values   = [aws_cloudfront_distribution.site.arn]
    }
  }

  statement {
    sid       = "DenyInsecureTransport"
    effect    = "Deny"
    actions   = ["s3:*"]
    resources = [aws_s3_bucket.site.arn, "${aws_s3_bucket.site.arn}/*"]

    principals {
      type        = "*"
      identifiers = ["*"]
    }
    condition {
      test     = "Bool"
      variable = "aws:SecureTransport"
      values   = ["false"]
    }
  }
}

resource "aws_s3_bucket_policy" "site" {
  bucket = aws_s3_bucket.site.id
  policy = data.aws_iam_policy_document.site.json

  depends_on = [aws_s3_bucket_public_access_block.site]
}
10-aws-site/site/cloudfront.tf(OAC / www のリダイレクト / ディストリビューション)
# ------------------------------------------------------------
# Phase 10-3:CloudFront(CDN + HTTPS + 独自ドメイン)
#   ブラウザ → https://<ドメイン> → CloudFront のエッジロケーション(キャッシュ)→ キャッシュがなければ OAC の署名付きで S3 から取得
# ------------------------------------------------------------

# OAC(Origin Access Control):CloudFront が SigV4 署名で S3 にアクセス → S3 の bucket policy がこの署名を認める
#   旧方式の OAI(Origin Access Identity)は非推奨
resource "aws_cloudfront_origin_access_control" "site" {
  name                              = aws_s3_bucket.site.id
  description                       = "tf-lab site bucket"
  origin_access_control_origin_type = "s3"
  signing_behavior                  = "always"
  signing_protocol                  = "sigv4"
}

# AWS 定義済みのポリシー:名前で ID を引き、UUID をコードにハードコードしない
data "aws_cloudfront_cache_policy" "optimized" {
  name = "Managed-CachingOptimized" # cookie / query string をキャッシュキーに含めない → 静的サイトでヒット率が最も高い
}

data "aws_cloudfront_response_headers_policy" "security" {
  name = "Managed-SecurityHeadersPolicy" # HSTS、X-Content-Type-Options、X-Frame-Options など
}

# www.<ドメイン> → https://<ドメイン>(301)。正式な URL は 1 つだけにする
#   CloudFront Functions:毎月 200 万回まで無料(Always Free)
resource "aws_cloudfront_function" "www_redirect" {
  name    = "tflab-www-redirect"
  runtime = "cloudfront-js-2.0"
  comment = "Redirect www to apex"
  publish = true
  code    = <<-JS
    function handler(event) {
      var request = event.request;
      if (request.headers.host.value === 'www.${var.domain_name}') {
        return {
          statusCode: 301,
          statusDescription: 'Moved Permanently',
          headers: { location: { value: 'https://${var.domain_name}' + request.uri } }
        };
      }
      return request;
    }
  JS
}

resource "aws_cloudfront_distribution" "site" {
  enabled             = var.site_enabled
  comment             = "tf-lab study site"
  aliases             = [var.domain_name, "www.${var.domain_name}"]
  default_root_object = "index.html" # https://<ドメイン>/ → index.html(サブディレクトリには効かないが、mkdocs は use_directory_urls: false)
  is_ipv6_enabled     = true
  web_acl_id          = aws_wafv2_web_acl.site.arn # 10-5:定額料金プランの必須条件(pricing.tf)
  http_version        = "http2and3"

  # エッジロケーションの範囲:定額料金プラン(10-5)では **PriceClass_All が必須**
  #   ドキュメントに記載なし。API は "distribution not eligible for this subscription tier" としか返さない
  #   → コンソールでサブスクライブすると自動で All に変更される(drift で判明)
  #   従量課金では All がいちばん高いが、定額料金プランなら差はない。プランをやめるなら PriceClass_200(日本を含む、南米 / オセアニアを除く)に戻す
  #   無料枠(毎月 1TB / 1,000 万リクエスト)は price class に関係ない
  price_class = "PriceClass_All"

  origin {
    origin_id                = "s3-site"
    domain_name              = aws_s3_bucket.site.bucket_regional_domain_name
    origin_access_control_id = aws_cloudfront_origin_access_control.site.id
  }

  default_cache_behavior {
    target_origin_id           = "s3-site"
    viewer_protocol_policy     = "redirect-to-https"
    allowed_methods            = ["GET", "HEAD"]
    cached_methods             = ["GET", "HEAD"]
    compress                   = true
    cache_policy_id            = data.aws_cloudfront_cache_policy.optimized.id
    response_headers_policy_id = data.aws_cloudfront_response_headers_policy.security.id

    function_association {
      event_type   = "viewer-request"
      function_arn = aws_cloudfront_function.www_redirect.arn
    }
  }

  # 存在しないページ:S3 は 403 を返す(OAC に ListBucket を与えていない)→ MkDocs の 404.html を表示し、ステータスコードは 404
  #   OCI 版ではできない(Object Storage は独自の JSON を返す)
  dynamic "custom_error_response" {
    for_each = [403, 404]
    content {
      error_code            = custom_error_response.value
      response_code         = 404
      response_page_path    = "/404.html"
      error_caching_min_ttl = 60
    }
  }

  viewer_certificate {
    acm_certificate_arn      = aws_acm_certificate_validation.site.certificate_arn
    ssl_support_method       = "sni-only"     # dedicated IP は毎月 $600
    minimum_protocol_version = "TLSv1.2_2021" # TLS 1.0 / 1.1 は受け付けない
  }

  restrictions {
    geo_restriction {
      restriction_type = "none"
    }
  }
}
10-aws-site/site/deploy.tf(OCI のビルド用の IAM ユーザー)
# ------------------------------------------------------------
# Phase 10-4:OCI DevOps の build がこのサイトへアップロードするための IAM ユーザー
#   OCI の build runner は AWS のリソースではない → IAM Role(AWS で「プログラムが自分の ID を使う」やり方)は使えない
#   → Access Key(長期有効)を発行するしかない。そのため、できることを最小限に絞る:
#     サイト bucket のオブジェクトの一覧 / 書き込み / 削除と、この distribution のキャッシュ削除だけ(bucket 自体やほかのリソースには触れない)
#   Access Key は Terraform では作成しない:aws_iam_access_key の secret が平文で state に残る
#     → tools/rotate_aws_deploy_key.py が Key を作成し、そのまま OCI Vault の Secret に書き込む(ローカルに保存せず、表示もしない)
# ------------------------------------------------------------

resource "aws_iam_user" "deployer" {
  name = "tflab-site-deployer"
  path = "/tflab/"
}

data "aws_iam_policy_document" "deployer" {
  # 同期時に bucket 内の既存オブジェクトの ETag と比較する(list_objects_v2)
  statement {
    sid       = "ListSiteBucket"
    actions   = ["s3:ListBucket"]
    resources = [aws_s3_bucket.site.arn]
  }

  statement {
    sid       = "WriteSiteObjects"
    actions   = ["s3:PutObject", "s3:DeleteObject"]
    resources = ["${aws_s3_bucket.site.arn}/*"]
  }

  statement {
    sid       = "InvalidateSiteCache"
    actions   = ["cloudfront:CreateInvalidation", "cloudfront:GetInvalidation"]
    resources = [aws_cloudfront_distribution.site.arn]
  }
}

# ユーザーのインライン policy(このユーザー専用。独立した管理 policy は不要)
resource "aws_iam_user_policy" "deployer" {
  name   = "site-deploy"
  user   = aws_iam_user.deployer.name
  policy = data.aws_iam_policy_document.deployer.json
}
10-aws-site/site/pricing.tf(WAF + 定額料金プラン)
# ------------------------------------------------------------
# Phase 10-5:CloudFront 定額料金プラン(flat-rate pricing plan)の Free($0/月)
#
#   理由:従量課金(pay-as-you-go)には上限がない → 悪意あるアクセスで費用が膨らむ(Phase 8 と同じ懸念)
#   Free:毎月 1M リクエスト / 100GB 転送、**超えても課金されない**(超過が続くと配信性能が下げられるだけで、請求は来ない)
#     + WAF / DDoS 保護でブロックされたリクエストは使用量に数えない
#     + 関連付けた Route 53 ホストゾーンの月額・レコード・クエリも含む(ホストゾーンの $0.50/月 も不要に)
#   条件:distribution に WAF web ACL の関連付けが必須(プラン中は外せない)。Free は WAF ルール 5 つまで
#         Free はアカウントあたり 3 つまで。1 プランに apex ドメインは 1 つだけ
#   ⚠ プランを解約すると WAF は従量課金に戻る(web ACL $5/月 + ルールごとに $1/月)→ 解約時は web ACL も削除する
# ------------------------------------------------------------

# CloudFront 用の web ACL は us-east-1 に作る必要がある(scope = CLOUDFRONT)
resource "aws_wafv2_web_acl" "site" {
  provider = aws.us_east_1

  name        = "tflab-site"
  description = "tf-lab study site - CloudFront flat-rate Free plan" # 括弧は使えない(API の文字制限)
  scope       = "CLOUDFRONT"

  default_action {
    allow {}
  }

  # 1. 同じ IP から 5 分間に 500 リクエストを超えたらブロック(1 ページ 15〜20 リクエスト程度なので、通常の閲覧では引っかからない)
  rule {
    name     = "rate-limit-per-ip"
    priority = 0

    action {
      block {}
    }

    statement {
      rate_based_statement {
        limit                 = 500
        evaluation_window_sec = 300
        aggregate_key_type    = "IP"
      }
    }

    visibility_config {
      cloudwatch_metrics_enabled = true
      metric_name                = "rate-limit-per-ip"
      sampled_requests_enabled   = true
    }
  }

  # 2〜4. AWS マネージド・ルール(ルール・グループ 1 つで 1 ルールと数える)
  #   IpReputationList:既知の悪意ある IP / bot
  #   KnownBadInputs:Log4j など既知の脆弱性を狙う攻撃文字列
  #   CommonRuleSet:OWASP Top 10 系の汎用ルール
  dynamic "rule" {
    for_each = {
      1 = "AWSManagedRulesAmazonIpReputationList"
      2 = "AWSManagedRulesKnownBadInputsRuleSet"
      3 = "AWSManagedRulesCommonRuleSet"
    }
    content {
      name     = rule.value
      priority = rule.key

      override_action {
        none {} # ルール・グループ自身のアクション(block)を使う
      }

      statement {
        managed_rule_group_statement {
          vendor_name = "AWS"
          name        = rule.value
        }
      }

      visibility_config {
        cloudwatch_metrics_enabled = true
        metric_name                = rule.value
        sampled_requests_enabled   = true
      }
    }
  }

  visibility_config {
    cloudwatch_metrics_enabled = true
    metric_name                = "tflab-site"
    sampled_requests_enabled   = true
  }
}

# Free をサブスクライブ:distribution + web ACL + ホストゾーンをまとめる
#   Free は IMMEDIATE のみ(即時有効、「承認」ステップなし)= デフォルト値 → approval_mode は書かない
#     (作成時にしか使われず、import 後に読み戻せない → 書くと plan が forces replacement になる)
#   実際はコンソールでサブスクライブしてから terraform import した:API で作ると "not eligible"(原因は price_class、cloudfront.tf 参照)
resource "aws_pricingplanmanager_subscription" "site" {
  provider = aws.us_east_1

  plan_family = "CloudFront"
  plan_tier   = "FREE"
  resource_arns = [
    aws_cloudfront_distribution.site.arn,
    aws_wafv2_web_acl.site.arn,
    data.aws_route53_zone.site.arn, # ホストゾーンの月額もプランに含まれる
  ]
}