Skip to content

Serverless Tradeoffs

Hsiehting Lin edited this page Aug 9, 2026 · 1 revision

無伺服器的代價

「Cloudflare Access + Worker 是不是有坑?跟一個 Docker 吃全部比,我們付了什麼?」

這頁不是 Cloudflare 的評測,是這個 repo 實際付過的帳單。每一條代價都指得到檔案或某一則 踩過的坑;每一條對策都是已經落地的東西,不是原則性建議。

架構為什麼是這個形狀,看 Head First 架構觀;那頁講的是「憑什麼這樣選」,這頁講的是「選完之後要天天付的東西」。

先講結論

買到 付出
成本 20 人重度使用 $0/月,沒有機器要顧、沒有 OS 要修補 免費額度直接變成架構約束:功能能不能做,先問額度
安全 沒有密碼、沒有 session、沒有註冊流程可以被攻破;沒有 server 可以被入侵 認證發生在你的程式之外,你既測不到它、也看不到它
延遲 邊緣同址,冷啟動比 VM 快一個量級 每一次資料存取都是一趟網路;單機上無害的 N+1 在這裡是使用者感覺得到的秒數
運維 沒有 docker logs、沒有磁碟會滿、沒有半夜的 OOM 也沒有 docker exec;出事時能拿到的資訊比單機少得多
部署 ./scripts/deploy.sh 一鍵,idempotent 部署不是一個原子單元:六種資源各自獨立,狀態可以不一致

一句話:這筆帳是賺的,但它把「難」從運維搬到了設計。 你不再需要 sysadmin,代價是每一個功能都得先想清楚它會發幾趟網路、有沒有跨請求的狀態、以及使用者裝置上留了什麼你收不回來的東西。

邊界在哪裡

Docker 全包的模型裡,認證、你的程式、資料庫在同一個行程或至少同一台機器上。這裡不是:

flowchart TB
    subgraph D["傳統:一個容器吃全部"]
        direction LR
        M1["Nginx / auth middleware"] --> M2["App process<br/>in-memory cache, cron, jobs"] --> M3[("Postgres<br/>local socket")]
    end
    subgraph S["本專案:邊界被切成三段"]
        direction LR
        E["Cloudflare Access<br/>你的程式看不到這一層"] --> W["Worker<br/>無狀態、10ms CPU、無檔案系統"] --> DB[("D1 / R2<br/>每次存取都是網路")]
    end
    D ~~~ S
Loading

三條邊界線,對應底下三種代價:你的程式之前發生的事你管不到(Access)、你的程式之內留不住任何東西(無狀態)、你的程式之後每一步都是往返(D1/R2)。

代價一:認證發生在你的程式之外

Worker 一律回 401 JSON(worker/lib/auth.ts)—— 但 session 過期時那段程式根本不會執行。302 是邊緣做的,fetch 預設跟隨轉址,於是拿到 status === 200res.ok === true、內容是登入頁 HTML(完整成因見 踩過的坑 第八則)。

同一條邊界的另外兩個面向:

  • bypass 是逐條精確路徑,不是前綴萬用Gotchas 第九則)。少一條 /manifest.webmanifest,PWA 就裝不起來。
  • 本地開發沒有 Access。 worker/lib/auth.tsCF_ACCESS_TEAM_DOMAIN === 'localhost' 時整層 bypass —— 也就是說全站最關鍵的那條路徑,在本地一次都跑不到。Docker 模型裡這是一個 in-process middleware,你可以單元測試它、可以在本地偽造一個過期 session。

對策

  1. 判斷 provenance,不判斷 status。 frontend/src/lib/sw-guards.tslib/api.ts 兩處共用同一套判準(res.redirected / 跨源 res.url / content-type),並且 fail-safe:判斷不出來就當成登入頁。Workbox 內建的 cacheableResponse 只看 status,看不出這件事 —— 這是必須自己寫守衛的原因,不是偏好。
  2. 把 bypass 清單當正式資產維護scripts/setup-public-bypass.sh),新增任何未登入就要拿得到的資源都要進去。
  3. e2e 用 fixture 伺服器模擬邊界行為,不接真的 Worker —— 邊界的行為是可以造假的,Access 的憑證與 D1 狀態不是。

代價二:每一次資料存取都是一趟網路

這是與單機直覺最相反的一條,而且它今天才咬過一次。

單機上,N+1 查詢很醜但走的是 local socket,微秒級;沒有人會為了它開 issue。在 Worker 上,每一次 db.prepare().first() 都是一趟網路往返。getActiveChallenges() 每個進行中的挑戰要跑約六次序列查詢,而它是題目揭曉後的必經路徑(issue #107)。

再疊上前端的往返就成了使用者回報的「按下同意挑戰,等了三十秒才有反應」:一票原本要走 POST /votesGET /challenges/activeGET /challenges 三趟,每趟都是一次 Access 驗證過的 Worker 呼叫。

對策

  1. 變更類端點直接回傳變更後的狀態,不要讓 client 再補抓。這條規則的反直覺之處在於:在 Worker 裡多做兩次貼著 D1 binding 的讀取,仍然遠優於讓瀏覽器多跑一趟往返。單機上這個算式是反過來的。
  2. 把「這個動作要發幾趟網路」列進 code review 的檢查項,跟「這段會不會爆炸」同等級。
  3. 讀取密集處放應用層快取frontend/src/lib/questionStore.ts),並且把失效寫進 API 模組自己的變更函式裡,不要交給呼叫端記得。
  4. 寫入用 db.batch() 收成一筆 —— 但先確認順序沒有語意issue #107 就是因為 recompute 會寫、且必須先跑,所以不能無腦並行)。

代價三:沒有一個「跨請求」的地方

Worker 每次呼叫都是全新的。這一條刪掉的東西比想像中多:

單機上理所當然的 在這裡
in-memory LRU 快取 沒有。快取只能放前端,或 KV/DO
連線池 沒有連線的概念
in-process cron / 背景工作 得另外用 Cron Triggers 或 Queues
長時間工作(產 PDF、跑報表) 10ms CPU 上限直接擋掉
讀寫檔案 沒有檔案系統

具體後果:真 PDF 匯出是明確的 non-goal —— Browser Rendering 要付費、CJK 字型塞不進 bundle、從 R2 拉字型再 subset 撐不住 10ms CPU。改成帶 @media print 的 HTML,讓瀏覽器自己列印。Workers AI 的呼叫一律帶 timeout(讀書計畫的弱點導讀是 6 秒),失敗就整段省略,不讓一個加值功能拖垮主流程。

唯一能放狀態的地方是 Durable Object,而它的計費模型會直接反過來決定功能怎麼設計:KV-backed DO 需要 Workers Paid,SQLite-backed(new_sqlite_classes)在免費方案可用 —— 聊天大廳與 2048 存檔都因此走後者。同一個理由讓共編維持 pessimistic lock 而不是 CRDT。

對策:把「這個功能需要跨請求的狀態嗎」當成設計第一問。需要的話,先確認它落在免費的那一格;落不進去就換設計,不要默默加付費服務。

代價四:部署不是一個原子單元

Docker 模型裡,一個 image tag 就是一個可回滾的單位。這裡 ./scripts/deploy.sh 要協調六種資源(D1 建庫、migrations、R2 bucket、Vectorize index、roster 同步、Worker、Pages),Zero Trust 的設定還是手動的一步。任何一步失敗,你就處在一個沒有名字的中間狀態。

實際踩到的四種不一致:

現象 成因 出處
CI 三支全綠但正式站沒變 Worker 與 Pages 各自 path-gated,同時動兩邊時互相擋掉,各自以 success 收場 Gotchas
新的 binding 進不了 PR wrangler.toml 是 gitignored 的產出物,只能改 wrangler.example.toml 並在 PR 描述交代 Gotchas
六份計畫都要用 migration 0023 每個人都正確地查了「最後一號」然後都挑下一號 Gotchas 十一
「新 Worker + 舊前端」看起來像快取問題 從 worktree 部署時,Pages 依分支名判定環境,悄悄上的是 Preview CLAUDE.md

對策

  1. 每一步都 idempotentdeploy.shsync-access.ts)。既然做不到原子性,就讓重跑永遠安全 —— 這是無伺服器多資源部署的核心紀律。
  2. 單一事實來源config.toml)。資源名散落在六個地方時,靠人記得同步是不可能的。
  3. 看 log,不看綠勾。 綠勾的語意是「workflow 成功執行了它的判斷」,不是「已部署」。
  4. 手動步驟要顯性化,寫進 PR 描述,別指望整合者自己發現。

代價五:使用者裝置上的狀態,你收不回來

單機部署搞砸了就回滾。但 Service Worker 一旦把登入頁快取起來,使用者每次開 app 都看到它,而且 SW 不再碰網路 —— 無法自我修復,Pages 回滾也救不回來。

對策:任何會寫進使用者裝置的東西都要有 kill switch。frontend/public/sw-kill.js 部署成 /sw.js 就能讓所有 client 下次開啟時自我解除。而且它必須在 Access bypass 清單裡 —— 需要用到它的人,通常正卡在登入不了的狀態。

代價六:可觀測性比單機少一截

  • 邊緣的 302 不會進你的 log,因為 Worker 從未被呼叫。你的日誌裡那次請求根本不存在。
  • wrangler tail 是即時串流,不是歷史查詢。事後才發現的問題,那段 log 已經沒了。
  • 沒有 docker exec 進去看一眼。

對策:把診斷搬到出問題的那台機器上(Gotchas 十九),並且在測試裡斷言看得到的代理指標 —— 量不到的東西就找一個量得到的替身(Gotchas 十八)。

如果重來一次,什麼時候該選 Docker

誠實的一節。這四個條件任一成立,一個容器全包會划算得多:

  1. 需要長時間或重 CPU 的工作(產 PDF、跑批次報表、影音轉檔)。
  2. 需要真即時協作(CRDT、WebSocket 廣播到大量連線)。
  3. 團隊需要一個 docker compose up 就能得到與正式環境同構的本地環境。
  4. 已經有人在當 sysadmin —— 那麼運維成本的節省就不是節省。

本專案四項都不成立,而「20 人 / $0 / 沒有人想當 sysadmin」成立。所以這筆帳是賺的 —— 但它賺在這個規模與這組需求上,不是普遍地賺

帶得走的規則

規則 落在哪
邊界回應判 provenance,永遠不判 status;判不出來就當失敗 lib/sw-guards.tslib/api.ts
每個部署步驟 idempotent,因為做不到原子性 scripts/deploy.shsync-access.ts
變更類 API 直接回傳新狀態,省掉 client 的補抓往返 worker/routes/challenges.ts
「這個動作發幾趟網路」是 review 檢查項 issue #107
需要跨請求狀態時,先確認它落在免費的那一格 聊天大廳與 2048 的 SQLite-backed DO
寫進使用者裝置的東西都要有 kill switch,且必須 bypass frontend/public/sw-kill.js
業務邏輯擠成純函式,讓它在邊界之外可測 worker/lib/ 85 檔中 36 檔有測試,604 條單元測試
邊界行為用 fixture 伺服器驗,不接真 Worker frontend/e2e/
設定收攏成單一事實來源 config.toml

最後一條沒進表格,因為它不是技術規則:這套架構把「難」從運維搬到了設計。 省下的 sysadmin 時間,會以「每個功能都要多想三件事」的形式重新收費 —— 認得這筆帳,就不會覺得它變複雜是意外。

相關頁面

頁面 內容
Head First 架構觀 為什麼選這套架構(驅動特性與 ADR)
踩過的坑 Gotchas 本頁引用的每一則陷阱的完整症狀與修法
技術債 Tech-Debt 這些代價裡尚未償還的部分
維運手冊 Maintenance 部署、遷移、名單同步的實際操作