docker をなんとなく使っていて「Ctrl-C
で抜けようとしてもコンテナが止まらない」・「docker stop
すると 10 sec くらいかかる」・「k8s で rolling upgrade になんか時間がかかる」といった経験はないでしょうか。もしくは以下のようなことをしていないでしょうか?
- とりあえず動くからという理由で
npm run start
のような形で docker の実行プロセスを指定している - 何か良からぬことが起こるらしいので tini を使っている
- もともと単一プロセスだけ実行される docker image だったが runtime に前処理を差し込みたくなり
CMD ["sh", "-c", "sh setup.sh && node server.js"]
のような書き方をしている
バッチリ設定しているから大丈夫だぜ!という方はブラウザバックで大丈夫です。逆に心当たりがある方は結論だけでも見ていくとハッピーになれるかもしれません。 以下ではサーバー的なプロセスを前提とします。特に nodejs 環境の話をしますが、それ以外のアプリケーションでも根本は共通です。 graceful shutdown の具体的な実装方法は利用しているフレームワークによるところが大きいので今回は触れません。
結論
常に意識するべきこと
CMD
もENTRYPOINT
も常にexec
形式にする- これは公式も推奨している JSONArgsRecommended | Docker Docs
- 前処理が必要なときは、
- 処理が簡単なとき:
CMD
にsh
で wrap しつつ、最後のメインの処理はexec
で呼び出す- e.g.,
CMD ["sh", "-c", "sh setup.sh && exec node server.js"]
- e.g.,
- 処理が複雑なとき:
ENTRYPOINT
にentrypoint.sh
を配置し、最後にexec "$@"
する。CMD にメインの処理を置く。 e.g.,
- 処理が簡単なとき:
# entrypoint.sh # some complex preprocessing # ./setup-something-1.sh # ./setup-something-2.sh # ... # finally exec "$@" # ------------------ # Dockerfile ENTRYPOINT ["sh", "entrypoint.sh"] CMD ["node", "server.js"]
nodejs 関連で意識すべきこと (nodejs 以外でも取るべき方針は同じ)
- graceful shutdown するための SIGTERM (& SIGINT) handler を常に実装する
CMD
ではnode
を使う- npm, yarn などは挟まない
- tini や
docker run --init
も基本的には不要docker run --init
に依存すると、local の docker では利用できるがいざ k8s で動かそうとすると手を加える必要が出てきてしまう- どうしてもゾンビプロセスを生む可能性が避けられないプログラムを動かしたいときに限り、tini を挟んだ方が良い
- k8s で動かす想定でも同じ。
shareProcessNamespace: true
も解決策になりうるが、そもそも docker image に閉じた問題であるためおすすめしない。
- k8s で動かす想定でも同じ。
その他のケースの話
CMD
で変数展開など shell 固有機能が実行コマンドに必要なときはentrypoint.sh
を作り、その中で最終的にexec <command>
になるようにする- PID=1 だとおかしくなるようなプロセスやゾンビプロセスを生む可能性が避けられないプログラムを動かしたい場合に限り
ENTRYPOINT
として tini を挟む必要がある- PID=1 で動かしたときに SIGTERM などで死んでくれてそれで良いなら tini を挟んでも何も変わらない
node
は signal handler を適切に実装すれば PID=1 でも期待通り動くようになるので不要
おまけ
- k8s ではこれらに加えて
preStop
での sleep を併用しないとユーザー視点で graceful にならない
結論に至るまで
この結論に至るためには結構いろんなことが必要だったので、ごく初歩的なところから雑に並べておきます。
linux process の振り返り (1)
子プロセスを作成すると親プロセスとは異なる PID を持つことになります。
例えば sh -c 'echo hello'
を実行したら、sh
が親プロセスとなり、echo
が子プロセスとして実行され親から wait
されます。
linux process の振り返り (2)
exec
コマンドで実行された場合は子プロセスを作ることなくそのプロセスで実行されます。
例えば sh -c 'cmd1 && exec cmd2'
を実行したら、cmd1
は sh
の子プロセスとして実行されますが cmd2
は sh
と同じプロセスで動くことになります。
linux process の振り返り (3)
親プロセスが先に死んでしまった場合、子プロセスは孤児プロセスになります。孤児プロセスは kernel によって reparenting され PID=1 の子になります。一般的な linux サーバーのように PID=1 で init
が動いている場合、元の親プロセス代わって reparenting されてきたプロセスを wait
します。
Docker で実行されるプロセス
単純に ENTRYPOINT
と CMD
(k8s 環境であれば加えて command, args
) を組み合わせて PID=1 のプロセスとして実行されます。
docker stop
docker stop
を実行すると、対象のコンテナの PID=1 プロセスに SIGTERM が送られます。そのプロセスが SIGTERM により速やかに終了すれば docker stop
もそのまま終了します。
ただし SIGTERM がタイムアウト (デフォルトだと 10 sec) 経ってもプロセスが終了しない場合、SIGKILL が送られます。docker stop
でちょうど 10 sec 程度かかる場合は SIGTERM でプロセス終了ができていないことを疑うとよいでしょう。
docker entrypoint での tini や docker run --init
プロセス管理
これらを使うとコンテナ内の PID=1 で実行されるプロセスが特別なものになり、ユーザーが実行したい node のようなプロセスはその子プロセスとして実行されます。
PID=1 で動くプロセスは linux の init
と同じように子プロセスの wait
をしてくれます。これは孤児プロセスが発生しても PID=1 への reparenting 後に wait
されるため、ゾンビ化を防げることを意味します。例えば node
や sh
のような一般的なプロセスはこのような機能を持ち合わせていないため、それらが PID=1 で動作しているときに孤児プロセスが発生するとやがてゾンビプロセスになってしまいます。
signal handling
PID=1 に来た signal は子プロセスに伝搬されます。signal を伝搬するだけなので、当然 tini や docker run --init
を利用しただけでは graceful に終了 (例えば Web サーバーであれば処理中のリクエストを待ってから exit) されません。依然として graceful shutdown のためには適切な signal handler の実装が必要です。
nodejs の挙動
nodejs は PID=1 で実行することが想定されていないため、PID=1 で実行したときとそれ以外で挙動が異なります。 docker-node/docs/BestPractices.md at main · nodejs/docker-node · GitHub
PID=1 以外で signal handler が実装されているとき
signal handler が実装されている場合はそれらを実行します。handler で process.exit()
などをした場合に限りプロセスが終了します。
PID=1 以外で signal handler が実装されていないとき
デフォルトだと SIGTERM、SIGINT などの signal を受け取ると雑に exit します。
PID=1 で signal handler が実装されているとき
signal handler が実装されている場合はそれらを実行します。handler で process.exit()
などをした場合に限りプロセスが終了します。
PID=1 で signal handler が実装されていないとき
デフォルトだと SIGTERM、SIGINT などの signal を無視します。つまり docker stop
してもプロセスは平然と生き続けます。(nodejs v10.9.2 で確認)
これが巷で「docker で node を動かすときは tini のようなものが必要だ」と言われる理由だと思います。ただし一つ前の項目で述べたように、signal handler を適切に実装すれば SIGTERM で graceful にプロセスを終了できるため、そちらを優先すべきだと思います。
npm の挙動
npm run
は内部的に @npmcli/promise-spawn
を利用して子プロセス sh
でスクリプトを実行します。
npm は SIGTERM、SIGINT などの signal を受け取ると基本的にエラーを吐いて終了します。ただしこのとき子プロセスに signal が送られるかは環境 (@npmcli/promise-spawn
の実装) に依存します。余計な混乱を避けるため、この辺りに依存したシステムは組むべきではないと思います。
確認していませんが、yarn なども似たようなことになっているようです。