跳至主要內容
技術

把 7,750 張 OG 圖改成 Cloudflare Worker 即時生成:Satori at the edge

把 7,750 張 OG 圖改成 Cloudflare Worker 即時生成:Satori at the edge
打造 TaxMap-TW:用 Astro 做台灣所得地圖 第 10 / 10 篇

⚠️ 開頭先誠實說:這篇是設計與 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,7500(不進 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 的 fetchr.json() 任何一步失敗,現在都會直接讓整個請求 500,而且這正是 build-time 沒有的失敗模式(build 階段抓不到字型,build 就紅燈了,根本上不了線;改成 runtime 之後,這些錯誤全被推到「使用者請求當下」才爆)。實作時這裡每一步都要包 try/catch,並準備一張 fallback OG 圖(純文字、不依賴外部資料的版本),抓不到資料時至少回得出一張像樣的圖,而不是讓爬蟲拿到 500。

踩雷預告(先研究過、實作時會遇到的)

  1. 別用 @vercel/og:WASM 打包不相容 Worker,改用 workers-og
  2. 中文字型要手動塞:非拉丁字型 Satori 不會自己載,要自己把 Noto Sans TC 的 WOFF buffer 餵給 fonts。WOFF ~1.4MB,但 Satori 只會 subset 實際用到的字,輸出 PNG 約 30KB。注意字型格式只能餵 TTF / OTF / WOFF,Satori 不支援 WOFF2——很多現成 CDN 字型預設給的是 .woff2,直接拿來會解析失敗,要找 .woff 版或自己轉一份。
  3. Satori 內建抓圖會默默失敗:在 Worker 裡,圖片要自己 fetch 轉成 base64 data URL,不要靠 Satori 內部抓。
  4. Worker CPU 時間:resvg-wasm render 吃 CPU,要靠 edge cache 讓每張只算一次。
  5. 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 有在運作。
  6. 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.defaultper-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-ogSatori6 Pitfalls of Dynamic OG on Cloudflare Workers

留言討論

esc
輸入關鍵字搜尋文章...
查看收藏 →