主站已經在 Cloudflare Pages 上跑得很好(Day 23–25)。今天做第二套部署到 AWS,理由在 Day 21 說過:
紀律:全程不點 console。 所有資源用 Terraform 描述。理由不是潔癖——手點的設定無法 review、無法重現、三個月後你不記得為什麼那個選項是勾的。
使用者 → CloudFront(TLS、快取、安全 header)
│ OAC 簽名請求
▼
S3 bucket(私有,無公開存取)
關鍵字是 OAC(Origin Access Control)。它是 OAI(Origin Access Identity)的後繼者,AWS 已建議新專案一律用 OAC。
絕對不要做的兩件事(網路上大量過時教學還在教):
| 反模式 | 問題 |
|---|---|
| S3 開 public read | 任何人可以繞過 CloudFront 直接打 S3,完全沒有 header、沒有快取、沒有 WAF |
| S3 static website hosting endpoint | 那個 endpoint 只支援 HTTP,CloudFront 到來源就變成明文 |
正確做法:S3 bucket 完全私有,只信任來自「我這個 CloudFront distribution」的簽名請求。
# terraform/main.tf
terraform {
required_version = ">= 1.6"
required_providers {
aws = { source = "hashicorp/aws", version = "~> 5.0" }
}
# 狀態檔放遠端,不要放本機(多人/多機才不會打架)
backend "s3" {
bucket = "learnpath-tfstate"
key = "site/terraform.tfstate"
region = "ap-northeast-1"
encrypt = true
use_lockfile = true # S3 原生鎖,取代已淘汰的 DynamoDB 鎖表
}
}
provider "aws" {
region = "ap-northeast-1" # 東京:S3 放這裡
default_tags {
tags = { Project = "learnpath", ManagedBy = "terraform" }
}
}
# ACM 憑證必須在 us-east-1(CloudFront 的硬性要求)
provider "aws" {
alias = "use1"
region = "us-east-1"
}
variable "domain" { type = string } # 例:aws.learnpath.example.com
resource "aws_s3_bucket" "site" {
bucket = "learnpath-site-${data.aws_caller_identity.me.account_id}"
}
data "aws_caller_identity" "me" {}
# 明確封鎖所有公開存取(預設值近年已改為封鎖,但明寫是好習慣)
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_server_side_encryption_configuration" "site" {
bucket = aws_s3_bucket.site.id
rule {
apply_server_side_encryption_by_default { sse_algorithm = "AES256" }
}
}
# 版本控制:誤刪/誤蓋可以回溯
resource "aws_s3_bucket_versioning" "site" {
bucket = aws_s3_bucket.site.id
versioning_configuration { status = "Enabled" }
}
# 舊版本 30 天後清掉,避免無限累積存儲費
resource "aws_s3_bucket_lifecycle_configuration" "site" {
bucket = aws_s3_bucket.site.id
rule {
id = "expire-old-versions"
status = "Enabled"
filter {}
noncurrent_version_expiration { noncurrent_days = 30 }
abort_incomplete_multipart_upload { days_after_initiation = 7 }
}
}
use_lockfile = true 取代了以前必須另建 DynamoDB 表來做狀態鎖的做法——少一個資源要管。
resource "aws_cloudfront_origin_access_control" "site" {
name = "learnpath-oac"
origin_access_control_origin_type = "s3"
signing_behavior = "always"
signing_protocol = "sigv4"
}
# 安全 header:與 Day 25 的 _headers 對齊(同一份策略,兩個平台)
resource "aws_cloudfront_response_headers_policy" "sec" {
name = "learnpath-security-headers"
security_headers_config {
content_security_policy {
override = true
content_security_policy = join("; ", [
"default-src 'self'",
"script-src 'self'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data:",
"font-src 'self'",
"connect-src 'self'",
"worker-src 'self'",
"manifest-src 'self'",
"object-src 'none'",
"base-uri 'self'",
"form-action 'none'",
"frame-ancestors 'none'",
"upgrade-insecure-requests",
])
}
strict_transport_security {
override = true
access_control_max_age_sec = 63072000
include_subdomains = true
preload = true
}
content_type_options { override = true } # nosniff
frame_options { override = true, frame_option = "DENY" }
referrer_policy {
override = true
referrer_policy = "strict-origin-when-cross-origin"
}
}
custom_headers_config {
items {
header = "Permissions-Policy"
value = "geolocation=(), camera=(), microphone=(), payment=(), usb=(), interest-cohort=()"
override = true
}
items {
header = "Cross-Origin-Opener-Policy"
value = "same-origin"
override = true
}
}
}
# 快取策略:帶哈希的資產(Day 24)
resource "aws_cloudfront_cache_policy" "assets" {
name = "learnpath-assets"
default_ttl = 31536000
max_ttl = 31536000
min_ttl = 31536000
parameters_in_cache_key_and_forwarded_to_origin {
enable_accept_encoding_gzip = true
enable_accept_encoding_brotli = true
cookies_config { cookie_behavior = "none" }
headers_config { header_behavior = "none" }
# ⚠️ query string 必須進 cache key——Day 24 的 ?v=hash 靠它區分版本
query_strings_config { query_string_behavior = "all" }
}
}
# HTML:不快取(永遠回來源問)
resource "aws_cloudfront_cache_policy" "html" {
name = "learnpath-html"
default_ttl = 0
max_ttl = 0
min_ttl = 0
parameters_in_cache_key_and_forwarded_to_origin {
enable_accept_encoding_gzip = true
enable_accept_encoding_brotli = true
cookies_config { cookie_behavior = "none" }
headers_config { header_behavior = "none" }
query_strings_config { query_string_behavior = "all" }
}
}
resource "aws_cloudfront_distribution" "site" {
enabled = true
is_ipv6_enabled = true
default_root_object = "index.html"
aliases = [var.domain]
price_class = "PriceClass_200" # 含亞洲節點,排除南美/非洲(省錢)
comment = "learnpath static site"
origin {
domain_name = aws_s3_bucket.site.bucket_regional_domain_name
origin_id = "s3-site"
origin_access_control_id = aws_cloudfront_origin_access_control.site.id
}
# 預設行為:HTML
default_cache_behavior {
target_origin_id = "s3-site"
viewer_protocol_policy = "redirect-to-https"
allowed_methods = ["GET", "HEAD", "OPTIONS"]
cached_methods = ["GET", "HEAD"]
compress = true
cache_policy_id = aws_cloudfront_cache_policy.html.id
response_headers_policy_id = aws_cloudfront_response_headers_policy.sec.id
}
# 帶哈希的資產:長快取
dynamic "ordered_cache_behavior" {
for_each = ["/js/*", "/css/*", "/vendor/*", "/icons/*"]
content {
path_pattern = ordered_cache_behavior.value
target_origin_id = "s3-site"
viewer_protocol_policy = "redirect-to-https"
allowed_methods = ["GET", "HEAD"]
cached_methods = ["GET", "HEAD"]
compress = true
cache_policy_id = aws_cloudfront_cache_policy.assets.id
response_headers_policy_id = aws_cloudfront_response_headers_policy.sec.id
}
}
# sw.js 絕對不能快取(Day 24)
ordered_cache_behavior {
path_pattern = "/sw.js"
target_origin_id = "s3-site"
viewer_protocol_policy = "redirect-to-https"
allowed_methods = ["GET", "HEAD"]
cached_methods = ["GET", "HEAD"]
compress = true
cache_policy_id = aws_cloudfront_cache_policy.html.id
response_headers_policy_id = aws_cloudfront_response_headers_policy.sec.id
}
# 404 用自訂頁,但保持 404 狀態碼(不是 SPA fallback!)
custom_error_response {
error_code = 404
response_code = 404
response_page_path = "/404.html"
error_caching_min_ttl = 300
}
custom_error_response {
error_code = 403 # S3 對不存在的 key 回 403
response_code = 404
response_page_path = "/404.html"
error_caching_min_ttl = 300
}
viewer_certificate {
acm_certificate_arn = aws_acm_certificate_validation.site.certificate_arn
ssl_support_method = "sni-only"
minimum_protocol_version = "TLSv1.2_2021"
}
restrictions { geo_restriction { restriction_type = "none" } }
logging_config {
bucket = aws_s3_bucket.logs.bucket_domain_name
prefix = "cf/"
include_cookies = false
}
}
三個容易寫錯的地方:
1. query_string_behavior = "all"。 漏了它,CloudFront 的 cache key 不含 query string,於是 style.css?v=aaa 與 style.css?v=bbb 被當成同一個資源——Day 24 的整套快取策略直接失效。(這也是為什麼 Cloudflare 那邊我不用擔心:它預設就把 query 納入 cache key。)
2. 403 也要映射到 404。 S3 對「不存在的 key」回的是 403 而不是 404(因為 bucket 是私有的,它不告訴你檔案存不存在)。只處理 404 的話,打錯網址會得到 CloudFront 的原生 403 錯誤頁。
3. response_code = 404 不是 200。 這是 Day 23 提過的 SPA fallback 陷阱的 AWS 版:如果寫 response_code = 200,所有不存在的網址都會回「首頁內容 + 200」,SEO 災難。
data "aws_iam_policy_document" "bucket" {
statement {
sid = "AllowCloudFrontServicePrincipalReadOnly"
actions = ["s3:GetObject"]
resources = ["${aws_s3_bucket.site.arn}/*"]
principals {
type = "Service"
identifiers = ["cloudfront.amazonaws.com"]
}
# ← 關鍵:限定「我這一個 distribution」,而不是所有 CloudFront
condition {
test = "StringEquals"
variable = "AWS:SourceArn"
values = [aws_cloudfront_distribution.site.arn]
}
}
}
resource "aws_s3_bucket_policy" "site" {
bucket = aws_s3_bucket.site.id
policy = data.aws_iam_policy_document.bucket.json
}
AWS:SourceArn 條件不能省。 少了它,policy 變成「任何 CloudFront distribution 都能讀我的 bucket」——別人在自己帳號建一個 distribution 指向我的 bucket 就能取用內容。這是 OAC 設定最常見的安全漏洞。
# 讓 GitHub Actions 用 OIDC 換取臨時憑證,不需要 AWS_SECRET_ACCESS_KEY
resource "aws_iam_openid_connect_provider" "github" {
url = "https://token.actions.githubusercontent.com"
client_id_list = ["sts.amazonaws.com"]
thumbprint_list = ["6938fd4d98bab03faadb97b34396831e3780aea1"]
}
data "aws_iam_policy_document" "assume" {
statement {
actions = ["sts:AssumeRoleWithWebIdentity"]
principals {
type = "Federated"
identifiers = [aws_iam_openid_connect_provider.github.arn]
}
condition {
test = "StringEquals"
variable = "token.actions.githubusercontent.com:aud"
values = ["sts.amazonaws.com"]
}
# ← 限定「我這個 repo 的 main 分支」,不是整個 GitHub
condition {
test = "StringEquals"
variable = "token.actions.githubusercontent.com:sub"
values = ["repo:myuser/learnpath:ref:refs/heads/main"]
}
}
}
resource "aws_iam_role" "deploy" {
name = "learnpath-deploy"
assume_role_policy = data.aws_iam_policy_document.assume.json
}
# 最小權限:只能碰這個 bucket 與這個 distribution
data "aws_iam_policy_document" "deploy" {
statement {
actions = ["s3:ListBucket"]
resources = [aws_s3_bucket.site.arn]
}
statement {
actions = ["s3:PutObject", "s3:DeleteObject"]
resources = ["${aws_s3_bucket.site.arn}/*"]
}
statement {
actions = ["cloudfront:CreateInvalidation", "cloudfront:GetInvalidation"]
resources = [aws_cloudfront_distribution.site.arn]
}
}
resource "aws_iam_role_policy" "deploy" {
role = aws_iam_role.deploy.id
policy = data.aws_iam_policy_document.deploy.json
}
兩個要點:
1. OIDC 取代長期金鑰。 沒有 AWS_ACCESS_KEY_ID 存在 GitHub secrets,也就沒有「金鑰洩漏」這個風險類別。Actions 每次執行換一組 15 分鐘的臨時憑證。
2. sub 條件必須夠精確。 寫成 repo:myuser/learnpath:* 的話,任何分支、任何 PR 都能取得部署權限——而 PR 可以來自 fork。這是實際被利用過的攻擊路徑。我限定到 ref:refs/heads/main。
#!/bin/bash
# scripts/deploy-aws.sh
set -euo pipefail
BUCKET="${S3_BUCKET:?}"
DIST="${CF_DIST_ID:?}"
# 1) 先上帶哈希的資產(長快取)。順序重要:先資產再 HTML,
# 否則中間有幾秒 HTML 指向還不存在的資產。
aws s3 sync . "s3://$BUCKET" \
--delete \
--exclude "*" \
--include "js/*" --include "css/*" --include "vendor/*" --include "icons/*" \
--cache-control "public, max-age=31536000, immutable"
# 2) 再上 HTML 與其他(不快取)
aws s3 sync . "s3://$BUCKET" \
--delete \
--exclude ".git/*" --exclude "scripts/*" --exclude "doc/*" --exclude "wiki/*" \
--exclude ".reference/*" --exclude ".tools/*" --exclude "terraform/*" \
--exclude "js/*" --exclude "css/*" --exclude "vendor/*" --exclude "icons/*" \
--exclude "sw.js" \
--cache-control "public, max-age=0, must-revalidate"
# 3) sw.js 單獨上,header 不同
aws s3 cp sw.js "s3://$BUCKET/sw.js" \
--cache-control "no-cache, no-store, must-revalidate"
# 4) invalidation:只失效 HTML 與 sw.js
# 帶哈希的資產永遠不需要 invalidate(新內容 = 新網址)
aws cloudfront create-invalidation \
--distribution-id "$DIST" \
--paths "/" "/index.html" "/course.html" "/chapter.html" \
"/tables.html" "/glossary.html" "/dashboard.html" "/404.html" "/sw.js" \
--query 'Invalidation.Id' --output text
invalidation 的成本邏輯(很多人踩這個錢坑):
/* 算一條(不是每個檔案一條)。聽起來 /* 最划算?但它會清掉所有快取,包括那些帶哈希、永遠不需要更新的資產(我的網站有幾十 MB 的 MathJax 與字型)。下次有人訪問時全部要回 S3 重抓,等於自己製造一次 cache miss 風暴。
正解:只失效真正會變的東西。我的清單是 8 個 HTML + sw.js = 9 條,每次部署遠低於免費額度。這正是 Day 24 內容哈希策略的第二個回報——大部分檔案根本不需要 invalidate。
--exclude 清單要與 Day 23 的 .cfignore 對齊(同樣的「部署範圍 ≠ 版控範圍」原則)。特別是 .reference/——這是版權風險,不是技術問題。
deploy-aws:
if: github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
permissions:
id-token: write # ← OIDC 必需
contents: read
steps:
- uses: actions/checkout@v4
with: { ref: ${{ github.event.workflow_run.head_sha }} }
- run: node scripts/stamp-assets.js
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/learnpath-deploy
aws-region: ap-northeast-1
- run: bash scripts/deploy-aws.sh
env:
S3_BUCKET: ${{ vars.S3_BUCKET }}
CF_DIST_ID: ${{ vars.CF_DIST_ID }}
- name: 線上驗證(與主站同一套腳本)
run: |
bash scripts/check-headers.sh https://aws.learnpath.example.com
bash scripts/check-cache.sh https://aws.learnpath.example.com
用同一套 check-headers.sh 打兩個平台,是這個「雙部署」設計最實用的副產品:它強迫兩邊的安全設定保持一致。哪天我在 Cloudflare 改了 CSP 卻忘記改 Terraform,CI 立刻紅燈。
| 項目 | 用量 | 費用 |
|---|---|---|
| Route 53 hosted zone | 1 個 | $0.50 |
| Route 53 查詢 | 約 12k | $0.005 |
| S3 儲存 | 42 MB | $0.001 |
| S3 請求(部署 sync) | 約 3k PUT | $0.015 |
| CloudFront 流量 | 1.8 GB | $0.00(前 1 TB 免費層) |
| CloudFront 請求 | 約 24k | $0.00(免費層) |
| ACM 憑證 | 1 個 | $0.00(CloudFront 用免費) |
| Invalidation | 約 270 條 | $0.00(前 1000 免費) |
| 合計 | 約 $0.52 |
CloudFront 的永久免費層(每月 1 TB 流出 + 1000 萬請求)對個人專案完全夠用。主要成本是 Route 53 的 $0.50 固定費——如果把 DNS 留在原註冊商,這套幾乎是免費的。
ACM 憑證必須在 us-east-1。 CloudFront 只認 us-east-1 的憑證,不管 distribution 或 bucket 在哪。所以 Terraform 需要那個 alias = "use1" 的第二個 provider。第一次做的人幾乎都會在這裡卡住(憑證明明簽好了,CloudFront 就是選不到)。
aws s3 sync --delete 的順序陷阱。 我第一版只跑一次 sync(帶 --delete),結果每次部署都把另一半檔案刪掉:第一次 sync 帶 --include js/* 會把不符合條件的檔案(HTML)視為「來源沒有」而刪除。
修法有兩條:分兩次 sync 但只在第二次帶 --delete(我的 --exclude 清單已經涵蓋全部檔案,所以第二次的 --delete 是安全的),或者用 --exclude 精確互補。我實際上用的是後者——上面腳本裡兩次 sync 的 include/exclude 剛好互補,各自的 --delete 只會影響自己的範圍。
這個雷差點造成線上網站半殘。 部署完首頁在、CSS 不在。教訓:--delete 是破壞性操作,第一次一定要先跑 --dryrun:
aws s3 sync . "s3://$BUCKET" --delete --dryrun | grep '^delete:' | head -20
Terraform 狀態檔不要進 git。 terraform.tfstate 含資源 ID 與(可能的)敏感輸出。用 S3 backend(上面的設定),並且 .gitignore 加:
terraform/.terraform/
terraform/*.tfstate
terraform/*.tfstate.backup
terraform/.terraform.lock.hcl # 這個其實應該 commit(鎖定 provider 版本)
最後一行是例外——.terraform.lock.hcl 應該 commit,它鎖定 provider 版本,是可重現性的一部分。
price_class 影響成本也影響延遲。 PriceClass_All 用全部邊緣節點但最貴;PriceClass_100 只有北美+歐洲(亞洲使用者會被導到很遠的節點,實測台灣 TTFB 從 30 ms 變 180 ms)。我用 PriceClass_200(含亞洲,排除南美/非洲/大洋洲部分節點)。
cd terraform
terraform init
terraform plan -var="domain=aws.learnpath.example.com" # 先看要建什麼
terraform apply -var="domain=aws.learnpath.example.com"
安全性驗證(這是今天的重點):
# 1. S3 不得能直接訪問(OAC 生效的證明)
curl -sI "https://learnpath-site-123456789012.s3.ap-northeast-1.amazonaws.com/index.html" \
| head -1
# 預期:HTTP/1.1 403 Forbidden
# 2. CloudFront 可以訪問
curl -sI "https://aws.learnpath.example.com/index.html" | head -1
# 預期:HTTP/2 200
# 3. 公開存取封鎖確認
aws s3api get-public-access-block --bucket learnpath-site-123456789012
# 四個欄位都應為 true
# 4. bucket policy 的 SourceArn 條件存在
aws s3api get-bucket-policy --bucket learnpath-site-123456789012 \
--query Policy --output text | python3 -m json.tool | grep -A3 SourceArn
# 5. 安全 header 與快取(與主站同一套腳本)
bash scripts/check-headers.sh https://aws.learnpath.example.com
bash scripts/check-cache.sh https://aws.learnpath.example.com
# 6. 404 行為
curl -so /dev/null -w '%{http_code}\n' https://aws.learnpath.example.com/nope.html # 404
# 7. 完整功能
SMOKE_BASE=https://aws.learnpath.example.com python3 scripts/smoke-test.py
第 1 項是整個架構的核心驗證:S3 直連必須是 403。如果它回 200,OAC 沒生效、bucket 是公開的,前面所有安全 header 都可以被繞過。
今天的重點:
AWS:SourceArn 條件不能省,否則任何人的 CloudFront 都能讀你的 bucket。sub 條件要限定到分支(不能用 *,PR 來自 fork)。/* 雖然便宜但會製造 cache miss 風暴。這是 Day 24 內容哈希的第二個回報。--delete 是破壞性操作,第一次先 --dryrun。我差點刪掉半個網站。明天要打破 Day 1 立下的「不做後端」原則——但會說清楚為什麼現在才值得,以及為什麼 localStorage 仍然是 source of truth。重點是進度同步的衝突合併策略(Day 17 已經想好了一半)。
iThome鐵人賽