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, # ホストゾーンの月額もプランに含まれる
]
}