把 7,750 張 OG 圖改成 Cloudflare Worker 即時生成:Satori at the edge
⚠️ 開頭先誠實說:這篇是設計與 How-to 指南,不是已上線的復盤——它正是上一篇我「逃避」掉的那個重構。等真的做完,我會再寫一篇實戰版。
起點:上一篇 7,750 張預先生成 OG 圖留下的尾巴
上一篇講到 TaxMap 因為「每個村里預先生成 1 張 OG 圖 × 7,750 = 7,750 個檔案」,把 dist/ 頂破 Cloudflare Pages 的 20,000 檔上限,最後搬去 Netlify。
但其實有個更漂亮的解法:根本不要預先產生那 7,750 張圖。改成「有人要分享某個村里時,才即時生成那一張,然後快取在邊緣」。
而且這招有個甜美的副作用——OG 圖從 7,750 個檔案變成 0 個,dist/ 從 23,331 降到 ~15,584,反而塞得回 Cloudflare Pages Free 版。繞了一圈又回得去。
核心概念:on-demand OG
預先生成(build-time)vs 即時生成(runtime)的差別:
| 比較項目 | Build-time(現在) | Runtime(這篇要做的) |
|---|---|---|
| 何時產圖 | build 時一次生 7,750 張 | 第一次有人請求才生那一張 |
| 檔案數 | +7,750 | 0(不進 dist/) |
| 新增村里 | 要重 build | 自動就有 |
| 首次延遲 | 無(已是檔案) | 有(首次渲染,之後吃快取) |
| 冷啟動 | 無 | 有(Worker 冷啟 + WASM 初始化) |
| 額外執行成本 | 無(純靜態檔) | 多一個 Worker(可能要 Workers Paid) |
| 外部相依 | build 完就定版 | 線上要 fetch 字型、村里 JSON、Cache API |
| 爬蟲可靠度 | 100%(檔案永遠在) | 看快取命中,首次被分享是冷渲染 |
| 可驗證性 | CI build 階段就能驗、壞了不會上線 | 失敗移到線上請求路徑,要靠監控才看得到 |
做法:一個 Worker 掛在 /og/v/:code.png,第一次被請求時 render 出 PNG、寫進 edge cache,之後都走快取。
技術棧:workers-og(Satori + resvg-wasm)
關鍵是 workers-og 這個套件——專為 Cloudflare Workers 設計的 OG 產生器,API 仿 @vercel/og,底層是:
- Satori:把 HTML/CSS → SVG(不需瀏覽器)
@resvg/resvg-wasm:把 SVG → PNG
為什麼不直接用 @vercel/og? 因為它的 WASM 打包方式在 Cloudflare Worker 上會出錯。workers-og 就是為了解決這件事而生,還額外支援用 HTML 字串(透過 Worker 的 HTMLRewriter 解析),不用寫 JSX。
底層的 Satori + resvg 其實就是我在 build-time 產 OG 圖時用的那套,差別只在這次搬到了 Worker 上跑。Satori 本身怎麼把 HTML/CSS 變成 OG 圖、為什麼是它而不是 puppeteer,我之前寫過一篇 build-time 的 Satori + resvg 教學;想先看「到底要不要自己生 OG 圖、有哪幾種做法」的,可以看 OG 圖自動生成的三種方案比較。這篇談的 runtime 生成,本質上是同一套引擎換個執行時機。
3 分鐘快速上手
npm create cloudflare@latest og-worker # 建一個 Worker 專案
cd og-worker
npm install workers-og
最小可動範例(src/index.ts):
import { ImageResponse } from "workers-og";
export default {
async fetch(request: Request) {
const html = `
<div style="display:flex;width:100%;height:100%;
align-items:center;justify-content:center;
background:#0b1220;color:white;font-size:72px;">
哪里最有錢 · TaxMap
</div>`;
return new ImageResponse(html, { width: 1200, height: 630 });
},
};
npx wrangler dev # 本機跑起來
# 開 http://localhost:8787 就看到一張 1200×630 的 PNG
ImageResponse 收 HTML 字串(或 JSX),回一個 body 是 PNG 的 Response。就這麼直接。
套用到 TaxMap 的設計
把村里資料、字型、快取串起來(設計草稿):
import { ImageResponse } from "workers-og";
let fontCache: ArrayBuffer | null = null;
async function getFont() {
// ⚠️ 中文字型必須「手動」載入並塞給 Satori,它不會自己抓
if (!fontCache) {
const r = await fetch("https://taxmap.bobochen.dev/fonts/NotoSansTC-Bold.woff");
fontCache = await r.arrayBuffer();
}
return fontCache;
}
export default {
async fetch(request: Request, env: unknown, ctx: ExecutionContext) {
const url = new URL(request.url);
const code = url.pathname.split("/").pop()?.replace(".png", "");
// 1) 先查 edge cache,命中就直接回(每個村里只 render 一次)
const cache = caches.default;
const hit = await cache.match(request);
if (hit) return hit;
// 2) 手動 fetch 村里資料(Satori 在 Worker 內建抓取會「默默失敗」)
const data = await fetch(
`https://taxmap.bobochen.dev/data/villages/${code}.json`
).then((r) => r.json());
// 3) render
const img = new ImageResponse(
`<div style="display:flex;flex-direction:column;width:100%;height:100%;
padding:80px;background:#0b1220;color:#fff;
font-family:'Noto Sans TC';">
<div style="font-size:40px;color:#9ca3af;">${data.county}${data.town}</div>
<div style="font-size:96px;font-weight:700;">${data.name}</div>
<div style="font-size:64px;margin-top:auto;">中位數所得 ${data.median} 萬</div>
</div>`,
{
width: 1200,
height: 630,
fonts: [{ name: "Noto Sans TC", data: await getFont(), weight: 700 }],
}
);
// 4) 寫進 cache,回傳
const res = new Response(img.body, img);
res.headers.set("Cache-Control", "public, max-age=31536000, immutable");
ctx.waitUntil(cache.put(request, res.clone()));
return res;
},
};
這段是設計骨架,刻意省了錯誤處理好讓主幹清楚——但正式版不能這樣留:getFont() 的 fetch、村里 JSON 的 fetch、r.json() 任何一步失敗,現在都會直接讓整個請求 500,而且這正是 build-time 沒有的失敗模式(build 階段抓不到字型,build 就紅燈了,根本上不了線;改成 runtime 之後,這些錯誤全被推到「使用者請求當下」才爆)。實作時這裡每一步都要包 try/catch,並準備一張 fallback OG 圖(純文字、不依賴外部資料的版本),抓不到資料時至少回得出一張像樣的圖,而不是讓爬蟲拿到 500。
踩雷預告(先研究過、實作時會遇到的)
- 別用
@vercel/og:WASM 打包不相容 Worker,改用workers-og。 - 中文字型要手動塞:非拉丁字型 Satori 不會自己載,要自己把 Noto Sans TC 的 WOFF buffer 餵給
fonts。WOFF ~1.4MB,但 Satori 只會 subset 實際用到的字,輸出 PNG 約 30KB。注意字型格式只能餵 TTF / OTF / WOFF,Satori 不支援 WOFF2——很多現成 CDN 字型預設給的是 .woff2,直接拿來會解析失敗,要找 .woff 版或自己轉一份。 - Satori 內建抓圖會默默失敗:在 Worker 裡,圖片要自己
fetch轉成 base64 data URL,不要靠 Satori 內部抓。 - Worker CPU 時間:resvg-wasm render 吃 CPU,要靠 edge cache 讓每張只算一次。
- Cache API 在
*.workers.dev根本不會運作:這是整套成本攤平的地雷。caches.default只在 custom domain(或 Pages Functions)上才真的快取,掛在預設的xxx.workers.dev網域時cache.put是 靜默 no-op——cache.match永遠 miss,每一次請求都重新 render 一張圖,「每個村里只算一次」的前提整個落空。所以這個 OG Worker 一定要掛在自己的 custom domain(例如taxmap.bobochen.dev),不能只用快速上手那個localhost:8787/ workers.dev 就上線。上面快速上手叫你開localhost:8787只是看渲染對不對,不代表 cache 有在運作。 - Workers Free 每次 invocation 只有 10ms CPU:Satori + resvg-wasm 第一次把 CJK PNG rasterize 出來,CPU 時間常常遠超過 10ms。如果跑在 Workers Free,這張首圖很可能直接被 runtime 中止——圖生不出來,也就寫不進快取,下一個請求又從頭再撞一次。所以要分清楚兩件事:TaxMap 主站的靜態
dist/回 Cloudflare Pages Free 沒問題(純靜態託管),但負責即時渲染的這個 OG Worker,很可能得開 Workers Paid($5/mo)才扛得住第一次冷渲染。下面「搬回 Free」講的是前者,別把它誤讀成「連渲染都免費」。
反思
技術面
這是個經典的 build-time vs runtime 取捨,而且不是 runtime 完勝。Build-time 換來的是零冷啟動、不用多付 Worker 錢、build 完就定版沒有外部相依、爬蟲 100% 抓得到、壞了在 CI 階段就會被擋下來——這些 runtime 全部要重新賺。Runtime 換來的是零檔案、新村里自動就有,代價是多一個 Worker、首次渲染延遲,還有一整類 build-time 不存在的「線上才會炸」失敗模式:fetch 字型、fetch 村里 JSON、Cache API 命中率,任何一個出包都是使用者請求當下才發現。
所以我不會說它「幾乎是必然」。我的門檻是這樣:如果頁面數會持續長大到撞檔案數上限、而且大多數頁面其實沒人會去分享(OG 圖很長尾),那 runtime 才划算——用「只渲染真的被分享到的那幾張」換掉「無差別預生幾千張」。反過來,如果頁面數可控、或熱門頁面就那幾個,build-time 的零延遲跟可驗證性還是比較省心。
而且要對「首次延遲」很誠實。社群平台的爬蟲(Facebook、Threads、LINE)抓 OG 圖是同步的、有 timeout、而且基本上不重試。偏偏 cache miss 那一次——也就是這個村里第一次被分享出去的那一刻——正好是最慢的冷渲染。第一個願意幫你分享的人,很可能就是看不到預覽圖的那個人,這對想靠分享擴散的站來說格外諷刺。要救有幾條路:用 sitemap 或 build 後跑一輪預熱、把已知熱門的村里在 build 時先 warm 進快取、對爬蟲走 stale-while-revalidate(先回舊圖、背景重算)。但說到底,如果一張圖幾乎都是「第一次被分享」時才被要求,那 build-time 預先準備好其實更安全——這也是我還沒急著把它換掉的原因。
還有快取本身也別想得太美。caches.default 是 per-PoP 的,每個邊緣節點各自一份、而且會被驅逐。所以「每個村里只 render 一次」嚴格說是「每個村里、在每個 PoP、在還沒被驅逐之前各 render 一次」;長尾村里散在各地、又久久才被看一次,命中率會很低,等於反覆 cache miss 反覆重算。真要做到全球只算一次,得把結果落到 R2 或 KV 這種持久層,而不是只靠 edge cache。
心態面
上一篇我為了快速上線,選了「換平台」這個 5 分鐘解法,把重構記成 TODO。這篇是把那張 TODO 攤開來「先想清楚怎麼做」——先設計、再實作,而不是一頭栽進去寫 code 才發現中文字型載不進來。光是先查清楚 Cache API 在 workers.dev 不運作、Free 版 10ms CPU 這兩個雷,預期就能省下實作時撞牆乾耗的半個下午——當然這還只是假設,真的動手做完才知道準不準。
有趣發現
最爽的是那個全循環:把 OG 改成即時生成,預生檔案數掉到 20,000 以下,TaxMap 的靜態站就能搬回 Cloudflare Pages Free。當初逼我搬家的限制,用對的架構就能繞回去。
不過先別太得意。所謂「搬回 Free」是把帳算在靜態託管那一塊——真實的代價是架構從「一坨靜態檔」拆成「Pages 靜態站 + 一個獨立 OG Worker(很可能還得 Workers Paid)」兩塊東西。檔案數歸零的同時,我換來的是兩套部署、Worker 的字型/快取/錯誤處理要長期顧、出事時要分清楚是哪一邊。本質上是用「長期維運複雜度」去換「檔案數歸零」,不是無痛的勝利。值不值得,要看我願不願意長期養這個 Worker——這也是我到現在還只把它停在設計稿、沒真的動手的原因。
這個系列其他文章:前情是 Cloudflare Pages 的 20,000 檔案上限:我把 TaxMap 搬到 Netlify(就是它逼出這篇的重構);整個專案怎麼蓋、踩了哪些坑的全紀錄在 打造 TaxMap-TW 完整心得:6 個技術決策、踩了 4 個坑。
參考:workers-og、Satori、6 Pitfalls of Dynamic OG on Cloudflare Workers