前回の記事では、コーポレートサイトを「静的サイトの速さと堅牢さを保ったまま、WordPressの編集画面だけを足す」構成に作り替えた話を書きました。
▶ まだの方はこちらから:WordPressの編集画面はそのままに、配信だけ静的にする — Jamstack構成
公開後も表示は速く、サイトが落ちることもなく、編集担当者はWordPressの管理画面から普通に記事を書けています。設計としては狙いどおりでした。
さて、この構成には追加・更新とは別に、必ず設計しておかなければならない操作があります。
削除です。
静的サイトにCMSを載せる構成では、「記事を消す」が思っているとおりには動きません。今回はその理由と、私たちがどう設計したかを書きます。
まず、静的サイトの仕組みをざっくり
技術的な話に入る前に、前提を共有させてください。ここは読み飛ばしていただいても構いません。
普通のWordPressサイトは、閲覧者がアクセスするたびにWordPressがページを組み立てて返しています。
閲覧者 ──▶ WordPress(PHP + データベース)──▶ ページを組み立てて返す
この方式は「記事を消したら即座に消える」という点では非常に分かりやすいです。データベースから記事が消えれば、次のアクセスからは表示されません。
一方、私たちが採用した静的サイトは、WordPressを閲覧者から切り離しています。
編集者 ──▶ WordPress(社内からのみアクセス)
│ ページをHTMLファイルとして書き出す
▼
オブジェクトストレージ ──▶ CDN ──▶ 閲覧者
閲覧者が見ているのは、あらかじめ書き出されたHTMLファイルです。WordPressは公開側に一切存在しません。だから速いし、落ちないし、攻撃対象にもなりません。
そして、ここに削除の論点があります。
「WordPressのデータベースから記事が消えること」と、 「書き出し済みのHTMLファイルが消えること」は、別のできごとである。
文字にすると当たり前です。ただ、追加と更新は何もしなくても自然に動いてくれるため、削除だけが設計対象として浮かび上がってこないという構造になっています。
設計すべき点は2つあります
この構成で削除を成立させるには、独立した2つの仕組みが要ります。どちらか片方だけでは動きません。
1つめ: 削除を「サイトを作り直す合図」として拾えているか
この構成では、編集者が記事を保存すると、WordPressがそれを検知して「サイトを作り直してね」というフラグを立てます。書き出し担当のプログラムがそのフラグを見て、HTMLを生成し直します。
検知には、WordPressの save_post という仕組みを使うのが定石です。ただし素直に書くと、たいていこうなります。
add_action('save_post', function (int $post_id, \WP_Post $post, bool $update): void {
if (wp_is_post_revision($post_id)) return;
if (wp_is_post_autosave($post_id)) return;
// ★ ここ
if (!in_array($post->post_status, ['publish', 'future'], true)) return;
update_option('site_rebuild_needed', time(), false);
// …
}, 10, 3);
★の行は「公開中(publish)と予約投稿(future)以外は無視する」という意味です。下書きを保存するたびにサイト全体を作り直していたら無駄なので、条件としては妥当に見えます。
ところが、この1行が削除を取りこぼします。
WordPressで記事をゴミ箱に入れると、内部的には記事のステータスが trash に変わるという扱いになります。つまり「保存」の一種で、save_post は実行されます。
されますが、★で弾かれます。trash は publish でも future でもないからです。
結果、フラグが立たない。書き出しが走らない。サイトは何も変わらない。
しかも厄介なことに、追加と更新は完璧に動きます。だから通常の運用では気づけません。
2つめ: 配信先から実ファイルを消す仕組みがあるか
仮に1つめを解決しても、それだけでは足りません。
書き出したHTMLをストレージへ送る部分は、こういうコマンドを使うのが一般的です。
aws s3 sync "$EXPORT_DIR" "s3://${BUCKET}/" \
--cache-control "public, max-age=60, s-maxage=60"
sync は「手元のフォルダとストレージの中身を同じ状態にする」コマンドです。ただしこのままでは「追加」と「上書き」しかしません。手元にないファイルがストレージに残っていても、消してくれません。
消したければ --delete を付けます。付ければ一行で解決します。
ただ、この構成では意図的に使っていません。
--delete は「手元にないものは向こうでも消す」という強い同期です。書き出し処理が何らかの理由で不完全だった場合、その不完全さがそのまま公開サイトに反映されます。極端な話、生成結果が0件なら公開サイトも空になります。
CMSと配信先が分離している構成では、書き出し側の異常を配信側へ波及させないという原則を優先すべきだと考えました。したがって同期は「追加・更新のみ」に限定しています。
安全側に倒した結果、削除は別途、明示的に実装する必要が出てきます。
「消えないページ」はどのくらい問題か
ここは運用面の話なので、非エンジニアの方にも読んでいただきたいところです。
一覧ページからリンクが外れるので、普通に閲覧している人は辿り着けません。だからこそ厄介です。
- 掲載終了したキャンペーンページ
- 公開日を間違えて出してしまったお知らせ
- 取引先の名前を出してしまい、取り下げを依頼された実績紹介
管理画面から消したのに、URLを直接叩けば見えたままになります。管理画面上は消えているので、消えていないことに誰も気づけません。検索エンジンやSNSのキャッシュ経由でだけ生き残る、という最も気づきにくい形で残ります。
「静的化」は生成の話ばかりが語られますが、削除は生成の裏返しではありません。ここを設計に含めているかどうかで、運用の安心感がまったく変わります。
どう設計したか
ここからはエンジニア向けです。実装上の判断がいくつかあったので、順に書きます。
削除を検知する
ゴミ箱と完全削除は別のできごとなので、検知点を2つ用意しました。
// 公開状態から外れたとき(ゴミ箱・下書き化を含む)
add_action('transition_post_status', function ($new_status, $old_status, $post): void {
if ($old_status !== 'publish' || $new_status === 'publish') return;
// …
}, 10, 3);
// 完全削除(ゴミ箱からの削除 / 直接削除)
add_action('before_delete_post', function ($post_id, $post = null): void {
// …
}, 10, 2);
trashed_post ではなく transition_post_status を使ったのは、「公開 → 下書きに戻す」も公開面から消す必要があるからです。ゴミ箱だけを見ていると、下書きに戻したページが残ります。
完全削除に before_delete_post(削除の直前)を使っているのにも理由があります。deleted_post(削除後)では、もう投稿がデータベースに存在せず、URLを復元できません。消すべきURLが分からなければ消しようがないので、投稿が残っているうちに取得しておく必要があります。
「公開されていたときのURL」を復元する
ここに小さな罠があります。
WordPressで記事をゴミ箱に入れると、スラッグの末尾に __trashed が付きます。そのまま get_permalink() を呼ぶと、公開時とは違うURLが返ってきます。
function resolve_published_path(\WP_Post $post): string {
$probe = clone $post;
$probe->post_status = 'publish';
$probe->post_name = preg_replace('/__trashed$/', '', (string) $post->post_name);
$url = get_permalink($probe);
// …
}
投稿オブジェクトを複製して「公開中」を被せ、__trashed を剥がしてからURLを解決します。下書きのプレビューURLを得るときによく使われる手ですが、削除にも同じ手が効きます。
ここを間違えると「存在しないURLを消しにいく」という無意味な処理になり、しかもエラーになりません。気づけないので、検証項目に必ず入れておくべきところです。
--delete は使わず、狙ったパスだけ消す
取得したURLは、いったんデータベースに記録しておきます。書き出しが成功した後で、記録されたパスだけを消します。
for _rel in $REMOVED_PATHS; do
aws s3 rm "s3://${BUCKET}/${_rel}/index.html"
done
全体の --delete を使わないのが要点です。この方式なら、取りこぼしても既存ページを巻き込みません。書き出しが不完全でも、消えるのは「消せと明示的に記録されたページ」だけです。
前述の「書き出し側の異常を配信側へ波及させない」という原則を保ったまま、削除だけを成立させられます。
なお、ストレージから消してもCDNのキャッシュには残るため、そちらの削除もあわせて依頼します。ここは「見えてはいけないものが見えている」状態なので、有効期限切れを待たずに明示的に消しにいく価値があります。
部分ビルドをどう維持するか(設計上いちばん悩んだところ)
この構成の売りのひとつが部分ビルドです。記事を1本更新しても、サイト全体(約1600ファイル・4分)を作り直すのではなく、必要なページだけを数十秒で作り直します。
削除でもこれを維持したいのですが、素直にやると詰みます。
消した投稿のIDを再生成キューに積んでも、意味がありません。 書き出し処理は get_permalink() でURLを解決してページを取得しますが、その投稿はもう存在しないので、何も生成できません。
かといって毎回フルビルドに倒すと、部分ビルドという利点を捨てることになります。
ここで、書き出し処理の仕様を読み直しました。部分ビルドは「指定された投稿 + トップページ + その投稿タイプの一覧ページ + 同じ投稿タイプの公開済み記事すべて」を再生成する作りになっています。
最後の「同じ投稿タイプの記事すべて」が入っているのは、関連記事カードの存在が理由です。記事Aの詳細ページには記事B・C・Dのカードが並んでおり、そのカードにはタイトルやタグが焼き込まれています。記事Aだけ作り直しても、B・C・Dのページに残る「Aのカード」は古いままになります。
つまり部分ビルドの再生成範囲は、削除後に作り直すべき範囲とちょうど一致していました。
そこで、こうしました。
// 消した投稿と同じ投稿タイプで、"残っている"記事を1件だけキューに積む
$siblings = get_posts([
'post_type' => $post->post_type,
'post_status' => 'publish',
'numberposts' => 1,
'exclude' => [$post->ID],
'fields' => 'ids',
]);
if (!empty($siblings)) {
queue_rebuild_post((int) $siblings[0]);
update_option('site_rebuild_mode', 'single', false);
} else {
// 兄弟が残っていない = 一覧が空になる。起点がないのでフルビルド
update_option('site_rebuild_mode', 'full', false);
}
消えた記事の代わりに、残っている記事を起点にする。 これだけで、既存の部分ビルドの仕組みに一切手を入れずに削除へ対応できました。書き出し処理本体は無改修です。
今回いちばん気持ちよくハマったところでした。
揮発するコンテナとプラグインの置き場所
運用上の工夫をひとつ書いておきます。
この構成のWordPressはコンテナ上で動いています。コンテナは再起動のたびに中身がまっさらになるため、「プラグインを管理画面から有効化する」という運用が成立しません。再起動すると無効に戻ってしまいます。
そこで、今回のコードはmu-plugins(must-use plugins)に置きました。この場所のPHPファイルは有効化の操作なしに常時読み込まれます。有効・無効の状態をデータベースに持たないので、「起動のたびに有効化し直す」という手当てそのものが不要になります。
コンテナ環境でWordPressを動かすなら、素直にこちらを選ぶのが楽です。
運用操作も管理画面に載せる
あわせて、管理画面に「静的サイト配信」という画面を追加しました。
- 最終反映の時刻
- 未反映の変更があるかどうか
- 次に作り直される範囲(差分 / 全体)
- 公開面から削除される予定のページ
- 書き出し担当プログラムが生きているか
- 【差分を今すぐ反映】【サイト全体を再生成】ボタン
前回の記事で「コンテンツ更新の属人化を解消した」と書きました。同じ考え方を運用操作にも適用した形です。コンテンツだけ誰でも触れて、配信操作はエンジニアしか実行できないのでは、属人化が場所を変えて残るだけになります。
通常は保存した時点で自動的に反映されるので、この画面を使う場面はほとんどありません。ただ「反映されているか」が見えること自体に価値がありました。
検証をどうやったか
削除まわりは本番で試しにくい領域です。テスト記事を公開すれば、その数分間は公開サイトに出てしまいます。
そこでローカル環境に同じ構成を立て、次の4パターンを確認しました。
| ケース | 期待する動き |
|---|---|
| ゴミ箱へ移動 | 消すべきURLが記録され、残っている記事を起点に部分ビルドが走る |
| 完全削除 | 同上 |
| 記事が1件もなくなる削除 | 部分ビルドの起点がないのでフルビルドに切り替わる |
| 公開 → 下書きに戻す | ゴミ箱と同じ扱いになる |
あわせて、ゴミ箱の __trashed が正しく剥がされ、公開時のURLが復元できているかも確認しています。
この論点、構成が同じならどこでも出ます
今回の話はWordPressと特定のクラウドサービスの組み合わせですが、構造はもっと一般的です。
コンテンツの管理場所と、配信されるファイルの置き場所が分かれている構成では、 「消す」は自動的には伝わらない。
ヘッドレスCMSでも、静的サイトジェネレータでも、構造が同じなら同じことが起きます。追加と更新はイベントが飛ぶので自然に実装されますが、削除は明示的に設計しないと成立しません。
静的サイト構成を検討されている方は、最初に「削除したらどうなるか」を確認してみてください。チェックすべきはこの2点です。
- 削除を検知する仕組みがあるか(ゴミ箱・完全削除・下書き化の3つ)
- 配信先から実ファイルを消す仕組みがあるか(同期コマンドの挙動を確認)
まとめ
- 静的サイトでは「データベースから消える」と「配信ファイルが消える」は別のできごと
- WordPressの
save_postはゴミ箱行きを公開扱いしないので、削除は別途検知する必要がある - 同期コマンドの
--deleteは書き出し側の異常を配信側へ波及させるため、消すパスを記録して個別に消す方が安全 - 部分ビルドは、消した記事の代わりに残っている記事を起点にすることで維持できる
- コンテナ環境では mu-plugins に置くと、有効化状態の管理が丸ごと不要になる
- 運用操作も管理画面に載せる(でないと属人化が場所を変えて残る)
前回は「構成をどう作ったか」、今回は「その構成で必ず設計が必要になる点」でした。
Jamstack構成は、速さ・堅牢さ・安さを同時に満たせるという点で非常に強い選択肢です。ただし強いぶん、動的サイトなら勝手に成立していたことを、ひとつずつ設計に含める必要があります。そこまで引き受けて初めて、構成のメリットを安心して享受できると考えています。
最後に
弊社では、WordPressを活かしたJamstack構成の設計・構築をお手伝いしています。
この構成の強みは、可用性です。
閲覧者に返しているのは事前に書き出されたHTMLファイルだけで、公開側にPHPもデータベースも存在しません。そのため、
- アクセスが急増しても落ちない — CDNが静的ファイルを配るだけなので、瞬間的にトラフィックが跳ねても構成側がボトルネックになりません。キャンペーンやメディア掲載、SNSでの拡散といった「読めない負荷」に強い構成です
- 公開側に攻撃対象がない — 管理画面もログインフォームも公開ネットワーク上に存在しません
- CMSが停止してもサイトは動き続ける — 編集ができなくなるだけで、閲覧には影響しません
- 費用が上がらない — 配信の主体はストレージとCDNなので、アクセス増がサーバー費用に直結しません
「WordPressの編集しやすさは残したい。でも、落ちるのは困る」 という要件に、正面から応えられる構成です。
既存サイトの静的化、アクセス集中に耐えるサイトの設計、WordPressの運用体制の見直しなど、ご相談がありましたらお気軽にお問い合わせください。