PHPシグナルハンドリングと常駐プロセスの極意:Zend VMのメモリ空間を支配し、Graceful Shutdownを完遂する
PHPは、本来「1リクエストを受け取り、全リソースを解放して消滅する」という短命なライフサイクルを前提に設計された言語である。この割り切りこそが、共有ードレススペースの汚染やリソースリークの恐怖からWebアプリケーションを解放してきた最大の強みだ。
しかし、現代のプロダクト開発において、メッセージキューのワーカーやWebSocketサーバー、あるいは高スループットなAPIゲートウェイなど、「プロセスを常駐させ、メモリ上にアプリケーションコンテナを保持し続けるアーキテクチャ」の必要性は高まる一方だ。
ここで開発者が直面するのが、Zend VMの内部構造、メモリ管理(Zend MM)、そしてオペレーティングシステム(OS)レベルのシグナルハンドリングの衝突である。本稿では、PCNTL拡張を用いたシグナル処理の本質と、メモリリークを絶ち、コネクションを安全に保ちながら終了するGraceful Shutdown(優雅なる停止)の設計解を、内部エンジンの挙動を踏まえて徹底解説する。
—
1. なぜ常駐PHPプロセスは「危険」なのか? —— Zend VMとメモリ管理の闇
リクエストごとにプロセスが破棄されるWebサーバー(Nginx + PHP-FPMの通常構成など)では、コード内の些細なメモリリークやステートの汚染は「数万リクエストに1回、FPMがプロセスを自動リサイクルする」ことで隠蔽されてきた。
しかし、常駐プロセス(CLIモード)では、以下の脅威がダイレクトに牙を向く。
1. グローバルスコープと静的プロパティ(Static)の蓄積
リクエスト(またはジョブ)を処理するたびに配列やオブジェクトを静的プロパティに蓄積し続けると、Zend MM(Zend Memory Manager)のヒープ領域は肥大化し続け、やがてOut of Memory (OOM)を引き起こす。
2. 外部リソース(DBコネクション、ファイルディスクリプタ)の枯渇
ループのなかで適切にクローズされないコネクションは、OS側のファイルディスクリプタ(FD)上限を突き破る。
3. シグナル非同期割り込みとPHPコードの非同期性
OSからシグナル(`SIGTERM`など)を受け取った瞬間、PHPのインタプリタが「何をしている最中か」を制御しなければ、トランザクションの途中でVMが強制終了し、データ破損を招く。
—
2. PCNTLとティック(Ticks)のメカニズム
PHPでシグナルを扱うためには `ext-pcnt` が不可欠だ。しかし、ここで一つ重要なZend VMの仕様上の事実を知る必要がある。
C言語やGo言語におけるシグナルハンドリングとは異なり、PHPのシグナルハンドラーは「OSのシグナルを受信した瞬間に任意のPHPコードへ割り込むわけではない」。デフォルトの状態では、OSからシグナルを受け取ると、Zend VM内では「シグナルフラグが立った」という事実が記録されるに留まる。このフラグを検知してユーザー定義のシグナルハンドラー(コールバック)を安全に発火させる仕組みが、PHPの Tick(ティック) である。
`declare(ticks=N);` の正体
コードの先頭に記述する `declare(ticks=1);` は、Zend VMに対し「オペコードをN回実行するごとに、暗黙的に内部の割り込みチェック関数を挿入せよ」という指示を出す。
declare(ticks=1); // すべてのオペコード実行ごとにチェックが走る(パフォーマンスコストに注意)
近年のPHP(PHP 7.1以降)では、シグナルハンドリングに関しては `pcnt_async_signals(true)` を用いることで、ティックによるオーバーヘッドを排除し、非同期シグナルを安全にハンドラーへディスパッチするアプローチが主流となっている。実務では必ず `pcnt_async_signals(true)` を選択すべきだ。
—
3. 実装:Graceful Shutdownを完遂する常駐ワーカーの設計
それでは、実際のプロダクトコードベースに組み込める、極めて堅牢な常駐プロセスのリファレンス実装を提示する。
このコードは以下の要件を満たす。
- 非同期シグナルの有効化(`SIGTERM`, `SIGINT`, `SIGHUP` のキャッチ)
- 現在実行中のジョブを安全に完了させるフラグ制御(Safe Point)
- メモリリーク対策としての定期的(またはジョブ数依存の)自己再起動
- 例外発生時・シグナル受信時におけるリソースの確実な解放
registerSignals();
echo “[INFO] Daemon started. PID: ” . getmypid() . “\n”;
// 2. メインループ
while ($this->isRunning) {
// シグナルによるフラグ変更を即座に反映させるため、ここでティックや割り込み処理が効く
pcntl_signal_dispatch();
if ($this->isShuttingDown) {
$this->gracefulExit();
}
try {
// ジョブの取得(ブロッキングまたは非ブロッキング)
$job = $this->fetchNextJob();
if ($job === null) {
// ジョブがない場合はCPUを焼き尽くさないようスリープ
usleep(250000); // 0.25秒
continue;
}
// ジョブの実行
$this->processJob($job);
$this->processedJobsCount++;
// メモリリーク対策のプロアクティブ・リスタート判定
if ($this->processedJobsCount >= self::MAX_JOBS_BEFORE_RESTART) {
echo “[INFO] Reached max job limit. Initiating graceful recycle…\n”;
$this->isShuttingDown = true;
}
} catch (Throwable $e) {
// 予期せぬ例外でワーカーを落とさないためのキャッチ&ログ
error_log(sprintf(‘[ERROR] Exception in worker: %s in %s:%d’, $e->getMessage(), $e->getFile(), $e->getLine()));
// 必要であればデッドレターキューへの退避処理など
}
}
}
private function registerSignals(): void
{
// PHP 7.1+ なら非同期シグナルを有効化(Ticksのオーバヘッドを回避)
if (function_exists(‘pcntl_async_signals’)) {
pcntl_async_signals(true);
}
$signalHandler = function (int $signal): void {
switch ($signal) {
case SIGTERM: // Kubernetesやsystemdからの一般的な終了要請
case SIGINT: // キーボードからの割り込み (Ctrl+C)
echo “[INFO] Received termination signal. Starting graceful shutdown…\n”;
$this->isShuttingDown = true;
break;
case SIGHUP: // 設定のリロードなどの合図として使う場合
echo “[INFO] Received SIGHUP. Reloading configurations…\n”;
// $this->reloadConfig();
break;
}
};
pcntl_signal(SIGTERM, $signalHandler);
pcntl_signal(SIGINT, $signalHandler);
pcntl_signal(SIGHUP, $signalHandler);
// 無視すべきシグナル(パイプ切断など)
pcntl_signal(SIGPIPE, SIG_IGN);
}
private function fetchNextJob(): ?array
{
// 実装例:RedisやDBからのキュー取得
// ここでは擬似的にnullまたはダミー配列を返す
return null;
}
private function processJob(array $job): void
{
echo “[DEBUG] Processing job ID: ” . ($job[‘id’] ?? ‘unknown’) . “\n”;
// 重い処理のシミュレーション
usleep(100000);
// ※ 各ジョブの終了時には、DBトランザクションのコミット/ロールバック、
// およびオープンしたファイルハンドルの確実なクローズを行うこと。
}
private function gracefulExit(): void
{
echo “[INFO] Flushing remaining resources and closing connections…\n”;
// 3. 外部リソース(DBプール、Redis、gRPCクライアントなど)のクリーンアップ
// Database::closeAllConnections();
// RedisClient::disconnect();
echo “[INFO] Daemon gracefully shutdown completed. Exit code 0.\n”;
exit(0);
}
}
// 実行エントリーポイント
// php worker.php で実行される想定
(new DaemonWorker())->run();
—
4. テクニカルリードが伝える設計上の要点と落とし穴
上記のコードベースをレビュー・運用するにあたり、シニアエンジニアが絶対に押さえておかなければならない「現場の知見」を共有する。
A. デッドロックとシグナルハンドラーの罠
シグナルハンドラーの内部で、複雑な処理やデータベースへの再接続、あるいはロックを取得するような重い処理を書くのは絶対の禁忌である。シグナルハンドラーは元の処理の流れを「非同期に割り込んで」実行されるため、ハンドラー内でミューテックスやグローバルな状態を書き換えると、メインスレッド側との間でデッドロックやメモリ破壊(Segmentation Fault)を引き起こす。
原則:シグナルハンドラー内では、フラグ変数を書き換える(`$this->isShuttingDown = true;`)だけに留めよ。
B. 安全な停止ポイント(Safe Point)の設計
`SIGTERM` を受け取ったからといって、その瞬間にプロセスを強制終了させてはならない。例えば、DBのトランザクションが `BEGIN` され、データを書き込んでいる最中にプロセスが死ねば、データが中途半端な状態で残る。
前述のコードのように、「重い処理のループの境界」や「ジョブ単位の処理が終わった直後」を Safe Point とし、そこでフラグを検知して安全にループを抜け出す設計が必須となる。
C. プロセスオーケストレーション(Supervisord / Kubernetes)との連携
Graceful Shutdownを実装しただけでは不十分だ。プロセスが `exit(0)` で終了した後、誰がそれを再起動するのか?
- ローカル・物理サーバー環境: `supervisord` などのプロセス監視ツールを使い、`stopwaitsecs`(シグナル送信から強制killするまでの猶予時間)を十分に長くとる(例: 30秒)。
- Kubernetes環境: Podの終了時に `terminationGracePeriodSeconds` を適切に設定し、K8sから `SIGTERM` が飛んできたあとにPHPワーカーが安全にジョブを消化し切る時間を保証する。
—
結び
PHPは「使い捨ての言語」という古いパラダイムは、現代のZend VMとOPcache、そして正確なPCNTL制御の組み合わせによって過去のものとなった。PHPであっても、プロセスアーキテクチャの内部挙動を完全に掌握し、メモリとシグナルのライフサイクルをデザインできれば、極めて堅牢で高パフォーマンスなバックエンド常駐システムを構築できる。
コードレビューの現場で「なぜこのフラグが必要なのか」「このシグナルハンドラーは安全か」を論理的に説明できるエンジニアであれ。Zend VMの息づかいを感じながら、美しく優雅に停止するシステムを実装しよう。