PHPのregister_shutdown_functionとerror_get_lastで致命的エラーを拾う方法と注意点
register_shutdown_function は、スクリプトの終了時に呼ばれるコールバックを登録する関数です。error_get_last と組み合わせると、set_error_handler では拾えない致命的エラー(Fatal error)の原因も、終了間際に記録できます。
本番で「画面が真っ白、ログにも何も残っていない」という状況、地味に心が削られますよね。私もメモリ上限に当たった処理を追うのに苦労したことがあり、それ以来、終了時フックを1本入れておくようにしています。
register_shutdown_functionはいつ呼ばれるのか
まず基本の挙動を押さえます。コールバックは、スクリプトが最後まで実行されたとき、exit() で止まったとき、致命的エラーで落ちたときのいずれでも呼ばれます。複数登録した場合は、登録した順に実行されます。
<?php
register_shutdown_function(function (): void {
echo "shutdown 1\n";
});
register_shutdown_function(function (string $name): void {
echo "shutdown 2: $name\n";
}, 'arg');
echo "main\n";
exit(0);
// main
// shutdown 1
// shutdown 2: arg
第2引数以降はコールバックにそのまま渡されます。クロージャで use するのとは別の選択肢として、覚えておくと少し楽です。
ひとつ注意点があります。登録済みのコールバックの中で exit() を呼ぶと、そこで処理が打ち切られ、後ろに登録したコールバックは実行されません。終了処理を複数に分けて登録している場合は、途中で exit しない作りにしておくのが安全です。
致命的エラーはなぜset_error_handlerで拾えないのか
set_error_handler で捕まえられるのは、警告や通知など、実行が続行できる種類のエラーです。メモリ上限超過のような E_ERROR は、ハンドラが呼ばれる前にエンジンが終了処理に入ってしまいます。そこで、終了時に error_get_last を見る、という手を使います。
<?php
declare(strict_types=1);
const FATAL_TYPES = E_ERROR | E_PARSE | E_CORE_ERROR | E_COMPILE_ERROR;
register_shutdown_function(static function (): void {
$e = error_get_last();
if ($e !== null && ($e['type'] & FATAL_TYPES)) {
error_log(sprintf(
'[FATAL] %s in %s:%d',
$e['message'],
$e['file'],
$e['line']
));
}
});
error_get_last は、直近のエラーを type・message・file・line を持つ配列で返し、エラーがなければ null を返します。ここで「null かどうか」だけで判断しないのがコツです。次の節で理由を書きます。
これで、白い画面の原因がログに残るようになります。ただ、この方法は「エラーを握りつぶして続行する」ものではなく、あくまで終了を見届けて記録するものだと考えておくのがよいと思います。
警告でもerror_get_lastはnullじゃなくなる
error_get_last は致命的エラー専用ではありません。警告や通知でも値が入りますし、@ で抑制したエラーにも反応します。そのため、type を見て絞り込まないと、正常終了したスクリプトで誤報を出すことになります。
<?php
register_shutdown_function(function (): void {
$e = error_get_last();
var_dump($e === null ? null : $e['type']);
});
$r = @file_get_contents('/nonexistent');
echo "done\n";
// done
// int(2) ← E_WARNING。正常終了でも null にはならない
先ほどの FATAL_TYPES によるビットマスクの絞り込みは、このためのものです。なお、手元の履歴を明示的に消したいときは error_clear_last() が使えます。
キャッチされなかった例外はどう見えるのか
PHP 8 では、どこにも捕まらなかった例外も、終了時には「Uncaught …」というメッセージの致命的エラーとして扱われます。手元の PHP 8.3 で確認したところ、error_get_last の type は E_ERROR になりました。
<?php
register_shutdown_function(function (): void {
$e = error_get_last();
echo $e['type'] === E_ERROR ? "E_ERROR\n" : "other\n";
echo strtok($e['message'], "\n"), "\n";
});
throw new RuntimeException('x');
// E_ERROR
// Uncaught RuntimeException: x in /path/to/script.php:7
message には複数行のスタックトレースが入ります。1行目だけを取り出したいときは、上のように最初の改行までを使うとログが読みやすくなります。
メモリ上限超過のときにハマりやすい点
メモリ不足で落ちた直後は、シャットダウン関数を動かす余裕すら残っていないことがあります。対策としてよく使われるのが、あらかじめ少量のメモリを確保しておき、ハンドラの冒頭で解放する書き方です。
<?php
ini_set('memory_limit', '8M');
$reserve = str_repeat('x', 32768);
register_shutdown_function(function () use (&$reserve): void {
$reserve = null; // 予約分を手放して、ハンドラが動く余白を作る
$e = error_get_last();
if ($e !== null && $e['type'] === E_ERROR) {
echo "FATAL: ", substr($e['message'], 0, 40), "\n";
}
});
$a = [];
while (true) {
$a[] = str_repeat('y', 1024);
}
// FATAL: Allowed memory size of 8388608 bytes exh
確保しておく量は、ハンドラでやる処理の重さに合わせて調整します。ハンドラの中でデータベース接続などの重い処理をするのは避けて、ログを1行書く程度にとどめるほうが、落ちる側として安定します。
もうひとつ、Apache などの環境では、シャットダウン関数の中でカレントディレクトリが変わることがあると公式ドキュメントに書かれています。ファイルへ書き出すときは、相対パスではなく __DIR__ を使った絶対パスにしておくと安心です。
まとめ
register_shutdown_function と error_get_last を組み合わせると、set_error_handler では届かない致命的エラーの原因を、終了時に記録できます。使うときは、type で致命的エラーに絞ること、ハンドラ内は軽く保つこと、メモリ不足に備えて予約領域を持つこと、の3点を押さえておけば大きく外さないと思います。
エラーを「直す」仕組みではなく「見失わない」仕組みとして入れておく、くらいの距離感がちょうどいい気がしています。
よくある質問
Q. register_shutdown_function はエラーを防げますか?
A. 防げません。致命的エラーで落ちること自体は変えられず、落ちたあとに原因を記録したり後始末をしたりするための仕組みです。
Q. 構文エラー(パースエラー)も拾えますか?
A. 同じファイルの構文エラーは、そのファイルが実行される前に失敗するため、そのファイル内で登録した関数は呼ばれません。include や require で読み込んだファイルの構文エラーであれば、読み込み元で登録済みの関数から error_get_last で確認できます。
Q. 例外はtry/catchとどう使い分けますか?
A. 想定できる失敗は try/catch で処理し、それでも漏れた予期しない終了を記録する最後の網として、シャットダウン関数を置くのが分かりやすい使い分けです。