-
Notifications
You must be signed in to change notification settings - Fork 2
Serverless Tradeoffs
「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
三條邊界線,對應底下三種代價:你的程式之前發生的事你管不到(Access)、你的程式之內留不住任何東西(無狀態)、你的程式之後每一步都是往返(D1/R2)。
Worker 一律回 401 JSON(worker/lib/auth.ts)—— 但 session 過期時那段程式根本不會執行。302 是邊緣做的,fetch 預設跟隨轉址,於是拿到 status === 200、res.ok === true、內容是登入頁 HTML(完整成因見 踩過的坑 第八則)。
同一條邊界的另外兩個面向:
-
bypass 是逐條精確路徑,不是前綴萬用(Gotchas 第九則)。少一條
/manifest.webmanifest,PWA 就裝不起來。 -
本地開發沒有 Access。
worker/lib/auth.ts在CF_ACCESS_TEAM_DOMAIN === 'localhost'時整層 bypass —— 也就是說全站最關鍵的那條路徑,在本地一次都跑不到。Docker 模型裡這是一個 in-process middleware,你可以單元測試它、可以在本地偽造一個過期 session。
對策
-
判斷 provenance,不判斷 status。
frontend/src/lib/sw-guards.ts與lib/api.ts兩處共用同一套判準(res.redirected/ 跨源res.url/ content-type),並且 fail-safe:判斷不出來就當成登入頁。Workbox 內建的cacheableResponse只看 status,看不出這件事 —— 這是必須自己寫守衛的原因,不是偏好。 -
把 bypass 清單當正式資產維護(
scripts/setup-public-bypass.sh),新增任何未登入就要拿得到的資源都要進去。 - e2e 用 fixture 伺服器模擬邊界行為,不接真的 Worker —— 邊界的行為是可以造假的,Access 的憑證與 D1 狀態不是。
這是與單機直覺最相反的一條,而且它今天才咬過一次。
單機上,N+1 查詢很醜但走的是 local socket,微秒級;沒有人會為了它開 issue。在 Worker 上,每一次 db.prepare().first() 都是一趟網路往返。getActiveChallenges() 每個進行中的挑戰要跑約六次序列查詢,而它是題目揭曉後的必經路徑(issue #107)。
再疊上前端的往返就成了使用者回報的「按下同意挑戰,等了三十秒才有反應」:一票原本要走 POST /votes → GET /challenges/active → GET /challenges 三趟,每趟都是一次 Access 驗證過的 Worker 呼叫。
對策
- 變更類端點直接回傳變更後的狀態,不要讓 client 再補抓。這條規則的反直覺之處在於:在 Worker 裡多做兩次貼著 D1 binding 的讀取,仍然遠優於讓瀏覽器多跑一趟往返。單機上這個算式是反過來的。
- 把「這個動作要發幾趟網路」列進 code review 的檢查項,跟「這段會不會爆炸」同等級。
- 讀取密集處放應用層快取(
frontend/src/lib/questionStore.ts),並且把失效寫進 API 模組自己的變更函式裡,不要交給呼叫端記得。 - 寫入用
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 |
對策
-
每一步都 idempotent(
deploy.sh、sync-access.ts)。既然做不到原子性,就讓重跑永遠安全 —— 這是無伺服器多資源部署的核心紀律。 -
單一事實來源(
config.toml)。資源名散落在六個地方時,靠人記得同步是不可能的。 - 看 log,不看綠勾。 綠勾的語意是「workflow 成功執行了它的判斷」,不是「已部署」。
- 手動步驟要顯性化,寫進 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 十八)。
誠實的一節。這四個條件任一成立,一個容器全包會划算得多:
- 需要長時間或重 CPU 的工作(產 PDF、跑批次報表、影音轉檔)。
- 需要真即時協作(CRDT、WebSocket 廣播到大量連線)。
- 團隊需要一個
docker compose up就能得到與正式環境同構的本地環境。 - 已經有人在當 sysadmin —— 那麼運維成本的節省就不是節省。
本專案四項都不成立,而「20 人 / $0 / 沒有人想當 sysadmin」成立。所以這筆帳是賺的 —— 但它賺在這個規模與這組需求上,不是普遍地賺。
| 規則 | 落在哪 |
|---|---|
| 邊界回應判 provenance,永遠不判 status;判不出來就當失敗 |
lib/sw-guards.ts、lib/api.ts
|
| 每個部署步驟 idempotent,因為做不到原子性 |
scripts/deploy.sh、sync-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 | 部署、遷移、名單同步的實際操作 |