前回の記事で書いた通り、Simply Static Core(無料版)をフックだけで拡張し、差分書き出し・自動デプロイができるようにしました。今回はその続きで、静的化の効果をさらに高めるために自作した「WebP一括変換プラグイン」の話です。
結論から言うと、プラグイン自体は正常に動作し、画像は100%WebPへ変換できていました。ところが実際にLighthouseで計測してみたら、静的化したサイトの方が本番サイトより重い、という想定外の結果が出ました。原因を追ってみると、WordPress core の奥深くにあるsrcset生成ロジックの整合性チェックに、自作プラグインが引っかかっていました。
前提: なぜWebP変換が必要だったか
静的化(Simply Static)の目的はページ表示の軽量化でしたが、書き出したサイトの中身を見ると、旧サイト時代からのJPEG/PNG画像がそのままの重さで残っていました。静的サイト自体の軽さを最大化するため、メディアライブラリの画像を一括でWebPへ変換する自社プラグイン(PD WebP Optimizer)を新規に開発しました。
- 変換対象:
image/jpegimage/pngimage/gif(登録済みの全サイズ、thumbnailからoriginalまで) - 新規アップロード時は自動変換、既存画像は管理画面またはWP-CLIから一括変換
- 「まだWebPになっていない画像」をクエリして処理する設計にしたため、途中で中断しても自動的に再開できる
対象1,543件の変換自体は最終的に**成功率100%**で完了しました。ただし、そこに至るまでに2つの実装バグを踏みました。
つまずいたポイント1: MySQLデッドロック
変換処理の最後に、サイト内の全参照(guid・投稿本文・ウィジェット)を新ファイル名へ付け替える必要があります。当初、この処理を「wp_postsテーブル全体に対する広範なLIKE検索付きUPDATE」で実装していたのですが、これを画像ファイル単位で繰り返し実行すると、MySQLのロック競合によるデッドロックが多発しました。
guidは変換対象の添付ファイル自身の1行だけを主キー指定で直接更新すれば十分だったため、全件スキャンのUPDATEはそもそも不要でした。これを削除し、post_contentとウィジェットオプションだけを対象にすることで解消しました。
// guidは呼び出し元(convert_attachment)で対象添付ファイル自身の行を
// 主キー指定で直接更新済みのため、ここでは全件LIKEスキャンを行わない
// (全postsテーブルへの広範なLIKE更新をファイル単位で繰り返すとロック競合で
// デッドロックが多発したため、必要最小限のpost_contentのみに限定する)。
foreach ( $rename_map as $old_name => $new_name ) {
$wpdb->query( $wpdb->prepare(
"UPDATE {$wpdb->posts} SET post_content = REPLACE(post_content, %s, %s) WHERE post_content LIKE %s AND post_type != 'revision'",
$old_name, $new_name, '%' . $wpdb->esc_like( $old_name ) . '%'
) );
}
つまずいたポイント2: Imagickのネイティブクラッシュ
wp_get_image_editor()はサーバー環境に応じてImagick/GDのどちらかを自動選択しますが、今回の環境ではImagickでの変換時にPHPプロセスごと異常終了(exit 135)する画像が一定数ありました。原因の完全な特定はできませんでしたが、wp_image_editorsフィルタでGDを優先させることで安定して変換できるようになりました。一括変換処理のような大量画像を扱うバッチ処理では、ネイティブ拡張のクラッシュでプロセスごと落ちるリスクを踏まえ、より枯れた実装を優先する判断をしました。
「変換成功」の先にあった落とし穴
ここまでで画像変換自体は完了し、サイトも問題なく表示されていました。ところが、静的化した本番相当サイトと実際の本番サイトをLighthouseで比較したところ、想定と逆の結果が出ました。
指標(/works/ページ) |
本番サイト | 静的化サイト(WebP変換後) |
|---|---|---|
| 総転送量 | 2,324 KB | 3,991 KB(本番の1.7倍) |
| Performanceスコア | 80 | 81 |
WebPに変換したのに、なぜか本番より重い。転送量の内訳を見て、原因がすぐに分かりました。
本番: DSC_1704-...-300x300.jpg 96KB (300×300のサムネイルサイズ)
静的化後: DSC_1704-....webp 173KB (トリミングなしのフルサイズ画像)
表示サイズに関わらず、常にフルサイズの画像が配信されていました。<img>タグのsrcset(ブラウザが表示サイズに応じて最適な画像を選べるようにする候補リスト)が、変換後の画像では完全に欠落していたのです。
<!-- 本番: srcsetが正しく出力されている -->
<img ... src="....jpg" srcset="....jpg 1081w, ...-300x300.jpg 300w, ...-150x150.jpg 150w, ...-768x767.jpg 768w" sizes="..." />
<!-- 静的化後(WebP変換済み): srcset自体が存在しない -->
<img ... src="....webp" />
原因調査: WordPress coreのsrcset生成ロジックに潜む整合性チェック
wp_calculate_image_srcset()を単体で直接呼び出すと正常な値が返ってきたため、コア関数自体は壊れていません。問題は、実際のページ描画で使われるwp_filter_content_tags()側にありました。wp-includes/media.phpのwp_image_add_srcset_and_sizes()には、次のような安全装置が入っています。
// Bail early if an image has been inserted and later edited.
if ( isset( $image_meta['file'] ) && preg_match( '/-e[0-9]{13}/', $image_meta['file'], $img_edit_hash )
&& ! str_contains( wp_basename( $image_src ), $img_edit_hash[0] )
) {
return $image;
}
「画像編集機能で後から差し替えられた画像かどうか」を、添付ファイルメタデータのfileキーと、実際に表示されているimg要素のsrcのファイル名を突き合わせて判定するチェックです。ここで自作プラグインの実装ミスが露呈しました。
// 3. 添付ファイルのDBレコードを新ファイルに向ける
update_attached_file( $attachment_id, $new_original ); // _wp_attached_fileは正しく.webpへ更新
wp_update_attachment_metadata( $attachment_id, $metadata ); // ← metadata['file']は.jpgのまま渡してしまっていた
添付ファイルの実体を指す_wp_attached_file(postmeta)は正しく新しい.webpパスへ更新していたのに、wp_get_attachment_metadata()が返す配列内のfileキー(オリジナル画像の相対パス)を更新し忘れていました。この結果、DBの中に「実体は.webpなのに、メタデータ上は.jpgのまま」という不整合が生まれ、WordPress core側の判定に引っかかってsrcset生成そのものが中止されていました。1,543件全てが同じ実装ミスの影響を受けていたため、変換した画像すべてで一律にsrcsetが消えるという規模の大きいバグになっていました。
直った後も反映されない: オブジェクトキャッシュの罠
修正はシンプルで、メタデータのfileキーも新しいファイル名へ揃えるだけです。
$metadata['file'] = _wp_relative_upload_path( $new_original );
update_attached_file( $attachment_id, $new_original );
wp_update_attachment_metadata( $attachment_id, $metadata );
既存の1,543件についても、_wp_attached_fileの値にmetadata['file']を合わせ直すワンタイムスクリプトで一括補正しました。ところが、修正を適用した直後にページを確認しても、srcsetはまだ出力されないままでした。
wp_get_attachment_metadata()をwp-cli経由で直接叩くと正しい値が返るのに、実際のページ描画には反映されない——ここで一瞬「まだ何か別の原因があるのでは」と焦りましたが、原因はシンプルにWordPressのオブジェクトキャッシュ/トランジェントが、修正前の状態をキャッシュしたままだったことでした。
wp cache flush
wp transient delete --all
これで即座にsrcsetが復活しました。「DB上は直っているのに画面に反映されない」症状に出会ったら、まずキャッシュを疑う、という教訓です。
修正後の計測結果
修正・再計測した結果、/works/ページの総転送量はほぼ半減し、目標にしていた「本番より軽い静的サイト」を達成できました。
指標(/works/ページ) |
本番サイト | 静的化サイト(修正前) | 静的化サイト(修正後) |
|---|---|---|---|
| 総転送量 | 2,324 KB | 3,991 KB | 1,798 KB(本番比 -23%) |
| Performanceスコア | 80 | 81 | 98 |
| TTFB | 1,630 ms | - | 10 ms |
srcsetの欠落を直しただけで転送量がほぼ半分になった計算です。画像のWebP化そのものよりも、この「表示サイズに応じた画像を選ばせる」仕組みが機能しているかどうかの方が、実際の転送量には大きく効いていました。
まとめ
- 独自のWebP一括変換プラグインを開発し、対象1,543画像の変換自体は成功率100%で完了した
- 開発中に2つの実装バグ(MySQLデッドロック、Imagickのネイティブクラッシュ)を踏み、それぞれ回避策で解消した
- 変換が「成功」した後も、実際にLighthouseで計測するまで、srcset欠落による転送量の逆転(本番の1.7倍)には気づけなかった
- 原因は、添付ファイルの実体パス(
_wp_attached_file)とメタデータのfileキーの不整合。WordPress core側の「画像編集済み判定」の安全装置に引っかかり、srcset生成そのものが中止されていた - DB修正後もオブジェクトキャッシュが古い状態を保持しており、
wp cache flushが必要だった - 修正後は本番より23%軽量な静的サイトを実現できた
「画像を変換できた」と「実際にブラウザへ届く転送量が減った」は別物で、後者は実機計測(Lighthouse)まで確認しないと分からない、というのが今回の一番の収穫でした。