Insights

技術情報

WordPressをFTPで直接アップロードすると、失敗しても成功に見える。ctimeから原因を絞り込んだ話

FTPでWordPress一式をアップロードして更新すると、転送が途中で失敗しても、欠損したファイルによってはエラーは出ません。サイトは普通に表示され続けます。壊れたのは、そのとき読み込まれていないファイルだからです。

ニュース一覧が404になっていると相談をいただきました。調査したところ、ファイルが欠損した状態のまま半年ほど動き続けていました。 表面化したのは、お客さまが管理画面の「外観」まわりを開いたときです。サイト全体がダウンしました。

落ちた直接の原因は、稼働テーマのファイル欠損です。よりによって、WordPressがテーマの生死を判定するのに使っている index.phpstyle.css の2つが消えていました。「外観」まわりの管理画面を開くと、テーマの検証が走ります。この2ファイルが無いので破損と判定され、WordPressはデフォルトテーマへ自動で切り替えます。ところが切替先のデフォルトテーマも同じ転送失敗で欠けていて、まともに動くテーマがどこにも残っていない。500を返してサイト全体が落ちました。

操作ミスではありません。ファイルは触る前から欠けていて、クリックはそれを表に出しただけです。

こういう単発の相談は、前提情報がありません。過去に誰が何をしたのか、こちらは知らない。残っている保守記録も断片的で、作業者に確認できるとも限らない。サーバに残った痕跡だけで確定させる必要がありました。使ったのは3つです。

  • ファイルの ctime(inodeの変更時刻)
  • WordPress公式のチェックサム照合
  • アクセスログ

これで、いつ・どこに・何が書き込まれたかを、かなりの精度まで絞り込めます。

転送の失敗は、WordPress側には出ない

FTPクライアントで数千ファイルを一括転送すると、一部が転送されないまま終わることがあります。回線、タイムアウト、同時接続数の制限、原因はいろいろです。

クライアント側は失敗をキューやログに出します。ただ数千件を流したあとの数件で、しかも全体としては「転送完了」で終わるので、見落とされやすい。

そしてWordPress側には何も出ません。サイトはHTTP 200を返し続けます。管理画面にも「ファイルが欠けています」という表示は存在しません。転送が途中で切れたファイルは、0バイトのまま残ります。

作業が終わった直後にサイトを開いて表示されたとしても、それは何の保証にもなっていない。

壊れていても、フロントでは検出されない

WordPressにはテーマの検証機能があります。壊れていたら気づくのでは、と思うところですが、これがほとんど動きません。

validate_current_theme() の呼び出し元は、WordPress本体を追うと2箇所しかありません。wp-admin/themes.phpWP_Customize_Manager です。フロントのページ表示では一度も呼ばれません。つまり訪問者が何万回アクセスしても、この検証機能によって破損が発覚することはありません。欠損したファイルが実際の表示処理で必要になれば、その時点で別のエラーとして表面化します。

検証の中身も想像よりずっと薄いです。見ているのは3点だけです。

  • templates/index.htmlblock-templates/index.htmlindex.php のいずれかがあるか
  • style.css があるか
  • 子テーマの場合、子テーマ側の style.css があるか

この3つさえ残っていれば、テンプレートが何百ファイル欠けていても「有効」と判定されます。逆に、クラシックテーマで index.phpstyle.css が両方消えていれば、確実に破損と判定されます。

ただし破損と判定されること自体は、サイトが落ちることを意味しません。正常なデフォルトテーマが1つでも残っていれば、そちらへ切り替わって表示は続きます。今回は切替先も壊れていたので500になりました。

検証に落ちると、switch_theme() でデフォルトテーマへの切り替えが走ります。カスタマイザー経由だと themes.php?broken=true にリダイレクトされるので、この文字列がアクセスログに残ります。wp-admin/themes.php 経由の場合はリダイレクトせず画面内に警告を出すだけです。アクセス自体はログに残りますが、破損検出が起きたことを識別できるクエリ文字列は付きません。今回もどちらの画面で最初の切替が起きたかまでは特定できませんでした。

そもそも、なぜ半年も動いていたのか

テーマファイルが欠けているのにサイトが表示され続けるのは、感覚的には変な話です。種を明かすと、WordPressはテーマ全体を読み込んでいません。

起点になるのは functions.php と、テンプレート階層で選ばれた1枚だけです。ニュース一覧なら archive-news.php、トップなら home.phpfront-page.php。そこから get_header()get_template_part() で他のファイルを読むかどうかは、そのテンプレートの書き方次第です。呼んでいないファイルは、無くても何も起きません。

index.php は階層の最後の受け皿です。より具体的なテンプレートが当たり続ける限り、一度も呼ばれない。

style.css はもっと誤解されやすいところです。これはWordPressがPHPとして読み込むファイルではありません。テーマ情報のヘッダーを書く場所であり、あとはブラウザが取りに行くURLというだけ。

しかも取りに行くのは、テーマがどこかで style.css を参照している場合だけです。get_stylesheet_uri() での enqueue が典型ですが、get_stylesheet_directory_uri() からURLを組み立てる書き方も、テンプレートに直接 <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.phpfooter.php もありません。共通部分は静的サイト側のファイルを直接読み込む作りでした。WordPressが担当していたのはニュース部分だけで、コーポレートサイト本体は静的HTML。稼働に必要なテンプレートが数枚しかなく、そのすべてが無事だった。

そして皮肉なことに、欠けていた index.phpstyle.css は、このサイトの通常のフロント表示では使われていないファイルでした。それでいて validate_current_theme() が見ているのは、ちょうどその2つ。

実際には正常に動くのに、WordPressの判定基準では壊れている。判定に使うファイルと、動作に必要なファイルが噛み合っていません。これが半年潜伏した本当の理由です。

カスタム投稿タイプの404は、切替の副作用

サイト自体が復旧した後も、カスタム投稿タイプのアーカイブだけ404が残りました。これはテーマ切替の副作用です。

テーマを切り替えると theme_switched オプションが立ちます。次のロードで init フックの check_theme_switched()(優先度99)がそれを拾い、after_switch_theme を発火させたあと flush_rewrite_rules() を呼びます。

問題はこのタイミングです。この時点で元のテーマの functions.php はもう読み込まれていません。カスタム投稿タイプをそこで登録していたので、未登録のままリライトルールが作り直される。結果、該当アーカイブのルールだけが消えたルールセットが保存されます。

ここまではコードで追える話です。ただ、実際には腑に落ちない点が残りました。

switch_theme() は戻すときも含めて毎回 theme_switched を立てます。ということは、テーマを元に戻した次のロードでも同じ経路の flush が走るはずです。しかもそのときには元テーマの functions.php が読み込まれていて、カスタム投稿タイプの登録は init の優先度10前後、check_theme_switched() は99。順番としては間に合っています。理屈の上では、戻した時点で勝手に直っていないとおかしい。

それでも404は4日間残りました。なぜ自動で戻らなかったかは特定できていません。最終的にはパーマリンク設定を開いて保存し、flush_rewrite_rules() を明示的に走らせて解消しています。

この手の「理屈では直るはずなのに直らない」は、原因を詰めきれないまま対処で終わることがあります。分からなかったことは分からなかったと書いておきます。

ctimeで変更時期と範囲を絞り込む

まずファイルのタイムスタンプを見ます。ただし mtime だけでは決め手になりません。

意味後から変えられるか
mtime内容が最後に更新された時刻FTPの MFMT、アーカイブ展開、touch で自由に変えられる
ctime内容またはinode情報が最後に変更された時刻任意の値には設定できない

mtime が無駄なわけではなく、ctime やログと突き合わせる材料にはなります。ただ単独で根拠にはできない、という話です。

ctime にも読み違えやすい点があります。名前から「作成時刻」と思われがちですが違います。内容の書き込みだけでなく、パーミッション変更や所有者変更でも更新されます。つまり「そのとき書き込まれた」ではなく「そのとき何かが変更された」までしか言えません。

それでも、任意の値に書き換えられない点は強い。ディレクトリ単位で日付別に集計すると、こうなりました。

[wp-includes]  ctime の日付別件数
  X月1日 : 2509 件
  X月4日 :   87 件
  X月11日:   10 件

2509件が同じ夜に変更されています。1件ずつの編集ではこうなりません。何らかの一括操作があったと考えて整合します。

これらは ctime == mtime で、配布物本来の日付(数年前)が残っていませんでした。タイムスタンプを保存しない書き込みと整合する状態です。ただしこれだけでFTPだと識別はできません。単純コピーでも rsync の設定次第でも同じ見え方になります。

次に、WordPressの自動更新の可能性を切り分けます。使ったのは、同じ時間帯に何が変更されていたかです。同時刻に wp-config.phpuploads、カスタムテーマが書き変わっていました。コア更新はこの3つを構造的に触りません。つまり自動更新以外の操作が入った可能性が高い、というところまでは言えます。

ここに保守記録の「WPのダウンロードができなかったため直接アップロードで対応」という記述が重なって、はじめて経緯の説明がつきました。痕跡だけで断定はできず、記録と突き合わせて絞り込む形です。

ドキュメントルート全体でも同じ走査をしました。その時間帯に該当したファイルは全件がWordPress配下で、静的ページは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点

0バイトファイルは転送失敗の証拠になりません。今回は8件見つかりましたが、うち5件はFlash時代の名残で、今はどこからも読み込まれないファイルでした。実害があるのは残りです。

欠損件数もそのまま被害の大きさにはなりません。プラグインによっては配布物に trunk/ のような開発用の重複コピーが入っていて、そこの欠損が件数を膨らませます。あるプラグインは照合上151件の欠損でしたが、実稼働で読み込まれるのは46件ほどでした。3倍以上ずれます。

そしてこれが一番厄介なのですが、メインプラグインファイルが欠損するか、Plugin Name ヘッダーを読み取れない状態になると、そのプラグインは管理画面の一覧から消えます。Version ヘッダーだけが欠けている場合は、通常は一覧に残ります。一覧に出ていないことは、プラグインのディレクトリまで消えていることを意味しません。今回1つ、この状態で見えなくなっていたものがありました。

ログで引き金と、疑うべきものを潰す

障害が起きた時刻の前後のアクセスログを見ます。今回は GET /wp-admin/themes.php?broken=true が記録されていました。これは破損判定が行われたリクエストそのものではなく、カスタマイザー側で判定された結果の遷移先です。つまり、この時刻にカスタマイザー経由の破損検出が起きたことと整合します。

同時に、成立しなかった仮説も潰しておきます。お客さまはコントロールパネルのバックアップ復元を実行していたので、正直はじめはそれを疑っていました。履歴を確認したら復元先が本番とは別領域で、本番を上書きしていない。原因ではないと分かりました。

不正アクセスも見ました。webshell探索もログインのブルートフォースも来ていましたが、応答は301か403か404ばかりで、200は返っていません。

ただ、これで「侵害なし」と書くことはできません。成功時に302を返す処理もありますし、そもそもアクセスログに現れない経路もあります。書けるのは「アクセスログ上、侵害が成功した明確な痕跡は確認できなかった」までです。報告書もその表現にしました。

棄却した仮説を報告書に残しておくと、「お客様の操作が原因ではありません」と根拠付きで書けます。今回はこれが一番大事な一文でした。

直すときと、次から

破損したプラグインは公式配布物から上書きします。ファイルの上書きだけで、DBに入っている設定が消えることは原則としてありません。ただし、プラグイン固有の更新処理がDBを変更する可能性は別途確認します。

wp plugin install plugin-slug --version=1.2.3 --force
wp plugin verify-checksums --all

plugin-slug1.2.3 は、対象プラグインのスラッグと現在インストールされているバージョンに置き換えます。

--version は省かないでください。付けないと最新の安定版が落ちてきます。ファイルを直すつもりで、同時にプラグインを更新することになる。障害対応の最中に、直近の変更点が1つ増えます。

元のバージョンが分からない場合は、そこを先に確定させます。壊れていてバージョンを取得できないなら、更新して問題ないかを確かめてから入れる。復旧と更新は別の作業として扱います。

再インストール後にもう一度照合して、欠損が実際に消えたことを確認するまでが1セットです。独自テーマや有償プラグインは公式チェックサムがないので、既知の正常な配布物かGit管理下のバージョンを用意して、そちらとハッシュで突き合わせます。実行前のバックアップは当然として、もう1つ。バックアップの保持期間は先に確認してください。今回は約2週間で、破損した時点の正常なファイルはとっくに流れていました。公式配布物から戻すしかありません。

次から、という話でいうと結論はシンプルです。WordPressの更新をFTPの一式直アップロードでやらない。WP-CLIか、サーバ上で取得して展開する。どうしても直アップロードするなら、転送後にチェックサム照合を通すまでを作業に含める。照合が効かない独自テーマは、アップロード元をそのまま正解として残しておき、転送後にハッシュで突き合わせる。ファイル数とサイズだけの確認では、サイズが変わらない改変や途中欠けを取りこぼします。

あとは予備テーマを1つ、正常な状態で置いておくこと。今回サイトが全停止したのは、テーマが壊れたからではなく、切替先が1つも残っていなかったからです。デフォルトテーマは消しがちですが、あれは非常用の着地点でもあります。

補足

ctime はサーバ移行やファイルシステム操作で一律に書き変わります。移行直後のサーバでは判断材料になりません。

参考

お問い合わせ・お見積もりはお気軽に

内容に応じたお見積もりも無料で行います。