システム開発関連 パソコン関連

サーバー移行後、PHPバッチが正常終了なのに動かない? Windowsパスの8進エスケープにハマった話

PHPバッチでWindowsパスの8進エスケープ問題を解説するサムネイル

これまで別のサーバで動かしてたバッチを別のサーバに移行して実行した際に「バッチを実行してもデータが増えない。でも終了コードは 0 で、致命的なエラーも出ていない」――そんな、一見すると正常に見える不具合に遭遇しました。
調査してみると、原因はWindowsのフォルダパスでした。PHPのダブルクォート文字列内で、バックスラッシュに続く数字が8進エスケープとして解釈され、実際とは異なるパスになっていたのです。
移行前は、たまたまこの問題が起きないフォルダパスだったため、同じ書き方でも問題が表面化していませんでした。
この記事では、問題が起きる最小コード、原因の見つけ方、修正方法、さらに「何もしなかったのに正常終了」を防ぐ改善例までメモします。

⚠ 注意事項

設定内容やプログラムの内容は、用途・環境に応じて適切なものが変わります。

各種設定値や環境情報についてよく理解を深め、壊れてもよい環境で十分に検証してください。

なるべく正確に書くよう心掛けていますが、本投稿内容を実施される際には自己責任の下でお願いいたします。

起きていた現象

バッチの役割は、指定フォルダからファイルを検索し、内容を解析してデータベースへ登録することでした。
ところが、実行結果は次のような状態でした。

  • 終了コードは 0
  • データベースへの登録は0件
  • 対象フォルダにはファイルが存在する
  • 実行環境によっては8進エスケープに関する警告が出る

終了コードだけを見れば成功です。しかし実際には、ファイル検索の時点で対象が0件になっていました。

問題を再現するコード

たとえば、次のようにWindowsパスをダブルクォートで記述したとします。

<?php

const IMPORT_DIRECTORY = "C:\data\4400-import\4401-target";

$files = glob(IMPORT_DIRECTORY . '/*.csv');

foreach ($files ?: [] as $file) {
    echo "処理中: " . basename($file) . PHP_EOL;
}

見た目は普通のWindowsパスですが、PHPのダブルクォート文字列ではバックスラッシュがエスケープ文字として働きます。特に \440 のような並びは8進エスケープとして解釈されようとします。

その結果、PHPが保持している文字列は、意図したフォルダパスと一致しません。glob() は存在しない場所を検索するため、対象ファイルを返さず、ループも一度も実行されません。

なぜ「正常終了」したのか

この問題を分かりにくくしたのは、対象が0件でもプログラム上は異常と判定されていなかったことです。

$files = glob($pattern);

if ($files === false) {
    echo "ファイル検索に失敗しました" . PHP_EOL;
    return [];
}

return $files;

glob() が空配列を返した場合は「検索自体は成功したが、一致するファイルがない」という扱いです。
その後の foreach は何もせず終了し、プロセスも終了コード 0 を返します。

つまり、終了コード 0 が示していたのは「期待したデータを処理できた」ではなく、「PHPプロセスが致命的エラーで停止しなかった」ということだけでした。

修正方法1:シングルクォートを使う

固定のWindowsパスなら、シングルクォート文字列にするのが分かりやすい方法です。

const IMPORT_DIRECTORY = 'C:\data\4400-import\4401-target';

PHPのシングルクォート文字列では、基本的に \\\' 以外のバックスラッシュはそのまま扱われます。
数字から始まるフォルダ名が後ろに続いても、8進エスケープとして解釈されません。

修正方法2:バックスラッシュをエスケープする

ダブルクォートが必要なら、バックスラッシュを二重にします。

const IMPORT_DIRECTORY = "C:\\data\\4400-import\\4401-target";

コード上の \\ が、実際の文字列では1つの \ になります。

修正方法3:Windowsでもスラッシュを使う

PHPからWindowsのファイルを扱う場合、多くの場面で / も利用できます。

const IMPORT_DIRECTORY = 'C:/data/4400-import/4401-target';

バックスラッシュ由来のエスケープ問題を避けやすく、見通しもよくなります。

対象0件を見逃さない改善例

パスを直すだけでなく、対象ファイルが0件だった場合に明示的なメッセージと異常終了コードを返すようにすると、同じタイプの障害を早く検知できます。

<?php

const IMPORT_DIRECTORY = 'C:/data/4400-import/4401-target';

function getImportFiles(string $directory): array
{
    if (!is_dir($directory)) {
        throw new RuntimeException(
            "取込フォルダが存在しません: {$directory}"
        );
    }

    $files = glob($directory . '/*.csv');

    if ($files === false) {
        throw new RuntimeException('ファイル検索に失敗しました');
    }

    if ($files === []) {
        throw new RuntimeException(
            "取込対象ファイルがありません: {$directory}"
        );
    }

    return $files;
}

try {
    $files = getImportFiles(IMPORT_DIRECTORY);

    printf("取込対象: %d件%s", count($files), PHP_EOL);

    foreach ($files as $file) {
        printf("処理中: %s%s", basename($file), PHP_EOL);
        // 解析・登録処理
    }

    exit(0);
} catch (Throwable $e) {
    fwrite(STDERR, $e->getMessage() . PHP_EOL);
    exit(1);
}

この形なら、次の3つを区別できます。

  1. フォルダ自体が存在しない
  2. ファイル検索に失敗した
  3. 検索はできたが対象ファイルが0件だった

調査時に確認したポイント

今回のようなバッチ障害では、次の順番で確認すると原因を絞り込みやすくなります。

1. 構文チェックを実行する

php -l import_batch.php

構文エラーだけでなく、今回のような文字列リテラルに関する警告が手掛かりになることがあります。

2. 入力元を直接確認する

  • フォルダは存在するか
  • 実行ユーザーに読み取り権限があるか
  • 拡張子や検索パターンは正しいか
  • 対象ファイル数はいくつか

3. バッチが認識しているパスを確認する

デバッグ時は var_export() を使うと、制御文字やエスケープの影響を確認しやすくなります。

var_export(IMPORT_DIRECTORY);
echo PHP_EOL;

ただし、本番ログに個人情報や認証情報を出さないよう注意が必要です。

4. 終了コードと処理件数を別々に見る

最低限、次の件数をログに残すと状態を判断しやすくなります。

  • 検出ファイル数
  • 解析成功件数
  • 登録件数
  • スキップ件数
  • エラー件数

まとめ

今回の原因は、PHPのダブルクォート文字列に書かれたWindowsパスでした。
バックスラッシュと数字の組み合わせが8進エスケープとして扱われ、ファイル検索先が意図せず変化していました。

重要なポイントは次のとおりです。

  • Windowsパスはシングルクォート、バックスラッシュの二重化、または / を使う
  • 終了コード 0 だけで業務処理の成功を判断しない
  • 対象0件を正常扱いにするか異常扱いにするか、仕様として決める
  • 入力件数・成功件数・スキップ件数をログへ残す

「エラーが出ていない」と「期待した処理が完了した」は別物です。バッチ処理では、プロセスの生存だけでなく、処理件数や入力条件まで観測できるようにしておくことが大切です。

-システム開発関連, パソコン関連
-, , ,