FTPでWordPress一式をアップロードして更新すると、転送が途中で失敗しても、欠損したファイルがその時点で読み込まれていなければエラーは出ません。サイトは通常どおり表示され続けます。
今回、「ニュース一覧が404になっている」という相談を受けて調査したところ、ファイルが欠損した状態のまま半年ほど稼働し続けていたことが分かりました。問題が表面化したのは、お客さまが管理画面の「外観」まわりを開いたタイミングで、そこからサイト全体がダウンしました。
サイトが落ちた直接の原因は、稼働テーマのファイル欠損です。WordPressがテーマの有効性を判定するために参照する index.php と style.css の2ファイルが消えていました。管理画面の「外観」まわりを開くとテーマの検証処理が走り、この2ファイルが無いため破損と判定されて、デフォルトテーマへの自動切り替えが行われます。ところが、切り替え先のデフォルトテーマも同じ転送失敗で欠損しており、正常に動作するテーマが1つも残っていなかったため、HTTP 500を返してサイト全体が停止しました。
つまり、お客さまの操作ミスではありません。ファイルは画面を開く前から欠損しており、管理画面へのアクセスがそれを表面化させた形です。
このような単発の障害相談では、過去に誰がどのような作業を行ったのかという前提情報がなく、残っている保守記録も断片的であることが少なくありません。今回はサーバに残った以下の3つの痕跡から、いつ・どこに・何が書き込まれたかを絞り込みました。
- ファイルの
ctime(inodeの変更時刻) - WordPress公式のチェックサム照合
- アクセスログ
転送の失敗は、WordPress側には出ない
FTPクライアントで数千ファイルを一括転送すると、回線の状態、タイムアウト、同時接続数の制限などによって、一部のファイルが転送されないまま終わることがあります。FTPクライアント側は失敗をキューやログに出力しますが、数千件を流した中の数件であり、全体としては「転送完了」と表示されるため見落とされがちです。
一方、WordPress側にはエラーが一切出ません。サイトはHTTP 200を返し続け、管理画面にもファイル欠損を知らせる表示はなく、転送が途中で切れたファイルは0バイトのまま残ります。そのため、作業直後にトップページを開いて表示を確認しただけでは、正常に転送できた保証にはなりません。
壊れていても、フロントでは検出されない
WordPressにはテーマの検証機能(validate_current_theme())がありますが、フロントのページ表示では一度も呼ばれません。WordPress本体で呼び出されるのは wp-admin/themes.php と WP_Customize_Manager の2箇所だけです。訪問者が何万回アクセスしても、欠損したファイルが実際の表示処理で読み込まれない限り、フロント側で破損が発覚することはありません。
また、この検証機能が見ているのは次の3点だけです。
templates/index.html、block-templates/index.html、index.phpのいずれかがあるかstyle.cssがあるか- 子テーマの場合、子テーマ側の
style.cssがあるか
この3つさえ残っていれば、他のテンプレートが何百ファイル欠けていても「有効」と判定されます。逆に、クラシックテーマで index.php と style.css が消えていれば、確実に破損と判定されます。
破損と判定されると、switch_theme() によってデフォルトテーマへの切り替えが走ります。正常なデフォルトテーマが1つでも残っていればそちらへ切り替わって表示は続きますが、今回は切り替え先も壊れていたため500エラーになりました。
なお、カスタマイザー経由で破損が検出されると themes.php?broken=true にリダイレクトされるため、アクセスログにその文字列が残ります。一方、wp-admin/themes.php 経由の場合はリダイレクトせず画面内に警告を出すだけなので、破損検出を識別できるクエリ文字列はログに残りません。今回も、最初の切り替えがどちらの画面で起きたかまでは特定できませんでした。
そもそも、なぜ半年も動いていたのか
テーマファイルが欠けているにもかかわらず半年間サイトが表示され続けていたのは、WordPressがテーマ内の全ファイルを読み込む仕組みではないためです。
リクエスト時に起点となるのは、functions.php と、テンプレート階層で選ばれた1枚のテンプレートファイル(ニュース一覧なら archive-news.php、トップなら home.php や front-page.php)だけです。そこから get_header() や get_template_part() で他のファイルを読み込むかどうかはテンプレートの実装次第であり、呼ばれていないファイルは存在しなくてもエラーになりません。
index.php はテンプレート階層の最後の受け皿であるため、より具体的なテンプレートが適用され続ける限り一度も呼ばれません。また、style.css もPHPとして読み込まれるファイルではなく、テーマ情報のヘッダーを記載するファイルです。フロント側で読み込まれるのも、get_stylesheet_uri() や get_stylesheet_directory_uri()、あるいはテンプレート内の <link> タグから明示的に参照されている場合だけです。
今回のサイトの functions.php を確認したところ、wp_enqueue_style() は管理画面用のCSSを1本読み込んでいるだけで、フロント側のCSSは別経路で配信されていました。そのため style.css が欠けた状態でも、フロントの表示崩れは起きていませんでした(参照が本当にゼロかを厳密に確かめる場合は、出力されたHTMLに style.css への <link> タグが含まれていないかまで確認します)。
実際にこのサイトで残っていたテンプレートは次のとおりです。
home.php 20.7K
archive-news.php 6.6K
functions.php 7.3K
page-*.php
header.php も footer.php もなく、共通部分は静的サイト側のファイルを直接読み込む構成でした。コーポレートサイト本体は静的HTMLで作られており、WordPressが担当していたのはニュース部分だけです。稼働に必要な数枚のテンプレートはすべて無事でした。
結果として、このサイトのフロント表示では使われていない index.php と style.css だけが欠損し、しかもその2ファイルこそが validate_current_theme() の判定対象だったという状況が生まれました。動作に必要なファイルと、WordPressが破損判定に使うファイルが食い違っていたことが、半年間問題が潜伏した理由です。
カスタム投稿タイプの404は、切替の副作用
サイト自体を復旧した後も、カスタム投稿タイプのアーカイブページだけ404エラーが残りました。これはテーマ切り替えに伴う副作用です。
テーマが切り替わると theme_switched オプションが記録され、次のページ読み込み時に init フックの check_theme_switched()(優先度99)がそれを検知して、after_switch_theme を発火させたあとに flush_rewrite_rules() を実行します。この時点では元のテーマの functions.php が読み込まれていないため、そこで登録していたカスタム投稿タイプが未登録のままリライトルールが再生成され、該当アーカイブのルールが消えた状態で保存されます。
ただし、コード上の仕組みだけでは説明がつかない点も残りました。switch_theme() はテーマを元に戻す際にも theme_switched を記録するため、元テーマに戻した次の読み込みでも再び flush_rewrite_rules() が走るはずです。その時点では元テーマの functions.php が読み込まれており、カスタム投稿タイプの登録(init の優先度10前後)は check_theme_switched()(優先度99)より先に実行されるため、計算上はテーマを戻した時点で自動的に解消されるはずです。
実際には404が4日間残り続け、なぜ自動で戻らなかったのかは特定できませんでした。最終的には管理画面のパーマリンク設定を開いて保存し、明示的に flush_rewrite_rules() を実行することで解消しています。
ctimeで変更時期と範囲を絞り込む
過去の作業経緯を調べるため、まずファイルのタイムスタンプを確認しました。このとき、mtime だけでは根拠になりません。
| 意味 | 後から変えられるか | |
|---|---|---|
mtime | 内容が最後に更新された時刻 | FTPの MFMT、アーカイブ展開、touch で自由に変えられる |
ctime | 内容またはinode情報が最後に変更された時刻 | 任意の値には設定できない |
mtime も ctime やログと突き合わせる材料にはなりますが、後から変更できるため単独では証拠になりません。一方、ctime は任意の値に書き換えられないため、サーバ上でいつ操作が行われたかを絞る手がかりになります。なお、ctime はファイル内容の書き込みだけでなくパーミッションや所有者の変更でも更新されるため、厳密には「その時刻に何かが変更された」ことを示します。
ディレクトリ単位で ctime を日付別に集計すると、次のようになりました。
[wp-includes] ctime の日付別件数
X月1日 : 2509 件
X月4日 : 87 件
X月11日: 10 件
X月1日の夜に2,509件のファイルが一斉に変更されており、個別編集ではなく一括操作が行われたことと整合します。また、これらのファイルは ctime == mtime となっており、配布物本来の日付(数年前)が残っていませんでした。タイムスタンプを保持しない書き込みが行われたことを示しています(ただし、単純コピーや rsync の設定でも同じ結果になるため、これだけでFTP転送と断定することはできません)。
次に、WordPressの自動更新による変更かどうかを切り分けました。同じ時間帯に更新されたファイルを調べると、wp-config.php、uploads、カスタムテーマも同時に書き変わっていました。WordPressのコア自動更新はこの3つを変更しないため、自動更新以外の操作が行われた可能性が高いと判断できます。
ここに、断片的に残っていた保守記録の「WPのダウンロードができなかったため直接アップロードで対応」という記述を突き合わせることで、一連の経緯が繋がりました。
あわせてドキュメントルート全体でも同じ ctime 走査を行ったところ、該当時間帯に変更されたファイルはすべてWordPress配下に収まっており、静的HTMLのページは1件も含まれていませんでした。影響範囲をここで明確に限定できたことで、復旧作業の見積もりと状況説明をスムーズに進められました。
チェックサム照合で「何が」を数える
WP-CLIが使える環境であれば、公式配布物とのチェックサム照合でファイルの破損状況を素早く把握できます。
wp core verify-checksums
wp plugin verify-checksums --all
検出結果を「欠損」「0バイト」「内容不一致」の3種類に分けて集計したところ、今回はコア本体はほぼ無傷で、5つのプラグインに欠損が見つかりました。
照合できる範囲は思ったより狭い
チェックサム照合を行う際は、コマンドがカバーできる範囲を把握しておく必要があります。照合が通ったからといって、サイト全体が無事だとは限りません。
まず、wp plugin verify-checksums が照合できるのはWordPress.orgの公式ディレクトリで配布されているプラグインだけです。独自プラグインや有償プラグインはチェックサムが存在しないため、警告を出してスキップされます。欠損件数としてはカウントされないため、大量の出力の中でスキップ警告を見落とすと、検証済みだと誤認してしまいます。
さらに、標準のWP-CLIにはテーマ用のチェックサム照合コマンドがありません。公式テーマであっても独自テーマであっても、コマンド一発で照合する手段は用意されていません。今回サイトを停止させた原因は独自テーマの欠損だったため、チェックサム照合だけでは直接の原因を1件も検出できませんでした。その空白を埋めたのが前述の ctime 走査であり、照合と走査の両方を組み合わせる必要があります。
数え方で引っかかる3点
チェックサム照合の結果をそのまま被害件数として読むと、実態とずれる点が3つあります。
1つ目は、0バイトファイルの扱いです。今回8件の0バイトファイルが見つかりましたが、そのうち5件は過去のFlash時代の名残で、現在はどこからも読み込まれないファイルでした。実害があるのは残りのファイルだけです。
2つ目は、配布物に含まれる開発用ファイルによる件数の水増しです。プラグインによっては配布パッケージ内に trunk/ のような重複コピーが含まれており、そこの欠損が件数を大きく膨らませます。あるプラグインは照合上151件の欠損と出ましたが、実稼働で読み込まれるファイルに限ると46件ほどでした。
3つ目は、管理画面のプラグイン一覧から消える現象です。メインのプラグインファイルが欠損するか、ファイル内の Plugin Name ヘッダーを読み取れない状態になると、ディレクトリ自体は残っていても管理画面の一覧からそのプラグインが消えます(Version ヘッダーだけが欠けている場合は一覧に残ります)。今回も1つのプラグインがこの状態で見えなくなっていました。
ログで引き金と、疑うべきものを潰す
障害発生時刻の前後のアクセスログを確認すると、GET /wp-admin/themes.php?broken=true が記録されていました。これはカスタマイザー側でテーマの破損判定が行われた結果のリダイレクト先であり、この時刻にカスタマイザー経由で破損検出が走ったことと一致します。
同時に、他の原因候補も確認して除外しました。お客さまが障害前後にコントロールパネルからバックアップ復元を実行していたため、当初はその影響も疑いましたが、履歴を確認すると復元先は本番環境とは別のディレクトリであり、本番ファイルを上書きしていませんでした。
また、不正アクセスの可能性についてもログを確認しました。webshell探索やログイン画面へのブルートフォース攻撃は記録されていましたが、ステータスコードはいずれも301、403、404であり、200は返っていませんでした。ただし、成功時に302を返す処理やアクセスログに残らない経路もあるため、「侵害なし」と断定することはできません。調査報告書には「アクセスログ上、侵害が成功した明確な痕跡は確認できなかった」と記載しました。
成立しなかった仮説も検証して報告書に残したことで、「お客さまの操作が原因ではない」という点を根拠とともに説明できました。
直すときと、次から
破損したプラグインは、WP-CLIで公式配布物から同じバージョンを指定して上書きインストールします。ファイルを上書きするだけであれば、原則としてデータベース内の設定が消えることはありません(プラグイン固有の更新処理が走る可能性については別途確認します)。
wp plugin install plugin-slug --version=1.2.3 --force
wp plugin verify-checksums --all
plugin-slug と 1.2.3 は、対象プラグインのスラッグと現在インストールされているバージョンに置き換えます。
このとき、--version の指定は省略しないでください。省略すると最新の安定版がインストールされるため、障害復旧の最中にプラグインのバージョンアップまで同時に行うことになり、変更点が増えてしまいます。元のバージョンが不明な場合は先に特定し、ファイル破損で読み取れない場合はアップデートして問題ないかを確認してから適用します。復旧と更新は別の作業として切り分けます。
再インストール後には再度 wp plugin verify-checksums --all を実行し、欠損が解消されたことを確認します。公式チェックサムがない独自テーマや有償プラグインは、正常な配布物やGit管理下のファイルとハッシュ値を突き合わせて確認します。なお、作業前のバックアップ取得はもちろんですが、サーバーの自動バックアップの保持期間も事前に確認しておく必要があります。今回は保持期間が約2週間だったため、半年前に破損した時点の正常なバックアップはすでに残っておらず、公式配布物から復元しました。
今後の再発防止策は、次の3点に集約されます。
- FTPでWordPress一式を直接アップロードしない:更新や設置にはWP-CLIを使うか、アーカイブをサーバ上で取得・展開します
- アップロード後のハッシュ照合を作業工程に含める:やむを得ず直接アップロードする場合は、転送後にチェックサム照合まで行います。独自テーマはアップロード元のファイルとハッシュ値で突き合わせます(ファイル数やサイズだけの確認では、サイズが変わらない改変や欠損を見落とします)
- 正常なデフォルトテーマを1つ残しておく:今回サイト全体が500エラーで停止したのは、切り替え先のデフォルトテーマまで壊れていたためです。使っていないデフォルトテーマは削除しがちですが、非常時のフォールバック先として1つは正常な状態で残しておきます
補足
ctime はサーバ移行やファイルシステム操作によって一律に書き変わることがあるため、移行直後のサーバでは判断材料になりません。