iT邦幫忙

2026 iThome 鐵人賽

DAY 27
0
Modern Web

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

Day27 - 認識 S3+CloudFront,手動讓首頁上線

  • 分享至 

  • xImage
  •  

從 Day11 到昨天,我們走完了 EC2 這條路:一台 24 小時開著的 Server,裡面跑著 Next.js 的 container,前面再擋一個 Caddy。不過很多前端專案,其實不需要一台一直開著的 Server。
今天換一條路:把 my-app 匯出成純靜態的檔案,放進 S3,再交給 CloudFront 送到使用者面前。做完會拿到一個 https://xxxx.cloudfront.net 的網址,打開就是我們的網站ㄅ!

兩條路差在哪裡?

EC2 這條路 S3+CloudFront 這條路
網站是什麼 一個一直在跑的 Next.js Server 一堆 build 好的 HTML、CSS、JS 檔案
誰在處理請求 我們的 EC2(Caddy+container) CloudFront 直接回傳檔案,沒有 Server 在跑程式
要顧的東西 作業系統更新、Docker、Caddy、憑證(昨天那份清單) 幾乎沒有,S3 和 CloudFront 都是 AWS 在顧
費用 開著就收錢,一個月大約 13 美元 照用量計算,小網站幾乎是 0
做得到的事 API route、每次請求才產生的頁面、讀 cookie 只有 build 的時候就產生好的頁面

像是我的 side project 中有些功能需要 Next.js Server 來處理,所以維持走 EC2 這條路。但像是用 Vite 做的 React SPA,build 出來或是單純只有 HTML、CSS、JS 的專案就都是些靜態檔案,就很適合放在 S3+CloudFront。
PS.「靜態」也不代表畫面不能互動。如果前端另外呼叫後端 API,還是能取得資料、登入或上傳;只是這些後端程式要有其他服務負責執行,S3 不會幫我們跑它們。

S3、CloudFront 和 OAC 是什麼?

  • S3(Simple Storage Service):在 Day10 有介紹過,AWS 的檔案倉庫。一個倉庫叫做 bucket,裡面的每個檔案叫做 object,檔案的路徑叫做 key,例如 _next/static/chunks/xxx.js。
  • CloudFront:AWS 的 CDN。它在世界各地有很多據點(edge location),使用者會連到離自己最近的據點;據點上有這個檔案就直接回傳,沒有才回 S3 拿。HTTPS 也是由它負責。
  • OAC(Origin Access Control):讓 bucket 保持私有,只有我們指定的 CloudFront 可以進去拿檔案。

可以想成一間網路商店:貨物倉庫(S3)不讓一般人進去,只有自家的物流車(CloudFront)拿著通行證(OAC)才能進去取貨;客人則是到離家最近的門市(edge location)取貨,門市有庫存就直接給,沒有才請物流車去倉庫調貨。
https://ithelp.ithome.com.tw/upload/images/20261011/20179793wjuZfLTXE2.png

S3 本身也有一個「靜態網站託管」的功能,但它要把 bucket 設成公開,而且不支援 HTTPS。這個系列一路都在講「只開需要開的」,所以我們讓 bucket 保持私有,交給 CloudFront 用 OAC 來讀。
CloudFront 除了讓使用者更快拿到檔案,也能減少直接打到來源的請求。例如活動頁突然被很多人分享,有 cache 的內容就能由各地據點回傳。至於有人故意用大量流量把網站塞滿,也就是 DDoS,CloudFront 搭配 AWS Shield Standard,可以協助抵擋常見的網路層攻擊。不過 cache 和攻擊防護是不同的事,也不是接上 CDN 就什麼攻擊都不用管了。

把 my-app 匯出成靜態檔案

Day9 為了包成 Docker Image,在 next.config.ts 設了 output: 'standalone'。要匯出成靜態檔案,則要改成 output: 'export'。但 output 只能設一個值,所以我們用一個環境變數來切換,平常 build(包含 Day25 的 deploy.yml)還是 standalone,EC2 那條路不受影響:

import type { NextConfig } from "next";

const isStaticExport = process.env.STATIC_EXPORT === "true";

const nextConfig: NextConfig = {
  output: isStaticExport ? "export" : "standalone",
  images: {
    unoptimized: isStaticExport,
  },
};

export default nextConfig;

images.unoptimized 是什麼?next/image 平常會透過 Server 上的 /_next/image 即時壓縮圖片,但靜態檔案沒有 Server,這個網址會打不開。我實際試過,在 Next.js 16.3 用 next/image 放一張 png 再匯出,build 不會報錯,但 HTML 裡的圖片網址還是 /_next/image?url=...,放上 S3 之後圖片就會破掉。設成 unoptimized 之後,圖片網址會變回原本的 /photo.png。預設首頁只用了 svg(Next.js 本來就不壓縮 svg),不加也能正常顯示,但先加上,之後放照片才不會踩到。
接著在 my-app 資料夾裡 build:

STATIC_EXPORT=true npm run build
ls out

build 完會多一個 out 資料夾,整個網站就是裡面的檔案:

檔案 用途
index.html 首頁
404.html 找不到頁面時用的頁面
_next/static/ JS、CSS、字型,檔名裡帶有 hash,內容改了檔名就會變
index.txt 和 __next 開頭的 .txt 在頁面之間切換時,Next.js 在背景讀取的資料
favicon.ico、*.svg public 資料夾和 app 裡的靜態檔案

想先在自己的電腦看看的話,可以執行 npx serve out,再打開終端機上顯示的網址(3000 沒被佔用的話,就是 http://localhost:3000)。
最後還有一件小事:out 資料夾已經寫在 create-next-app 產生的 .gitignore 裡了,但 Day9 的 .dockerignore 沒有,記得在 .dockerignore 加上一行 out,免得 Docker build 時把它也送進去。

建立 S3 bucket

到 S3 的 Console,為了方便 region 沿用跟之前的 EC2 一樣的,再按 Create bucket(建立儲存貯體):

  1. Bucket type 選 General purpose。
  2. Bucket name 填一個名字,例如 my-app-static-20261010。bucket 名稱在全世界的 AWS 都不能重複,所以後面加個日期或亂數比較不會撞名。
    https://ithelp.ithome.com.tw/upload/images/20261011/20179793bSraAu7DC3.png
  3. Object Ownership 維持預設的 ACLs disabled。
  4. Block Public Access settings 維持預設的 Block all public access(全部勾選)。
    這一項千萬不要取消,我們就是要讓 bucket 保持私有。
    https://ithelp.ithome.com.tw/upload/images/20261011/20179793fiYxrisrHp.png
  5. 其他(版本控制、加密)都維持預設,按 Create bucket。

把檔案上傳到 S3

回到 Mac 的終端機,在 my-app 資料夾裡執行(BUCKET 換成自己的 bucket 名稱):

BUCKET=my-app-static-20261010
aws s3 sync out/ "s3://$BUCKET/" --region ap-east-2
aws s3 ls "s3://$BUCKET/" --region ap-east-2

aws s3 sync 會把 out 裡的檔案上傳到 bucket,只傳有變動的檔案,所以第二次之後會快很多。上傳時 AWS CLI 會依副檔名設定每個檔案的類型(Content-Type),例如 .html 是 text/html,瀏覽器才知道要怎麼處理它。如果你的 AWS CLI 是用 named profile 登入的,指令後面再加上 --profile 和 profile 名稱。

記得上傳 out/ 裡的全部內容,包含 _next/、.txt 和圖片,不是只上傳 index.html。也不要多包一層 out/,要讓 index.html 直接放在 bucket 根目錄。

上傳完,先試試看直接連 S3:

curl -I "https://$BUCKET.s3.ap-east-2.amazonaws.com/index.html"

會看到 HTTP/1.1 403 Forbidden。bucket 是私有的,所以從外面直接連 S3 會被拒絕,這正是我們要的。接下來就讓 CloudFront 拿到進去的通行證。

建立 CloudFront distribution

到 CloudFront 的 Console 按 Create distribution。CloudFront 是全球服務,不用選 region。建立流程的畫面這一兩年改過幾次,按鈕的名稱可能跟你看到的不完全一樣,重點是下面這幾個設定:

步驟 選什麼 為什麼
方案(Pricing plan) 有的話選 Free(每月 0 元) 不收超額費;持續大幅超額時,AWS 可能調整傳輸效能。
Distribution name 自己取,例如 my-app-static 只是方便辨認的名字,之後可以改
Distribution type Single website or app 一個網站用一個 distribution
Domain 先跳過 自訂 Domain 留到 Day28

https://ithelp.ithome.com.tw/upload/images/20261011/20179793ncFoZpNLBb.png

步驟 選什麼 為什麼
Origin type Amazon S3 檔案從 S3 來
S3 origin 按 Browse S3,選剛剛建立的 bucket 不要選網址裡有 s3-website 的那種,那是靜態網站託管用的
Origin settings Use recommended origin settings 會一起設定好 OAC,並讓 Console 自動幫 bucket 加上 policy
安全防護(Enable security protections) Free 方案會自動包含;用量計費的話選不啟用 用量計費時,WAF 要另外付費

最後按 Create distribution。建議設定裡也包含了用 http:// 進來會轉到 HTTPS(Redirect to HTTPS),以及適合靜態檔案的快取設定 CachingOptimized,這兩個先用預設就好。
關於方案:CloudFront 的 flat-rate 方案不開放給還在 AWS 免費方案(Free plan)的帳號。如果你的帳號是免費方案、畫面上沒有 Free 可以選,就用一般的用量計費,小網站一樣在免費額度內,只要記得安全防護那一步選不啟用。
建好之後還有一個設定要補:Default root object,也就是打開網站根目錄(/)時要回傳哪個檔案,建立的流程裡沒有這一項。到 distribution 的 General Tab,在 Settings 按 Edit,Default root object 填 index.html(前面不要加 /),儲存。沒設的話,打開首頁會看到 AccessDenied。
接著確認 bucket policy 有沒有加上去:到 S3 點進 bucket,切到 Permissions(許可) Tab,Bucket policy 裡應該有一段 cloudfront.amazonaws.com 的設定(下面會解釋)。Console 偶爾會沒加成功,這時回到 distribution 的 Origins Tab,編輯那個 origin,在 OAC 的設定旁邊可以複製 policy,再到 Bucket policy 按 Edit 貼上、儲存。
distribution 剛建立或修改設定之後,Last modified 那一欄會顯示 Deploying,等幾分鐘變成日期和時間,就部署完成了。

打開網站,看看 CloudFront 做了什麼

在 distribution 的頁面複製 Distribution domain name,長得像 d111111abcdef8.cloudfront.net,用瀏覽器打開 https://d111111abcdef8.cloudfront.net,就會看到 my-app 的首頁了!
接著用 curl 連續看兩次 header:

CF_DOMAIN=d111111abcdef8.cloudfront.net
curl -sI "https://$CF_DOMAIN/" | grep -iE '^(HTTP|x-cache|x-amz-cf-pop|content-type)'
curl -sI "https://$CF_DOMAIN/" | grep -iE '^(HTTP|x-cache|x-amz-cf-pop|content-type)'
header 意思
x-cache: Miss from cloudfront 這個據點本來沒有這個檔案,剛剛回 S3 拿
x-cache: Hit from cloudfront 這個據點已經有快取,直接回傳,沒有去 S3
x-amz-cf-pop 回應你的據點代號,前三個英文字母是機場代碼,在台灣連線通常會看到 TPE

第一次通常是 Miss,第二次就變成 Hit 了。這就是 CDN 的用處:檔案放在離使用者近的地方,S3 也不用每次都被打擾。
再回 S3 看看 CloudFront 改了什麼。點進 bucket 的 Permissions(許可) Tab,Bucket policy 會多出這一段:

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "AllowCloudFrontServicePrincipalReadOnly",
            "Effect": "Allow",
            "Principal": {
                "Service": "cloudfront.amazonaws.com"
            },
            "Action": "s3:GetObject",
            "Resource": "arn:aws:s3:::my-app-static-20261010/*",
            "Condition": {
                "StringEquals": {
                    "AWS:SourceArn": "arn:aws:cloudfront::123456789012:distribution/E1A2B3C4D5E6F7"
                }
            }
        }
    ]
}

意思是:CloudFront 這個服務可以讀這個 bucket 裡的檔案(s3:GetObject),但只限 AWS:SourceArn 寫的這一個 distribution。別人的 CloudFront 進不來,從外面直接連 S3 也一樣是 403。
最後試一個不存在的路徑:

curl -sI "https://$CF_DOMAIN/abc" | head -1

會看到 403,而不是 404。因為 policy 只給了「讀檔案」的權限,沒有給「列出 bucket 內容」的權限,S3 沒辦法告訴 CloudFront「這個檔案不存在」,只能回 403。Day28 會把它換成我們自己的 404 頁面。

PS. 如果你改了內容、重新 build 和 aws s3 sync,CloudFront 可能還是給你舊的,因為 CachingOptimized 預設會快取 24 小時。Day28 會調整快取的設定,在那之前想馬上看到新版,可以清掉快取(每個月前 1,000 個路徑免費,/* 只算一個):

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

今天把之前的練習專案換了一條路上線,不用開 EC2,也不用跑 Docker 和 Caddy,網址暫時是 CloudFront 給的 cloudfront.net。下一篇來把它接上自己的 Domain ,再替自己的 Domain 設定 HTTPS 憑證ㄅ!

參考資料


上一篇
Day26 - 網站上線之後,EC2 還有哪些事要顧?
下一篇
Day28 - ACM:幫 CloudFront 接上自己的 Domain 和 HTTPS
系列文
前端來點 AWS 技能樹! 共 28 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言