シグナル: 捕まえられるものと、そうでないもの
一言でいうと
シグナルはプロセスに送る短い通知で、そのうちSIGKILL(9)と SIGSTOP(19)の2つだけは、プログラムが捕捉することも、ブロックすることも、無視することもできません。ほかはすべて、アプリケーションが処理方法を変えられます。
なぜ必要なのか
「プロセスが死なない」という文は、実は少なくとも4つの異なる状況をひとまとめにした言い方です。
killを送ったのに変わらない -> アプリケーションが SIGTERM を捕捉して無視しているか、後始末の最中であるkill -9も効かない -> プロセスが D 状態(中断不可の待機)である。シグナルはキューにたまっていて、カーネルから戻ってきてから処理される- すでに死んでいるのに一覧に見える -> ゾンビである。死んでいるので、殺すことはできない
- コンテナを止めたらデータが途切れた -> シグナルが PID 1 のシェルで止まり、アプリまで届かなかった
4つの状況で、対応はすべて異なります。そのため「死なない」で止まらずに、どの階層で止まっているのかを問う必要があります。
どう動くのか
正常な終了は、常に2段階です。 SIGTERM を送って後始末の時間を与え、猶予時間が過ぎても生きていれば SIGKILL を送ります。docker stop の既定の猶予は10秒で、systemd の TimeoutStopSec も同じ役割を果たします。シェルでは、timeout -k 10 30 명령(最後の語がご自身のコマンドを表します)の1行で同じポリシーを作れます。
SIGKILL が危険なのは、後始末の機会をまったく与えないからです。バッファはフラッシュされず、トランザクションは途中で切れ、ロックファイルや一時ファイルが残ります。DB やキューのコンシューマーにとって、SIGKILL は事実上、電源を引き抜くのと同じです。
よく使うシグナルの慣例も知っておくと役立ちます。番号はアーキテクチャによって異なることがあるので、スクリプトでは常に名前を使います。
| 名前 | 既定の動作 | 慣例的な用途 |
|---|---|---|
| SIGHUP | 終了 | 設定の再読み込み(本来の意味は端末の接続切断) |
| SIGINT | 終了 | Ctrl-C |
| SIGTERM | 終了 | 丁寧な終了要求 |
| SIGKILL | 終了 | 捕捉できない。最後の手段 |
| SIGSTOP / SIGCONT | 停止 / 再開 | しばらく止めてから再開 |
| SIGUSR1 / SIGUSR2 | 終了 | アプリ次第(nginx ではログの再オープン / バイナリの入れ替え) |
ゾンビと孤児は、対で理解します。子が死ぬと、カーネルは終了ステータスを残しておき、親が wait でそれを回収してはじめて一覧から消えます。回収前の状態がゾンビ(Z)です。ゾンビはメモリをほとんど使いませんが、PID を占有するため、数千個たまると新しいプロセスを作れなくなります。逆に、親が先に死ぬと子は孤児になって PID 1 に引き取られ、PID 1 が代わりに回収してくれます。ゾンビを消す確実な方法が「親を終了させること」である理由がここにあります。
終了コードの規約も覚えておいてください。シグナルで死んだ場合、終了コードは 128 + 시그널 번호(128 にシグナル番号を足した値)です。SIGTERM は 143、SIGINT は 130 です。CI のログで 143 を見たら、「誰かが丁寧に終了を要求した」と読めば大丈夫です。
現場での姿
コンテナのシェル形式の罠。 Dockerfile に CMD myapp と書くと、/bin/sh -c が PID 1 になり、アプリはその子になります。ランタイムが送った SIGTERM はシェルが受け取り、シェルは子に転送しません。猶予時間が過ぎると、SIGKILL が cgroup 全体に適用され、アプリは何の後始末もできずに死にます。解決策は、exec 形式(CMD ["myapp"])にするか、init プロセスを置くことです。
pkill の射程。 pkill -f はコマンドライン全体をパターンマッチするので、広く使うと無関係なプロセスまで殺してしまいます。必ず同じパターンで先に pgrep -a -f を実行し、一覧を目で確認してから実行します。そして kill -9 -1 は絶対に実行してはいけません。権限が届くすべてのプロセスに SIGKILL を送ります。
trap の実行が遅れる理由。 シェルスクリプトで sleep 300 の実行中に届いたシグナルは、そのコマンドが終わったあとに処理されます。すぐに反応させたければ、sleep 300 & wait $! の形で書きます。
死なないプロセスと終わらない終了
kill を送ったのに死なない状況は、3つに分かれます。どれなのかがわかれば、次の一手が決まります。
シグナルを無視している。 プログラムが SIGTERM のハンドラを持ち、その中で後始末をしているうちに詰まっているか、最初から無視するように作られています。SIGKILL(9)はプロセスが捕捉できないので必ず死にますが、後始末を飛ばします — 開いているファイルを閉じず、ロックを解放せず、進行中の書き込みを捨てます。データベースやキューのコンシューマーに対して先に 9 を使うと、復旧が必要になります。
cat /proc/<pid>/status | grep -E 'SigBlk|SigIgn|SigCgt'
D 状態でカーネルの中で眠っている。 ps の状態が D(uninterruptible sleep)なら、ディスクや NFS の応答を待っているところで、9 を送っても死にません。 カーネルがそのシステムコールから戻ってきてはじめてシグナルを処理するからです。このとき直すべきなのはプロセスではなく、その下にあるストレージです。
ps -eo pid,stat,wchan:20,cmd | awk '$2 ~ /D/'
すでに死んでいるのに親が回収していない。 状態が Z(ゾンビ)なら、そのプロセスはすでに終わっており、終了コードだけが残っています。ゾンビ自体はリソースをほとんど使いませんが、たまると PID が尽きます。殺すべき対象はゾンビではなく親です。コンテナでゾンビがたまるのは、PID 1 が回収しないからで、--init や tini がその役目を埋めます。
終了の順序を自分で決める。 サービスなら、SIGTERM を受け取ったら、新しいリクエストの受け付けをやめ、処理中のものを終わらせ、そのあとで終了する必要があります。この順序がないと、デプロイのたびにリクエストが途切れます。時間が経っても終わらなければ、そのときに強制的に殺すのが正しいやり方です。
timeout -s TERM -k 30s 5m ./worker # TERM 뒤 30초 안에 안 끝나면 KILL
シグナルはプロセスグループに届く。 シェルでの Ctrl+C は、フォアグラウンドのプロセスグループ全体に SIGINT を送ります。スクリプトが子を残して死ぬ問題は、たいてい子が別のグループにいるからです。kill -- -<pgid> でグループ全体に送ります。
次のラボですること
バックグラウンドのプロセスを起動して PID と親 PID を確認し、停止・終了・強制終了をそれぞれ送って状態がどう変わるかを見ます。SIGTERM を捕捉するスクリプトを作って、SIGKILL は捕捉されないことを自分で証明し、ゾンビを1つ作ってみたあと、最後にパターンに合うプロセスを安全に片付けるスクリプトを書きます。