WordPress管理画面が真っ白に!原因はPowerShell 5.1が付けたBOMでした
WordPressにAdSenseコードを設置したあと、管理画面にログインしようとしたらページが真っ白になった。
原因調査したところ、PowerShell 5.1で作成したPHPファイルに含まれるBOMが原因。
ただし「白画面になったら必ずBOM」「BOMがあれば必ず白画面」という話ではない。この記事は、下に書いた環境で実際に起きたことと、サーバーのログで確認できた事実の記録。同じ問題に遭遇した人の参考になれば。
何をしていたか
私のWordPressはOracleクラウドのVPS(Virtual Private Server、インターネット上に借りる自分専用のサーバー)上でDockerを使って運用してます。
実行環境(発生当時)
| 項目 | 内容 |
|---|---|
| ファイルを作ったPC | Windows 11/Windows PowerShell 5.1 |
| サーバー | Oracleクラウド上のVM+Docker |
| WordPress | 7.0(Docker公式イメージ、PHP 8.3+Apache) |
| PHPの出力バッファ | 無効(php.iniが読み込まれておらず output_buffering = 0) |
| 問題のファイル | /var/www/html/wp-content/mu-plugins/adsense.php |
PowerShellのバージョンは $PSVersionTable.PSVersion で確認できる。Windowsに最初から入ってる「Windows PowerShell」は5.1で、別途インストールする「PowerShell 7」とは挙動が違う(後述)。
今回ついにAdSense審査ということで審査用のコードをWordPressの全ページの<head>に設置するため、mu-plugins(Must-Use Plugins、WordPressが起動時に必ず自動で読み込む特殊なプラグインフォルダ。通常のプラグインと違い、管理画面から無効化できない)としてPHPファイル adsense.php を作成してアップロードした。
作業の流れは以下。
- WindowsのPowerShell 5.1でPHPファイル(
adsense.php)を作成 - SCPでOracleクラウドのサーバーに転送
- DockerコマンドでWordPressコンテナ内の
wp-content/mu-plugins/にコピー
しかしその後、管理画面にログインしようとしたら、ログインフォーム自体は普通に表示されるのに、IDとパスワードを入れてログインボタンを押した瞬間ページが真っ白になった。何度やり直しても同じ。
エラーの確認
サーバーは正常に動いてる。Dockerコンテナも全て起動中。外部からHTTPステータス(Webサーバーがリクエストに対して返す結果コード。200=正常、302=別のページへ転送、404=ページが見つからない、503=サーバーダウンなど)を確認すると200が返ってくる。
つまりサーバー自体は生きていてページを返せているが、中身は正常ではなかった。「外から見ると正常そうに見えるのに実際は壊れている」という状況。
WordPressコンテナ内から直接ログインページのHTMLを取得したところ、エラーメッセージが見つかった。
エラー: 予期しない出力により Cookie がブロックされました。
「予期しない出力」がCookieの設定を妨げているということだ。WordPressのログインはCookie(ブラウザがログイン状態を記憶するために保存する小さなデータ)を使って認証するため、Cookieがセットできない=ログイン不可、という状態になっていた。
原因:PHPファイルの先頭にBOMが含まれていた
直前にアップロードしたPHPファイルが怪しいと判断した。
根拠は3つ。
①PHPファイルをアップロードした直後に問題が発生しており、それ以外に変更は何もしていない。
②エラーメッセージの「予期しない出力」はPHPファイルが何かを意図せず出力しているという意味で、mu-pluginsはログインページを含む全ページで読み込まれるため、新たに追加したファイルが出力元として最も疑わしかった。
③コンテナのログ(docker logs wordpress)に、PHPの警告が出てた。ログインボタンを押したときのものを抜粋する(日時と接続元IPアドレスは省略)。
PHP Warning: Cannot modify header information - headers already sent by (output started at /var/www/html/wp-content/mu-plugins/adsense.php:1) in /var/www/html/wp-includes/pluggable.php on line 1193
PHP Warning: Cannot modify header information - headers already sent by (output started at /var/www/html/wp-content/mu-plugins/adsense.php:1) in /var/www/html/wp-includes/pluggable.php on line 1535
意味は「ヘッダーをもう送れない。adsense.php の1行目で出力が始まってしまってるから」。出力元のファイルと行番号まで名指しされてる。1行目に目に見える出力は何も書いてないので、見えない何かが先頭にある、ということになる。
そこでファイルの先頭バイトを確認。
正常なPHPファイルの先頭は<?php(PHPプログラムの開始を示す宣言文)の最初の文字<を示す0x3Cから始まる。
ところが問題のファイルの先頭は0xEF 0xBB 0xBFの3バイトだった。
これがBOM(Byte Order Mark)。
サーバー上のファイルなら、次のコマンドで先頭3バイトを確認できる。ef bb bf と出ればBOM付き、3c 3f 70(<?p)ならBOMなし。
docker exec wordpress sh -c "head -c 3 /var/www/html/wp-content/mu-plugins/adsense.php | od -An -tx1"
BOMとは何か
BOMはファイルの文字コードを識別するための目印として先頭に付加される特殊なバイト列。UTF-8(日本語を含む世界中の文字を扱えるテキストの文字コード規格)の場合はEF BB BFの3バイト。
目には見えない。
PHPはファイルを読み込む際、<?phpタグより前にある文字をそのままHTMLとしてブラウザに出力する。
BOMも例外ではなく、見えない3バイトが出力される。
ここで、HTTPの通信を理解するために、以下3者を整理する。
- ブラウザ(自分のPC上):WordPressにアクセスしてページを要求する側
- PHP(サーバー上):サーバー側でプログラムを実行するエンジン。wp-login.phpなどのファイルを読み込んで処理し、結果をブラウザに返す
- WordPress(サーバー上):PHPによって実行されるブログ管理ソフト。ログイン処理やCookieの発行を担当する
HTTPの通信はヘッダーとボディという2段構造になっている。ヘッダーは「Cookieをセットしろ」「このページへ移動しろ」といった指示情報、ボディは実際のHTMLコードだ。そしてHTTPの仕様上、ヘッダーは必ずボディより先に送らなければならない。ボディを1バイトでも送り始めたら、もうヘッダーは追加できない。
ログインボタンを押したときの、本来の正常な流れは以下。
①ブラウザ(自分のPC)→ IDとパスワードを入れてログインボタンを押す(サーバーへ送信)
↓
②サーバー上のPHPがwp-login.phpを読み込んでWordPressを起動(mu-pluginsもここで読み込まれる)
↓
③WordPressがIDとパスワードを照合
↓
④照合OKなら「ログイン状態を示すCookieをセットしろ」「管理画面(/wp-admin/)へ移動しろ」という指示をヘッダーに乗せて返す(ステータスは302=転送)
↓
⑤ブラウザ(自分のPC)がCookieを保存し、管理画面へ移動
↓
⑥ダッシュボードが表示される
BOMがある場合はこれが狂う。
今回の環境は出力バッファ(出力をいったん溜めておく仕組み)が無効だったため、PHPは<?phpタグより前にある文字を即座にボディとしてブラウザに送ってしまう。
adsense.phpはBOMが先頭にあるため、WordPressがログイン処理をする前の段階でPHPがBOMをブラウザに送信してしまう。
①ブラウザ → IDとパスワードを入れてログインボタンを押す
↓
②サーバー上のPHPがmu-pluginsのadsense.phpを読み込んだ瞬間、先頭のBOM(3バイト)をボディとしてブラウザに送信してしまう(この時点でヘッダーも送信済みになる)
↓
③WordPressがIDとパスワードを照合(ここは成功してる)
↓
④Cookieのセットと管理画面への移動をヘッダーで指示しようとするが、すでにボディ送信済みのためヘッダーを送れない(ログの警告はここで出てた)
↓
⑤WordPressは移動を指示したつもりで処理を終える。ブラウザに届いたのは「200(正常)」とBOMの3バイトだけで、表示できる中身がほぼ何もない
↓
⑥ログイン白画面(Cookieも保存されていないので、ログインもできていない)
本来302が返るはずのところで200が返ってたのは、この⑤のため。
この症状が出る条件(今回の場合)
今回、白画面になったのは次の3つが揃ってたから。
- PHPファイルの先頭にBOMがある
- そのファイルがmu-pluginsにあり、ログイン処理を含む全リクエストで読み込まれる
- PHPの出力バッファが無効(
output_buffering = 0)。Docker公式のWordPressイメージはphp.iniを読み込んでおらず、この状態だった
逆に、出力バッファが有効な環境(PHP同梱の本番用設定ファイル php.ini-production の値は 4096)では、3バイトがバッファに溜まってる間にヘッダーが先に送られるので、同じBOMでも白画面にならないことがある。その場合もBOM自体は出力に混ざる。
なので、白画面の原因がいつもBOMとは限らないし、BOMがあれば必ず白画面になるわけでもない。ログに output started at 〜.php:1 と出てるかどうかが判断材料になる。
なぜPowerShell 5.1でBOMが付いたのか
WindowsはもともとShift-JISやANSIという文字コードを使ってきた。
UTF-8が普及した際、WindowsアプリがUTF-8ファイルを識別できるよう、Microsoftがファイル先頭にBOMを付ける慣習を作った。
PowerShell 5.1はそのWindows流儀を引き継いでいるため、Out-File -Encoding utf8(PowerShellでファイルを保存するコマンド。Out-Fileが「ファイルに書き出せ」、-Encoding utf8が「UTF-8という文字コードで保存しろ」という意味)では必ずBOM付きで保存される。
一方、Linux・PHP・Webの世界では「UTF-8にBOMは不要」が標準。
PowerShell 7以降はこの点を改め、BOMなしがデフォルトになってる。
| バージョン | Out-File -Encoding utf8の挙動 |
|---|---|
| PowerShell 5.1(Windows標準) | BOM付き ⚠️ |
| PowerShell 7以降 | BOMなし ✅ |
実際に直した操作
やったことは、adsense.phpをBOMなしで保存し直して、コンテナ内のファイルを上書きしただけ。作り方は次の「再発防止」の方法で、アップロードは最初と同じ手順(SCP→Dockerコマンドでコピー)。
後からサーバーのログを見返すと、前後の経緯がそのまま残ってた(時刻は日本時間)。
| 時刻 | ログに残ってたこと |
|---|---|
| 16:13 | 最初の警告。この時点でadsense.phpは設置済み |
| 17:02〜17:10 | ログインを7回試行。フォームの表示は200で正常、ログインボタンを押すと毎回200・176バイト(本来は302で管理画面へ転送) |
| 17:10 | コンテナ内からログインページを取得(10,727バイト、エラー文言あり) |
| 17:11 | 最後の警告。その直後にadsense.phpを差し替え |
| 17:11 | もう一度ログインページを取得(10,531バイト)。196バイト減った分がエラー文言で、表示から消えてた |
| 17:12 | ログインボタンを押すと302で管理画面へ転送、ログイン成功 |
警告は約1時間で合計938件、出力元はすべて adsense.php の1行目。差し替えてからは同じ警告は1件も出てない。今のadsense.phpも、先頭は 3c 3f 70(<?p)から始まってる。
影響はログインだけではなかった。警告の出どころはログインCookieの発行と管理画面への転送のほか、REST API(外部ツールや編集画面がWordPressとやり取りする窓口)、XML-RPC(古い外部連携の窓口)にも及んでた。一方で記事ページは通常のサイズで配信されてて、読者側から見た表示は普通だったと思う。
再発防止:BOMなしで保存する
もともとは以下のコードでPHPファイルを保存していた。
$phpContent | Out-File -FilePath "adsense.php" -Encoding utf8
Out-FileはPowerShellでファイルに書き出す命令、-Encoding utf8はUTF-8で保存する指定。一見問題なさそうだが、PowerShell 5.1の-Encoding utf8はBOM付きで保存するため、意図せずBOMが混入していた。
対策としてOut-Fileの代わりに[System.IO.File]::WriteAllText()を使う。これは.NET(Windowsに標準搭載されているプログラムの部品群)のファイル書き込み機能を直接呼び出すもので、文字コードの指定を細かくコントロールできる。
作成から確認までを通した最小の例が以下(PowerShell 5.1で動作する形)。ca-pub-XXXX の部分は自分のAdSenseのIDに置き換える。
$PSVersionTable.PSVersion
$phpContent = @'
<?php
add_action('wp_head', function () {
echo '<script async src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-XXXX" crossorigin="anonymous"></script>';
});
'@
$path = "C:\work\adsense.php"
[System.IO.File]::WriteAllText($path, $phpContent, [System.Text.UTF8Encoding]::new($false))
Format-Hex -Path $path | Select-Object -First 1
上から順に、やってることは次のとおり。
$PSVersionTable.PSVersion:PowerShellのバージョンを表示する(Majorが5なら5.1)$phpContent = @' 〜 '@:保存するPHPコードを変数に入れるWriteAllText:第1引数が保存先、第2引数が中身、第3引数の[System.Text.UTF8Encoding]::new($false)が「UTF-8・BOMなし」の指定。$falseを渡すことで「BOMを付けるな」という明示的な指定になる。Out-Fileでは制御できなかった部分をここで解決しているFormat-Hex:保存したファイルの先頭バイトを表示する。3C 3F 70 68 70(<?php)で始まればBOMなし、EF BB BFで始まればBOM付き
注意点が2つある。
- 保存先は絶対パスで書く。
WriteAllTextに"adsense.php"のような相対パスを渡すと、cdで移動したフォルダではない場所に保存されることがある - このコードを.ps1ファイルに保存して使う場合、コード内に日本語のコメントを足さない。PowerShell 5.1はBOMなしの.ps1を日本語として正しく読めず、コメントの文字化けが原因で構文エラーになることがある(上の例はそのために英数字だけで書いてる)
PowerShell 7をインストールすればOut-File -Encoding utf8でもBOMなしになるが、5.1のままでもこの方法で対処できる。
サーバー側で差し替えるときの手順
今振り返ると、Docker環境でmu-pluginを差し替えるなら次の順番が安全。
①今のファイルをサーバー側に退避する(コンテナの外に置いておく)
docker cp wordpress:/var/www/html/wp-content/mu-plugins/adsense.php ./adsense.php.bak
②新しいファイルの先頭3バイトを確認してから、コンテナ内へ上書きする
head -c 3 adsense.php | od -An -tx1
docker cp adsense.php wordpress:/var/www/html/wp-content/mu-plugins/adsense.php
③ログインできることを確認し、警告が止まったかをログで見る(何も出なければ解消)
docker logs --since 5m wordpress 2>&1 | grep "headers already sent"
すぐに作り直せないときは、ファイル名を adsense.php.off のように変えればmu-pluginとして読み込まれなくなる。mu-pluginsは管理画面から無効化できないので、これが応急処置になる(その間は当然AdSenseのコードも出なくなる)。
まとめ
- PowerShell 5.1の
Out-File -Encoding utf8で作ったPHPファイルはBOM付きになる - 今回の環境(Docker公式イメージ・php.ini無し=
output_buffering = 0)では、mu-plugins先頭のBOMでログインCookieと転送のヘッダーが送れず、ログインボタンを押した後が白画面になった - 決め手は
headers already sent by (output started at …/adsense.php:1)というログ [System.IO.File]::WriteAllText()でBOMなしで保存し直して差し替えたら、1分後にはログインできた