gen-og.tsのキャッシュが壊れた画像を「更新済み」と誤認し、日英95本中91本が同じOGP画像になっていた
※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。
結論
OG画像の生成スクリプトは日英で同じ `og/${slug}.png` に書き込んでいたため、後から処理される `ja` 側が `en` 側を上書きし、95スラッグ中91件で英語記事のシェアカードに日本語のタイトルが描かれたまま検査はすべて緑だった。
結論
OG画像を生成する gen-og.ts は、日本語版と英語版の両方をまわしていたのに、書き込み先が og/${slug}.png の1本しかありませんでした。 日英は同じスラッグを使うため、後から処理される側が前を上書きします。しかもインクリメンタルキャッシュが「ハッシュ一致・ファイルも存在する」と誤判定して上書きされた側を作り直さなかったため、95スラッグのうち91件で日英の画像が同一バイトになっていました。ページの描画も og:image のURLも正常に200を返し、検査はすべて緑のままでした。
症状
gen-og.ts は記事ディレクトリを次のように処理します。
for (const lang of readdirSync(POSTS)) {
const dir = join(POSTS, lang);
if (!statSync(dir).isDirectory()) continue;
for (const file of readdirSync(dir)) {
// ...
POSTS 配下には en/ と ja/ の2つのディレクトリがあり、readdirSync が返す順で処理されます。修正前のコードでは、出力先が言語に関係なく同じパスでした。
const out = join(OUT, `${slug}.png`);
日英の記事は同じスラッグを共有する対訳ペアなので、en/xxx.md と ja/xxx.md はどちらも og/xxx.png という同じファイルに書き込みます。ページ自体は正しくレンダリングされ、<meta property="og:image"> が指すURLも200を返すため、通常のクロールベースの検査では何も引っかかりません。画像ファイルの中身が日英で違うかどうかを見ている検査は、この時点では存在していませんでした。
原因
このリポジトリの readdirSync(POSTS) は、実際には en を先に、ja を後に処理する順で返っていました。修正コミットのコメントに、そのまま経緯が残っています。
// ⚠ 2026-09-05 まで、日英が同じファイル名に書いて潰し合っていた。
// 出力が `og/${slug}.png` の1本しかなく、日英で同じスラッグを使っているため、
// 後に走ったほう(readdir 順で `ja`)が上書きしていた。
// キャッシュのキーは `lang/slug` で両言語ぶん191件あるのに、実ファイルは96枚しか無い
// ── キャッシュが「作った」と言い続けるので、上書きされていることに気づけない。
ja が後に処理されるため、共有ファイル og/${slug}.png に最終的に残るのは日本語タイトルの画像です。ところが Post.astro 側の og:image は、修正前は言語を区別せず同じURLを組み立てていました。
{/* ⚠ 英語は og/en/ を見る。日英で同じスラッグなので、同じパスにすると
生成側で潰し合い、英語ページに日本語のタイトル画像が出る(2026-09-05 修正) */}
ogImage={`${site}/og/${entry.data.lang === 'en' ? 'en/' : ''}${entry.id.replace(/^(ja|en)\//, '').replace(/\.md$/, '')}.png`}
コメントにある通り、影響を受けていたのは英語記事のシェアカードでした。英語ページも同じ og/${slug}.png を参照するため、そこに描かれているのは自分の言語ではなく日本語タイトルの画像だったということです。
さらに厄介だったのが、インクリメンタルキャッシュの仕組みです。キャッシュのキーは lang/slug の組み合わせで、日英分で191件記録されていました。しかし実際に og/ 以下に存在するファイルは96枚(95スラッグぶん+既定画像1枚)しかありません。en 側の処理では、上書きされる前に一度は自分の画像を書き込んでキャッシュへハッシュを記録しますが、そのすぐ後に ja 側が同じパスへ上書きします。次回以降のビルドで en 側を見ると、キャッシュのハッシュは一致し、ファイルも存在するため「変更なし」と判定されて再生成がスキップされ続けます。壊れた状態のまま、二度と直る機会がありませんでした。
実測では、95スラッグのうち91件で日英の画像が同一バイトになっていました。
直す
修正は3点です。
- 英語の出力先を
og/en/${slug}.pngに分離する
const out = lang === 'en' ? join(OUT, 'en', `${slug}.png`) : join(OUT, `${slug}.png`);
mkdirSync(dirname(out), { recursive: true });
Post.astroのog:imageも、言語ごとに参照先を分ける
ogImage={`${site}/og/${entry.data.lang === 'en' ? 'en/' : ''}${entry.id.replace(/^(ja|en)\//, '').replace(/\.md$/, '')}.png`}
- インクリメンタルキャッシュを一度捨てて、日英合わせて190枚のOG画像を全て作り直す
日本語側の96枚(既定画像込み)は実際には壊れていませんでしたが、キャッシュのキー自体が汚染されていたため、区別せず全部作り直しています。
あわせて、同じ事故を機械的に検知する check:og を新設しました。
// ⚠ 同じスラッグの日英が同一バイト=どちらかが上書きされている
const collided: string[] = [];
for (const s of en) {
const a = `${OG}/${s}.png`;
const b = `${OG}/en/${s}.png`;
if (!existsSync(a) || !existsSync(b)) continue;
if (readFileSync(a).equals(readFileSync(b))) collided.push(s);
}
記事に対応する画像が存在しない場合と、同じスラッグの日英画像が完全に同一バイトの場合の両方を落とします。修正後の陽性対照として、英語画像を日本語画像で意図的に上書きしてこの検査が落ちることを確認しています。
再発防止
この事故を機械で検知する仕組みは今回の修正で入りましたが、同じクラスの「URLは200・描画も正常・でも中身が違う」という壊れ方が他の生成物にも起きうるかについては、根拠のコミットに記述がありません。
構造として見えるのは、言語別に出し分けるべき生成物が、パス設計の時点で言語を区別していなかったという点です。しかもインクリメンタルキャッシュは「前回と同じなら作り直さない」という前提で動くため、一度上書きで壊れた状態を「正しい」と記憶してしまうと、以後のビルドでは自己修復されません。ページの描画やURLの到達性だけを見る検査では、この種の取り違えは原理的に検出できず、生成されたファイルの中身そのものを比較する検査が必要だったことが、今回はっきりしました。
よくある質問
Q1なぜ検査が全部緑なのに気づけなかったのですか?
og:imageのURLは200を返し、ページの描画自体も正しかったためです。既存の検査はURLの到達性やページ構造しか見ておらず、画像ファイルの中身(バイト列)が日英で同じかどうかは誰も見ていませんでした。
Q2英語側と日本語側、どちらの画像が壊れていたのですか?
gen-og.tsは記事ディレクトリをja→enではなくreaddir順に処理しており、実際にはenが先、jaが後に書き込まれる順でした。同じパスog/${slug}.pngを共有していたため、後から処理されたja側の内容が残り、英語記事のシェアカードには日本語のタイトルが描かれていました。
Q3直したあと、既存の画像はどうしましたか?
インクリメンタルキャッシュを一度捨てて、日英合わせて190枚のOG画像を全て作り直しました。上書きされていたのはenの出力先だけでなく、キャッシュのキー自体がlang/slugで壊れていたため、日本語側も含めて作り直しています。
確認した環境
- Astro ^7.2.2 / satori ^0.29.0 / sharp ^0.35.3 / Node >=22.12.0
- 2026-09-06 に自社のブログビルドパイプラインで発生・修正
この記事の根拠
- TypeScriptファイル 113〜140行目コミット b826c5e
- TypeScriptファイル 1〜68行目コミット b826c5e
- Astroファイル 224〜231行目コミット b826c5e
本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。