跳至主要內容
技術

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

把 7,750 張 OG 圖改成 Cloudflare Worker 即時生成:Satori at the edge
哪里最有錢-台灣所得地圖 (TaxMap) 第 10 / 10 篇 ,前往系列總覽

本篇是「哪里最有錢-台灣所得地圖 (TaxMap)」系列的第 10 / 10 篇。你可以從系列總覽開始閱讀,也可以直接接著看本文。

起點:上一篇 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)跟即時生成(runtime)的差別攤開來看:

比較項目Build-time(現在跑的)Runtime(on-demand 方案)
何時產圖build 時一次生 7,750 張第一次有人請求才生那一張
檔案數+7,7500(不進 dist/
新增村里要重 build自動就有
首次延遲無(已是檔案)有(首次渲染,之後吃快取)
冷啟動有(Worker 冷啟 + WASM 初始化)
額外執行成本無(純靜態檔)多一個 Worker(CJK 渲染得開 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 的設計

把村里資料、字型、快取串起來,完整的 Worker 就這幾十行:

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——這個我在 build-time 產 OG 圖時就被咬過:CDN 預設給的 .woff2 直接 parse 失敗,得找 .woff 版或自己轉一份。同一套 Satori 搬到 Worker,這個雷原封不動還在。
  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」講的是前者,別把它誤讀成「連渲染都免費」。

六個雷裡只有四個落在請求路徑上——雷 1 是選套件的決定、雷 3 是圖片怎麼餵給 Satori,兩個都不在這條路徑的節點上;字型、CPU、Cache API、10ms 上限這四個,各自卡在路徑的不同段落:

flowchart TD
    REQ["社群爬蟲請求<br/>/og/v/:code.png"] --> C{"cache.match<br/>命中?"}
    C -->|"命中就直接回"| RET["回傳一張 1200×630 的 PNG"]
    C -->|"miss:冷渲染"| JSON["手動 fetch 村里 JSON<br/>county / town / name / median"]
    JSON --> FONT["手動把中文字型餵給 Satori<br/>fontCache 是空的才去抓"]
    FONT --> SAT["Satori<br/>HTML/CSS 轉 SVG"]
    SAT --> RSVG["resvg-wasm<br/>SVG 轉 PNG"]
    RSVG --> CPU{"CPU 撐得完<br/>這次 rasterize?"}
    CPU -->|"Free 版 10ms 上限"| KILL["渲染被 runtime 中止<br/>圖生不出來也寫不進快取"]
    CPU -->|"開了 Workers Paid"| PUT{"cache.put<br/>寫得進邊緣?"}
    PUT -->|"掛 custom domain"| RET
    PUT -->|"掛在 workers.dev"| NOOP["cache.put 靜默 no-op<br/>這張回得出去,下次又 miss"]
    NOOP --> C

命中那條路徑最短也最便宜,但它幾乎不會在關鍵時刻出現:爬蟲來抓某個村里的 OG 圖,通常就是這個村里第一次被分享的那一刻,走的正是圖上最長的冷渲染——村里 JSON 得現抓,fontCache 還空著的時候字型也要多抓一次。而圖上真正該盯的是從「靜默 no-op」繞回 cache.match 的那條迴圈:第 5 個雷是把第 6 個雷從一次性放大成常態的開關——掛在 workers.dev 時 cache.put 寫不進去,每一次請求都得重走整條冷渲染,於是 10ms CPU 上限從「只撞第一次」變成每次都撞。

我最後沒做這個 Worker——而且越查越確定這是對的

上面這套 Worker,我把設計寫到底、把六個雷一個個查清楚之後,決定不做——不是「先擱著」,是查得越深越確定這條路不划算。

真正逼出這篇的問題其實只有一個:7,750 張 OG 圖被 commit 進 git,撐爆 Cloudflare Pages 的 20,000 檔上限。而那個問題,我用一個無聊很多、也安全很多的方法解掉了——還是 build-time 預先生成,但不再把 PNG commit 進 repo

  • 7,747 張 PNG(約 466MB)改成 gitignore,打包成 og-v.tar.gz 丟進一個 GitHub Release(og-snapshot-2023)。
  • CI 在 astro build 之前,用 actions/cache 把它們還原到 public/og/v,cache miss 就 gh release download 補。
  • 站續留在 Netlify,靠它 CDN 的差異比對,沒變的 OG bytes 不會每次 deploy 重傳。

效果是:commit 進 git 的 OG 檔案數 → 0,但 build 仍然 deterministic、CI 階段就驗得到、零冷啟動、爬蟲 100% 抓得到、沒有 Worker 要顧、不用開 Workers Paid。這套現在就跑在 production,穩到我幾乎不用想它。唯一沒拿到的,是這篇開頭那個甜美副作用——「搬回 Cloudflare Pages Free」。

但這裡有個誠實的轉折:我根本不需要那個副作用。站一旦搬到 Netlify,20,000 檔上限這條限制就消失了,而這整套 runtime 設計從頭到尾都是為了繞過它。換句話說,這份精心設計的 Worker,解的是一個我換平台之後已經不存在的問題;真正剩下的問題(別把幾千張圖塞進 git),快照法用十分之一的複雜度就解掉了。

所以這篇的定位很清楚:它是一次 「做還是不做」的可行性研究,我把它查到能下判斷為止,然後下了判斷——不做。下面的反思,就是這個決定背後的帳。

反思

技術面

說穿了這就是 build-time 跟 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 預先準備好其實更安全——這正是我最後守在 build-time、沒把它換成 runtime 的關鍵原因。

還有快取本身也別想得太美。caches.defaultper-PoP 的,每個邊緣節點各自一份、而且會被驅逐。所以「每個村里只 render 一次」嚴格說是「每個村里、在每個 PoP、在還沒被驅逐之前各 render 一次」;長尾村里散在各地、又久久才被看一次,命中率會很低,等於反覆 cache miss 反覆重算。真要做到全球只算一次,得把結果落到 R2 或 KV 這種持久層,而不是只靠 edge cache。

心態面

上一篇我為了快速上線,選了「換平台」這個 5 分鐘解法,把重構記成 TODO。這篇是把那張 TODO 攤開來「先想清楚怎麼做」——而先設計、再實作的真正回報,是它讓我決定不要實作。要不是先把「Cache API 在 workers.dev 不運作」「Free 版只有 10ms CPU」這兩個雷查清楚,我就會一頭栽進去寫 code,撞牆乾耗半個下午後才發現這條路要嘛得開 Workers Paid、要嘛在「第一次被分享」那個最關鍵的時刻渲染最慢。最便宜的實作,往往就是研究完之後決定不寫的那一個。

有趣發現

設計這篇時最讓我心動的,是那個「全循環」:把 OG 改成即時生成 → 預生檔案數掉到 20,000 以下 → TaxMap 就能搬回 Cloudflare Pages Free,當初逼我搬家的限制,用對的架構繞一圈又回得去。

漂亮歸漂亮,它其實是個陷阱——為了拿回一個「我換到 Netlify 之後已經不需要」的平台,我得把架構從「一坨靜態檔」拆成「Pages 靜態站 + 一個獨立 OG Worker(還得開 Workers Paid)」,外加長期要顧的字型、快取、錯誤處理。用「長期維運複雜度」去換一個我已經不缺的東西,不划算。

所以最後真正有趣的發現反而很無聊:讓檔案數歸零,根本不必動到「即時生成」這麼重的架構。把 PNG 移出 git、丟進 GitHub Release、CI 還原——這個無聊十倍的動作拿到了一模一樣的「commit 檔案數 → 0」,卻一個 runtime 的缺點都沒沾上。那個華麗的全循環很迷人,但迷人的架構和對的架構不是同一回事


這個系列其他文章:前情是 Cloudflare Pages 的 20,000 檔案上限:我把 TaxMap 搬到 Netlify(就是它逼出這篇的重構);整個專案怎麼蓋、踩了哪些坑的全紀錄在 打造 TaxMap 完整心得:6 個技術決策、踩了 4 個坑

參考:workers-ogSatori6 Pitfalls of Dynamic OG on Cloudflare Workers

留言討論

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