WordPress を WP2Static で静的 HTML にして S3 から配信しているサイトで、テンプレートに直書きしていたナビゲーション(約 4,200 行)を、別システムの API のデータで表示する形に作り替えました。
やったこと自体は「API を呼んで表示する」だけなのですが、静的配信の構成だと「いつ API を呼ぶか」「いつ静的 HTML を作り直すか」「デプロイが重なったらどうするか」を考える必要があり、WP2Static の内部にも何度かつまずきました。その設計とハマりどころをまとめておきます。
構成と前提
- 編集用の WordPress は外部に公開しておらず、公開サイトは WP2Static で出力した静的 HTML(S3)
- 対象は、トップページの「テーマ一覧」(大・中・小の 3 階層)
- 元データは別システム(ASP.NET)の管理画面で管理されていて、公開/非公開や「公開する月」の設定がある
- そのシステム側に、JSON を返す API を追加してもらった
{"month":10,"themes":[
{"code":"7","name":"グルメ・果物狩り","icon":"theme-01.svg","children":[
{"code":"1","name":"果物狩り","children":[
{"code":"147","name":"いちご狩り"}]}]}]}
全体の流れ
API ──(取得・保存)──▶ wp_options(データ・ハッシュ・保存日時)
│ │
│ 差分があれば「未反映」の印を付ける
▼ ▼
テンプレートは保存データだけを読む
│
WP2Static で静的 HTML を生成 → S3 へデプロイ(成功したら「未反映」の印を消す)
ポイントは 3 つです。
- テンプレートから API を直接呼ばない(取得と表示を分ける)
- 差分はハッシュで検知し、差分があったときだけ「未反映」にする
- デプロイは既存の WP2Static をそのまま使い、同時実行だけ防ぐ
順に書いていきます。
1. テンプレートから API を直接呼ばない
WP2Static は、サイトの全ページをクロールして HTML を保存します。テンプレートで API を呼ぶと、生成のたびにページ数の分だけ API へアクセスが飛びます(このサイトだと約 1,900 回)。しかも、生成中に API が落ちていたら、そのページだけ表示が欠けた HTML が公開されてしまいます。
そこで、取得と表示を分けました。
- 取得:WP-CLI のコマンドか管理画面のボタンで API を呼び、
wp_optionsに保存する - 表示:テンプレートは保存データだけを読む
// テンプレート用
function get_theme_nav() {
$saved = get_option( 'mysite_theme_nav' );
if ( ! is_array( $saved ) || empty( $saved['themes'] ) || ! is_array( $saved['themes'] ) ) {
return null;
}
return $saved['themes'];
}
こうしておくと、WP2Static の生成中は DB を読むだけになり、API の状態に左右されません。
2. 取得:失敗したら保存データに触れない
取得は wp_remote_get で行い、次のどれかに当てはまったら「取得失敗」として WP_Error を返します。
- 通信エラー(名前解決できない、タイムアウトなど)
- HTTP 200 以外(API 側の DB エラーで 500 が返る、など)
- JSON として読めない
themesが空、または配列でない
function fetch_theme_nav() {
$response = wp_remote_get( MYSITE_THEME_NAV_API_URL, array( 'timeout' => 20 ) );
if ( is_wp_error( $response ) ) {
return $response;
}
$code = (int) wp_remote_retrieve_response_code( $response );
if ( 200 !== $code ) {
return new WP_Error( 'theme_nav_http', sprintf( 'HTTP %d が返されました。', $code ) );
}
$data = json_decode( wp_remote_retrieve_body( $response ), true );
if ( ! is_array( $data ) ) {
return new WP_Error( 'theme_nav_json', 'JSON の解析に失敗しました。' );
}
if ( empty( $data['themes'] ) || ! is_array( $data['themes'] ) ) {
return new WP_Error( 'theme_nav_empty', 'themes が空、または配列ではありません。' );
}
$themes = normalize_themes( $data['themes'] ); // 必要なキーだけを文字列で取り出す
if ( empty( $themes ) ) {
return new WP_Error( 'theme_nav_empty', '有効なテーマがありません。' );
}
return $themes;
}
失敗したときは、保存済みのデータに一切触れません。前回保存した内容がそのまま表示され続けるので、API が一時的に落ちても、テーマ一覧が消えることはありません。「空のデータで上書きしない」のは特に大事で、themes が空のレスポンスも、正規化した結果が空になった場合(どの項目も code や name が欠けていた、など)も失敗として扱っています。
API の URL は定数にしておき、未定義なら本番の URL を使うようにしました。テスト環境やローカルは wp-config.php で差し替えるだけです。
if ( ! defined( 'MYSITE_THEME_NAV_API_URL' ) ) {
define( 'MYSITE_THEME_NAV_API_URL', 'https://example.com/api/theme_nav' );
}
3. 差分検知はハッシュ比較にした
API は「公開する月」で絞り込んでいるので、月が替わると、元データを誰も更新していなくても返る内容が変わります。元データの「更新日時」や「更新フラグ」では、これを検知できません。
そこで、正規化したデータを wp_json_encode して md5 を取り、前回と比べるだけにしました。管理画面での変更も、月の切り替えも、同じ仕組みで拾えます。
function sync_theme_nav( $dry_run = false ) {
$themes = fetch_theme_nav();
if ( is_wp_error( $themes ) ) {
return $themes; // 保存データには触れずに終わる
}
$saved = get_option( 'mysite_theme_nav' );
$hash = md5( wp_json_encode( $themes ) );
$changed = ! is_array( $saved ) || ! isset( $saved['hash'] ) || $saved['hash'] !== $hash;
if ( $changed && ! $dry_run ) {
update_option(
'mysite_theme_nav',
array(
'themes' => $themes,
'hash' => $hash,
'fetched_at' => current_time( 'mysql' ),
),
false // autoload しない
);
mark_pending(); // 「未反映」の印を付ける(後述)
}
return array( 'changed' => $changed, 'count' => count( $themes ) );
}
正規化(必要なキーだけを文字列として取り出す)を挟んでいるのは、API 側でキーが増えたり型が揺れたりしても、ハッシュが無駄に変わらないようにするためです。差分が無ければ何も保存しないので、何度呼んでも静的 HTML の作り直しは発生しません。
4. 取得の入口:WP-CLI と管理画面のボタン
sync_theme_nav() を、WP-CLI のコマンドと管理画面のボタンの両方から呼べるようにしました。
wp mysite theme-nav sync --dry-run # 差分の有無だけ表示(保存しない)
wp mysite theme-nav sync # 取得・保存(差分があれば「未反映」に)
wp mysite theme-nav sync --deploy # 取得・保存し、未反映なら WP2Static で再生成・デプロイ
WP-CLI のオプションは、メソッドの PHPDoc(## OPTIONS)に書いておくと、WP-CLI がそれを読み取って定義とヘルプにしてくれます。書いていないオプション(打ち間違いなど)はエラーにしてくれるので、cron に書くコマンドの事故も防げます。
管理画面のボタンは、admin-post.php にフォームを送って sync_theme_nav() を呼び、結果(更新した/差分なし/取得失敗)を transient に入れてリダイレクトで戻す、よくある形にしました。画面には「公開サイトへの反映:未反映/反映済み」も出しています。
5. デプロイ:既存の WP2Static をそのまま呼ぶ
静的 HTML の作り直しには、WP2Static がもともと持っている WP-CLI のコマンドを使いました。
| コマンド | 内容 |
|---|---|
wp wp2static generate |
サイトをクロールして静的 HTML を作業ディレクトリに保存 |
wp wp2static deploy |
作業ディレクトリの内容を S3 へアップロード(差分のみ) |
これを、PHP の中から WP_CLI::runcommand() で順に呼びます。
function run_wp2static_deploy() {
$lock = acquire_cli_lock(); // 他の実行が終わるまで待つ(後述)
if ( is_wp_error( $lock ) ) {
return $lock;
}
$started = time(); // generate を始めた時刻
$options = array( 'launch' => true, 'exit_error' => false, 'return' => 'all' );
try {
foreach ( array( 'wp2static generate', 'wp2static deploy' ) as $command ) {
$result = WP_CLI::runcommand( $command, $options );
if ( 0 !== $result->return_code ) {
return new WP_Error( 'wp2static_failed', "{$command} が失敗しました: {$result->stderr}" );
}
}
} finally {
release_lock( $lock );
}
clear_pending( $started ); // 成功したら「未反映」の印を消す
return true;
}
'launch' => trueで別プロセスとして起動します。WP2Static の処理が途中で落ちても、呼び出し元が巻き込まれません'exit_error' => falseと'return' => 'all'で、失敗しても呼び出し元は終了せず、終了コードと標準エラー出力を受け取れます。generate が失敗したら deploy には進みません
似た名前の WP_CLI::run_command() は同じプロセスの中でコマンドを実行するもので、第 2 引数はコマンドに渡すオプションです。launch などの指定ができるのは runcommand() のほうなので、取り違えに注意してください。
WP2Static 本体には手を入れず、「前後に処理を足して外側から呼ぶ」形にしたので、プラグインのコードを追いかけなくても仕組みが把握できるようにしています。generate はこのサイトで 1 回あたり約 7 分でした。
6. 「未反映」の印でデプロイ漏れを防ぐ
取得・保存とデプロイを分けたので、「保存したけどデプロイしていない」状態が生まれます。そこで、「静的サイトに未反映の変更がある」という印を option に持たせました。
- 付けるとき:テーマ一覧に差分があって保存したとき。既存の「おすすめ枠の掲載終了バッチ」が投稿を下書きにしたときも、同じ印を付ける
- 消すとき:デプロイが成功したとき(WP-CLI からでも、WP2Static の画面からの手動実行でも)
WP2Static の generate はサイト全体を作り直すので、どこから付いた印でも、1 回のデプロイでまとめて反映されます。既存の日次バッチを「下書き化が 0 件の日でも、印が残っていればデプロイする」ように変えたので、テーマ一覧を保存してデプロイし忘れても、翌朝までには反映されます。デプロイに失敗した場合も印が残るので、次の機会に再実行されます。
印の値は「付けた時刻」にする
印は true/false ではなく、付けた時刻で持たせました。
デプロイが成功しても、generate を始めた時刻より後に付いた印は消しません。生成中に別の変更が入った場合、その変更は今回の出力に含まれていない可能性があるからです。
function clear_pending( $started ) {
$pending_since = (int) get_option( 'mysite_wp2static_pending', 0 );
if ( $pending_since && $pending_since <= (int) $started ) {
delete_option( 'mysite_wp2static_pending' );
}
}
管理画面からの手動デプロイでも印を消す
WP2Static の画面から手動でデプロイした場合は、上の run_wp2static_deploy() を通りません。そこで、WP2Static がデプロイ完了時に呼ぶ wp2static_post_deploy_trigger フックを使いました。
generate を始めた時刻は、WP2Static の出力ディレクトリ名(wp-static-html-output-<UNIX 時刻>)からわかります。
add_action( 'wp2static_post_deploy_trigger', function ( $archive ) {
if ( preg_match( '/wp-static-html-output-(\d+)/', (string) $archive->path, $m ) ) {
clear_pending( (int) $m[1] );
}
} );
これで、どの経路でデプロイしても、同じルールで印が消えるようになりました。
7. WP2Static の同時実行を防ぐ
ここが一番ハマったところです。
WP2Static は、uploads 直下の作業ファイル(出力ディレクトリ、URL の一覧、アップロード済みファイルの記録など)を全実行で共有しています。2 つの実行が重なると、後から始まった方が作業ディレクトリを全削除したり、出力先の記録を上書きしたりします。厄介なのは、エラーにならず「成功」と表示されたまま、S3 の一部のファイルだけが古いという壊れ方をすることです。
既存の日次バッチ(WP-CLI)に加えて、管理画面からの手動実行も発生するので、両方を直列化しました。プラグイン本体は改修していません。
バッチは flock で
バッチは、generate → deploy の間ずっとロックファイルに flock をかけておき、他が実行中なら最大 30 分待ちます。超えたらデプロイを中止し、「未反映」の印を残します(次の機会に再実行される)。
DB のフラグではなく flock にしたのは、プロセスが落ちても OS が自動で外してくれるからです。DB のフラグだと、落ちたときに「実行中」のまま残ってしまいます。
管理画面からの実行は、AJAX のステップに分かれている
WP2Static の管理画面からの実行は、ブラウザの JS が次のステップを 1 つずつ AJAX で送る仕組みでした。
prepare_for_export → crawl_site → crawl_again → post_process_archive_dir
→ s3_prepare_export → s3_transfer_files → cloudfront_invalidate_all_items → finalize_deployment
1 つのプロセスが握り続ける flock だけでは、リクエストをまたぐ処理を守れません。そこで役割を分けました。
| 状態 | 守り方 |
|---|---|
| ステップの実行中 | その 1 リクエストの間、同じロックファイルに flock をかける |
| ステップの合間 | option に「期限|実行者」を書き、ステップが終わるたびに期限を延ばす(10 分)。ブラウザを閉じて放置されても、期限が来れば外れる |
クロールは設定上 1 リクエストで全ページを処理していて、それだけで約 7 分かかります。期限だけで守ろうとすると処理の途中で切れるので、「実行中は flock、合間は期限付きの印」という組み合わせにしたのがポイントでした。
バッチ側は、flock が取れても、期限内の「管理画面で実行中」の印があれば、flock を一度手放して待ちます(握ったまま待つと、管理画面の次のステップが進めなくなるため)。
ハマり 1:重いステップは wp_ajax_ フックを通らない
最初は、wp_ajax_… のフックで WP2Static より先に割り込めば止められると考えていました。ところが、テストで止めたはずのクロールが動きかけます。
コードを追うと、WP2Static はクロールやアップロードなどの重いステップを、プラグインを読み込んだ時点で直接実行して終了していました(Dispatcher.php)。wp_ajax_ フックまで処理が届かないのです。
プラグインより先に動ける場所は、mu-plugin の読み込み時点しかありません。そこで判定することにしましたが、この時点ではまだログインユーザーを判定できません。
そこで、WP2Static の画面のフォームに、画面ごとのランダムなトークンを入れた隠し項目を JS で差し込みました。WP2Static の JS はフォームの全項目を毎ステップ送るので、プラグインを改修せずに「どの画面からの実行か」を区別できます。他の処理が実行中なら、WP2Static に渡す前に HTTP 409 を返し、画面には「実行中のため待ってください」という案内を出しています。
ハマり 2:ロックファイルの置き場所
ロックファイルを最初は /tmp に置いていました。CLI 同士ならそれで動きます。
ところが、PHP-FPM や Apache を systemd で動かしていて PrivateTmp が有効だと、Web のプロセスは専用の /tmp を見ます。管理画面(Web)とバッチ(CLI)で別々のロックファイルを見ることになり、ロックが効きません。そこで、両方から同じ実体が見える uploads に置きました。
逆に、ローカル(Docker Desktop for Mac)では、uploads をホストからバインドマウントしていて、ロックファイルを新規作成した直後だけ flock が効かないという挙動がありました。ローカルだけ、定数でロックファイルをコンテナ内に置いています。「本番では Web と CLI の都合で /tmp が使えず、ローカルでは Docker の都合で uploads が使えない」という、逆向きの制約でした。
8. テスト環境で出た 403
テスト環境で管理画面からデプロイしたところ、「未反映」の印が消えません。印の仕組みは正しく動いていて、デプロイ自体が S3 への 403 で失敗していたのが原因でした(失敗したので、印が残っていた)。
WP2Static のログには、S3 のエラー応答がそのまま残っていました。
...<CanonicalRequest>PUT
/maintenance/%E3%80%90%E5%B9%B4%E6%9C%AB...%E3%80%91/index.html
...</CanonicalRequest>...
BAD RESPONSE STATUS FROM API (403)
S3 がエラー応答に CanonicalRequest を含めるのは、署名が一致しない(SignatureDoesNotMatch)ときです。中の URL エンコードされた部分をデコードすると、パスに日本語(【年末年始の休業期間】)が入っていました。
WP2Static の S3 アップロードは、署名を計算するときにパスを URL エンコードせずにそのまま使っています。一方 S3 は、エンコードしてから計算します。そのため、パスに日本語を含むファイルだけ署名が合わず 403 になり、1 件でも失敗するとデプロイ全体がエラーで止まります。
さらに原因:古い rewrite ルールが残っていた
ではなぜ、そんな日本語のページが生成されたのか。調べると、
- そのページは、詳細ページを持たないはずのカスタム投稿タイプのもの(以前のテーマ改修で
'rewrite' => falseにしていた) - 投稿自体はもう存在しない
- クロール中に見つかったリンクの一覧(
WP-STATIC-DISCOVERED-URLS.txt)にだけ、その URL があった wp_optionsのrewrite_rulesに、そのカスタム投稿タイプのルールが残っていた
という状態でした。テーマの変更後にパーマリンクを再生成していなかったため古いルールが残り、存在しない URL がトップページの複製として 200 を返していたのです。WP2Static はそれをページとして保存し、日本語パスのせいでアップロードに失敗していました。
管理画面の「設定 → パーマリンク」で何も変えずに保存する(wp rewrite flush でも可)だけで、その URL は 404 になり、デプロイも通るようになりました。
CPT の rewrite 設定を変えたら、パーマリンクの再生成をセットで行う。静的配信の構成だと、これを忘れると「存在しないはずのページが公開される」という形で表に出てくるので、気づきにくいと感じました。
運用で決めたこと
最終的な運用は、次のとおりです。
- 元データのシステムでテーマを変更したとき、または毎月の月初に、WP の管理画面のボタンで取得・保存
- WP2Static の画面から手動でデプロイ
- デプロイし忘れても、既存の日次バッチが「未反映」の印を見てデプロイする
月が替わると API の内容が変わるので、月初の作業は手動で残しましたが、正直なところバッチにした方が確実だと思っています。ただ、wp mysite theme-nav sync --deploy を cron に 1 行足すだけで切り替えられるようにしてあります。差分が無い回は API を 1 回呼ぶだけで、デプロイは走りません。
WP2Static との付き合い方
このサイトで使っている WP2Static の旧版(6.x 系)は公式のサポートが終わっていて、今回も内部の作りにかなり振り回されました。それでも、WordPress を外に出さず、公開サイトを静的 HTML にしておく構成自体は、セキュリティ面でとても強いと改めて感じています。公開サイトに PHP も DB も無いので、WordPress やプラグインに脆弱性が見つかっても、外から攻撃される経路がほとんどありません。
今は運用実績があるので、問題が起きたら必要な分だけ直す方針にしています。まずは WP2Static の処理の流れや既知の不具合を資料にまとめるところから進める予定です。
Claude Code と進めてみて
今回は、Claude Code(AI のコーディングエージェント)と相談しながら進めました。
- WP2Static のコードを読ませて、「どのステップがどこで実行されるか」を一緒に追えた。
Dispatcher.phpの直接実行は、自分一人だったら気づくのにもっと時間がかかったと思う - 模擬サーバーを使った異常系(HTTP 500、空のデータ、壊れた JSON)や、2 プロセスでのロックのテストなど、確認の手間がかかる作業を任せられた
- ログの URL エンコードのデコードなど、地味な切り分けが速かった