包包baobaolin.com
date
entry
022
topic
infrastructure
rev

內容站要真的 404,不要 SPA fallback

我在第一篇寫過那個目錄 key 陷阱:/blog/ 變成首頁而且回 200。當時我只講怎麼修,沒講根源——根源是把 SPA 的錯誤設定,套在一個沒有前端路由的站上。

SPA 的路由發生在瀏覽器裡。使用者直接開 /settings/profile 時,伺服器上並沒有這個檔案,但那個網址對前端來說是有意義的。所以 SPA 必須告訴 CDN:任何找不到的路徑,都回 index.html 並且狀態碼給 200——讓 JavaScript 載進來,再由它決定要渲染什麼。

這個設定對 SPA 是必要的。而它對內容站是有害的,因為內容站的每一個有效網址,在儲存體裡都有一個對應的檔案。找不到就是真的找不到。

把 404 說成 200 的三個代價

問題不在使用者看到什麼——他看到首頁,會知道自己走錯了。問題在於這句話是對機器說的,而機器全部信了。

  1. 搜尋引擎會收錄不存在的網址。拼錯的連結、舊的網址、別人手打錯的路徑,每一個都回 200 加一份完整的首頁內容。這在 Search Console 裡叫 soft 404,而後果是同一份內容出現在幾十個網址上,稀釋掉真正那頁的權重。
  2. 監控與連結檢查全部失效。任何以狀態碼為準的工具——uptime 檢查、爬蟲、CI 裡的 link checker——都會說一切正常。要比對內容才測得出來的那類壞法,就是這樣長出來的。
  3. 你自己也會被騙。部署少上傳一個檔案、目錄 key 沒補回去、路徑打錯——這些全部表現成「這頁看起來是首頁」,而不是一個會跳出來的錯誤。

狀態碼是講給機器聽的那一句話。對機器說謊,最後被誤導的是你自己。

內容站該怎麼設

錯誤回應要指向一個真的錯誤頁,而且狀態碼要保持 404

Error code:            404
Response page path:    /404.html
HTTP Response code:    404      ← 關鍵,不要改成 200

CloudFront 的自訂錯誤回應允許你在這裡填一個不同的狀態碼,SPA 就是靠它把 404 變成 200。內容站要做的正好相反:換頁面,不換狀態碼。

這樣使用者看到的是一個有站台外觀、告訴他這裡沒東西的頁面,而爬蟲和監控收到的是一個誠實的 404。

S3 私有 bucket 回的是 403,不是 404

有一個實作細節會讓上面那條規則失效。用 OAC 把 bucket 關成私有時,bucket 政策通常只授權 s3:GetObject,沒有授權 s3:ListBucket。在這個組合下,要求一個不存在的 key,S3 回的是 403 AccessDenied 而不是 404 NoSuchKey——因為沒有列表權限的呼叫端,不該被告知某個 key 存不存在。

所以錯誤回應規則要兩個都設,403 和 404 都指向 /404.html,並且都回 404。

代價是你失去了「403 代表權限設定有問題」這個訊號——真正的權限錯誤會被偽裝成 404。要保留這個訊號的話,另一條路是在 bucket 政策裡加上 s3:ListBucket,讓 S3 能誠實回 404,403 就留給真正的權限問題。多一個權限換一個可診斷性,我認為值得。

不要自動重導向

「找不到就導回首頁」是另一個常見做法,它比 fallback 好一點,但也不夠好。

對使用者來說,重導向會把網址列裡那個打錯的路徑換掉——他失去了看出自己哪裡打錯的機會,也沒辦法修正一個字元再試一次。對機器來說,一個 301 或 302 是在說「這個資源搬到首頁了」,而那不是事實。

停在原本的網址、回 404、給一個「這裡沒東西,回列表看看」的頁面,三件事都比重導向誠實。

驗證只要一條指令

curl -sI https://example.com/this-does-not-exist | head -1
# 期望: HTTP/2 404
# 若是 HTTP/2 200 → 你的內容站正在用 SPA fallback

值得把它放進部署後的驗證腳本裡。這條規則是設定在 CDN 上、不在 repo 裡,所以它會在沒有人改過程式碼的情況下被改掉——而那時候不會有任何 commit 可以怪。

如果只記得一件事

SPA fallback 是為了讓不存在的路徑能運作,內容站需要的是讓不存在的路徑看起來就是不存在 兩種需求相反,設定不能共用。抄設定之前先問一句:我這個站的網址,在硬碟上有沒有對應的檔案?

修訂紀錄

  1. 首次發布