水底

ScalaとかC#とかk8sとか

Docker で動かすプロセスをちゃんと考える会

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 の具体的な実装方法は利用しているフレームワークによるところが大きいので今回は触れません。

結論

常に意識するべきこと

  • CMDENTRYPOINT も常に exec 形式にする
  • 前処理が必要なときは、
    • 処理が簡単なとき: CMDsh で wrap しつつ、最後のメインの処理は exec で呼び出す
      • e.g., CMD ["sh", "-c", "sh setup.sh && exec node server.js"]
    • 処理が複雑なとき: ENTRYPOINTentrypoint.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 などは挟まない
    • tinidocker run --init も基本的には不要
      • docker run --init に依存すると、local の docker では利用できるがいざ k8s で動かそうとすると手を加える必要が出てきてしまう
      • どうしてもゾンビプロセスを生む可能性が避けられないプログラムを動かしたいときに限り、tini を挟んだ方が良い
        • k8s で動かす想定でも同じ。shareProcessNamespace: true も解決策になりうるが、そもそも docker image に閉じた問題であるためおすすめしない。

その他のケースの話

  • CMD で変数展開など shell 固有機能が実行コマンドに必要なときは entrypoint.sh を作り、その中で最終的に exec <command> になるようにする
  • PID=1 だとおかしくなるようなプロセスやゾンビプロセスを生む可能性が避けられないプログラムを動かしたい場合に限り ENTRYPOINT として tini を挟む必要がある
    • PID=1 で動かしたときに SIGTERM などで死んでくれてそれで良いなら tini を挟んでも何も変わらない
    • node は signal handler を適切に実装すれば PID=1 でも期待通り動くようになるので不要

おまけ

結論に至るまで

この結論に至るためには結構いろんなことが必要だったので、ごく初歩的なところから雑に並べておきます。

linux process の振り返り (1)

子プロセスを作成すると親プロセスとは異なる PID を持つことになります。 例えば sh -c 'echo hello' を実行したら、sh が親プロセスとなり、echo が子プロセスとして実行され親から wait されます。

linux process の振り返り (2)

exec コマンドで実行された場合は子プロセスを作ることなくそのプロセスで実行されます。 例えば sh -c 'cmd1 && exec cmd2' を実行したら、cmd1sh の子プロセスとして実行されますが cmd2sh と同じプロセスで動くことになります。

linux process の振り返り (3)

親プロセスが先に死んでしまった場合、子プロセスは孤児プロセスになります。孤児プロセスは kernel によって reparenting され PID=1 の子になります。一般的な linux サーバーのように PID=1 で init が動いている場合、元の親プロセス代わって reparenting されてきたプロセスを wait します。

Docker で実行されるプロセス

単純に ENTRYPOINTCMD (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 で動くプロセスは linuxinit と同じように子プロセスの wait をしてくれます。これは孤児プロセスが発生しても PID=1 への reparenting 後に wait されるため、ゾンビ化を防げることを意味します。例えば nodesh のような一般的なプロセスはこのような機能を持ち合わせていないため、それらが 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 なども似たようなことになっているようです。