Cloudflare Workers で OGP 画像生成したら CPU 時間オーバーした話
Cloudflare WorkersでのOGP画像生成でCPU時間オーバーに苦戦した失敗談。satoriからRust/WASMへ自前実装するも改善せず、真のボトルネックは「毎回のフォント解析」だったという気付きと現在のキャッシュ戦略について解説します。
このサイトのブログ記事 OGP 画像は、当初 satori と resvg で生成していました。しかし、Cloudflare Workers 上で動かしたところ「Worker exceeded resource limits」エラーが頻発し、Rust/WASM で自前実装し直しても大きな改善には至りませんでした。今回は、その失敗と、原因の認識を途中で誤っていた話を残しておきます。
当初の構成: satori + resvg
OGP 画像を動的に生成する実装は、個人ブログであっても SNS 共有時の見栄えに直結するため、最初から導入したかった機能です。当初は、Vercel が開発している satori で JSX から SVG を生成し、resvg で PNG に変換するという一般的な構成を採用していました。
satori は React ライクな JSX で OGP 画像のレイアウトを記述できるため、実装の敷居は低く、ローカル環境では十分な速度で動作していました。
ただし、satori は内部でレイアウトエンジンの yoga を使っており、import satori from "satori" のような通常のインポートでは Workers で動きません。yoga.wasm を動的にロードしようとしてランタイム制約に引っかかります。Workers で動かすには satori/standalone ビルドを使い、yoga の WASM 自体を satori/yoga.wasm から直接インポートして init(yogaWasm) で渡してやる必要がありました。Workers 上で yoga と resvg という複数の WASM モジュールを明示的に読み込んで初期化する必要があり、通常の npm パッケージを import するだけの構成よりも扱いが少し複雑でした。
発生した問題: Worker exceeded resource limits
この構成を Cloudflare Workers にデプロイしてみると、OGP 画像を生成しようとした際に Worker exceeded resource limits(Error 1102)が頻発しました。
Cloudflare Workers の無料プランでは、1 リクエストあたりの CPU 時間は 10 ms に制限されています。ただし、これは絶対的な壁ではありません。公式ドキュメントには「各 isolate には built-in flexibility があり、設定された制限をたまに超えるリクエストは許容される」と明記されています。実際に私の環境でも、500 ms 程度の処理が一定の頻度で完走していました。
しかし、この 500 ms のリクエストが連続すると、リソース制限に抵触してしばらくページが閲覧できない状態になります。SNS クローラーからの連続アクセスや、複数記事の OGP 画像が同時にリクエストされる状況で顕著に発生しました。
問題の原因を、当時は次のように推測していました。
satori も resvg も Rust で構築された WASM 上で動作しており、それ自体は十分に高速である。しかし satori は「HTML → SVG → PNG」という多段階の変換を行っており、この回り道が CPU 時間を圧迫しているのではないか。
この推測は、結論から言うと部分的にしか合っていませんでした。詳しくは後述します。
対応策: Rust/WASM への移行
「HTML → SVG → PNG という回り道をせず、直接 Rust で画像を描画すれば、もっと軽量にできるのではないか」と考え、OGP 画像生成のコア部分を Rust で自前実装し、WASM にコンパイルして Workers 上で動かす方針に切り替えました。
WASM 内へのフォント・画像埋め込みの失敗
最初は、フォントファイルや背景画像を WASM 内に埋め込もうとしました。実行時にファイルを読み込むオーバーヘッドを省き、WASM 単体で完結させることで速度を出せるはず、という期待です。
しかし、フォント(Noto Sans JP のサブセット、約 4 MB)と背景画像を埋め込むと、WASM のサイズが Cloudflare Workers のデプロイ制限を突破してしまいました。
Workers 無料プランのスクリプトサイズ制限は、gzip 圧縮後で 3 MB(無圧縮では 64 MB)です。WASM を含むスクリプトバンドル全体がこの対象になります。フォントや画像のような既に圧縮されにくいバイナリを含めると、圧縮後のサイズは簡単に 3 MB を超えていきます。
一方、Cloudflare Workers Static Assets はスクリプトバンドルのサイズ制限に含まれません。個別ファイルの上限は 25 MiB と十分大きく、フォントや画像のようなアセットを置く場所として適しています。そこで、フォント(TTF 形式)と背景画像(生の RGBA データ)を Static Assets に配置し、実行時に動的に読み込む方式に変更しました。
最終的な構成: Static Assets からの動的読み込み
Static Assets に配置したフォントと背景画像を、OGP 画像エンドポイントで読み込みます。
const background = await fetchAsset(request, "/og-assets/background.rgba");
const font = await fetchAsset(request, "/og-assets/noto-sans-jp-subset.ttf");
WASM 側では、読み込んだデータを直接レンダリングに使用します。
import wasmModule from "./pkg/og_image_bg.wasm?module";
import { initSync, render_og as renderOgWasm } from "./pkg/og_image";
import type { OgImageInput } from "./types";
initSync({ module: wasmModule });
export function renderOgImage(input: OgImageInput) {
return renderOgWasm(
input.background,
input.font,
input.title,
input.tags,
input.writerName ?? null,
input.writerImage ?? null,
);
}
initSync で WASM を初期化し、render_og 関数に背景データ、フォントデータ、タイトル、タグ、著者情報を渡すと、PNG のバイナリが返ってきます。
Rust 側の実装
OGP 画像の描画処理は、以下の Rust クレートを使って実装しています。
[dependencies]
fontdue = "0.9.3"
png = "0.18.1"
tiny-skia = "0.12.0"
wasm-bindgen = "0.2.120"
fontdue: フォントの読み込み、ラスタライズ、テキストレイアウトpng: PNG のエンコードtiny-skia: 2D 描画(ピクセルバッファ、パス、ブレンド)wasm-bindgen: JavaScript との連携
WASM のエントリポイントは以下の通りです。
#[wasm_bindgen]
pub fn render_og(
background_rgba: Vec<u8>,
font_bytes: Vec<u8>,
title: &str,
tags: Vec<String>,
writer_name: Option<String>,
writer_image: Option<Vec<u8>>,
) -> Result<Vec<u8>, JsValue> {
render_og_png(
&background_rgba,
&font_bytes,
title,
&tags,
writer_name.as_deref(),
writer_image.as_deref(),
)
.map_err(|error| JsValue::from_str(&format!("render failed: {error}")))
}
メインのレンダリング処理では、背景画像から Pixmap を作成し、ガラスパネル、ブランド名、タイトル、タグ、著者情報を順に描画しています。
pub fn render_og_png(
background_rgba: &[u8],
font_bytes: &[u8],
title: &str,
tags: &[String],
writer_name: Option<&str>,
writer_image: Option<&[u8]>,
) -> Result<Vec<u8>, String> {
let font = Font::from_bytes(font_bytes, FontSettings::default())
.map_err(|error| format!("font parse failed: {error}"))?;
let size = IntSize::from_wh(WIDTH, HEIGHT)
.ok_or_else(|| "canvas size is invalid".to_string())?;
let mut pixmap = Pixmap::from_vec(background_rgba.to_vec(), size)
.ok_or_else(|| "background asset dimensions are invalid".to_string())?;
draw_glass_panel(&mut pixmap);
draw_branding(&mut pixmap, &font);
draw_title(&mut pixmap, &font, title);
draw_tags(&mut pixmap, &font, tags);
draw_writer(&mut pixmap, &font, writer_name, writer_image)?;
encode_png(pixmap.data())
}
テキストの折り返し処理は、fontdue のメトリクスを使って自前で実装しています。
pub fn wrap_text(
font: &Font,
text: &str,
px: f32,
max_width: f32,
max_lines: usize,
) -> Vec<String> {
let mut lines = Vec::new();
let mut current = String::new();
let mut current_width = 0.0;
let mut truncated = false;
for ch in normalize_text(text).chars() {
let ch_width = font.metrics(ch, px).advance_width;
if current.is_empty() || current_width + ch_width <= max_width {
current.push(ch);
current_width += ch_width;
continue;
}
push_line(&mut lines, &mut current, max_lines, &mut truncated);
if truncated { break; }
current.push(ch);
current_width = ch_width;
}
if truncated || !current.is_empty() {
if lines.len() == max_lines {
let last = lines.pop().unwrap_or_default();
lines.push(truncate_with_ellipsis(font, &last, px, max_width));
}
}
lines
}
最後に、png クレートで PNG エンコードしています。
fn encode_png(pixels: &[u8]) -> Result<Vec<u8>, String> {
let mut output = Vec::new();
{
let mut encoder = png::Encoder::new(&mut output, WIDTH, HEIGHT);
encoder.set_color(png::ColorType::Rgba);
encoder.set_compression(png::Compression::Fast);
encoder.set_depth(png::BitDepth::Eight);
let mut writer = encoder.write_header().map_err(|error| error.to_string())?;
writer.write_image_data(pixels)
.map_err(|error| error.to_string())?;
}
Ok(output)
}
OGP 画像のエンドポイントでは、生成した PNG を長期キャッシュ前提のヘッダで返しています。
return new Response(Buffer.from(png), {
headers: {
"Cache-Control": "public, max-age=3600, s-maxage=86400, stale-while-revalidate=604800",
"Content-Type": "image/png",
"X-Content-Type-Options": "nosniff",
},
});
s-maxage=86400 で CDN 側に 1 日分のキャッシュを依頼し、stale-while-revalidate=604800 で 7 日間は古いキャッシュを許容する想定です。SNS クローラーからのリクエストを極力キャッシュで返すことを狙っています。
なお、Workers のレスポンスを Cloudflare CDN レイヤーでどこまで思った通りにキャッシュさせられるかは、ルーティング構成や Cache Rules の設定によって挙動が変わります(特に stale-while-revalidate は Cache API 経由では公式に未サポートです)。詳細は Cloudflare のキャッシュ概要 を参照してください。本記事では「キャッシュに救われている」という事実関係のみを扱います。
移行後の結果: 改善は限定的だった
Rust/WASM に移行してみたものの、期待したほど劇的な改善は得られませんでした。Workers Logs で観測した CPU 時間の合計値は、依然として 500 ms 程度のままです(内訳までは取得していません)。
「satori の HTML → SVG → PNG という多段階処理を省略できた」ことは確かです。しかし、最終的なボトルネックは別の場所にありました。
真のボトルネックはどこにあったのか
振り返ると、当初「satori が重い」と推測していた認識自体が、原因の優先順位を誤らせていました。実際に CPU 時間を最も消費していた可能性が高いのは、移行後も残り続けていた次の処理です。
1. 毎リクエストでフォントをフルパースしている
私の render_og_png は、リクエストのたびに Font::from_bytes(font_bytes, FontSettings::default()) を呼び出してフォントオブジェクトを作り直しています。
これが致命的でした。fontdue は README に「Fonts are fully parsed on creation」と明記している通り、from_bytes の時点で全グリフ情報を読んでメモリ上のデータ構造に展開する設計です。fontdue の GitHub Issue では、CJK フォント全体をロードするのに 232 ms かかったというベンチマークが報告されています(mooman219/fontdue#59)。
私が読み込んでいるのは Noto Sans JP のサブセット約 4 MB ですが、CJK 文字を多く含むため、ここで数百 ms 単位の時間が CPU に乗っている可能性が高いと考えています。
つまり、satori から Rust/WASM に移行しても、「4 MB のフォントを毎回フルパースする」というコストはどちらにも乗っていたことになります。satori 構成でも、ローカル fontdue 構成でも、ここを共有してしまっていたので、当たり前のように同じコストが残ります。
2. WASM 環境では tiny-skia の SIMD 最適化が効きづらい
tiny-skia は Skia の CPU レンダリングパイプラインを Rust に移植したライブラリで、x86-64 native では Skia の 20–100% 程度遅い、という公式ベンチマークがあります。一方、WASM 環境では SIMD の制約が大きく、-Ctarget-cpu=haswell のような最適化フラグも当然効きません。
1200 × 630 ピクセルのキャンバスにテキストを描画する際、各グリフのビットマップを背景にアルファブレンドする処理は、ピクセル数が増えるほど直線的にコストが上がります。ここは「Rust だから速い」が成立しにくい領域でした。
3. PNG エンコード
最終出力の 1200 × 630 RGBA バッファ(生で約 3 MB)を PNG にエンコードする処理も、純粋な計算処理として CPU 時間にそのまま乗ります。png::Compression::Fast を指定して圧縮レベルは下げていますが、それでもゼロにはなりません。
なお、Static Assets からのフォント・背景画像の読み込みは fetch() 経由の I/O なので、CPU 時間にはカウントされません(公式ドキュメント)。Static Assets 化したことで「読み込み」が新たなボトルネックになることはなく、ボトルネックは純粋にレンダリング側、特にフォントパースに残っていた、というのが正確な姿です。
機能している理由はキャッシュ戦略
現状、このサイトの OGP 画像生成は表向き安定して動作しています。それは Rust/WASM 移行による劇的なパフォーマンス向上のおかげではなく、キャッシュ前提のレスポンス設計によるものです。
s-maxage=86400 と stale-while-revalidate=604800 を組み合わせ、SNS クローラーからのリクエストを極力キャッシュで返すように構成しています。一度生成された OGP 画像はほとんど再計算されず、Workers の CPU 時間を消費する場面は限定的です。
つまり「毎回高速に生成できるようになった」のではなく、「生成が必要な頻度を減らして誤魔化している」というのが正直なところです。Cloudflare 側のキャッシュ配置がうまくいかない時間帯や、新しい記事を公開した直後のクローラーラッシュには、いまでも Error 1102 のリスクが残っています。
まとめ
個人ブログで OGP 画像生成を検討している場合、以下の選択肢が現実的です。
- 外部サービスを使う: Cloudinary のような画像処理サービスに任せる。コストはかかりますが、運用の手間は減ります。
- 画像生成サーバーを隔離する: Workers とは別に、CPU 時間制限が緩いサーバー(Cloud Run など)で画像生成サーバーを立て、Workers からそちらを呼び出します。Workers 側は軽量なプロキシとして動作し、重いレンダリング処理は隔離できます。
- エッジで動的生成する場合はキャッシュを徹底する: このサイトと同じ方向性です。長期キャッシュを必ず設定し、生成回数を最小限に抑えます。ただし、キャッシュミス時に重い処理が走るリスクは残るので、有料プランへの移行も含めて検討する価値はあります。
satori から Rust/WASM への移行は、技術的には面白い挑戦でしたが、Workers の CPU 時間制限を突破するには至りませんでした。理由は、ボトルネックの場所を見誤って「言語を変えれば速くなる」と思い込んでいたからです。実際には毎リクエストの 4 MB フォントの再パースが主犯で、これは言語を変えても消えません。
Workers の無料プラン(10 ms)で個人サイトの OGP 画像生成を成立させたい場合、まず「外部サービス」や「画像生成サーバーの隔離」を真剣に検討するのが安全です。エッジで動的生成する道を選ぶなら、ボトルネックを 1 つずつ正確に潰しつつ、キャッシュで頻度を抑える二段構えが必要です。そして、キャッシュミス時に重い処理が走るリスクは、必ず受け入れた上で運用すべきだと痛感しました。