Phase 10:同じサイトを AWS にも置く(独自ドメイン)¶
目標¶
- この学習サイトを独自ドメイン + HTTPS で公開します(OCI 版の URL は長くて覚えにくく、ネームスペースも含まれます)
- あわせて AWS の流れを一通り体験します:アカウントの初期設定 → Route 53 → ACM → CloudFront → S3(OAC)→ IAM
- OCI 版は残します → 同じサイトを 2 つのクラウドで。1 回の push で両方が更新されます
構成¶
flowchart LR
push["git push"] --> build["OCI DevOps のビルド"]
build -->|"リソース・プリンシパル"| oci["OCI Object Storage<br/>(OCI 版)"]
build -->|"Access Key を読む"| vault["OCI ボールト<br/>シークレット"]
build -->|"Access Key(最小権限)"| s3["S3(非公開)"]
user["ブラウザ"] -->|"https://独自ドメイン"| r53["Route 53"]
r53 --> cf["CloudFront<br/>ACM 証明書"]
cf -->|"OAC(SigV4)"| s3
| ステップ | 内容 | OCI との対応 |
|---|---|---|
| 10-1 | アカウントのセキュリティ設定(ルートの MFA、Budget、Identity Center の管理者)、ローカル PC の CLI、state 用バケット | Phase 0 / 3 |
| 10-2 | Route 53 でドメインを登録(コンソールで手動) | — |
| 10-3 | S3 + CloudFront(OAC)+ ACM + Route 53 のレコード | Phase 7(公開バケット) |
| 10-4 | OCI DevOps のビルドから S3 にもアップロード(AWS のキーは OCI のボールト(Vault)に保管) | Phase 7 / 9 |
10-aws-site/
├── bootstrap/ state 用の S3 バケット(このディレクトリ自身は local state)
└── site/ state:S3 backend + use_lockfile
├── dns.tf ホストゾーン(data source)/ ACM 証明書 / 検証用 CNAME / A・AAAA エイリアス
├── s3.tf 非公開バケット + バケット・ポリシー(このディストリビューションだけ許可)
├── cloudfront.tf OAC / www リダイレクトの Function / ディストリビューション
└── deploy.tf OCI DevOps 用の IAM ユーザー(最小権限)
07-site/aws.tf OCI 側:ボールト + キー + シークレット(AWS の Access Key)
tools/upload_site_s3.py site/ → S3 同期 + CloudFront のキャッシュ削除
tools/rotate_aws_deploy_key.py 新しいキーを発行 → OCI のボールトに書き込む → 古いキーを削除
費用(このプロジェクトで初めての出費)¶
| 項目 | 費用 |
|---|---|
| ドメイン(.com) | 年 $16(Route 53 の登録料金。申し込み時に課金され、返金不可) |
| Route 53 のホストゾーン | 月 $0.50 |
| ACM のパブリック証明書 | 無料(DNS 検証 → 自動更新) |
| Route 53 のエイリアス・クエリ(CloudFront 宛て) | 無料 |
| CloudFront | 無期限の無料枠:月 1TB の転送、1,000 万リクエスト、200 万回の Function 実行 |
| S3 | サイト数 MB + state 数 KB → 月 $0.01 未満 |
| OCI のボールト / シークレット | 無料(ソフトウェア・キー、シークレット 150 個まで) |
→ 年間でおよそ $22 です。AWS アカウントには Budget(月 30 USD、メール通知)を設定しています。
OCI 版と AWS 版の比較¶
| OCI 版(Phase 7) | AWS 版 | |
|---|---|---|
| URL | objectstorage.<region>.oraclecloud.com/n/<namespace>/b/.../o/index.html |
https://<独自ドメイン>/ |
| バケット | 公開(ObjectReadWithoutList) |
非公開、CloudFront だけが読める(OAC) |
| HTTPS 証明書 | oraclecloud.com のもの(変更不可) | ACM(独自ドメイン、自動更新) |
| CDN / キャッシュ | なし | CloudFront(世界中のエッジ) |
| 存在しないページ | Object Storage の JSON | MkDocs の 404 ページ(ステータス 404) |
| セキュリティ・ヘッダー | 付けられない | HSTS、nosniff、X-Frame-Options など(マネージドのレスポンス・ヘッダー・ポリシー) |
ディレクトリの URL(/xxx/) |
index.html を返さない | ルートは可(default_root_object)、サブディレクトリは同じく不可 → どちらも use_directory_urls: false |
| 費用 | 0(Always Free) | ドメイン + ホストゾーンで年 $22 程度 |
| 緊急停止 | site_public = false(バケットを非公開に) |
site_enabled = false(ディストリビューションを無効化、反映に数分) |
アカウントと権限¶
10-1:アカウントの初期設定(コンソールで手動)¶
- ルート・ユーザー:MFA を有効化し、Access Key がないことを確認 → 以後ルートは使いません
- Budget:月 30 USD(実績 85% / 100%、予測 100% でメール)
- AWS Organizations(アカウント 1 つ)→ IAM Identity Center → ユーザー + 許可セット
AdministratorAccess(セッション 4 時間) - 普段はアクセス・ポータル(
https://d-xxxxxxxxxx.awsapps.com/start)からサインインします。ローカル PC の CLI はaws configure sso→ 一時的な認証情報なので、ディスク上に長期のキーはありません
| ID の種類 | 通常のコンソールのサインイン画面を使えるか |
|---|---|
| ルート(メール + パスワード) | 使える |
| IAM ユーザー(アカウント ID + ユーザー名) | 使える |
| Identity Center のユーザー | 使えない → アクセス・ポータル |
AdministratorAccess ≒ ルート、ただし同じではない
ルートにしかできないこと:ルートのメール / パスワードの変更、アカウントの解約、サポート・プランの変更、一部の請求設定、「全員を締め出した」バケット・ポリシーの解除など。 また、Organizations の管理アカウントは SCP の制限を受けません → MFA と長期キーを持たないことで守ります。
10-3:S3 は CloudFront にだけ読ませる¶
Allow cloudfront.amazonaws.com s3:GetObject <サイトのバケット>/*
条件:AWS:SourceArn = <このディストリビューションの ARN>
Deny * s3:* (aws:SecureTransport = false)
s3:ListBucketを与えていない → 存在しないファイルに S3 は 403 を返す → CloudFront で 403 / 404 をどちらも/404.html(ステータス 404)に置き換えます
10-4:OCI のビルドから AWS にアップロード¶
OCI のビルド・ランナーは AWS のリソースではない → IAM ロールが使えない → IAM ユーザーの Access Key(無期限)を発行するしかありません。その代わりに次の対策をとりました。
| 対策 | 内容 |
|---|---|
| 最小権限 | s3:ListBucket(サイトのバケット)、s3:PutObject / s3:DeleteObject(サイトのバケットのオブジェクト)、cloudfront:CreateInvalidation / GetInvalidation(このディストリビューション)だけ。GetObject すらありません |
| キーを Terraform に通さない | aws_iam_access_key の secret は state に平文で残る → スクリプトでキーを作成し、そのまま OCI ボールトのシークレットに書き込みます(表示しない、ファイルに残さない) |
| OCI 側はこのシークレットしか読めない | read secret-bundles ... where target.secret.id = '<このシークレット>' |
| キーを環境変数に入れない | build_spec の vaultVariables は使わず、アップロード・スクリプトがリソース・プリンシパルでシークレットを読みます。キーはプロセスのメモリ上にだけあります |
| 定期的なローテーション | tools/rotate_aws_deploy_key.py:新しいキーを作成 → シークレットに書き込み → 成功してから古いキーを削除(書き込みに失敗したら新しいキーを削除) |
# OCI(07-site/devops.tf のポリシーに 1 行追加)
Allow dynamic-group tflab-site-build to read secret-bundles in compartment tf-lab where target.secret.id = '<シークレット>'
- Terraform ではシークレットの「枠」だけを作ります:中身はプレースホルダー +
ignore_changes = [secret_content](そうしないと次の apply で本物の値がプレースホルダーに戻されます) - バケット名 / ディストリビューション ID / シークレットの OCID はビルド・パイプラインのパラメータ →
build_spec.yamlには書きません(サイトのページに埋め込まれるため)
手順と観察¶
10-1:ローカル PC の AWS CLI と state 用バケット¶
| 確認項目 | 結果 |
|---|---|
aws sts get-caller-identity --profile tflab-aws |
assumed-role/AWSReservedSSO_AdministratorAccess_.../<ユーザー名> |
| state 用バケット(リソース 7 個) | バージョニング / SSE-S3 / ブロック・パブリック・アクセスすべて有効 / ACL 無効 / 古いバージョンは 90 日で削除 |
| state 用バケットに匿名で HTTPS アクセス | 403 |
| 認証情報ありで HTTP | AccessDenied ... explicit deny in a resource-based policy(HTTPS のみ許可) |
- S3 のバケット名は全世界で一意(OCI はネームスペース内で一意なら可)→
bucket_prefix+ ランダムな接尾辞 - AWS provider v4 以降、バケットのバージョニング / 暗号化 / ライフサイクルなどは別リソースです(
aws_s3_bucketの中に書く方法は deprecated) - ロック:S3 backend の
use_lockfile = true(Terraform 1.10 以降)→ バケット内の.tflockオブジェクト。以前は DynamoDB のテーブルが別途必要でした
10-2:ドメイン¶
- Route 53 → ドメインの登録:1 年、自動更新はオフ、プライバシー保護はオン(WHOIS に個人情報が出ない)
- 登録には数時間かかります。完了するとホストゾーンが自動で作られ、レジストラの NS とゾーンの NS が一致します
- 届いた確認メールのリンクを 15 日以内にクリックする必要があります(しないとドメインが停止されます)
- Terraform ではホストゾーンを data source で読み、import はしません(import すると destroy でゾーンごと削除されます → Phase 8 の予算と同じ考え方)
10-3:apply(リソース 16 個)¶
| リソース | 所要時間 |
|---|---|
| ACM 証明書(us-east-1) | 9 秒 |
| 検証用 CNAME ×2 | 33 秒 |
| 証明書の ISSUED 待ち | 1 秒(DNS が Route 53 にある → すぐに検証完了) |
| CloudFront ディストリビューション | 10 分 47 秒(世界中のエッジに展開) |
| A / AAAA エイリアス ×4 | 32 秒 |
| 確認項目 | 結果 |
|---|---|
| トップ / 日本語ページ / Phase のページ | 200、Content-Type も正しい |
| 存在しないページ | 404 + MkDocs の 404 ページ |
http:// |
301 → https:// |
www.<ドメイン>/xxx |
301 → https://<ドメイン>/xxx(CloudFront Function) |
| レスポンス・ヘッダー | HSTS、X-Content-Type-Options、X-Frame-Options、Referrer-Policy、Alt-Svc: h3 |
| 2 回目のリクエスト | X-Cache: Hit from cloudfront |
| S3 の URL に直接アクセス | 403 |
| POST | 拒否(GET / HEAD のみ許可) |
- ACM 証明書は us-east-1 に必要です(CloudFront はグローバル・サービスで、このリージョンの証明書しか使えない)→ provider に
alias = "us_east_1"を追加 - アップロード:
tools/upload_site_s3.py(OCI 版と同じルール:Content-Type を自分で指定、MD5 が変わったファイルだけ送る、余分なオブジェクトは削除)+ 変更があれば invalidation/*(月 1,000 パスまで無料)aws s3 syncを使わない理由:Content-Type を推測に頼る(Windows ではレジストリを読む)、「変わったか」をサイズと時刻で判断する(ビルドのたびに時刻が変わる → 毎回全件送信)
10-4:権限のテスト(deployer のキー)¶
| 操作 | 結果 |
|---|---|
| サイトのバケットの一覧 / PUT / DELETE / CreateInvalidation | 成功 |
| サイトのバケットの GetObject | AccessDenied |
| state 用バケットの List / PUT | AccessDenied |
| サイトのバケットのポリシーを削除 | AccessDenied |
| ListBuckets / CloudFront の設定 / IAM | AccessDenied |
- キーをシークレットに書き込んだあとに再度 plan → No changes(
ignore_changesが効いている)
ハマりどころ¶
新しいドメインが利用中のネットワークのフィルターでブロックされる
HTTP → ブロック・ページ(Category: high-risk)、HTTPS → TLS ハンドシェイクが reset されます。登録したばかりのドメインは高リスクと判定されがちです。
→ 回避はしません。ローカル PC では https://<xxxx>.cloudfront.net + Host ヘッダーで確認し(curl -H "Host: www.<ドメイン>" で www のリダイレクトも確認可)、独自ドメインはスマートフォン(モバイル回線)で確認しました。
winget install Amazon.AWSCLI → exit 1602
MSI には管理者権限(UAC)が必要で、ローカル PC のアカウントは管理者ではない → キャンセル扱いになりました。 → MSI の「管理者インストール」は展開だけで、管理者権限は不要です:
...\AWSCLIV2\Amazon\AWSCLIV2\aws.exe をユーザーの PATH に追加 → VS Code ごと再起動します(ターミナルは VS Code の起動時にしか PATH を読まない)。
アクセス・ポータルのブックマークが翌日開けない
「セッションがタイムアウトしたか、停止したようです」→ ブックマークしたのはサインイン途中の URL(...signin.aws/platform/login?workflowStateHandle=...、使い捨て)でした。
→ https://d-xxxxxxxxxx.awsapps.com/start(~/.aws/config の sso_start_url)をブックマークします。
アクセス・ポータルにパスワードでサインインできない
「サインイン認証情報を確認できませんでした」:入力するのはユーザー名(メールではない)です。ユーザー名が存在しなくても、いったんパスワード画面に進んでからエラーになります。 → コンソールでこのユーザーの「パスワードをリセット」→ ワンタイム・パスワード → シークレット・ウィンドウでサインイン。
CLI は使えるのに Terraform は failed to refresh cached SSO token
CLI がキャッシュしたロールの認証情報はまだ有効でも、Terraform は SSO トークンで取り直そうとする → トークンが期限切れでした。
→ aws sso login --profile tflab-aws。
.gitignore の site/ が 10-aws-site/site/ まで除外していた
site/(MkDocs の出力)はどの階層の同名ディレクトリにも一致します → /site/(ルートだけ除外)に変更。git status --ignored で確認します。
その他
- CloudFront のマネージド・ポリシー(
Managed-CachingOptimizedなど)は data source で名前から ID を引き、UUID を直書きしない - Route 53 Domains の API は us-east-1 だけ(
--region us-east-1) - ルートの
404.htmlが日本語版になっている(多言語プラグインで後からビルドされた言語が上書き)→ 中国語ページの 404 も日本語で表示される(未対応)
コード全体¶
10-aws-site/bootstrap/main.tf
# ------------------------------------------------------------
# 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
# ------------------------------------------------------------
# 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
# ------------------------------------------------------------
# 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
http_version = "http2and3"
# エッジロケーションの範囲:200 = 南米 / オセアニア以外(日本を含む)。100 は北米 + 欧州のみ → 日本からのアクセスが遠回りになる
# 無料枠(毎月 1TB / 1,000 万リクエスト)は price class に関係ない
price_class = "PriceClass_200"
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
# ------------------------------------------------------------
# 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
}
07-site/aws.tf
# ------------------------------------------------------------
# Phase 10-4:build 後に AWS にもアップロード(10-aws-site/site の S3 + CloudFront)
# AWS の Access Key(IAM ユーザー tflab-site-deployer)はここの Vault Secret に入れる
# build runner は Resource Principal でこの Secret を読む → コード、build_spec、build ログのどこにも Key はない
#
# Vault(DEFAULT、無料)
# └─ マスター暗号化キー(SOFTWARE、無料)
# └─ Secret:{"access_key_id": ..., "secret_access_key": ...}(JSON)
#
# Secret の中身は Terraform を通さない:
# Terraform は Secret(中身はプレースホルダー)を作成するだけ + ignore_changes
# 本当の値は tools/rotate_aws_deploy_key.py が新しいバージョンとして書き込む(そうしないと次の apply で本当の値がプレースホルダーに戻される)
#
# ⚠ 削除:destroy 後は「削除保留中」になり(7〜30 日)、その間は同名の Secret を再作成できない
# ------------------------------------------------------------
resource "oci_kms_vault" "site" {
compartment_id = var.compartment_ocid
display_name = "${var.project_name}-site-vault"
vault_type = "DEFAULT"
freeform_tags = local.common_tags
}
resource "oci_kms_key" "site" {
compartment_id = var.compartment_ocid
display_name = "${var.project_name}-site-key"
management_endpoint = oci_kms_vault.site.management_endpoint
protection_mode = "SOFTWARE"
key_shape {
algorithm = "AES"
length = 32
}
freeform_tags = local.common_tags
}
resource "oci_vault_secret" "aws_deployer" {
compartment_id = var.compartment_ocid
vault_id = oci_kms_vault.site.id
key_id = oci_kms_key.site.id
secret_name = "${var.project_name}-aws-site-deployer"
description = "AWS access key of IAM user tflab-site-deployer (written by tools/rotate_aws_deploy_key.py)"
secret_content {
content_type = "BASE64"
content = base64encode(jsonencode({ placeholder = true }))
}
lifecycle {
ignore_changes = [secret_content]
}
freeform_tags = local.common_tags
}