iT邦幫忙

2026 iThome 鐵人賽

DAY 29
0
Modern Web

前端來點 AWS 技能樹!系列 第 29 篇

Day29 - 設好 Cache,讓 GitHub Actions 自動部署到 S3+CloudFront

  • 分享至 

  • xImage
  •  

Day28 幫 S3+CloudFront 這條路接上了自己的 Domain,不過每次改內容,還是要自己手動 build、aws s3 sync、再清 CloudFront 的 cache。所以今天先把 cache 的規則設好,再把這些步驟交給 GitHub Actions,push 到 main 之後就自動部署ㄅ!

為什麼改了內容,還是看到舊的?

先看看現在的網站,回了哪些跟 cache 有關的 header:

DOMAIN=static.example.com
curl -sI "https://$DOMAIN/" | grep -iE '^(cache-control|last-modified|age|x-cache)'

結果會出現類似:

last-modified: Sun, 11 Oct 2026 13:14:42 GMT
x-cache: Hit from cloudfront
age: 29905

裡面沒有 cache-control。S3 只是放檔案的倉庫,上傳時沒有幫檔案設定,回應時就不會帶。沒有 cache-control 的時候,兩邊的 cache 是這樣決定的:

誰的 cache 沒有 cache-control 的時候
CloudFront 照 Day27 選的 CachingOptimized,預設放 24 小時。age 是這份 cache 已經放了幾秒,29905 秒大約是 8 小時
瀏覽器 自己猜(heuristic caching)。常見的做法是拿「距離 last-modified 過了多久」的 10%,例如 10 天前上傳的檔案,瀏覽器可能一整天都不回來問

如果看到的是 x-cache: RefreshHit from cloudfront、也沒有 age 的話,表示 CloudFront 手上那份已經放滿 24 小時而過期了。所以它會先回去問 S3 檔案有沒有變;沒變的話就繼續用手上那份,並且重新計時,也因此不會有 age。再執行一次,就會看到 Hit from cloudfront 和一個很小的 age。

CloudFront 的 cache,我們可以用 invalidation 清掉;但每個使用者瀏覽器裡的 cache,我們清不到。已經被瀏覽器猜著存起來的舊檔案,只能等它自己過期,所以 cache-control 最好在網站一上線就設好喔!
那 EC2 那條路為什麼沒遇到這個問題?拿 EC2 上的網站來對照,看看首頁和 _next/static 底下的一個 JS 檔:

curl -sI https://example.com/ | grep -i '^cache-control'
JS=$(curl -s https://example.com/ | grep -oE '/_next/static/[^"]+\.js' | head -1)
curl -sI "https://example.com$JS" | grep -i '^cache-control'

會看到類似:

cache-control: s-maxage=31536000
cache-control: public, max-age=31536000, immutable

這兩行是 EC2 上的 Next.js Server 自己加的,它會依照檔案的種類送不同的 Cache-Control。換到 S3 之後沒有 Server 了,這件事就要我們在上傳的時候自己做。

檔案分成兩種

out 裡的檔案,可以照「網址會不會變」分成兩種:

網址會不會變 例子
_next/static 底下的 JS、CSS、字型 會。內容一改,檔名裡的 hash 就跟著變 _next/static/chunks/0a1b2c3d4e5f6789.js
其他檔案 不會,永遠是同一個網址 index.html、index.txt、404.html、favicon.ico

檔名會變的檔案,舊網址永遠是舊內容,新內容會換一個新網址,所以可以放心 cache 很久。網址不變的檔案就不一樣了,改版之後一定要讓大家拿到新版。剛剛也有提到,CloudFront 的 cache 是我們清得到,但使用者瀏覽器的我們不行了,所以訂下的規則是這樣:

檔案 Cache-Control 瀏覽器 CloudFront
_next/static 底下 public, max-age=31536000, immutable 一年內直接用 一年
其他檔案 public, max-age=0, s-maxage=31536000 每次用之前先問 一年,每次部署時清掉

每個值的意思:

寫法 意思
public 瀏覽器和 CloudFront 都可以把它存起來
max-age=31536000 存起來之後,31,536,000 秒(一年)內直接用,不用回來問
immutable 這個檔案不會變,使用者按重新整理也不用回來問
max-age=0 存起來之後馬上就算過期,下次要用之前,要先問一次有沒有新版
s-maxage=31536000 只給 CloudFront 這種很多人共用的 cache 看:放一年。CloudFront 看到 s-maxage 就照它,不看 max-age;瀏覽器則只看 max-age

_next/static 的設定跟 EC2 上的 Next.js 一模一樣。HTML 也跟 Next.js 一樣用 s-maxage 讓 CloudFront 放一年,那為什麼多了一個 max-age=0?因為 S3 的回應一定會帶 last-modified,如果沒寫 max-age 的話,瀏覽器就會拿 last-modified 來猜要存多久。
「每次都先問」其實很輕:瀏覽器會帶著上次拿到的 ETag 去問,內容沒變的話,CloudFront 直接從最近的據點回 304 Not Modified,不用再傳一次整個檔案。可以自己試試看:

ETAG=$(curl -sI "https://$DOMAIN/" | grep -i '^etag:' | cut -d' ' -f2 | tr -d '\r')
curl -sI -H "If-None-Match: $ETAG" "https://$DOMAIN/" | grep -iE '^(HTTP|x-cache)'

應該會看到類似結果:

HTTP/2 304
x-cache: Hit from cloudfront

CloudFront 這邊,Day27 選的 CachingOptimized 允許的 cache 時間是最少 1 秒、最多一年。檔案有寫 s-maxage 或 max-age 時,就照檔案寫的時間;都沒寫,才用預設的 24 小時。

上傳要分兩次,順序也有關係

兩種檔案要設定不同的 Cache-Control,所以 aws s3 sync 要分兩次執行,而且要先傳 _next/static:

BUCKET=my-app-static-20261010

# 1. 先傳 _next/static, cache 一年
aws s3 sync out/_next/static "s3://$BUCKET/_next/static" \
  --cache-control "public, max-age=31536000, immutable"

# 2. 再傳其他檔案,瀏覽器每次都要先問
aws s3 sync out/ "s3://$BUCKET/" \
  --exclude "_next/static/*" \
  --cache-control "public, max-age=0, s-maxage=31536000" \
  --delete
  • --cache-control:上傳時把 Cache-Control 存進檔案的設定(metadata),之後 S3 回應這個檔案時都會帶上它。
  • 先傳 _next/static:新的 index.html 會指向新檔名的 JS 和 CSS。如果先傳 HTML,剛好在這幾秒打開網站的人,會拿到新的 HTML、卻找不到新的 JS 和 CSS,畫面就壞了。
  • --exclude "_next/static/*":第二次跳過 _next/static,剛剛已經傳過了。
  • --delete:把 bucket 裡有、但 out 裡已經沒有的檔案刪掉,例如被刪掉的頁面。被 --exclude 排除的檔案不會被刪,所以舊版的 _next/static 會留著。已經開著舊頁面的人,之後還可能去讀舊的 JS,留著才不會壞掉。這些檔案都很小,想清的話再找時間手動清就好。

如果是在自己的電腦上執行,記得跟 Day27 一樣加上 --region。另外,aws s3 sync 只會上傳「大小不一樣」或「本機比較新」的檔案,沒有重新 build 就再 sync 一次的話,檔案沒變,Cache-Control 也不會被更新。等一下交給 GitHub Actions 之後,每次都是重新 build,所有檔案也都會重新上傳,就不會遇到這個問題。
傳完再清 CloudFront 的 cache,就是 Day27 用過的這個指令:

aws cloudfront create-invalidation --distribution-id E1A2B3C4D5E6F7 --paths "/*"

/* 記得加引號,不然終端機會先把它展開成檔案名稱。每個 AWS 帳號每個月前 1,000 個 invalidation 路徑免費,/* 不管清掉多少檔案都只算 1 個,一天部署好幾次也用不完。

幫 Role 加上 S3 和 CloudFront 的權限

Day25 的 gha-deploy-role 只有 ECR 和 SSM 的權限,今天幫它多加一份 S3 和 CloudFront 的。
到 IAM 的 Console:

  1. 左邊選單點 Roles(角色),點進 gha-deploy-role。
  2. 在 Permissions(許可) Tab 按 Add permissions(新增許可),選 Create inline policy(建立內嵌政策)。
  3. 切到 JSON,把內容換成下面這段,再把 bucket 名稱、123456789012、E1A2B3C4D5E6F7 換成自己的。
  4. 按 Next,Policy name 填 static-deploy-permissions,按 Create policy(建立政策)。
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "ListStaticBucket",
            "Effect": "Allow",
            "Action": "s3:ListBucket",
            "Resource": "arn:aws:s3:::my-app-static-20261010"
        },
        {
            "Sid": "UploadAndDeleteFiles",
            "Effect": "Allow",
            "Action": [
                "s3:PutObject",
                "s3:DeleteObject"
            ],
            "Resource": "arn:aws:s3:::my-app-static-20261010/*"
        },
        {
            "Sid": "ClearCloudFrontCache",
            "Effect": "Allow",
            "Action": [
                "cloudfront:CreateInvalidation",
                "cloudfront:GetInvalidation"
            ],
            "Resource": "arn:aws:cloudfront::123456789012:distribution/E1A2B3C4D5E6F7"
        }
    ]
}

逐段來看:

  • ListStaticBucket:aws s3 sync 會先列出 bucket 裡已經有哪些檔案,才知道哪些要傳、哪些要刪。s3:ListBucket 是對 bucket 本身,所以 Resource 結尾沒有 /*。
  • UploadAndDeleteFiles:上傳和刪除是對 bucket 裡的檔案,所以 Resource 結尾要有 /*。這兩段的 Resource 很容易寫反,寫反就會看到 AccessDenied。
  • ClearCloudFrontCache:建立 invalidation,以及查詢它清完了沒,只限這一個 distribution。CloudFront 是全球服務,所以 ARN 裡沒有 region。

這裡沒有給 s3:GetObject,因為上傳不需要讀 S3 上的檔案內容。

練習專案的兩條路都是從 main 部署,所以共用 Day24 那個只信任 main 的 Role 就好。但像是我的 side project 不一樣:正式站從 main 部署,S3 的展示版則是從另一個 branch 部署,所以我另外開了一個只信任那個 branch、只有上面這三段權限的 Role,正式站那個 Role 的信任設定一個字都不用改。

為什麼要拆成兩個 job?

今天的 workflow 會拆成 build 和 deploy 兩個 job,原因跟 npm ci 有關。
npm ci 會裝上百個套件,有些套件在安裝或 build 的時候會執行自己的程式。但 workflow 只要給了 id-token: write,同一個 job 裡的每個步驟,都可以拿得到跟 GitHub 要 OIDC token 用的網址和憑證(ACTIONS_ID_TOKEN_REQUEST_URL、ACTIONS_ID_TOKEN_REQUEST_TOKEN 這兩個環境變數)。也就是說,套件裡的程式也能自己去換 gha-deploy-role 的身分。這不是想太多:2025 年 11 月的 Shai-Hulud 2.0 事件,就是被植入惡意程式的 npm 套件在安裝時執行,偷走 CI 環境裡的 token 和雲端憑證。
Day25 的 npm ci 是在 docker build 裡面跑的,碰不到 Runner 上的環境變數;今天則是直接改在 Runner 上 build,所以把它們拆開:

job 做什麼 有沒有 id-token: write
build npm ci、npm run build,把 out 打包交給下一個 job 沒有,換不到 AWS 的身分
deploy 拿到 out、換 AWS 身分、上傳 S3、清 cache 有,但不執行任何套件的程式,只跑 AWS CLI

寫 deploy-static.yml

在練習專案中新增 .github/workflows/deploy-static.yml,env 底下的四個值換成自己的:

name: Deploy static site

on:
  push:
    branches: [main]
  workflow_dispatch:

permissions:
  contents: read

concurrency:
  group: deploy-static
  cancel-in-progress: false

env:
  AWS_REGION: ap-east-2
  S3_BUCKET: my-app-static-20261010
  CLOUDFRONT_DISTRIBUTION_ID: E1A2B3C4D5E6F7
  SITE_URL: https://static.example.com

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v7

      - name: Setup Node
        uses: actions/setup-node@v7
        with:
          node-version: 22
          cache: npm

      - name: Install dependencies
        run: npm ci

      - name: Build static site
        run: npm run build
        env:
          STATIC_EXPORT: "true"

      - name: Upload build output
        uses: actions/upload-artifact@v7
        with:
          name: site
          path: out/
          if-no-files-found: error
          retention-days: 1

  deploy:
    needs: build
    runs-on: ubuntu-latest
    permissions:
      id-token: write
    steps:
      - name: Download build output
        uses: actions/download-artifact@v8
        with:
          name: site
          path: out

      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v6
        with:
          role-to-assume: ${{ vars.AWS_ROLE_ARN }}
          aws-region: ${{ env.AWS_REGION }}

      - name: Upload to S3
        run: |
          aws s3 sync out/_next/static "s3://$S3_BUCKET/_next/static" \
            --cache-control "public, max-age=31536000, immutable"
          aws s3 sync out/ "s3://$S3_BUCKET/" \
            --exclude "_next/static/*" \
            --cache-control "public, max-age=0, s-maxage=31536000" \
            --delete

      - name: Invalidate CloudFront cache
        run: |
          INVALIDATION_ID=$(aws cloudfront create-invalidation \
            --distribution-id "$CLOUDFRONT_DISTRIBUTION_ID" \
            --paths "/*" \
            --query Invalidation.Id --output text)
          echo "Invalidation ID: $INVALIDATION_ID"
          aws cloudfront wait invalidation-completed \
            --distribution-id "$CLOUDFRONT_DISTRIBUTION_ID" \
            --id "$INVALIDATION_ID"

      - name: Smoke test
        run: |
          curl --silent --show-error --head "$SITE_URL/" | grep -iE '^(HTTP|cache-control|x-cache)'
          curl --fail --silent --show-error --output served-index.html "$SITE_URL/"
          cmp served-index.html out/index.html

on、concurrency、env、Configure AWS credentials 這幾段,跟 Day25 的 deploy.yml 一樣,就不重複了。來看不一樣的地方:

  • permissions:最上面只給 contents: read,讓 build job 可以 checkout。deploy job 自己寫了 permissions,這個 job 就照自己的設定,只有 id-token: write。它不用 checkout,所以不需要 contents: read。
  • concurrency:group 跟 Day25 的 deploy 不一樣。兩個 workflow 可以同時跑,但同一個 workflow 不會有兩次部署同時進行。
  • Setup Node:裝 Node.js 22,跟 Day9 Dockerfile 的 node:22-alpine 一樣。cache: npm 會把下載過的套件存起來,下次 npm ci 比較快。
  • Build static site:跟 Day27 一樣加上 STATIC_EXPORT=true,build 出 out 資料夾。
  • Upload build output、Download build output:兩個 job 跑在不同的 Runner 上,檔案不會共用。所以 build 完先把 out 上傳成 artifact,deploy 再下載回來。retention-days: 1 是讓 GitHub 一天後刪掉它,真正的網站已經在 S3 上了。
  • needs: build:deploy 要等 build 成功才開始,build 失敗就不會部署。
  • Upload to S3:就是剛剛那兩次 aws s3 sync。region 在 Configure AWS credentials 設好了,不用再加 --region。
  • Invalidate CloudFront cache:清 cache 之後,用 wait 等它真的清完。
  • Smoke test:先印出首頁的 header,再把線上的首頁抓下來,用 cmp 跟這次 build 出來的 index.html 比對。兩個檔案一模一樣時,cmp 不會印出任何東西;不一樣的話,會印出第一個不同的位置,這一步就會失敗。

push 上 main,看它自己部署

為了看得出有沒有更新,把 Day25 在首頁加的那行字改一下:

<p>Deployed by GitHub Actions: Day29</p>
git add .github/workflows/deploy-static.yml app/page.tsx
git commit -m "ci: push 到 main 自動部署到 S3+CloudFront"
git push origin main

這次 push 會同時觸發兩個 workflow:Day25 的 Deploy to EC2,和今天的 Deploy static site。點進 Deploy static site,可以看到 build 和 deploy 兩個 job 連成一條線:

  • build:npm ci 和 Next.js build 的 logs,最後是上傳 artifact。
  • Upload to S3:每一行 upload: 都是一個上傳的檔案。第一次 sync 只有 _next/static,第二次是其他檔案;如果有被刪掉的頁面,會看到 delete:。
  • Invalidate CloudFront cache:印出 Invalidation ID,等它清完。
  • Smoke test:印出 HTTP/2 200 和新的 cache-control。cmp 沒有輸出,就代表線上的首頁就是這次 build 的版本。

全部綠燈之後,用瀏覽器打開 https://static.example.com,就會看到剛剛改的字了。EC2 那邊的網站,也會在 Deploy to EC2 跑完之後更新。push 一次,兩條路都換上新版!
最後在 Mac 上確認 Cache-Control 有設好:

DOMAIN=static.example.com
curl -sI "https://$DOMAIN/" | grep -iE '^(HTTP|cache-control|x-cache)'
JS=$(curl -s "https://$DOMAIN/" | grep -oE '/_next/static/[^"]+\.js' | head -1)
curl -sI "https://$DOMAIN$JS" | grep -iE '^(HTTP|cache-control|x-cache)'

會看到類似:
https://ithelp.ithome.com.tw/upload/images/20261013/201797938Mpsdd942n.png
部署完的第一次可能會是 Miss from cloudfront,再執行一次就會變成 Hit。
也可以用 Chrome 的 DevTools 看:打開 Network Tab(Disable cache 不要勾),重新整理兩次。第二次時,首頁那一列的 Status 會是 304;_next/static 的 JS 和 CSS,Size 那一欄會顯示 (memory cache) 或 (disk cache),代表瀏覽器直接用了自己存的那一份,連問都沒問。
https://ithelp.ithome.com.tw/upload/images/20261013/20179793EaL548AyOg.png

費用的部分

  • GitHub Actions:跟 Day25 一樣,private repo 每個月有免費的執行時間,這個 workflow 兩個 job 加起來大約三分鐘,大部分的時間都花在 npm ci。
  • S3:每次部署上傳幾十個檔案。台北的上傳請求每 1,000 次 0.00423 美元,一次部署不到 0.001 美元;刪除檔案不收費。舊的 _next/static 會一直留著,但都很小,幾乎不用錢。
  • CloudFront invalidation:每次部署用掉 1 個路徑,每個月前 1,000 個免費。

到這裡,S3+CloudFront 這條路也做到 push 之後自動部署了!明天是最後一篇,來回顧這 30 天:比較 EC2 和 S3+CloudFront 兩條路、算一下總共花了多少錢,再畫一張完整的架構圖ㄅ!

資安小提醒:id-token: write 只給真的需要換 AWS 身分的 job,會執行套件程式的 job 不要給。

參考資料


上一篇
Day28 - ACM:幫 CloudFront 接上自己的 Domain 和 HTTPS
下一篇
Day30 - 回顧兩條部署路線,前端該怎麼選?
系列文
前端來點 AWS 技能樹! 共 30 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言