Simply Static CoreをフックだけでPro相当の差分デプロイに拡張した話
前回の記事(WordPressの脆弱性はなぜ増えているのか、AIの影響を調べてみた)で書いた通り、WordPressを取り巻くセキュリティ状況が厳しくなってきていることを踏まえ、自分たちが運用支援しているWordPressサイトの静的化を検討しました。
静的化ツールとしてはSimply Static(GPL、無料)を採用したのですが、無料版には大きな制約がありました。この記事では、その制約をCore本体を一切改造せず、フックだけで解決した話を書きます。
前提: Simply Staticの無料版とProの違い
Simply Staticには無料版とPro版($99/年/サイト)があり、無料版は毎回サイト全体をフルビルドする必要があります。中〜大規模サイトだと1回の書き出しに数十分かかることもあり、更新のたびに全ページを再生成するのは現実的ではありません。
差分(変更ページだけ)の書き出しと自動デプロイはPro版の機能です。ただしマルチサイト運用や複数クライアントサイトへの展開を考えると、1サイト=1ライセンスのProは費用が積み上がっていきます。
そこで、「Core本体は無料版のまま、外側からフックで差分書き出しを実現できないか」を調べたところ、実はCore内部に差分書き出しの土台がすでに存在していることが分かりました。
Core内部にあった差分書き出しの土台
Simply Static Coreのソースを読むと、以下が分かりました。
-
generate_type === 'update'のとき、last_modified_atが処理済み時刻より新しい行だけを対象にする「差分(Update)モード」がすでに実装されている -
simply-static-use-singleオプション(カンマ区切りの投稿ID)をセットしておくと、対象を該当IDだけに絞り込める。これは管理画面の「単一ページ書き出し」機能がそのまま使っている経路 - 起動口である
Plugin::run_static_export( $blog_id, $type, $prepare_callback, $failure_callback )の$prepare_callbackは、エクスポート開始ロックを保持したまま呼ばれる公式の拡張ポイントになっている
つまり、ゼロから差分エンジンを作る必要はなく、この$prepare_callbackの中でsimply-static-use-singleを安全にセットしてから$type='update'を返せば、差分スコープを注入できるということです。これはSimply Static Pro自体も同じ仕組みで統合されています。
$prepare_callback = function ( $blog_id, $type, $job ) use ( $running_batch ) {
backfill_post_id_links( $running_batch );
reset_processing_state( $running_batch );
$cascade_urls = collect_cascade_urls( $running_batch );
$synthetic_ids = backfill_cascade_ids( $cascade_urls );
$all_ids = array_merge( $running_batch, $synthetic_ids );
update_option( 'simply-static-use-single', implode( ',', $all_ids ), false );
configure_local_delivery();
return 'update'; // 差分(Update)モードを強制
};
return (bool) \Simply_Static\Plugin::instance()->run_static_export( $blog_id, 'update', $prepare_callback );
つまずいたポイント1: post_idが誰も設定していなかった
simply-static-use-singleによる絞り込みは、内部のwp_simply_static_pagesテーブルのpost_idカラムで判定されます。ところが実際に動かしてみると、差分書き出しの対象が常に0件になりました。
調査すると、標準のクローラー(Post_Type_Crawlerや基底のCrawlerクラス)は、投稿を発見してキューに積む際にpost_idカラムを一切設定していませんでした。configure_static_page()をオーバーライドしてpost_idを紐付けるクローラーが、実質的に存在していなかったのです。
対策として、投稿保存のタイミングで、変更のあった投稿のパーマリンクから既存のページ行を検索し、直接SQLでpost_idを補完する処理を追加しました。
function backfill_post_id_links( array $post_ids ) {
global $wpdb;
$table = $wpdb->prefix . 'simply_static_pages';
foreach ( $post_ids as $post_id ) {
$permalink = get_permalink( $post_id );
if ( ! $permalink ) {
continue;
}
// Simply Static内部はURLをtrailingslash等で正規化して保存しているため、
// 有無どちらのパターンでもマッチするよう2通り試す。
$candidates = array( $permalink, untrailingslashit( $permalink ), trailingslashit( $permalink ) );
foreach ( array_unique( $candidates ) as $url ) {
$wpdb->query(
$wpdb->prepare(
"UPDATE {$table} SET post_id = %d WHERE url = %s AND ( post_id IS NULL OR post_id = 0 )",
$post_id,
$url
)
);
}
}
}
つまずいたポイント2: 「処理済み」判定に引っかかって差分が拾われない
post_idを補完しても、まだ対象が0件になるケースがありました。原因は、差分モードの絞り込み条件が「処理済みカラム(last_checked_at)がlast_modified_atより前」というものだったのに対し、直前のフルクロールで両方のタイムスタンプが同時刻に揃ってしまっていたことです。結果、「すでに処理済み」と誤判定され、対象から漏れていました。
対応として、変更のあった投稿の処理済みフラグを意図的に古い日時(1970-01-01)へリセットする処理を追加しました。これで「未処理」判定になり、差分の対象に正しく含まれるようになります。
依存グラフ: カテゴリアーカイブ等の巻き込み
投稿単体だけでなく、所属カテゴリのアーカイブページやホーム、サイトマップなども合わせて再生成する必要があります。ただしこれらはWP投稿ではないため、そもそもpost_idという概念を持ちません。
そこで、Simply Static内部のページ行ID(wp_simply_static_pages.id)に、実投稿IDと衝突しない大きなオフセット(10,000,000,000)を加算した「合成ID」を割り当て、simply-static-use-singleのスコープに含める方式にしました。
const SYNTHETIC_ID_OFFSET = 10000000000;
// ...
if ( empty( $row->post_id ) ) {
$id = SYNTHETIC_ID_OFFSET + (int) $row->id;
$wpdb->update( $table, array( 'post_id' => $id ), array( 'id' => $row->id ) );
} else {
$id = (int) $row->post_id; // 既存の紐付け(実ID/合成ID問わず)を再利用
}
差分デプロイ: rsyncを自作する必要はなかった
差分だけを書き出せるようになったら、次は配信です。当初はrsyncによる差分転送を自作するつもりでしたが、調べてみるとCore標準の「Local Directory」配信方式(delivery_method = 'local')に切り替えるだけで、対象スコープに含まれるファイルだけが自動的に指定ディレクトリへコピーされることが分かりました。差分(update)モードと組み合わせれば、それだけで差分デプロイが完結します。
function configure_local_delivery() {
$target = get_deploy_target();
$options = get_option( 'simply-static', array() );
$options['delivery_method'] = 'local';
$options['local_dir'] = $target;
update_option( 'simply-static', $options );
if ( ! file_exists( $target ) ) {
wp_mkdir_p( $target );
}
}
途中失敗からの再開と、ハマった非同期の罠
差分書き出し中にサーバーのメモリ不足でジョブが無応答のまま止まってしまう事故が実際に発生したため、WP-Cronで1分間隔の監視(ウォッチドッグ)を実装しました。進捗(last_checked_at等の最終更新時刻)が一定時間動かなければ停滞とみなし、ジョブをキャンセルして自動リトライします。
再開の仕組み自体はシンプルで、Simply Static Core側のget_pages_to_process_sql()が「処理済み時刻が更新時刻より前のものだけ」を対象にする性質を利用し、同じスコープのまま再実行するだけで、すでに完了したページは自動的にスキップされます。「実行中バッチ」をIDリストとして永続化しておき、成功完了時にのみクリアするようにしているのがポイントです。
ここで1つ、実機でしか分からなかった落とし穴がありました。cancel_static_export()を呼んだ直後に、同一PHPプロセス内ですぐrun_static_export()を呼ぶと失敗するのです。Core側の内部状態がまだ解放されていないためと考えられますが、正確なメカニズムまでは特定できていません。
対策として、キャンセルした直後は「次のtickで再開する」というフラグだけを立てて即座にreturnし、実際の再開処理は次のcron tick(=別プロセス)に持ち越すようにしました。
// スタック確定。ジョブをキャンセルし、監視状態をリセット。
// 再開の判断・実行はこのリクエストでは行わず、次のtickのmaybe_resume_after_cancel()に委ねる。
\Simply_Static\Plugin::instance()->cancel_static_export();
delete_option( OPTION_WATCHDOG_STALL_COUNT );
delete_option( OPTION_WATCHDOG_LAST_ACTIVITY );
update_option( OPTION_PENDING_RESUME, true, false );
同一プロセス内での即時再実行と、プロセスをまたいだ再実行とで挙動が変わるという、非同期処理特有の罠でした。
全体のアーキテクチャ
バージョン互換ガードという後付けの安全策
ここまでの実装は、Simply Static Coreの非公開の内部実装(DBテーブルの列構成、内部オプションの配列構造)に直接依存しています。公式に文書化された拡張ポイント($prepare_callbackやss_completedフック)だけでなく、こうした内部実装にまで踏み込んでいるため、Core側のバージョンアップで前提が崩れるリスクは常につきまといます。
そこで後から、起動前に以下を検証し、いずれかを満たさない場合は安全側(内部スキーマに依存しないフルリビルド)へ自動フォールバックするガードを追加しました。
function is_diff_mode_supported() {
return class_exists( '\\Simply_Static\\Plugin' )
&& is_ss_version_verified() // 動作確認済みバージョンと一致するか
&& simply_static_schema_ok(); // 想定カラム構成が揃っているか(SHOW COLUMNSで検証)
}
「壊れたまま差分処理を強行してSQLエラーになる」という最悪のケースを避け、気づかないうちに差分が反映されなくなる、という事故を防ぐのが狙いです。
ライセンスについて
Simply Static CoreはGPL-2.0+です。今回作ったプラグインはCore本体のソースを一切改造せず、WordPressの標準フック経由で外側から連携する「伴走プラグイン」という設計にしています。
ただし、内部DBテーブル・内部オプション構造に直接アクセスしている以上、GPL上は「派生物」とみなすのが安全だと考え、こちらもGPL-2.0-or-laterで公開しています。GPLは無料である必要はなく、有償販売しているWordPressプラグイン(ACF Pro、Gravity Forms、そしてSimply Static Pro自体もそうです)は数多くあります。「販売の自由」と「配布後は相手も自由に再配布できる」がセットになっている、という理解です。
まとめ
- Simply Static Core(無料版)には、実は差分書き出しの土台(Updateモード、
simply-static-use-single、$prepare_callback拡張ポイント)がすでに存在していた - 標準クローラーが
post_idを設定しないため、そのままでは差分が機能しない。伴走プラグイン側でURL一致による補完が必要だった - 配信の差分化はCore標準の「Local Directory」方式で完結し、rsyncの自作は不要だった
- キャンセル直後の同一プロセス内再実行が失敗するという、実機でしか分からない非同期の罠があった
- 内部実装への依存はGPL上も設計上もリスクがあるため、バージョン互換ガードを後付けした
Core本体を一切改造せず、フックとDB操作の組み合わせだけでPro版相当の機能(差分書き出し・自動デプロイ・スタック監視・自動再開)を再現できた、というのがこの実装の一番の収穫でした。