コンテンツへスキップ

PHP 8.2+ × 古い WP-CLI で出る Deprecated ノイズを 3 段防御で吸収する — JSON パース汚染を構造的に防ぐ

多サイト保守ツールから WordPress サイトに wp plugin list --format=json を投げる経路で、ある日から特定のサーバー(Xserver)に対してだけプラグイン情報取得が失敗する不具合が出るようになりました。

しかも症状の出方が独特で、SSH 接続テストも WP-CLI パステスト(wp --version)もすべてグリーン。「テスト全部通るのに実運用で失敗する」非対称な状態でした。原因を辿ると、PHP 8.2+ と古い WP-CLI 2.x の組合せで吹き出す Deprecated 警告が、JSON 出力に混入していたという話に行き着きました。

今回はこの「警告ノイズが JSON パースを汚染する」問題を 3 段防御で構造的に吸収した話をまとめておきます。

何が起きていたか — Deprecated 警告が stdout に出る

実機の stderr/stdout を生で見ると、こんな出力が混ざっていました。

PHP Deprecated:  Creation of dynamic property
WP_CLI\Dispatcher\CompositeCommand::$longdesc is deprecated
in phar:///usr/bin/wp/vendor/wp-cli/wp-cli/php/...
[
  {"name":"akismet","status":"active","update":"none", ...},
  ...
]

PHP 8.2 から、#[\AllowDynamicProperties] 属性のないクラスへの動的プロパティ代入が Deprecated 警告を出すようになりました。Xserver の /usr/bin/wp(古い WP-CLI 2.x)は内部で動的プロパティを多用しているため、PHP 8.2+ のサーバーで実行すると警告がぼろぼろ吹き出す構造です。

補足: PHP 8.2+ の動的プロパティ Deprecated は、コードの品質向上を狙った正しい方向の変更ですが、過渡期に「警告は出るが動くは動く」状態のライブラリが大量に発生します。WP-CLI もその一つでした。

問題は、ホスティング側 php.inidisplay_errors 設定によっては、この警告が stderr ではなく stdout に出力されること。wp plugin list --format=json を呼ぶと、stdout に Deprecated 警告と JSON が混ざって返ってきて、json_decode() がパースに失敗します。

「テストはグリーン → 実運用エラー」の非対称が起きる理由

ここで地味に厄介だったのが、診断系のテストでは症状が出ないこと。

  • SSH 接続テスト: echo ok を実行 → stdout に余計な行が混ざっても ok が含まれていれば通る
  • WP-CLI パステスト: wp --version を実行 → 警告と一緒にバージョン文字列が出れば「動いている」と判定できる
  • 実運用: wp plugin list --format=json構造化出力を JSON パースする経路で、初めて混入ノイズが致命的になる

ユーザーから見ると「テスト全部緑なのに、実運用だけ落ちる」状態で、何が違うのか分かりにくい。診断系を「単に exit code と出力の有無を見る」設計にしていると、構造化出力時にだけ顕在化する問題は素通りします。これは bash 構文の SSH コマンドが csh で詰まる横断バグ で踏んだ「単純コマンドは通るのに構造化処理が落ちる」と同じ系列の罠です。

3 段防御で吸収する

警告自体を抑制する方法はありますが、ホスティング側の php.ini 設定差を完全には予測できないので、多重防御で構造的に吸収する設計にしました。

第 1 段 — WP_CLI_PHP_ARGS で警告を最初から消す

WP-CLI には WP_CLI_PHP_ARGS という環境変数があり、内部の PHP 実行時に渡す引数を指定できます。これを使って error_reporting を Deprecated 系だけ除外するように設定します。

_WP_CLI_PHP_QUIET_ARGS = (
    "-d error_reporting='E_ALL & ~E_DEPRECATED & ~E_USER_DEPRECATED'"
)

def _wp_with_quiet_php(wp_cli_path: str) -> str:
    """WP-CLI 呼び出しを WP_CLI_PHP_ARGS でラップして Deprecated を抑制。"""
    return (
        f"WP_CLI_PHP_ARGS={shlex.quote(_WP_CLI_PHP_QUIET_ARGS)} "
        f"{wp_cli_path}"
    )

shlex.quote で値をシェルエスケープしておくのは、サイト単位で WP-CLI パスを上書きできる機能(v1.6.1 既存)と共存させるための後方互換策です。Parse error や Fatal error は引き続き出るので、致命傷は隠れません。

第 2 段 — JSON パース前にノイズ行を除去

第 1 段で大半は消えますが、ホスティング側で error_reporting がオーバーライドされる構成(php.inierror_reportingini_set() 等で上書きしている環境)では警告が残ることがあります。defense in depth として、JSON パース前に stdout からノイズ行を機械的に除去します。

_PHP_NOISE_LINE_RE = re.compile(
    r'^\s*PHP\s+(Deprecated|Warning|Notice|Strict Standards):.*$',
    re.MULTILINE | re.IGNORECASE
)

def _strip_php_noise(text: str) -> str:
    """stdout から PHP Deprecated/Warning/Notice/Strict Standards 行を除去。
    Parse error / Fatal error は対象外(致命傷でユーザーに見せるべき)。"""
    return _PHP_NOISE_LINE_RE.sub('', text)

ここで意図的に Parse error / Fatal error は除去しない設計にしているのが重要です。これらは「動かない」シグナルで、ユーザーが気づくべき問題です。ノイズと致命傷を機械的に区別して、ノイズだけを消す方針。

第 3 段 — exit code != 0 でも JSON があれば救う

第 1 段・第 2 段で大半は救えますが、警告だけで exit code が 1 になるホスティング環境がまだ稀に存在します。Fabric (paramiko) で res.ok=False が返ってくるけれど、stdout には実は綺麗な JSON が入っている、というケース。

stdout_clean = _strip_php_noise(res.stdout or '').strip()
plugins = None
if stdout_clean:
    try:
        plugins = json.loads(stdout_clean)
    except json.JSONDecodeError:
        plugins = None

if plugins is None:
    # ここでようやく「JSON が取れなかった」エラー分岐
    if not res.ok:
        return error_response(res.stderr or res.stdout)

「exit code を見る前に、stdout に JSON があるか試す」順序に変える。これで非ゼロ exit でも実は JSON が取れていれば救済できます。

3 つの API すべてに横展開

V12 で確立した「同型バグは初回修正時に横断 grep + 全箇所修正」原則をここでも適用しました。プラグイン情報取得を行っている API を grep で洗い出すと 3 つありました。

  • /api/fetch_plugins — 横断ダッシュボードのプラグイン一覧
  • /api/site_plugins — サイトカード詳細のプラグイン一覧
  • _do_fetch_pending_plugins_for_site — メンテナンス実行時の更新待ちプラグイン検出

3 つすべてを _wp_with_quiet_php + _strip_php_noise + JSON 先行パース のパターンに揃えました。1 箇所だけ直していたら、別の経路で同じ問題が再発します。

回帰防止用に tests/test_wp_cli_php_noise.py に 18 件のテストを追加(ノイズ行除去 / Parse error 保持 / 環境変数形式 / shlex 引用 / サイト単位 WP-CLI パス上書きとの互換性 / 3 API での使用検証)。CI で「うっかり生 c.run で json_decode に直接渡すコードを追加」したら落ちる構造です。

学び — 「警告レベル差 × 構造化出力」の罠

このラウンドから取り出せる原則は次の 3 つでした。

  1. テストはグリーン・実運用は失敗 の非対称を起こしやすい — 診断系(echo ok / wp --version)は単純コマンドで「動いた / 動かない」しか見ないので、構造化出力時にだけ顕在化する警告ノイズ混入は検出されない。診断系の中に 構造化出力をパースする経路 を 1 つ含めておくと、この罠を早期検出できる
  2. ノイズ抑制は多重防御で組むerror_reporting 抑制 / 行除去 / exit code 先行回避 のような独立した防御策を重ねると、ホスティング設定差で 1 段抜けても他の段で吸収できる。完璧に予測できない環境差には多重化が効く
  3. ノイズと致命傷を機械的に区別する — Deprecated / Warning / Notice / Strict Standards は除去 OK だが、Parse error / Fatal error は絶対に温存する。「全部の警告を消す」と致命傷も見えなくなるので、正規表現で 4 種に限定するのが安全策

PHP / WP-CLI のような「環境依存の警告が出やすい」ツールを CLI 経由で呼び出す保守ツールでは、似た問題が水面下で起きていることが多いです。error_reporting 抑制 + ノイズ行除去 + JSON 先行パース の 3 段は、テンプレートとして他にも適用できる引き出しだと思います。