Rebounder Tech Blog

運用している当事者が書く、本番システムの記録。

gen-og.tsのキャッシュが壊れた画像を「更新済み」と誤認し、日英95本中91本が同じOGP画像になっていた

公開 更新 読了時間 約5分執筆: Rebounder 開発チーム(当該システムの運用当事者)

※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。

結論

OG画像の生成スクリプトは日英で同じ `og/${slug}.png` に書き込んでいたため、後から処理された側がもう一方を上書きし、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を返すため、通常のクロールベースの検査では何も引っかかりません。画像ファイルの中身が日英で違うかどうかを見ている検査は、この時点では存在していませんでした。

原因

gen-og.ts は POSTS 配下の ja/ en/ 両方のディレクトリを順に処理しますが、出力先 og/${slug}.png は言語に関係なく1本だけでした。同じスラッグの日英ペアは同じファイルに書き込むため、後から生成された言語の内容が、先に書かれたもう一方を上書きします。

{/* ⚠ 英語は og/en/ を見る。日英で同じスラッグなので、同じパスにすると
       生成側で潰し合い、日本語ページに英語のタイトル画像が出る(2026-09-05 修正) */}
ogImage={`${site}/og/${entry.data.lang === 'en' ? 'en/' : ''}${entry.id.replace(/^(ja|en)\//, '').replace(/\.md$/, '')}.png`}

修正コミット(b826c5e)で英語版の出力先が og/en/${slug}.png に切り出された際、切り出し前後で同じスラッグのファイルサイズを比較すると、修正前の共有ファイルは、修正後に確定した英語版と同一のバイト数でした。つまり共有ファイルに最終的に残っていたのは英語タイトルの画像です。ところが Post.astro 側の og:image は、修正前は言語を区別せず同じURLを組み立てていたため、日本語ページのシェアカードにも同じ英語タイトルの画像が表示されていました。

さらに厄介だったのが、インクリメンタルキャッシュの仕組みです。キャッシュのキーは lang/slug の組み合わせで、日英分で191件記録されていました。しかし実際に og/ 以下に存在するファイルは96枚(95スラッグぶん+既定画像1枚)しかありません。上書きされて消えた側も、生成した瞬間には自分のハッシュをキャッシュへ記録しています。次回以降のビルドでそのキャッシュを見ると、ハッシュは一致し、ファイルも(上書きされた状態のまま)存在するため「変更なし」と判定されて再生成がスキップされ続けます。壊れた状態のまま、二度と直る機会がありませんでした。

実測では、95スラッグのうち91件で日英の画像が同一バイトになっていました。

直す

修正は3点です。

  1. 英語の出力先を og/en/${slug}.png に分離する
const out = lang === 'en' ? join(OUT, 'en', `${slug}.png`) : join(OUT, `${slug}.png`);
mkdirSync(dirname(out), { recursive: true });
  1. Post.astro の og:image も、言語ごとに参照先を分ける
ogImage={`${site}/og/${entry.data.lang === 'en' ? 'en/' : ''}${entry.id.replace(/^(ja|en)\//, '').replace(/\.md$/, '')}.png`}
  1. インクリメンタルキャッシュを一度捨てて、日英合わせて190枚のOG画像を全て作り直す

共有ファイルに最終的に残っていたのは英語タイトルの画像だったため、**日本語側もキャッシュの整合性だけを保ったまま内容が英語のまま止まっていました。**修正コミットのメッセージにも「キャッシュを捨てて190枚を全部作り直した(日本語側も汚染されていたため)」とある通り、どちらが「壊れていないはず」と決めつけず、区別せず全部作り直しています。

あわせて、同じ事故を機械的に検知する 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/を両方処理していましたが、出力先og/${slug}.pngを日英で共有していたため、後から書き込まれた言語の内容が残ります。実際に残っていたのは英語タイトルの画像で、同じパスを参照していた日本語記事のシェアカードには英語のタイトルが描かれていました。

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

本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。