iT邦幫忙

2026 iThome 鐵人賽

DAY 26
0
AI Engineering

知識圖譜 : 技能樹式學習歷程系列 第 26

Day 26 — 第二套:S3 + CloudFront + OAC(用 IaC,不點 console)

  • 分享至 

  • xImage
  •  

今天要解的問題

主站已經在 Cloudflare Pages 上跑得很好(Day 23–25)。今天做第二套部署到 AWS,理由在 Day 21 說過:

  1. 練基礎建設:OAC、最小權限 IAM、IaC、invalidation。託管平台把這些都藏起來了,但它們是真正可轉移的技能。
  2. 廠商風險分散:兩條獨立部署路徑。

紀律:全程不點 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:S3

# 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 表來做狀態鎖的做法——少一個資源要管。

Terraform:CloudFront + OAC

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=aaastyle.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 災難。

Bucket policy:只信任這個 distribution

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 OIDC,不用長期金鑰

# 讓 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

部署腳本:sync 的順序有講究

#!/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 的成本邏輯(很多人踩這個錢坑):

  • 每月前 1000 條 path 免費,之後 $0.005/條
  • /*一條(不是每個檔案一條)。

聽起來 /* 最划算?但它會清掉所有快取,包括那些帶哈希、永遠不需要更新的資產(我的網站有幾十 MB 的 MathJax 與字型)。下次有人訪問時全部要回 S3 重抓,等於自己製造一次 cache miss 風暴。

正解:只失效真正會變的東西。我的清單是 8 個 HTML + sw.js = 9 條,每次部署遠低於免費額度。這正是 Day 24 內容哈希策略的第二個回報——大部分檔案根本不需要 invalidate

--exclude 清單要與 Day 23 的 .cfignore 對齊(同樣的「部署範圍 ≠ 版控範圍」原則)。特別是 .reference/——這是版權風險,不是技術問題

CI 接上

  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 都可以被繞過。

小結與明天預告

今天的重點:

  1. OAC 不是 OAI,S3 一定要私有。 公開 bucket 或 website endpoint 都會讓 CloudFront 的安全設定可被繞過。
  2. AWS:SourceArn 條件不能省,否則任何人的 CloudFront 都能讀你的 bucket。
  3. OIDC 取代長期金鑰sub 條件要限定到分支(不能用 *,PR 來自 fork)。
  4. invalidation 只清會變的東西——/* 雖然便宜但會製造 cache miss 風暴。這是 Day 24 內容哈希的第二個回報。
  5. --delete 是破壞性操作,第一次先 --dryrun。我差點刪掉半個網站。
  6. 同一套驗證腳本打兩個平台,強迫設定保持一致。

明天要打破 Day 1 立下的「不做後端」原則——但會說清楚為什麼現在才值得,以及為什麼 localStorage 仍然是 source of truth。重點是進度同步的衝突合併策略(Day 17 已經想好了一半)。


上一篇
Day 25 — 安全 header 上線版:CSP 從 meta 升級成 header
下一篇
Day 27 — 跨裝置同步(一):Serverless 後端與衝突合併
系列文
知識圖譜 : 技能樹式學習歷程29
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言