コンテナはどのように死ぬのか
一言でいうと
docker stopはSIGTERMを送り、デフォルトで10秒待ってからSIGKILLを送ります。終了コードの143はSIGTERM(128+15)、137はSIGKILL(128+9)です。この2つの数字を区別できれば、障害の原因の半分が絞り込めます。
なぜ必要なのか
デプロイのたびに終了に10秒かかり、終了コードが137と表示されるサービスがありました。同じアプリのコマンド形式だけを変えて計測し直すと、次のように変わります。
CMD npm start -> real 0m10.412s, ExitCode 137
CMD ["node","server.js"] -> real 0m0.284s, ExitCode 143
10秒はDockerのデフォルトの猶予時間で、137は強制終了です。正常終了であれば、すぐに終わって143が出ているはずです。
どう動くのか
原因は二重になっています。
1つ目は、PID 1がカーネルで特別扱いされることです。ハンドラーを明示的に登録していないシグナルには、デフォルトの動作が適用されません。シェル形式のCMDは実際には/bin/sh -c "npm start"として実行されるため、PID 1がシェルになります。シェルにはSIGTERMのハンドラーがないので、シグナルは単に無視されます。
2つ目に、「シェルがシグナルを転送しない」という説明は半分しか正しくありません。dashやbusybox ashは、sh -cに単純なコマンドが1つだけの場合、forkせずにexecで自分自身を置き換える最適化を行います。そのため、シェルが消えて対象がPID 1になることもあります。問題は、その最適化が条件付きだという点です。パイプ、&&、変数展開、リダイレクトが1つでも挟まると、シェルが残ります。シェルに頼った動作は、いつか静かに壊れます。
正解は、exec形式のJSON配列です。エントリーポイントスクリプトを使う場合、最後の行は必ずexec "$@"にします。
現場での姿
PID 1には2つ目の責務があります。親を失ったプロセスはPID 1に引き取られ、PID 1がその終了ステータスを刈り取る必要があります。initシステムはこの仕事をしますが、NodeやPythonのプロセスはしません。実際にゾンビを数えると1847個まで積み上がった事例があり、プロセステーブルが満杯になると、アプリケーションのログにfork: Resource temporarily unavailableが出力されます。
そして、137はOOMキラーが出したものかもしれません。両者を分けるのは、State.OOMKilledフィールドとカーネルログです。アプリケーションはSIGKILLに対していかなる例外も受け取れないので、コンテナが静かに再起動を繰り返している場合は、アプリケーションのログではなくカーネルログを見る必要があります。
シグナルがPID 1に届かない2つのケース
コンテナを停止すると、ランタイムがPID 1にSIGTERMを送り、--stop-timeout(デフォルト10秒)が過ぎても生きていればSIGKILLを送ります。ところが、シグナルがアプリケーションに届かないケースが2つあります。
シェル形式のCMDでは、CMD npm startのように書くと/bin/sh -c "npm start"がPID 1になり、シェルはシグナルを子プロセスに転送しません。
CMD npm start # ❌ sh 가 PID 1. SIGTERM 을 삼킨다
CMD ["npm", "start"] # ✅ exec 형식 — 애플리케이션이 PID 1
ラッパースクリプトでは、エントリーポイントスクリプトが最後にexecなしでプログラムを呼び出すと、スクリプトがPID 1として残ります。
#!/bin/sh
설정_준비
exec "$@" # ← exec 이 있어야 프로세스가 교체되어 PID 1 이 된다
症状は同じです。停止要求に反応せず、10秒後に強制終了されます。ログには何も残らず、処理中のリクエストとバッファーは失われます。
PID 1のもう1つの責務
LinuxではPID 1は孤児プロセスを刈り取る役割も担います。アプリケーションがこの仕事をしないと、ゾンビプロセスが積み上がります。子プロセスを作るコンテナ(シェルスクリプト、ビルドツール)では特にそうです。
docker run --init ... # tini 를 PID 1 로 넣어 준다
KubernetesではshareProcessNamespace: trueを使うと、一時停止コンテナ(pause)がその役割を担います。そうでなければ、イメージにtiniを入れます。psにZ状態が積み上がっていたら、この問題です。
正常終了を設計する
1. SIGTERM 수신
2. 새 요청 받기를 멈춘다 (헬스 체크를 실패로 바꾼다)
3. 진행 중 요청이 끝나기를 기다린다
4. 연결·파일을 닫고 종료
2つ目が特に重要です。Kubernetesは、Podを削除するときにエンドポイントの削除とSIGTERMを同時に送りますが、エンドポイントの伝播には数秒かかります。その間に届いたリクエストが、終了中のPodに送られて失敗します。preStopフックに短いsleepを入れると、その隙間が閉じます。
lifecycle:
preStop:
exec: {command: ["sh", "-c", "sleep 5"]}
terminationGracePeriodSeconds: 30
次のラボですること
ハンドラーを付けたコンテナと付けていないコンテナをそれぞれ立ち上げ、143と137を自分で再現して、アプリケーションの終了コードと再起動ポリシーまで確認します。