跳转至

AWS 对照

一句话

同一个网站,在 AWS 上换成"私有 bucket + CDN 在前面":S3 完全不公开,只让 CloudFront 读;自定义域名、HTTPS、404 页面、安全响应头、缓存都由 CloudFront 负责。 被刷流量导致扣费的风险,用 CloudFront 的固定价格方案兜底(超出也不收费)。

本页整理自 Phase 10 的实验。发布流程(OCI 的 build 怎么往 AWS 上传)见 CI/CD 和网站发布,IAM 用户和 Key 轮换见 安全,费用的明细见 费用护栏。

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 对象存储。这次 bucket 不公开
OAC(Origin Access Control) — CloudFront 用签名(SigV4)去读 S3;S3 只认这个签名
WAF(Web Application Firewall) — 按规则挡掉恶意请求(限流、已知攻击字符串等)
CloudFront Functions — 在边缘节点执行的小段 JavaScript。这次用来做 www → 根域名的跳转
IAM Identity Center (OCI 的 IAM 用户 + API Key) 统一登录入口。本机 CLI 拿到的是临时凭证
IAM 用户 + 内联 policy Dynamic Group + Policy 这次给 OCI 的 build 用(见第 7 节)
S3 backend + use_lockfile OCI backend(Object Storage) Terraform 的 state 放 S3,锁是 bucket 里的 .tflock 对象

2. 原理:一个请求怎么走

flowchart TB
  user["浏览器"] -->|"https://<域名>"| r53["Route 53<br/>DNS"]
  r53 --> cf["CloudFront 边缘节点<br/>ACM 证书 / WAF / 缓存"]
  cf -->|"没缓存时<br/>带 OAC 签名去取"| s3["S3(私有)<br/>只让这个 CloudFront 读"]
  user -.->|"绕过 CloudFront<br/>直接访问 S3"| x["403 拒绝"]

和 OCI 版最大的区别:

OCI 版 AWS 版
谁能读 bucket 任何人(bucket 本身公开,ObjectReadWithoutList) 只有这一个 CloudFront distribution
HTTPS / 域名 oraclecloud.com 的 URL 和证书,换不了 自己的域名 + ACM 证书
不存在的页面 Object Storage 的 JSON MkDocs 的 404 页面(状态码 404)
安全响应头 加不了 HSTS、X-Content-Type-Options、X-Frame-Options 等
缓存 没有 CloudFront 全球缓存

为什么 bucket 要私有:bucket 公开的话,访问者可以绕过 CDN 直接读,CDN 上做的 HTTPS 强制、WAF、限流都会被绕过。私有 + OAC 让 CloudFront 成为唯一入口。

3. 账号的初始设置

做什么 为什么
root 开 MFA、确认没有 Access Key,以后不用 root root 的权限无法限制,泄露了就是全部
Budget(月 20 USD,实际 / 预测超过阈值时发邮件) 按量计费没有上限,至少要能尽早知道
开 AWS Organizations(单账号)→ IAM Identity Center → 用户 + 许可集 AdministratorAccess 日常从 access portal 登录;本机 CLI 用 aws configure sso → 临时凭证,磁盘上没有长期有效的 Key

AdministratorAccess ≒ root,但不完全一样

只有 root 能做的事:改 root 的邮箱 / 密码、关闭账号、改 Support plan、部分账单设置、解除"把所有人都锁在外面"的 bucket policy 等。 Organizations 的管理账号不受 SCP 限制 → 保护靠 MFA + 不存长期 Key。

Identity Center 的用户不能用一般的 Console 登录页(那是 root 和 IAM 用户的),要从 access portal 进。

4. 域名和证书

  • 域名在 Route 53 注册(Console 手动):1 年、自动续费关、WHOIS 隐私保护开。注册完成后 hosted zone 自动建好
  • hosted zone 用 data source 读,不 import:import 进 Terraform 的话,destroy 时会把 zone 一起删掉
  • ACM 证书必须建在 us-east-1:CloudFront 是全球服务,只认这个 region 的证书 → 给 provider 加一个别名
# 10-aws-site/site/provider.tf(摘录):第二个 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"   # 在 zone 里加 CNAME 证明域名是自己的;CNAME 在就自动续期
}
  • 根域名(apex)不能用 CNAME 指向 CloudFront → 用 Route 53 的 alias 记录(A 和 AAAA 都建,指向 AWS 资源的 alias 查询免费)

5. CloudFront + 私有 S3(OAC)

S3 的 bucket policy 只允许"这个 distribution"读:

# 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]   # 别人的 distribution 也读不了
  }
}

另外还有一条 Deny:不是 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
托管的 cache policy / response headers policy 用 data source 按名字查 ID 不在代码里写死 UUID
CloudFront Function www.<域名> → 301 → https://<域名> 只保留一个正式 URL
minimum_protocol_version = "TLSv1.2_2021" 不接受 TLS 1.0 / 1.1 —

6. Terraform 的 state:S3 backend + use_lockfile

OCI(Terraform) AWS
state 放哪 Object Storage(backend "oci",Terraform 1.12+) S3(backend "s3")
锁 backend 自带 use_lockfile = true(Terraform 1.10+)→ bucket 里的 .tflock 对象。以前要另建 DynamoDB 表
state bucket 自己的 state bootstrap/ 目录,local state 同左
bucket 名 namespace 内唯一 全世界唯一 → bucket_prefix + 随机后缀

state bucket 的设置:版本管理、默认加密、Block Public Access 全开、ACL 禁用、旧版本 90 天删除、只允许 HTTPS。

AWS provider v4 以后,S3 bucket 的版本管理 / 加密 / 生命周期等都是单独的资源(aws_s3_bucket_versioning 等),写在 aws_s3_bucket 里的写法已经 deprecated。

7. OCI 的 build 往 AWS 上传:只能用 Access Key

AWS 里"程序用自己的身份"的标准做法是 IAM Role;但 OCI 的 build runner 不是 AWS 的资源,用不了 → 只能发 IAM 用户的 Access Key(长期有效)。

# 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]  # 只有这个 distribution
}

Key 放在 OCI Vault、不经过 Terraform、定期轮换等补偿措施,见 CI/CD 和网站发布 和 安全。

8. 费用失控的兜底:固定价格方案 Free 档 + WAF

按量计费没有上限,被刷流量时费用跟着涨。OCI 版的对策是自己做"告警 → Function 把 bucket 改成私有"(见 费用护栏);AWS 在 2025 年 11 月推出了 CloudFront 的固定价格方案:

按量计费 固定价格方案 Free 档($0/月)
每月额度 永久免费额度 1TB / 1000 万请求 100GB / 100 万请求(这个网站用不完)
超出时 照常收费,没有上限 不收费;持续超出时降低分发性能
被攻击时 攻击流量也算钱 WAF / DDoS 挡掉的请求不算用量
WAF 另外收费 包含(最多 5 条规则)
hosted zone $0.50/月 包含
  • 条件:distribution 必须关联 WAF web ACL(方案期间不能拿掉);账号不能是 Free Tier;Free 档每个账号最多 3 个
  • WAF 的 4 条规则:同一 IP 5 分钟 500 次以上挡掉 + 3 个 AWS 托管规则组(恶意 IP、已知漏洞的攻击字符串、通用规则)
  • price_class 必须是 PriceClass_All:文档没写,API 只报 not eligible for this subscription tier。在 Console 订阅一次后,用 terraform plan 看 drift 才发现 Console 偷偷改了这个值(见第 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(subnet 级,stateful) NACL(subnet 级,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(namespace 内唯一) S3(全世界唯一)
数据库 Autonomous Database RDS / Aurora(近似)
身份 IAM 用户 / 组 / Policy(文本语法) IAM 用户 / 组 / Policy(JSON)
Compartment (近似)账号划分 / Resource Group
Dynamic Group + Instance Principal / Resource Principal IAM Role(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 版的做法 差异从哪来
网站的入口 bucket 本身公开 私有 bucket + CDN 唯一入口 OCI 版没有 CDN,只能直接公开
凭证 build 用 Resource Principal,没有 Key 跨云只能用 Access Key → 最小权限 + Vault + 轮换 身份只在同一朵云内有效
被刷流量 每分钟检查请求数 → Function 把 bucket 改私有(自己做) 固定价格方案:超出也不收费 + WAF AWS 提供了现成的"费用封顶"
紧急停止 site_public = false(bucket 变私有) site_enabled = false(distribution 停用,几分钟生效) —
region 的限制 Always Free 只在 Home Region ACM 证书、CloudFront 用的 WAF 必须在 us-east-1 CloudFront 是全球服务
名字的唯一性 bucket 名在 namespace 内唯一 S3 bucket 名全世界唯一 —
费用 0(Always Free) 域名 $16/年 + 消费税,其他在 Free 档里 域名要钱

11. 设计决策一览

决策 选了 没选 理由
域名注册 Route 53 其他注册商 DNS / 证书 / CloudFront 直接衔接,Terraform 管 DNS 记录
bucket 的公开方式 私有 + OAC 公开 bucket CloudFront 是唯一入口,HTTPS / WAF 绕不过去
CloudFront → S3 的认证 OAC OAI(旧方式) OAI 已不推荐
证书 ACM(DNS 验证) 邮件验证 DNS 验证只要 CNAME 在就自动续期
hosted zone data source 读 import import 后 destroy 会删掉 zone
本机凭证 Identity Center 的临时凭证 IAM 用户的 Access Key 磁盘上没有长期有效的 Key
state 的锁 use_lockfile DynamoDB 表 少一个资源
费用防护 固定价格方案 Free 档 + WAF 按量计费 + 告警 超出也不收费;攻击流量不算用量
上传工具 自己的脚本(MD5 比较) aws s3 sync sync 的 Content-Type 靠猜,"变没变"看时间(每次 build 都全部重传)

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 bucket 只允许 HTTPS 带凭证、用 HTTP 访问 AccessDenied ... explicit deny
WAF 生效 ?q=<script>...(XSS) WAF BLOCK(访问者看到 404 页面);对照 ?q=hello → 200
deployer 的 Key 权限最小 实际调用 写网站 bucket、清缓存成功;读对象、碰 state bucket、改 policy、列 bucket、看 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(文档没写) 在 Console 做一次 → 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 token 过期(CLI 用的是缓存的 role 凭证) aws sso login --profile <profile>
access portal 的书签第二天打不开 收藏的是登录途中的一次性 URL 收藏 https://d-xxxxxxxxxx.awsapps.com/start
access portal 用密码登录不了 要填用户名(不是邮箱) Console 里重置密码 → 一次性密码
新注册的域名在所在网络打不开 过滤器把新域名判为高风险 不绕过。用 xxxx.cloudfront.net + Host 头确认,或用手机确认
.gitignore 的 site/ 把 10-aws-site/site/ 也排除了 site/ 匹配任何层级的同名目录 改成 /site/(只排除根目录)

14. 自测问题

1. 为什么 S3 bucket 不直接公开,而是只让 CloudFront 读?

公开的话访问者可以绕过 CloudFront 直接读 S3,CDN 上的 HTTPS 强制、WAF、限流都会被绕过。私有 + OAC 让 CloudFront 成为唯一入口。

2. 东京 region 建的 ACM 证书,CloudFront 为什么用不了?

CloudFront 是全球服务,只认 us-east-1 的 ACM 证书。CloudFront 用的 WAF web ACL 也一样要建在 us-east-1。

3. 访问不存在的页面,S3 为什么返回 403 而不是 404?

CloudFront 的 OAC 只有 s3:GetObject,没有 s3:ListBucket。没有列清单的权限时,S3 不告诉你"文件不存在",统一返回 403。用 custom_error_response 换成 404 页面。

4. hosted zone 为什么用 data source 读,而不 import 进 Terraform?

hosted zone 是注册域名时 AWS 自动建的。import 进来就归 Terraform 管,destroy 时会被一起删掉,域名就解析不了了。

5. OCI 的 build 往 S3 上传,为什么不能用 IAM Role?

IAM Role 是给 AWS 自己的资源(EC2、Lambda、CodeBuild 等)用的。OCI 的 build runner 不是 AWS 的资源,只能用 IAM 用户的 Access Key,所以要靠最小权限、Vault 保管、轮换来补偿。

6. 固定价格方案 Free 档和按量计费,被刷流量时有什么不同?

按量计费没有上限,攻击流量也算钱;Free 档超出额度也不收费(持续超出会降低分发性能),被 WAF / DDoS 挡掉的请求也不算用量。

7. API 报错只说「not eligible」,文档里也找不到原因,怎么办?

在 Console 里做一次同样的操作,然后 terraform plan 看 drift:Console 自动改掉的设置就是隐藏的条件。这次是 price_class 被改成了 PriceClass_All。

8. 本机的 AWS CLI 为什么用 Identity Center,而不用 IAM 用户的 Access Key?

Identity Center 给的是临时凭证(会过期),本机磁盘上没有长期有效的 Key;IAM 用户的 Access Key 一直有效,泄露的风险更大。

完整代码

10-aws-site/bootstrap/main.tf(state bucket)
# ------------------------------------------------------------
# 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(hosted zone / 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(私有 bucket + bucket policy)
# ------------------------------------------------------------
# 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(OAC / www 跳转 / distribution)
# ------------------------------------------------------------
# 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
  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"
  #   → Console 订阅时会自动改成 All(drift 里发现的)
  #   按量计费时 All 最贵,但固定价格方案下没有差别。取消方案的话改回 PriceClass_200(含日本、不含南美 / 大洋洲)
  #   免费额度(每月 1TB / 1000 万请求)和 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 build 用的 IAM 用户)
# ------------------------------------------------------------
# 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
}
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 hosted zone 的月费、记录、查询也包含在内(hosted zone 的 $0.50/月 也不用付了)
#   条件:distribution 必须关联一个 WAF web ACL(方案期间不能拿掉);Free 档最多 5 条 WAF 规则
#         每个账号最多 3 个 Free 档;一个方案只能有 1 个 apex 域名
#   ⚠ 取消方案后 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 次请求 → 挡掉(一页大约 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 条规则)
  #   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 + hosted zone 绑在一起
#   Free 档只能 IMMEDIATE(马上生效,不需要"批准"这一步)= 默认值 → 不写 approval_mode
#     (只在新建时用、import 后读不回来 → 写了的话 plan 显示 forces replacement)
#   实际是在 Console 里订阅后 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, # hosted zone 的月费也由方案覆盖
  ]
}