TT Lab
はじめる
学ぶ 学習パス コース

Docker基礎

コンテナはどのように死ぬのか

TT Labで続きを見る

一言でいうと

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を自分で再現して、アプリケーションの終了コードと再起動ポリシーまで確認します。