はじめに
私はとあるプロダクトを EC2 から EKS へ構成変更する仕事を請け負っていました。
夜間に移行を完了し、「無事終わった!」と安心していた翌日、プロダクトチームから連絡がありました。
「ooo(移行対象プロダクト) がたまに 502 エラーを吐くんですよね…」
とりあえずプロダクトを見にいきますが、自分の手元の画面では正常に稼働しているように見えます。
しかし、確かにリクエスト全体の中で 3%程度エラーが発生しているのが見えました。
当時自分一人では原因特定ができず、先輩方の助力により本件は解決に至りました。
自省も含め、一体何が起きていたのかをこの記事で振り返っていこうと思います。
調査を開始
502 エラーの発生箇所を特定
まずはどこで 502 エラーが発生しているのかを調査しました。
ログやメトリクスを確認したところ、特定の API エンドポイントで断続的にエラーが発生していることがわかりました。さらに掘り下げると、Laravel のスケジューラーで実行されるバッチ処理が関係していることが判明しました。
症状
- 特定の処理を進めるバッチコマンドが並列で 3 つ動く設計になっていた
- そのうち 2 つが停止し、処理が進まなくなっていた
- スケジューラーのログには
No scheduled commands are ready to run.と表示され続けていた
バッチ処理が止まっていることで、関連する API が正常にレスポンスを返せなくなり、502 エラーとして表れていたのです。
原因の特定
さらに調査を進めると、以下のことがわかりました。
- Laravel の
php artisan schedule:workは、子プロセスを起動する際にユニークなロックキーを生成してロックを取得する - 子プロセスの処理が完了すると
schedule:finishが呼ばれてロックが解除される - しかし、Pod が終了する際に子プロセスが正常に終了せず、ロックがキャッシュに残り続けていた
schedule:work からは以下のようなコマンドが実行される
('/usr/local/bin/php' 'artisan' batch:SomeBatchCommand 2 3 > '/dev/null' 2>&1 ;
'/usr/local/bin/php' 'artisan' schedule:finish "framework/schedule-xxx" "$?") > '/dev/null' 2>&1 &
つまり、Pod が強制終了されると schedule:finish が呼ばれず、ロックが永続的に残ってしまう問題でした。
試行錯誤
tini の導入を試みる
最初に疑ったのは、Kubernetes 環境における PID 1 問題でした。
コンテナ内で PID 1 として動作するプロセスは、Linux カーネルから特別な扱いを受けます。通常のプロセスとは異なり、明示的にシグナルハンドラを設定していない場合、SIGTERM などのシグナルが無視されることがあります。
そこで、軽量な init システムである tini を導入しました。
# Dockerfile に追加
RUN apt-get update && apt-get install -y tini
ENTRYPOINT ["/usr/bin/tini", "--"]
# Kubernetes manifest
args:
- php
- artisan
- schedule:work
導入後、プロセス構成は以下のようになりました。
PID USER TIME COMMAND
1 65535 0:00 /pause
7 1000 0:00 /usr/bin/tini -- php artisan schedule:work
13 1000 0:00 php artisan schedule:work
しかし解決せず
tini を導入して検証しましたが、結果は変わりませんでした。
Pod を再起動した後もロックが残り続け、バッチ処理は動作しませんでした。キャッシュをクリアすると処理が再開されることを確認。
さらに調査を進めると、根本的な原因が判明しました。
php artisan schedule:work 自体が SIGTERM の割り込みに対応していなかった
つまり、SIGTERM を受け取ると子プロセスの終了を待たずに即座に落ちる作りになっていたのです。これは tini の問題ではなく、Laravel のスケジューラーコマンドの実装の問題でした。
解決策
schedule:work-graceful コマンドの実装
最終的な解決策として、SIGTERM を適切にハンドリングする独自コマンド schedule:work-graceful を実装しました。
このコマンドは以下の動作をします。
- SIGTERM を受信したらフラグを立てる
- 現在実行中の子プロセスの終了を待機する
- すべての子プロセスが終了してから、自身も終了する
これにより、Pod が終了する際に子プロセスが schedule:finish を正常に実行でき、ロックが適切に解除されるようになりました。
Received SIGTERM, waiting for child processes to finish...
Child process completed, shutting down gracefully.
EC2 と EKS の違い
なぜ EC2 では問題なく、EKS で問題が発生したのでしょうか。
EC2 環境では、cron や supervisor で管理されており、プロセスの終了タイミングが緩やかでした。また、インスタンス自体が頻繁に再起動されることも少なかったため、問題が顕在化しにくかったと考えられます。
一方、EKS 環境では以下の特性があります。
- デプロイのたびに Pod が再作成される
- リソース不足時に Pod が Evict される可能性がある
-
terminationGracePeriodSeconds(デフォルト 30 秒)を超えると SIGKILL で強制終了
このため、シグナルハンドリングが適切に実装されていないアプリケーションでは、EKS 移行後に問題が表面化しやすくなるのではないかと考えています。
まとめ
- EKS 環境では Pod のライフサイクルが EC2 より頻繁に変わるため、graceful shutdown の実装が重要
-
php artisan schedule:workはデフォルトで SIGTERM を適切にハンドリングしない - tini は PID 1 問題を解決するが、アプリケーション側のシグナルハンドリングが未実装だと効果がない