docker stopはなぜ10秒もかかるのか
一言でいうと
コンテナの最初のプロセスはPID 1で、Linuxカーネルは、PID 1を特別に扱います。docker stopが10秒待つのも、Ctrl+Cが効かないのも、ゾンビプロセスが溜まるのも、すべてここから来ています。
なぜ必要なのか
デプロイスクリプトにdocker stopを入れたところ、コンテナ1つあたりきっかり10秒かかります。20個なら200秒です。ログを見てもエラーはなく、ただ遅いだけです。
原因は次のとおりです。docker stopは、まずSIGTERMを送り、待って、死ななければSIGKILLを送ります。その「待つ」時間のデフォルトが10秒です。つまり、10秒かかったということは、SIGTERMを受けても死ななかったという意味です。
なぜ死ななかったのでしょうか。通常のプロセスは、シグナルハンドラーを登録しなければ、カーネルが既定の動作(終了)を代わりに行ってくれます。ところが、PID 1にはその既定の動作がありません。カーネルは、PID 1がinitだと想定し、ハンドラーが登録されていないシグナルは単に捨てます。システムの最後のプロセスが誤って死ぬとカーネルパニックになるので、保護装置というわけです。
そのため、CMD ["python", "app.py"]で起動したPythonがSIGTERMハンドラーを登録していなければ、そのコンテナはSIGTERMを無視します。10秒後にSIGKILLで強制終了されます。開いていたコネクションも、書きかけのファイルも、そのまま途切れます。
どう動くのか
2つ目の落とし穴は、シェル形式です。
CMD python app.py # 셸 폼 → /bin/sh -c "python app.py"
CMD ["python", "app.py"] # exec 폼 → python 이 직접 PID 1
シェル形式で書くと、PID 1はPythonではなく/bin/shになります。shは、SIGTERMを受けても子プロセスに転送しません。アプリケーションは、シグナルを見ることすらできません。
| 状況 | PID 1 | SIGTERMの結果 |
|---|---|---|
シェル形式のCMD python app.py |
/bin/sh |
アプリに届かない → 10秒後にKILL |
| exec形式、ハンドラーなし | python |
カーネルが捨てる → 10秒後にKILL |
| exec形式、ハンドラーあり | python |
すぐに正常終了 ✅ |
--initを使用 |
docker-init |
届く + ゾンビの刈り取り ✅ |
3つ目はゾンビです。子プロセスが死ぬと、親がwait()で終了ステータスを刈り取らなければ、プロセステーブルから消えません。親が刈り取らないと、ゾンビとして残ります。本来この後始末はinitの仕事ですが、コンテナのPID 1がアプリケーションなら、そのアプリはゾンビの刈り取りをしません。シェルスクリプトを実行し続けるコンテナで、プロセス数が数日かけて増えていくのは、たいていこれです。
--initは、この2つの問題を一度に解決する、ごく小さなinitプロセスをPID 1に差し込みます。
execとattachの違い
docker exec |
docker attach |
|
|---|---|---|
| 動作 | コンテナ内で新しいプロセスを実行 | PID 1の標準入出力に接続 |
| 抜けるとき | そのプロセスだけが終了 | Ctrl+Cするとコンテナが死ぬ |
| 用途 | デバッグ、ファイルの確認 | 元のプロセスの出力を直接見る |
運用中のコンテナにattachで接続し、ついCtrl+Cを押してサービスを落とした経験のある人は、とても多いです。接続するときはexecを使ってください。
現場での姿
- デプロイが遅い →
docker stopが毎回10秒かかります。アプリがSIGTERMを捕捉していません。 - リクエストが途切れる → SIGKILLで死ぬため、処理中だったリクエストが応答なしで途切れます。ロードバランサーから外す前に死ぬのと症状が同じで、混同しやすくなります。
- プロセスが溜まる →
psに<defunct>が増えます。ゾンビの刈り取りがありません。
入れないコンテナの中を見る方法
docker exec -it ... shは、シェルがあるときにしか通用しません。distrolessやscratchで作ったイメージには、シェルもpsもありません。それでも中を見ることはできます。ネームスペースは、外からでも覗けるからです。
ホストから、そのプロセスをそのまま見ます。コンテナのPID 1は、ホストから見ても1つのプロセスです。名前空間が違うだけです。
pid=$(docker inspect -f '{{.State.Pid}}' myapp)
ps -p $pid -o pid,ppid,rss,etime,args
ls -l /proc/$pid/fd # 열린 파일과 소켓
cat /proc/$pid/limits # ulimit
cat /proc/$pid/cgroup # 어느 cgroup 에 매였는지
ファイルシステムは、マウントしなくても読めます。/proc/<pid>/rootは、そのプロセスから見えるルートです。ホストのツールで、コンテナ内のファイルを読めます。
cat /proc/$pid/root/etc/app/config.yml
ツールは、外から持ち込みます。nsenterは、指定した名前空間に入ってコマンドを実行します。イメージの中にそのツールがなくても構いません。
sudo nsenter -t $pid -n ss -tlnp # 그 컨테이너의 네트워크에서 포트를 본다
sudo nsenter -t $pid -m ls /tmp # 그 컨테이너의 마운트에서 파일을 본다
Kubernetesでは、同じことをエフェメラルコンテナが行ってくれます。対象のPodにツールの入ったコンテナを1つ接続し、プロセスの名前空間を共有させます。
kubectl debug -it pod/myapp --image=nicolaka/netshoot --target=app
--targetを外すと、プロセスが見えません。その場合、新しいコンテナはネットワークだけを共有し、PIDの名前空間は別です。プロセスを見るには、必ず対象を指定します。
ログが見えない理由は、たいていバッファーです。アプリケーションがstdoutをパイプに書くと、libcがブロックバッファリングに切り替えるため、4KBが溜まるまで何も出力されません。PythonはPYTHONUNBUFFERED=1、一般のコマンドはstdbuf -oLで、行単位に切り替えます。「ログが出力されない」が、実は「まだ出ていない」だけの場合が多いです。
次の確認で見ること
続くクイズでは、前のラボでの観察をもとに、PID 1のシグナル転送とゾンビの刈り取り、docker stopの強制終了の条件を区別します。exec形式とシェル形式の違いが、終了時間とプロセスツリーにどう現れたかを根拠に答えてみてください。