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

Docker基礎

docker stopはなぜ10秒もかかるのか

TT Labで続きを見る

一言でいうと

コンテナの最初のプロセスはPID 1で、Linuxカーネルは、PID 1を特別に扱います。docker stopが10秒待つのも、Ctrl+Cが効かないのも、ゾンビプロセスが溜まるのも、すべてここから来ています。

なぜ必要なのか

デプロイスクリプトにdocker stopを入れたところ、コンテナ1つあたりきっかり10秒かかります。20個なら200秒です。ログを見てもエラーはなく、ただ遅いだけです。

原因は次のとおりです。docker stopは、まずSIGTERMを送り、待って、死ななければSIGKILLを送ります。その「待つ」時間のデフォルトが10秒です。つまり、10秒かかったということは、SIGTERMを受けても死ななかったという意味です。

docker stopの10秒を4種類のPID 1で比較したタイムライン: シェル形式と、ハンドラーのないexec形式では、SIGTERMを誰も受け取れず、10秒を使い切ってSIGKILLで死にます。ハンドラーのあるexec形式と--initでは、すぐに正常終了します

なぜ死ななかったのでしょうか。通常のプロセスは、シグナルハンドラーを登録しなければ、カーネルが既定の動作(終了)を代わりに行ってくれます。ところが、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 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形式とシェル形式の違いが、終了時間とプロセスツリーにどう現れたかを根拠に答えてみてください。