docker stop を叩いただけなのに、なぜコンテナは 10 秒も止まらないのか?
ローカルで docker stop や docker compose down を叩いて、妙に長く待たされた経験はないだろうか。ターミナルで動かしたときは Ctrl+C で一瞬で終わるアプリが、コンテナに入れた途端に止まらなくなる。原因はアプリのバグではなく、コンテナの中で「PID 1」という特別な席に誰が座っているかにある。
docker stopは PID 1 に SIGTERM を送り、既定では 10 秒待っても終わらなければ SIGKILL で強制終了する。- Linux カーネルは PID 1 に対して「ハンドラを登録していないシグナルは届けない」という特別扱いをするため、SIGTERM を処理しないプロセスが PID 1 だと SIGTERM は無視される。
- Dockerfile を exec 形式で書く、
docker run --initや tini で init を挟む、のいずれかで SIGTERM がアプリまで届くようになる。
docker stop は「お願い」してから「強制」する
シグナルとは、カーネルがプロセスに送る短い通知のことだ。SIGTERM は「終了してほしい」というお願いで、受け取ったプロセスは後片付け(処理中のリクエストを返し切る、DB 接続を閉じる)をしてから自分で終われる。SIGKILL はお願いではなく、プロセスに何もさせずカーネルが即座に消す命令であり、ハンドラで捕まえることもできない。
docker stop はこの 2 つを順番に使う。まずコンテナのメインプロセスに SIGTERM を送り、猶予時間内に終わらなければ SIGKILL を送る。猶予時間は、コンテナ作成時に --stop-timeout を指定していなければ Linux コンテナで 10 秒だ。つまり「毎回きっかり 10 秒待たされる」なら、SIGTERM が何の効果も持たず、10 秒後の SIGKILL でようやく止まっている、という意味になる。
# 猶予を変える(-1 でタイムアウト無効) docker stop -t 30 myapp # 作成時にコンテナごとの既定値を決める docker run --stop-timeout 30 myimage
猶予を延ばしても、SIGTERM が無視されている限り待ち時間が延びるだけで何も解決しない。問題は「なぜ SIGTERM が効かないのか」の方にある。
PID 1 だけはカーネルに守られている
普通のプロセスは SIGTERM のハンドラ(受け取ったときに実行する関数)を登録していなくても、カーネルが既定の動作として終了させてくれる。だからターミナルで動かしたアプリは、終了処理を何も書いていなくても Ctrl+C や kill で止まる。
ところが PID 1 は例外だ。PID 1 は本来 OS 全体の init(最初に起動し、他の全プロセスの祖先になるプロセス)で、これがうっかり死ぬとシステムが丸ごと止まる。そのため Linux は「PID 1 には、ハンドラを登録したシグナルしか届けない」という保護をかけている。コンテナは PID 名前空間(プロセス番号の空間を分離する Linux の機能)の中で動くので、コンテナ内で最初に起動したプロセスがその名前空間の PID 1 になり、同じ保護を受ける。
ホストの root から送ったシグナルなら届くのではないか、と思うかもしれない。しかし man ページによれば、親側の名前空間から強制的に届けられるのは SIGKILL と SIGSTOP の 2 つだけだ。docker stop の SIGTERM はホストから送られても、PID 1 がハンドラを持っていなければ黙って捨てられる。これが「10 秒待たされる」の正体である。
PID 1 は、閉店の館内放送が自動では届かない「店長室」にいる。一般の店員は放送を聞けば帰り支度を始めるが、店長室には「放送を聞く」と自分で決めてスピーカーを付けた場合にしか音が流れない。スピーカーの無い店長は放送に気づかず、最後は警備員(SIGKILL)に力ずくで連れ出される。
shell 形式は、アプリを PID 1 の席から追い出す
Rails の Puma のように SIGTERM ハンドラを自前で登録するサーバーなら、PID 1 でも正しく止まる。それでも 10 秒待たされるなら、次に疑うのは Dockerfile の書き方だ。CMD と ENTRYPOINT には 2 つの書き方がある。
# shell 形式: /bin/sh -c でラップされる CMD ./migrate.sh && node server.js # exec 形式: 指定したバイナリがそのまま PID 1 CMD ["node", "server.js"]
shell 形式では /bin/sh -c が PID 1 になり、アプリはその子プロセスとして起動する。Docker の公式リファレンスも、この形式ではアプリが PID 1 にならず docker stop の SIGTERM を受け取らないと明記している。シェルは SIGTERM を子へ中継しないうえ、PID 1 なので自分自身も止まらない。
shell 形式
┌──────────────────────────────┐
│ PID 1 /bin/sh -c "..." │◀ SIGTERM
│ │ ハンドラ無しで破棄 │
│ └▶ PID 7 node server.js │ 届かない
└──────────────────────────────┘
│ 10 秒経過
▼ SIGKILL で全員強制終了
exec 形式
┌──────────────────────────────┐
│ PID 1 node server.js │◀ SIGTERM
└──────────────────────────────┘
ハンドラがあれば終了処理へ進む
シェルの実装によっては、-c の中身が単一コマンドだけなら自分をそのコマンドで置き換える最適化をするため、shell 形式でも偶然うまく動くことがある。ただし && でつないだ時点でシェルは残るので、挙動を期待するなら exec 形式で書くべきだ。起動前処理が必要でラッパースクリプトを使う場合は、最後の行を exec "$@" や exec node server.js にする。exec はシェル自身をアプリに置き換えるので、アプリが PID 1 の席を引き継ぐ。
init を挟むと、ゾンビ回収まで任せられる
アプリが SIGTERM ハンドラを持たない場合は、exec 形式にしても止まらない。Node.js の公式 Docker イメージのドキュメントは「Node.js は PID 1 として動くようには設計されていない」と書き、--init の利用を勧めている。docker run --init を付けると Docker 同梱の小さな init(tini)が PID 1 になり、アプリはその子として起動する。tini は受け取った SIGTERM をアプリへ転送し、アプリは PID 1 ではないのでハンドラが無くても既定動作で終了する。
# Kubernetes には --init が無いので、イメージに入れる FROM node:22-alpine RUN apk add --no-cache tini ENTRYPOINT ["/sbin/tini", "--"] CMD ["node", "server.js"]
PID 1 にはもう一つ仕事がある。親より先に子が終わると、親が終了状態を回収(wait)するまで子は「ゾンビ」としてプロセス表に残る。親が先に死んで孤児になったプロセスは PID 1 に引き取られるので、PID 1 が回収をしないとゾンビが溜まり続ける。ヘッドレスブラウザやシェルスクリプトを子プロセスとして起動するアプリでは、tini を挟む理由はシグナルよりこちらの方が大きい。
なお PID 1 が終了すると、カーネルは同じ名前空間の残りのプロセスを SIGKILL で一斉に終わらせる。コンテナの寿命が「メインプロセスの寿命」と一致するのはこの仕組みによる。
Kubernetes でも構造は同じで、Pod 削除時に kubelet はコンテナランタイム経由で SIGTERM(イメージに STOPSIGNAL があればそれ)を送り、terminationGracePeriodSeconds(既定 30 秒)を過ぎると SIGKILL を送る。PID 1 が SIGTERM を捨てていると、Pod の終了に毎回 30 秒かかり、しかも終了処理が走らないので処理中のリクエストは切断される。コンテナの終了コードが 137(128 + SIGKILL の 9)なら強制終了されたサインなので、まず Dockerfile の CMD の形式と、PID 1 に座っているプロセスを確認するとよい。
SIGTERM ハンドラを持たない sleep を PID 1 にしたコンテナと、--init を付けたコンテナで、停止時間と終了コードを比べる。
docker run -d --name no-init alpine sleep 300
docker exec no-init ps
time docker stop no-init
docker inspect -f '{{.State.ExitCode}}' no-init
docker run -d --init --name with-init alpine sleep 300
docker exec with-init ps
time docker stop with-init
docker inspect -f '{{.State.ExitCode}}' with-init
docker rm no-init with-init
no-init では ps の PID 1 が sleep 300 になり、停止に約 10 秒かかって終了コードは 137 になるはずだ。with-init では PID 1 が /sbin/docker-init(tini)に変わり、停止は一瞬で終わる。終了コードは SIGTERM(15)で終わったことを示す 143 になる。
- 10 秒かかるのは Docker が遅いから、
-tを短くすれば解決する — 待ち時間を縮めると SIGKILL が早まるだけで、終了処理が一切走らないことは変わらない。直すべきは SIGTERM が届かない構成の方だ。 - 終了処理を書いていないアプリでも、SIGTERM を受ければ止まる — それは PID 1 以外の場合に限る。PID 1 ではハンドラの無いシグナルはカーネルが捨てるので、終了処理を書くか init を挟む必要がある。
- ENTRYPOINT にシェルスクリプトを指定すれば、中で起動したアプリにもシグナルは伝わる — スクリプトのシェルが PID 1 に残り、アプリはその子になる。最後の起動コマンドを
execにしない限りシグナルは中継されない。
- SIGTERM / SIGKILL
- 前者は終了のお願いでハンドラで捕まえて後片付けできる。後者はカーネルによる即時強制終了で捕まえられない。
- PID 1
- PID 名前空間で最初に起動するプロセス。ハンドラ未登録のシグナルを受け取らず、孤児プロセスの引き取り役も担う。
- PID 名前空間
- プロセス番号の空間を分離する Linux の機能。コンテナごとに PID 1 から番号が振られる。
- exec 形式 / shell 形式
- Dockerfile の CMD・ENTRYPOINT の書き方。JSON 配列の exec 形式はバイナリを直接 PID 1 にし、shell 形式は
/bin/sh -cで包む。 - tini
- シグナル転送とゾンビ回収だけを行う小さな init。
docker run --initで使われる。 - ゾンビプロセス
- 終了済みだが親に終了状態を回収されず、プロセス表に残り続けているプロセス。
- pid_namespaces(7) — Linux manual page — PID 1 へのシグナル配送の制限と、孤児の引き取り・PID 1 終了時の SIGKILL の一次情報。
- Dockerfile reference — ENTRYPOINT の節で shell 形式がシグナルを受け取らない理由と exec での回避策が実例付きで説明されている。
- docker container stop — SIGTERM → SIGKILL の流れと、10 秒の既定値・
--stop-timeoutの関係。 - krallin/tini — tini が何をするのか、なぜ PID 1 で動く必要があるのかを作者自身が解説している。