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 的月费也由方案覆盖
]
}