Phase 10:同一个网站放到 AWS(自己的域名)¶
目标¶
- 用自己的域名 + HTTPS 发布这个学习网站(OCI 版的 URL 又长又难记,还带着 namespace)
- 顺便走一遍 AWS 的流程:账号的初始设置 → Route 53 → ACM → CloudFront → S3(OAC)→ IAM
- OCI 版保留 → 同一个网站、两朵云,push 一次两边都更新
结构¶
flowchart LR
push["git push"] --> build["OCI DevOps build"]
build -->|"Resource Principal"| oci["OCI Object Storage<br/>(OCI 版)"]
build -->|"读 Access Key"| vault["OCI Vault<br/>Secret"]
build -->|"Access Key(最小权限)"| s3["S3(私有)"]
user["浏览器"] -->|"https://自己的域名"| r53["Route 53"]
r53 --> cf["CloudFront<br/>ACM 证书"]
cf -->|"OAC(SigV4)"| s3
| 步骤 | 内容 | 对照 OCI |
|---|---|---|
| 10-1 | 账号的安全设置(root MFA、Budget、Identity Center 的管理员)、本机 CLI、state bucket | Phase 0 / 3 |
| 10-2 | Route 53 注册域名(Console 手动) | — |
| 10-3 | S3 + CloudFront(OAC)+ ACM + Route 53 记录 | Phase 7(公开 bucket) |
| 10-4 | OCI DevOps 的 build 也上传到 S3(AWS 的 Key 放 OCI Vault) | Phase 7 / 9 |
10-aws-site/
├── bootstrap/ state 用的 S3 bucket(这个目录自己用 local state)
└── site/ state:S3 backend + use_lockfile
├── dns.tf hosted zone(data source)/ ACM 证书 / 验证 CNAME / A・AAAA alias
├── s3.tf 私有 bucket + bucket policy(只允许这个 distribution)
├── cloudfront.tf OAC / www 跳转 Function / distribution
└── deploy.tf OCI DevOps 用的 IAM 用户(最小权限)
07-site/aws.tf OCI 侧:Vault + 密钥 + Secret(AWS 的 Access Key)
tools/upload_site_s3.py site/ → S3 同步 + CloudFront 清缓存
tools/rotate_aws_deploy_key.py 发新 Key → 写进 OCI Vault → 删旧 Key
费用(这个项目第一次花钱)¶
| 项目 | 费用 |
|---|---|
| 域名(.com) | $16 / 年(Route 53 的注册价格。下单即扣款、不能退) |
| Route 53 hosted zone | $0.50 / 月 |
| ACM 公开证书 | 免费(DNS 验证 → 自动续期) |
| Route 53 的 alias 查询(指向 CloudFront) | 免费 |
| CloudFront | 永久免费额度:每月 1TB 流量、1000 万次请求、200 万次 Function 调用 |
| S3 | 网站几 MB + state 几 KB → 每月不到 $0.01 |
| OCI Vault / Secret | 免费(软件密钥、Secret 150 个以内) |
→ 一年大约 $22。AWS 账号设了 Budget(月 30 USD,邮件通知)。
OCI 版和 AWS 版的比较¶
| OCI 版(Phase 7) | AWS 版 | |
|---|---|---|
| URL | objectstorage.<region>.oraclecloud.com/n/<namespace>/b/.../o/index.html |
https://<自己的域名>/ |
| bucket | 公开(ObjectReadWithoutList) |
私有,只有 CloudFront 能读(OAC) |
| HTTPS 证书 | oraclecloud.com 的(不能换) | ACM(自己的域名,自动续期) |
| CDN / 缓存 | 没有 | CloudFront(全球边缘节点) |
| 不存在的页面 | Object Storage 的 JSON | MkDocs 的 404 页面(状态码 404) |
| 安全响应头 | 加不了 | HSTS、nosniff、X-Frame-Options 等(托管的 response headers policy) |
目录 URL(/xxx/) |
不返回 index.html | 根目录可以(default_root_object),子目录同样不行 → 两边都用 use_directory_urls: false |
| 费用 | 0(Always Free) | 域名 + hosted zone,约 $22/年 |
| 紧急停止 | site_public = false(bucket 变私有) |
site_enabled = false(distribution 停用,几分钟生效) |
账号和权限¶
10-1:账号的初始设置(Console 手动)¶
- root:开 MFA、确认没有 Access Key → 以后不用 root
- Budget:月 30 USD(实际 85% / 100%、预测 100% 时发邮件)
- AWS Organizations(单账号)→ IAM Identity Center → 用户 + 许可集
AdministratorAccess(session 4 小时) - 日常从 access portal(
https://d-xxxxxxxxxx.awsapps.com/start)登录;本机 CLI 用aws configure sso→ 临时凭证,磁盘上没有长期有效的 Key
| 身份 | 能不能用一般的 Console 登录页 |
|---|---|
| root(邮箱 + 密码) | 能 |
| IAM 用户(账号 ID + 用户名) | 能 |
| Identity Center 的用户 | 不能 → access portal |
AdministratorAccess ≒ root,但不完全一样
只有 root 能做的:改 root 的邮箱 / 密码、关闭账号、改 Support plan、部分账单设置、解除"把所有人都锁在外面"的 bucket policy 等。 另外 Organizations 的管理账号不受 SCP 限制 → 保护靠 MFA + 不存长期 Key。
10-3:S3 只让 CloudFront 读¶
Allow cloudfront.amazonaws.com s3:GetObject <网站 bucket>/*
条件:AWS:SourceArn = <这个 distribution 的 ARN>
Deny * s3:* (aws:SecureTransport = false)
- 没给
s3:ListBucket→ 不存在的文件 S3 返回 403 → CloudFront 把 403 / 404 都换成/404.html(状态码 404)
10-4:OCI 的 build 往 AWS 上传¶
OCI 的 build runner 不是 AWS 的资源 → 用不了 IAM Role → 只能发 IAM 用户的 Access Key(长期有效)。补偿措施:
| 措施 | 内容 |
|---|---|
| 最小权限 | 只有 s3:ListBucket(网站 bucket)、s3:PutObject / s3:DeleteObject(网站 bucket 的对象)、cloudfront:CreateInvalidation / GetInvalidation(这个 distribution)。连 GetObject 都没有 |
| Key 不经过 Terraform | aws_iam_access_key 的 secret 会以明文留在 state 里 → 脚本建 Key 后直接写进 OCI Vault 的 Secret(不显示、不落盘) |
| OCI 侧只能读这一个 Secret | read secret-bundles ... where target.secret.id = '<这个 Secret>' |
| Key 不进环境变量 | 不用 build_spec 的 vaultVariables;上传脚本用 Resource Principal 读 Secret,Key 只在进程的内存里 |
| 定期轮换 | tools/rotate_aws_deploy_key.py:建新 Key → 写进 Secret → 成功后才删旧 Key(写入失败时删新 Key) |
# OCI(07-site/devops.tf 的 Policy,加了一条)
Allow dynamic-group tflab-site-build to read secret-bundles in compartment tf-lab where target.secret.id = '<Secret>'
- Secret 在 Terraform 里只建"框":内容是占位符 +
ignore_changes = [secret_content](不然下次 apply 会把真值改回占位符) - bucket 名 / distribution ID / Secret 的 OCID:build pipeline 的参数 → 不写进
build_spec.yaml(网站页面会嵌入它)
流程和观察¶
10-1:本机的 AWS CLI 和 state bucket¶
| 确认项 | 结果 |
|---|---|
aws sts get-caller-identity --profile tflab-aws |
assumed-role/AWSReservedSSO_AdministratorAccess_.../<用户名> |
| state bucket(7 个资源) | 版本管理 / SSE-S3 / Block Public Access 全开 / ACL 禁用 / 旧版本 90 天删除 |
| 匿名 HTTPS 访问 state bucket | 403 |
| 带凭证、但用 HTTP | AccessDenied ... explicit deny in a resource-based policy(只允许 HTTPS) |
- S3 的 bucket 名全世界唯一(OCI 只要 namespace 内唯一)→
bucket_prefix+ 随机后缀 - AWS provider v4 以后,bucket 的版本管理 / 加密 / 生命周期等都是单独的资源(
aws_s3_bucket里的写法是 deprecated) - 锁:S3 backend 的
use_lockfile = true(Terraform 1.10+)→ bucket 里的.tflock对象。以前要另建 DynamoDB 表
10-2:域名¶
- Route 53 → ドメインの登録:1 年、自动续费关、隐私保护开(WHOIS 里看不到个人信息)
- 注册要几个小时;完成后 hosted zone 自动建好,注册商的 NS 和 zone 的 NS 一致
- 收到验证邮件后 15 天内要点链接(不点的话域名会被停用)
- Terraform 里 hosted zone 用 data source 读,不 import(import 的话 destroy 会把 zone 一起删掉 → 和 Phase 8 的 Budget 同一个道理)
10-3:apply(16 个资源)¶
| 资源 | 用时 |
|---|---|
| ACM 证书(us-east-1) | 9 秒 |
| 验证用 CNAME ×2 | 33 秒 |
| 等证书 ISSUED | 1 秒(DNS 在 Route 53 里 → 马上验证完) |
| CloudFront distribution | 10 分 47 秒(同步到全球的边缘节点) |
| A / AAAA alias ×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 是全球服务,只认这个 Region 的证书)→ provider 加
alias = "us_east_1" - 上传:
tools/upload_site_s3.py(和 OCI 版同样的规则:自己指定 Content-Type、只传 MD5 变了的文件、删掉多余的对象)+ 有变化时 invalidation/*(每月 1000 条路径免费)- 没用
aws s3 sync:Content-Type 靠猜(Windows 上读注册表)、"变没变"看大小和时间(每次 build 时间都变 → 全部重传)
- 没用
10-4:权限测试(deployer 的 Key)¶
| 操作 | 结果 |
|---|---|
| 列网站 bucket / PUT / DELETE / CreateInvalidation | 成功 |
| 网站 bucket 的 GetObject | AccessDenied |
| state bucket 的 List / PUT | AccessDenied |
| 删除网站 bucket 的 policy | AccessDenied |
| ListBuckets / CloudFront 的设置 / IAM | AccessDenied |
- Key 写进 Secret 后再 plan → No changes(
ignore_changes生效)
坑¶
新域名被所在网络的过滤器拦截
HTTP → 拦截页(Category: high-risk),HTTPS → TLS 握手被 reset。刚注册的域名常被判为高风险。
→ 不绕过。本机用 https://<xxxx>.cloudfront.net + Host 头确认(curl -H "Host: www.<域名>" 也能测 www 跳转),自己的域名用手机(移动网络)确认。
winget install Amazon.AWSCLI → exit 1602
MSI 要管理员权限(UAC),本机账号不是管理员 → 被取消。 → MSI 的"管理员安装"只解压、不需要管理员权限:
...\AWSCLIV2\Amazon\AWSCLIV2\aws.exe 加到用户 PATH → VS Code 整个重启(终端只在 VS Code 启动时读 PATH)。
access portal 的书签第二天打不开
"セッションがタイムアウトしたか、停止したようです" → 收藏的是登录途中的 URL(...signin.aws/platform/login?workflowStateHandle=...,一次性)。
→ 收藏 https://d-xxxxxxxxxx.awsapps.com/start(~/.aws/config 的 sso_start_url)。
access portal 用密码登录不了
"サインイン認証情報を確認できませんでした":要填的是用户名(不是邮箱);用户名不存在也会先进入密码页再报错。 → Console 里对这个用户"パスワードをリセット"→ 一次性密码 → 无痕窗口登录。
CLI 能用,Terraform 却 failed to refresh cached SSO token
CLI 缓存的 role 凭证还没过期,但 Terraform 要用 SSO token 重新取 → token 过期了。
→ 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是日文版(多语言插件后 build 的语言覆盖了)→ 中文页面的 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 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 完全私有,只允许"这一个 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
# 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 / 1000 万请求)和 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 里"程序用自己的身份"的做法)
# → 只能发 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
}