/ Custom Domain
從第一原理講起。
這是一份從 「客戶網域為什麼要 routing」 開始講起的長文。 一路解釋 Caddy 為什麼簡單、CF 的設計取捨、為什麼 1000 條 routes 是極限、 為什麼 hostname 需要綁定 zone、為什麼 worker 不能直接當 origin。
讀完之後你會懂:今天 ship 的這些 cf-saas-origin.vibehost.com /
fallback_origin / per-host route 不是隨便寫的、每個都對應 CF 設計
一個約束。也會懂為什麼 10w 規模再來會撞牆、撞牆原因是什麼、要怎麼準備。
TL;DR
- Caddy 沒這問題是因為它是 single-tenant binary、沒有「這個 hostname 屬於誰」的問題——所有 :443 都是它的。
- CF 邊緣是 multi-tenant anycast——同一個 IP 服務全網路所有客戶、必須查 internal index 才知道 hostname 屬於哪個 zone、哪個 SaaS provider。
- CF for SaaS 解的是這個 multi-tenant 索引問題——不是 cert 自動化(那是順帶的)。
- 1000 routes 限制是 edge config 全球 propagation 的物理約束、不是商業限制。
- fallback_origin 不能直接是 worker是因為 CF for SaaS 在 routing layer 之前運作、worker route layer 在它之後、設計上這兩條 pipeline 是序列。
01 問題本質:custom domain routing 要解什麼
把所有變數丟一邊、純粹從問題定義開始。我們有一個 SaaS、客戶想用 他們自己的網域名稱 訪問他們的內容。具體場景:
客戶的瀏覽器 │ │ GET https://blog.example.com/posts/hello │ (客戶買了 example.com、希望放在我們的 SaaS 上) │ ▼ ??? 我們要做什麼 ??? │ ▼ 回客戶上傳的 HTML 或 SSR Worker 結果
從「??? 」要做什麼角度看、需要解決 三件事:
1.1 DNS 把客戶的 hostname 解到我們的 IP
這個簡單、不是我們做的——客戶在他自己 DNS provider(GoDaddy / Cloudflare / route53)設一條 record 指向我們。我們頂多告訴他「請設 blog.example.com CNAME → cname.vibehost.host」。
1.2 TLS 終止 — 替客戶的 hostname 拿到 cert
這是真的工程問題。Browser 連 https://blog.example.com、TLS handshake 時看 server cert 的 CN/SAN 必須是 blog.example.com。我們的 server 不能用自己 vibehost.com 的 cert——browser 會抱怨 host mismatch。
所以我們需要 動態替每個 customer hostname 拿到對應的 cert。Cert 來源通常是 Let's Encrypt(免費)、需要 ACME 協議完成 domain ownership challenge(HTTP-01 或 DNS-01)。
1.3 Routing — 知道這個 hostname 屬於哪個 customer
同一個 server 上、blog.example.com、acme.com、myapp.io 都連進來。我們收到一個 TLS-already-terminated 的 request、看 Host: header、要查到「這個 host 對應到哪個 customer 的哪個 deployment」、然後 forward 給對的 worker / R2 bucket / 等等。
這三件事看起來不複雜——但每一件都有規模問題、安全問題、跟誰能 issue 那個 cert 的歸屬問題。
02 Caddy 怎麼解的(為什麼簡單)
Caddy 是個跑在 單一 VM 上 的 reverse proxy binary。它有個叫 on-demand TLS 的功能、解釋我們的問題很乾淨:
Caddy on-demand TLS 流程 1. browser 連 https://blog.example.com 2. TLS handshake、SNI 帶 blog.example.com 3. Caddy 看「我有沒有這個 hostname 的 cert?」 └─ 沒有 → 觸發 on-demand 流程 ├─ 先 ask 上游 API:「這個 hostname 我能 issue 嗎?」 │ └─ API 查 DB、確認該客戶有付費 + DNS 真的指我們 ├─ 跑 ACME 跟 Let's Encrypt 對話 │ └─ HTTP-01: LE 打 :80 拿 challenge file │ └─ Caddy 自己接 :80、回 token ├─ 拿到 cert、cache 到 disk └─ 完成 TLS handshake、proxy request 到 origin
2.1 為什麼這樣 work?
三個 enabler:
| 條件 | Caddy 怎麼滿足 |
|---|---|
| 同一個 IP 接所有 hostname | 客戶 DNS 全部 CNAME 到 cname.vibehost.host 的兩個 GCE IP。Caddy 就在那兩個 IP 上、所有 port 443 都是它的、無爭議。 |
SNI 直接讀 Host | TLS handshake 第一個 ClientHello 就帶 SNI。Caddy 從 SNI 拿 hostname、用作 cert lookup key。沒有「這 hostname 屬於哪個 zone」問題——hostname 屬於 Caddy。 |
| ACME HTTP-01 work | LE 要打 http://blog.example.com/.well-known/acme-challenge/<token>。客戶 DNS 已經指 Caddy 的 IP、:80 也是 Caddy 接、Caddy 知道 token、回給 LE。1 跳完成。 |
2.2 為什麼 quota / route limit 不是問題
Caddy 跑在 我們自己的 VM 上、disk / memory / CPU 都是我們的、cert 存我們 disk。沒人會說「你只能 issue 1000 個 hostname」——我們 disk 有多大就放多大。每個 hostname 是 dict 裡的 key、無上限。
Routing index 也是 in-memory hashmap、O(1) lookup、10 萬個 hostname 也就 ~10MB 的 keys。
Caddy 是 single-tenant 邏輯——這台 VM 的所有 :443 traffic 都是「我們」的、沒有「另一個 SaaS provider 也在這 IP 上」的問題。所有 hostname、cert、routing index 都在 Caddy 自己 process 內、O(1) 查、無 quota。
03 為什麼 Cloudflare 結構上不一樣
CF 不是 single VM。CF 是 全球 anycast 網路——300+ 個 PoP、每個 PoP 都響應 CF 的 anycast IP block(104.21.x.x / 172.67.x.x 等)。同一個 IP 同時是全網所有 CF 客戶的 IP。
CF anycast 模型 Browser 連 104.21.52.16 │ │ BGP announces — 全球 300+ PoP 都 ad 這個 IP │ 最近的 PoP 接住(geo + load + RTT) │ ▼ 假設 PoP-SIN 接住 │ │ TLS handshake: SNI 帶 hostname │ 比方說 SNI=blog.example.com │ ▼ PoP 內部要查: 「這個 hostname 屬於哪個 CF 客戶?」 └─ 因為同一個 IP 上、 paypal.com 也在 └─ netflix.com 也在、acme.com 也在... └─ 每個 CF 客戶都用這 anycast IP └─ PoP 必須有 index:hostname → tenant config
3.1 CF 的 internal routing index — zone-based
CF 把客戶分為 zone(一個 zone 通常對應一個 domain:vibehost.com 是一個 zone、example.com 是另一個 zone)。每個 zone 有自己的設定:firewall rules、cache rules、worker routes、SSL settings、page rules、等等。
當 PoP 收到 SNI blog.example.com、它做:
1. 從 hostname 抽 apex domain → example.com 2. 查 CF 全球 zones index → example.com 是 zone X 3. 載 zone X 的所有設定(rules / routes / SSL config) 4. 對這個 request 應用 zone X 的 pipeline 5. 沒有 zone X 對應 → 預設行為(拒絕、或上游 fallback)
注意:「找到 zone」是任何 CF 行為的前提。zone 沒被 CF 認得、就什麼都不會發生——CF 邊緣完全不知道要怎麼處理這條 request、最多回 530 generic error。
3.2 為什麼這帶來 scale 問題
Caddy 一台 VM、一個 in-memory dict、10 萬 entries 沒事。CF 邊緣是 300 個 PoP、每個 PoP 都要持有完整 routing index——所以 entries 多大、要 sync 多廣、傳播多慢、都有物理限制。
CF 內部 routing config 有兩層:
- Hot path(in-memory、每個 PoP 全有、~ms 級 lookup):zone metadata、SSL config、active worker routes
- Slower(lazy-loaded from CF central、KV-style):個別 page rules、custom hostnames 列表(per-zone)、analytics config
每個 PoP 都要全有 hot path 的東西、所以這部分 必然有 quota——不是商業策略、是物理約束。
1000 worker routes / zone 不是 CF 想壓榨你 ── 是 每個 PoP 都要快速 hash 比對「這個 request URL 要 trigger 哪個 worker」。Hash table 跨 300 個 PoP 都要快速更新——表越大、傳播越慢、cache invalidation cost 越高。
04 CF for SaaS 到底是什麼產品
名字有點 misleading——它不是「給 SaaS 用的 CF」、就只是 CF。CF for SaaS 解的具體問題是:
當客戶的 hostname (e.g. blog.example.com) 不在我的 zone (e.g. vibehost.com) 時、怎麼讓 CF 邊緣認得它?
因為 CF routing 是 zone-based、預設 blog.example.com 邊緣會被解析為「這 hostname 屬於 example.com zone」——這個 zone 不在我的 CF account 下、CF 邊緣不會把流量送到我這裡。
4.1 CF for SaaS 的核心抽象:Custom Hostname
它讓我(SaaS provider)聲明領養一個不屬於我 zone 的 hostname:
POST /zones/{vibehost.com_zone_id}/custom_hostnames
{
"hostname": "blog.example.com",
"ssl": { "method": "http", "type": "dv" }
}
這個 API 對 CF 說的是:
- 「
blog.example.com是 vibehost.com zone 的客戶。」 - 「請替它自動 issue cert(用 HTTP-01 DCV)。」
- 「如果 anycast 收到 SNI 是這 hostname 的 request、把它當 vibehost.com zone 的 traffic 處理。」
但客戶的 DNS 還是要先指 CF anycast——通常透過 customer 設 blog.example.com CNAME → cname.vibehost.host、cname.vibehost.host 是個 proxied record(指 CF anycast)。
4.2 為什麼一定要在 SaaS provider 自己的 zone 註冊
因為 zone 是 CF routing 的 unit。CF 邊緣收到 request、做的第一件事是「這 hostname 屬於哪個 zone?」。CF for SaaS 把答案改成「不是 example.com zone(客戶的)、而是 vibehost.com zone(SaaS provider 的)」、然後套用 SaaS provider zone 的設定(包含 worker routes、fallback origin、etc)。
這也是 issue #333 那個 zone 環境變數錯誤撞到的事——我們把 hostname 註冊在 .space zone(tenant FQDN zone)、但 CNAME target cname.vibehost.host 在 .com zone。CF 邊緣收到 lacoste.projectd.cc → 查 example.com 上游 → 看到 CNAME 指 vibehost.com → 以 .com zone 處理 → 找不到 lacoste.projectd.cc 在 .com zone 的 custom_hostname 註冊、結果 522。
CF for SaaS 是個「替別人 zone 的 hostname、在我自己 zone 上開後門」的機制。這個後門的設定(fallback origin、worker route)必須跟 hostname 註冊的 zone 一致——否則 CF 邊緣不知道要套用哪個 zone 的 pipeline。
05 為什麼 hostname 要綁 zone — routing index 是 per-zone
具體 trace 一個 request、從 SNI 到實際 worker 觸發:
browser https://blog.example.com │ ├─ 1. DNS resolve blog.example.com │ └─ CNAME → cname.vibehost.host │ └─ A → 104.21.52.16 (CF anycast) │ ├─ 2. TLS handshake to 104.21.52.16 │ └─ SNI: blog.example.com │ ├─ 3. CF PoP 收到 → "這個 SNI 屬於誰?" │ ├─ 查 zones index for "blog.example.com" → none │ ├─ 查 zones index for "example.com" → 不在我們 CF account │ └─ 查 custom_hostnames index → 找到 blog.example.com 註冊在 vibehost.com zone │ ├─ 4. 確定屬於 vibehost.com zone、套用該 zone pipeline: │ ├─ 載 vibehost.com zone 的 worker routes │ ├─ 載 vibehost.com zone 的 firewall / WAF / cache rules │ ├─ 對這個 request URL 比對 routes │ │ └─ pattern blog.example.com/* 比對 → 中 │ │ └─ 觸發 vibehost-dispatch worker │ └─ 沒中 → 走 fallback_origin (HTTP fetch 出去) │ └─ 5. worker 收到 request、處理、回給 browser
5.1 為什麼這 design 必然要 hostname 跟 zone 一致
第 4 步是關鍵——CF 邊緣 只用一個 zone 的設定(找到 zone X、就套用 zone X 的所有 routes / rules)。如果你 worker route 設在 zone Y、但 hostname 註冊在 zone X、那 zone Y 的 routes 不會被應用。
這是為什麼 P6.5 第一輪我設錯——hostname 註冊在 .space zone、worker route 也設在 .space zone。乍看一致——但 customer 的 CNAME target 是 cname.vibehost.host(.com zone)、這條 CNAME chain 把 traffic 跟 .com zone 綁在一起、CF 找 hostname 時是查 CNAME chain 終點所在的 zone。
5.2 結論
| 東西 | 必須在哪個 zone | 原因 |
|---|---|---|
CNAME target host (cname.vibehost.host) | SaaS provider zone (vibehost.com) | 客戶 CNAME 指這、CF 邊緣 trace 到這個 zone |
| Custom Hostname registration | 同 SaaS provider zone | 邊緣才能在這 zone 認領 |
| Fallback origin record | 同 SaaS provider zone | 同 zone 邊緣 routing pipeline 才認 |
| Worker route | 同 SaaS provider zone | route 比對是 per-zone、跨 zone 不會 trigger |
06 為什麼 worker route 有 1000 條上限
這是 P6.5 第二輪卡住的真實原因。CF API token 拒絕 */* wildcard、我只能加 per-host 精確 pattern:
POST /zones/{vibehost.com}/workers/routes
{ "pattern": "lacoste.projectd.cc/*", "script": "vibehost-dispatch" } → ok
{ "pattern": "cpbl.ladykaren.org/*", "script": "vibehost-dispatch" } → ok
...
{ "pattern": "*/*", "script": "vibehost-dispatch" } → 9106 rejected
6.1 為什麼有 1000 條上限
回到第 03 節講的——CF 邊緣 每個 PoP 都要全 cache hot routing index。每條 worker route 是 hot path 的一個 entry:
PoP 收到 request URL https://blog.example.com/posts/123 ↓ 比對 zone (例如 vibehost.com) 上所有 worker routes: ├─ blog.example.com/* hash match? → yes → trigger ├─ acme.com/* hash match? → no ├─ cf-saas-origin.vibehost.com/* hash match? → no └─ ... (依此類推、每條都要比)
1000 條已經很多——每個 request 都要過完比對。對 CF 而言、想保證 PoP 內 routing decision ≤1ms、就得限制 hot index 大小。
6.2 為什麼 */* wildcard 是逃出口
一條 wildcard pattern */* 取代 N 條 per-host pattern——hot index 只有 1 個 entry。比對時 hash 比對快、O(1)、無上限問題。
但 wildcard 有個語意 trap:它會 match zone 上所有 traffic。如果 zone 是 vibehost.com、wildcard 會 catch vibehost.com apex / api.vibehost.com / www.vibehost.com——所有 host。Worker 要自己邏輯處理「這 host 是 platform-internal 還是 customer hostname」、不能再依賴 route pattern 過濾。
我們 P6 round 4 加的 dispatcher worker PLATFORM_HOSTS bypass 邏輯就是處理這個——當看到 host 是 platform-internal 就 fall through fetch 過去:
// dispatcher worker
const platformHosts = (env.PLATFORM_HOSTS || "").split(",");
const isPlatformHost = platformHosts.some(h => hostname === h || hostname.endsWith("." + h));
if (isPlatformHost) {
return fetch(request); // passthrough — let zone-default routing handle
}
// otherwise it's a customer hostname → KV lookup → dispatch
6.3 為什麼 API token 拒絕 */*
CF wildcard pattern 影響面太大、需要 account-level admin 權限。Scoped API token(即使有 Workers Edit)也無法加 */*。
解法:在 dashboard 手動加(一次性、5 分鐘)、或 rotate 到 legacy global API key(不 recommend、安全性差)。
10w hostname × 1 條 per-host route = 10w 條、爆 1000 上限 100 倍。必須走 wildcard 路。今天 ship 是 per-host hack(5 個客戶內 OK)、未來規模化前必修。
07 為什麼是 fallback_origin、不是 worker 直接當 origin
這是 P6.5 第二輪 522 卡住的關鍵 misconception。直觀想:worker 直接當 origin 不就好了?實際上、CF 設計上 不行。
7.1 CF for SaaS pipeline 跟 Worker pipeline 是序列、不是並列
CF 邊緣 pipeline (簡化版) Layer 1: TLS terminate (SNI lookup) ▼ Layer 2: CF for SaaS resolution └─ 「這個 hostname 屬於哪個 zone?」 └─ 透過 custom_hostnames 註冊找到 SaaS zone ▼ Layer 3: zone-level pipeline (firewall / WAF / cache rules / ...) ▼ Layer 4: Worker routes └─ 比對 request URL pattern ▼ Layer 5: 沒中 worker route → go to origin └─ 從 zone 設定查「origin 是什麼?」 └─ 如果 zone 是 SaaS zone、查 fallback_origin ▼ origin server (HTTP fetch) └─ HTTP response 回來、流回 layer 4 → 3 → 1 → browser
7.2 fallback_origin 的角色
fallback_origin 是 layer 5 的東西——CF 邊緣處理完所有 layers 1-4 之後、需要從某個 origin 拿 response 才能回給 browser。fallback_origin 告訴 CF:「對這 zone 的 SaaS traffic、最後 fallback 去 fetch 這個 origin host」。
那 worker route(layer 4)行為是怎樣?worker route 在 layer 5 之 前觸發——如果 worker route 比對中、worker 就接管整個 response、不會 hit fallback origin。
7.3 為什麼 cf-saas-origin.vibehost.com 這 host 是 originless
我們的 setup:
fallback_origin (.com zone) = cf-saas-origin.vibehost.com DNS record cf-saas-origin = AAAA 100:: proxied worker route (.com zone) = *(per-host, e.g. lacoste.projectd.cc/*) → vibehost-dispatch script
具體 trace 客戶 hostname 流量:
1. browser → CF anycast (TLS, SNI=lacoste.projectd.cc) 2. layer 2 CF for SaaS: 找到 lacoste.projectd.cc 註冊在 .com zone 3. layer 3-4: 套用 .com zone pipeline 4. worker route 比對: pattern lacoste.projectd.cc/* 中 → trigger dispatcher 5. dispatcher 接管、查 KV、forward 到 customer worker、回 response 6. fallback_origin 從未 fetch(因為 layer 4 就接管了)
fallback_origin 在 happy path 不會被打到——但 CF 邊緣依然要求它存在、得 active 才允許 SaaS hostname 流量進入這 zone。它是 fail-safe、不是實際 origin。所以 100:: 這個假地址 OK——只要 record 在、CF 邊緣 routing 就會考慮這 zone。
7.4 為什麼不能直接「fallback_origin = worker URL」
這是 P6.5 round 1 我嘗試的——直接 bind cname-fallback.vibehost.space 到 dispatcher worker(透過 PUT /accounts/.../workers/domains)。CF 拒絕——因為 fallback_origin 是 HTTP fetch 對象、不是 worker route 對象。CF 邊緣要從那 host 拿 HTTP response、不是 invoke 一個 worker。
結果:fallback_origin 設成 worker custom domain → CF 邊緣 fetch HTTP → worker custom domain bind 機制不接 SaaS internal proxy 的 fetch(only direct external request fires)→ 整個 hop 走不通 → 522。
CF for SaaS 不允許 worker 直接當 origin、因為 layer 4 (worker route) 跟 layer 5 (fallback_origin) 在邊緣 pipeline 裡是序列、不是並列。要讓 worker 處理 SaaS traffic、必須讓 worker route 在 layer 4 fire(透過 zone 上的 worker route 設定)、不是讓 worker 出現在 layer 5。
08 */* wildcard 那個坑
解開了 layer 4 / layer 5 的設計、剩下的問題是 worker route pattern 怎麼寫才會在 SaaS internal forward 時 fire。
8.1 Worker route pattern 比對的是 outer request URL
關鍵 nuance——CF 邊緣處理 SaaS traffic 時、不會改寫 incoming request URL。即使內部 routing 把 traffic 帶到 SaaS zone、worker route 比對的還是 https://lacoste.projectd.cc/...(原始客戶 hostname)。
customer hostname: lacoste.projectd.cc CNAME target: cname.vibehost.host (in .com zone) fallback_origin: cf-saas-origin.vibehost.com (also .com) CF 邊緣 pipeline 看到 request URL = https://lacoste.projectd.cc/path worker route 比對的是 lacoste.projectd.cc/... └─ 不是 https://cf-saas-origin.vibehost.com/path └─ 不是 https://cname.vibehost.host/path
所以 pattern cf-saas-origin.vibehost.com/* 不會 match lacoste.projectd.cc 的 SaaS traffic——這個 pattern 只 match 「客戶或我自己直接 hit cf-saas-origin.vibehost.com 的請求」、跟 SaaS forwarding 無關。
8.2 為什麼 CF 文件推薦 */*
因為 zone 上 SaaS traffic 的 outer hostname 是「任何客戶 hostname」——預先列舉不完。*/* 是「比對任何 zone 內 traffic」的唯一方式。
但 */* 連 platform-internal traffic 都接、所以 dispatcher worker 必須有 PLATFORM_HOSTS bypass 邏輯(節 06.2)。
8.3 為什麼今天 ship 用 per-host 精確 pattern
API token 不允許加 */*(節 06.3)。為了 unblock cutover、我們手動加 per-host 精確 pattern 給 lacoste / cpbl / cf-saas-test 三個。三個還可以——10 萬不行。
正式解法兩條:
- 到 CF dashboard 手動加
*/*route on vibehost.com zone(一次性、5 分鐘)。 - 長期:ship 一個 follow-up PR、自動化 verify 時 add per-host route(API token 加 per-host OK),然後在 1000 條接近時警示、轉到 wildcard。
第一條更乾淨——一條 */* route + dispatcher PLATFORM_HOSTS bypass 解所有未來 hostname。
09 我們最後 ship 了什麼
最終 production 架構 customer browser │ │ DNS: blog.example.com → cname.vibehost.host │ cname.vibehost.host → CF anycast (proxied CNAME) ▼ CF edge (anycast PoP) │ ├─ TLS terminate │ └─ cert auto-issued by Google CA via CF for SaaS │ ├─ CF for SaaS resolution │ └─ blog.example.com 註冊在 vibehost.com zone │ ├─ Worker route on vibehost.com zone │ └─ pattern (per-host, e.g. blog.example.com/*) → vibehost-dispatch │ ▼ vibehost-dispatch worker │ ├─ KV.get(custom-domain:blog.example.com) → { subdomain: "..." } ├─ KV.get(subdomain) → routeData (R2 prefix, visibility, password) ├─ /authz/check → AND-compose visibility / password / share-link gates │ ▼ R2 bucket serves static assets (or SSR worker for Next.js) │ ▼ response → CF edge → browser
對應今天 commit / merge 的東西:
| Phase | 動了什麼 | Ship 形式 |
|---|---|---|
| P1 | dispatcher worker 加 custom-domain branch + ownership-HTTP self-answer | PR #306 |
| P2 | CF Custom Hostnames API service + DB cf_* 欄位 | 同上 |
| P3 | API verify/delete fail-open sync 到 CF + KV | 同上 |
| P4 | /authz/check 認 custom hostname、塞 cleanup_failed orphan deny path | 同上 |
| P5 | backfill script (--dry-run / --confirm / --max-rows) | 同上 |
| P6 | prod 手動 ops: migration / KV ID env / backfill / fallback origin / worker routes / DNS flip | manual |
| P7 | Caddy GCE VM stop(保留 disk) | manual |
| — extra — | install.sh BSD grep 修正 | PR #331 |
9.1 哪些是 hack、哪些是長期的
- 長期:dispatcher worker 的 custom-domain routing、KV mapping、ownership self-answer、API service wrapping、backfill 流程——這些都會留下。
- Hack:per-host worker route(接近 1000 上限就要換
*/*)、cf_id 寫到 .space zone 的 bug(issue #333、要修)。
10 10 萬 hostname 規模的 trade-offs
| 面向 | 現況 | 10w 撞牆? | 解法 |
|---|---|---|---|
| CF for SaaS hostname quota | Free 100、之後 $0.10 / hostname / month | $10k/mo | 付錢即可 — Dcard 可承受 |
| Worker routes per zone | per-host 精確 pattern × N | 硬上限 1000 | 改用 1 條 */* route + dispatcher 內部 PLATFORM_HOSTS bypass |
| KV reads per request | 2 reads/req (custom-domain + subdomain mapping) | ~$300/mo | 付費 OK;可選加 in-worker memory cache 降 p95 |
| /authz/check load on API | 每個 non-public req 一個 DB query | 600 req/s peak | API in-process cache + DB connection pool 即可 |
| Cert renewals storm | CF auto-renew、無 webhook 監控 | 每天 1700 個 renewal | 必做 webhook receiver + dashboard alert (P9 follow-up) |
| Apex domain instructions | 目前回 A → Caddy IP(壞的) | ~30-40% 客戶會撞 | 改成 ALIAS / CNAME flattening guidance、或 CF anycast IP |
| DB scale | partial unique index 已建 | 無壓 | Postgres 對 10w rows 完全無痛 |
10.1 真正的 scale 順序
按 priority:
*/*wildcard route + dispatcher PLATFORM_HOSTS bypass — 沒這條、第 1001 個客戶就死。- issue #333 (zone env split) — 沒這條、新客戶都需要 manual zone migration。
- cert renewal webhook receiver — 沒這條、10w 規模下每天有客戶 cert 出狀況、變紅鎖、不知道。
- apex domain instructions 修正 — 30-40% 客戶 onboarding 會 stuck。
1 + 2 是 hours 工作、3 + 4 是 days 工作。1 + 2 + 3 + 4 全做之後、架構就 scale 到 10w 沒問題。
10.2 結語
今天 ship 的 cf-saas-decommission 是 架構正確、規模需 4 個 follow-up。Caddy 的單 VM 模型在 5–10 個客戶內最簡單、但對 multi-tenant edge anycast 沒有路;CF for SaaS 的設計把 Caddy 那層複雜度從 「我們 own 一台 server」轉成 「我們 own 邊緣 routing 的 zone-level entries」——entries 跨全球 PoP 同步、所以有 quota;但同時也免費獲得 anycast、WAF、bot management、analytics、L7 DDoS 保護——這些 Caddy 沒有。