Docker で node.js を動かすときは PID 1 にしてはいけない
これは、node.js on Docker の構成で 2〜3日ハマってしまった時の話です。忘れないように記録しておきます。なお、将来は改善・改良されているかもしれませんのでご注意ください。
何が起こったのか
node.js の Docker コンテナを、"docker stop" でコンテナを止めようとしても正常に停止せず、10秒くらい経過した後に強制終了してしまうという症状が発生しました。いつも等しくそうなるので、状態とかタイミングとかそういった要因ではなく、そもそも根本的に何かがおかしいと考えられます。
1. node on Docker の構成
Docker コンテナ上で node.js が動いているだけの極めてシンプルな構成でこの問題が発生しました。
node.js で動くアプリは、"Hello World" を出すだけの超簡単な hello.js です。こんな感じです。
const http = require('http'); http.createServer((req, res) => { res.writeHead(200, {'Content-Type': 'text/plain'}); res.end('Hello World\n'); }).listen(3000);
hello.js アプリを Docker に仕込むための Docker ファイルは以下の通りです。ベースとなる Docker イメージは、Docker Hub で node.js が提供する node:8.4 (現在のlatest) を利用しました。
FROM node:latest ADD ./hello.js /usr/src/app/ EXPOSE 3000 CMD ["node", "/usr/src/app/hello.js"]
普通にビルドして、生成したイメージからコンテナを作って起動します。たぶん … 無事に動作するはずです。
$ docker build -t nodetest . $ docker run --name hello_node -P 3000:3000 nodetest
2. 問題を発生させる
動いている node.js アプリのコンテナを docker stop で停止しますと、今回の問題が発生します。
$ docker stop hello_node
恐らく、10秒ほど経過した後にコンテナが停止しプロンプトが戻ってくると思いますが、この停止に10秒かかることが問題です。
3. docker stop で何が起こっているか?
“docker stop” コマンドを実行すると、Docker はそのコンテナのルートプロセス(すなわち、PID=1 のプロセス)に SIGTERM シグナルを投げます。
このシグナルを受信したプロセスは、該当するシグナルハンドリング処理を実行します。多くのプログラムでは、SIGTEM を受けた場合、自身を安全に停止するシグナルハンドリング処理が実装されています。
SIGTERM を投げた後、Docker は コンテナ(ルートプロセス)が終了するまで デフォルト10秒間待ちます。そして、10秒経過しても終了しない場合は、コンテナのルートプロセスに対して SIGKILL を投げます。SIGKILL は強制終了を指示するものなので、これを受信したプロセスは、直ちにABORTします。
この時、プロセスの終了に必要な処理は全てスキップされますので ABORT 後は正常な状態であることが保証されません。したがって、次回起動時にうまく立ち上がらなくなる等、思わぬ障害のリスクがあります。
今回の node.js のコンテナも、10秒後に停止しています。これはすなわち SIGKILL で強制終了していることになり、このままではやばい感じです。
原因調査
A. 物理サーバ環境と Docker 環境での違いを検証
ここで、そもそも Docker に起因する問題なのか? それとも node.js の問題なのか?を切り分けたいと思います。
ということで、仮想化しない物理サーバ環境と Docker 環境の両方で node.js の hello アプリを動かし、そのプロセスに対してそれぞれ SIGTERM を送信してみます。
(1)物理サーバ環境で検証
#### Terminal 1 で node アプリを起動 $ node hello.js #### Terminal 2 で node に SIGTERM を送信 $ ps -ef | grep node ubuntu 8526 4061 3 05:21 pts/0 00:00:00 node hello.js $ kill -TERM 8526 #### 成功、node hello.js は、直ちに問題なく終了した
うまくいきました。
物理サーバ環境において、SIGTERM を受信した node.js プロセスは、直ちに自分自身を終了させています。これにより、node.js は SIGTERM を受信した際のシグナルハンドリングがきちんと実装されていることが分かります。
(2) Docker コンテナ環境で検証
#### nodeアプリが入った Docker コンテナを起動 $ docker run --name hello_node -d nodetest $ docker ps CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES ef2b7ab09468 nodetest "node /usr/src/app..." 50 seconds ago Up 49 seconds 3000/tcp hello_node #### 起動したコンテナにアタッチ $ docker exec -it hello_node /bin/bash #### ---- hello_node コンテナ内 ---- # ps -ef UID PID PPID C STIME TTY TIME CMD root 1 0 1 01:53 pts/0 00:00:00 node /usr/src/app/hello.js root 16 0 0 01:54 pts/1 00:00:00 /bin/bash # kill 1 ### しなない!!
ダメです!!
幾ら SIGTERM を投げても反応しません。Docker コンテナの内の node は、何かの理由で SIGTERM を受信できないか、もしくはシグナルを無視しているように見えます。
B. プロセスID 1 に注目してさらに調査
Linux についてご存知の方は、Dockerコンテナ内の node.js プロセスIDが “1” であることに、限りない怪しさを覚えるかもしれません。
普通の Linux 環境において、プロセスID = 1 は init プロセスです。これは カーネルから起動されますが、(特に Linux においては)プロセスD=1 に対してシグナルを送ることはいろいろ制限されています(see ”man 2 kill" on Linux)。
ということで、今度は、特に、プロセスID 1 に何か特殊な理由が無いか?ということを留意しながら、さらに node.js のソースコードを確認したり、同様の問題がなかったか?という調査を行いました。
・
・・
・・・
見つけました
上記ページの「Handling Kernel Signals」というところに、下記の記載があります。
Node.js was not designed to run as PID 1 which leads to unexpected behaviour when running inside of Docker. For example, a Node.js process running as PID 1 will not respond to SIGTERM (CTRL-C) and similar signals. As of Docker 1.13, you can use the –init flag to wrap your Node.js process with a lightweight init system that properly handles running as PID 1.
docker run -it –init node
You can also include Tini directly in your Dockerfile, ensuring your process is always started with an init wrapper.
まじか。。そもそもnode.js は PID 1 で動くように設計されていないとか、なので Docker コンテナで動かすときは、SIGTERM とか Ctrl+C とか効かないとか、まさに、今回の問題について言及されています。
もう一つ、"lightweight init system" というツールがあって、Docker で動かすときは、それを PID 1 として動作させ、node はその子プロセス にすればいいじゃん的なことも書いてあります。
というか、"Tini" という便利なものがあったのか!! しかも、Docker 1.13 からは “–init” オプションでこれが自動的にラップするように組み込まれているということです。知らなかった。
GitHub - krallin/tini: A tiny but valid `init` for containers
対処内容
問題の原因が分かったので、もう簡単に対策ができます。ということで、早速 Docker 本体にも組み込まれている “Tini” を活用します。
結局、今回は、以下のように Dockerfile に Tiny の設定を追加するだけで、万事 OK となりました。
FROM node:latest # Add Tini ENV TINI_VERSION v0.15.0 ADD https://github.com/krallin/tini/releases/download/${TINI_VERSION}/tini /tini RUN chmod +x /tini ENTRYPOINT ["/tini", "--"] # MyApp ADD ./test3.js /usr/src/app/ EXPOSE 3000 CMD ["node", "/usr/src/app/hello.js"]
まとめ
node は PID1 で動作するようにデザインされていないので、 docker にいれるときは、直接 Docker の管理プロセス(つまりPID1)にするのはやめよう!
Docker で PID 1 で Linux の init みたいに制御してくれる Tiny という便利なツールがあるので、それを活用するといいかも
そもそも、node やその他 サーバプログラムをDocker が直接起動するプロセス(つまり PID 1 となるプロセス)とするのは、いろいろ問題があるなぁと思います。
以前、Unicorn が SIGTERM ではなく SIGQUIT を期待しており、Docker stop で異常終了してしまう問題にも遭遇しています。
なので、個別に作成したスーパバイザ Shell でラップするとか、今回ご紹介した Tiny をかませるとか、その方が安全ではないか?と、最近はそう思っています。
なお、PID 1 とする Tiny のような lightweight init プログラムは、例えば、自分自身が SIGTERM を受信したときに、そのシグナルを、子プロセスまできちんと伝播させたり、子プロセスがゾンビにならないよう wait したり、そういった init の作法に則ったものでなければなりません。その辺は十分ご注意ください。
おまけ
Docker で使える lightweight init たち
ここでは、Docker コンテナで PID 1 のプロセスとして使える Tiny をご紹介しました。その後調べたところ、この他にも、幾つか似たようなものがありましたので、まとめとして、以下にリンクしておきます。
Tiny
本記事でご紹介した lightweight init 、前途した通りDocker にも組み込まれており “docker run” の “–init” オプションで自動的にラップできます。
dumb-init
“Yelp” で使用している lightweight init
my_init
“baseimage-docker” という本来の Linux に近いプロセス環境を Docker で提供している。そのイメージで使用している my_init、こちらはマルチプロセスを管理できるようです。
inits_on_docker/init_node.sh
ちなみに、私も Shell で簡易に作成してみました。node.js 専用ですが、せっかくだし、ご紹介させてください。(つっこみ歓迎)
inits_on_docker/init_node.sh at master · ngzm/inits_on_docker · GitHub
#!/bin/bash # # init_node.sh # - init process for aplications using node.js. # # Usage # - add your Dockerfile as follows. # ------------ # ADD ./init_node.sh /usr/local/bin # RUN chmod +x /usr/local/bin/init_node.sh # CMD ["init_node.sh", [path_to_your_app]] # ------------ # echo "start init_node.sh" # set application path path_to_your_app=${1:-''} if [ -z ${path_to_your_app} ]; then echo "require first argment for path_to_your_app" echo "quit this container" exit 1 fi if [ ! -e ${path_to_your_app} ]; then echo "${path_to_your_app} is not exists" echo "quit this container" exit 2 fi # Application PID initialize your_app_pid=0 # SIGINT handler int_handler() { echo "int_handler called" if [ ${your_app_pid} -ne 0 ]; then kill -INT ${your_app_pid} fi } # SIGTERM handler term_handler() { echo "term_handler called" if [ ${your_app_pid} -ne 0 ]; then kill -TERM ${your_app_pid} fi } # trap SIGINT - usually caught by Ctrl+c trap 'int_handler' INT # trap SIGTERM - sent when 'docker stop' trap 'term_handler' TERM # run application echo "run ${path_to_your_app}" node ${path_to_your_app} & your_app_pid="${!}" # wait untill the application (child process) will be killed wait ${your_app_pid} your_app_pid=0 echo "finish init_node.sh"
補足とか
何もこんなに苦労しなくても最初から “npm run” で起動するようにしておけば問題なく起動・終了ができそうです!という情報もあって、一応動作させてみたら、よさげに見えました。確かにこれだと、node の PID は 1 ではなくなるのでいい感じです。
ただし、本当にこれで、子プロセス、孫プロセスまで管理できているのか?すなわち、ゾンビにならずに、また強制終了せずに健全に起動、終了しているのか?といったところまでは裏付けができていません。なので継続して調べていきたいと思います。
誰か知っている人が居たらぜひ教えてください。
最後に苦情です
docker-node/README のページ、本家のトップページで思いっきり PID 1 で起動するインストラクションしてるじゃん
docker-node/README.md at master · nodejs/docker-node · GitHub
あと、こんなシビアな問題があるなら、もっと目立つところで情報公開してください