Docker で node.js を動かすときは PID 1 にしてはいけない

これは、node.js on Docker の構成で 2〜3日ハマってしまった時の話です。忘れないように記録しておきます。なお、将来は改善・改良されているかもしれませんのでご注意ください。

何が起こったのか

node.js の Docker コンテナを、"docker stop" でコンテナを止めようとしても正常に停止せず、10秒くらい経過した後に強制終了してしまうという症状が発生しました。いつも等しくそうなるので、状態とかタイミングとかそういった要因ではなく、そもそも根本的に何かがおかしいと考えられます。

1. node on Docker の構成

Docker コンテナ上で node.js が動いているだけの極めてシンプルな構成でこの問題が発生しました。

f:id:ngzm:20170822140126p:plain

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 を受けた場合、自身を安全に停止するシグナルハンドリング処理が実装されています。

f:id:ngzm:20170822140136p:plain

SIGTERM を投げた後、Docker は コンテナ(ルートプロセス)が終了するまで デフォルト10秒間待ちます。そして、10秒経過しても終了しない場合は、コンテナのルートプロセスに対して SIGKILL を投げます。SIGKILL は強制終了を指示するものなので、これを受信したプロセスは、直ちにABORTします。

f:id:ngzm:20170822140148p:plain

この時、プロセスの終了に必要な処理は全てスキップされますので 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” であることに、限りない怪しさを覚えるかもしれません。

f:id:ngzm:20170822140159p:plain

普通の Linux 環境において、プロセスID = 1 は init プロセスです。これは カーネルから起動されますが、(特に Linux においては)プロセスD=1 に対してシグナルを送ることはいろいろ制限されています(see ”man 2 kill" on Linux)。

ということで、今度は、特に、プロセスID 1 に何か特殊な理由が無いか?ということを留意しながら、さらに node.js のソースコードを確認したり、同様の問題がなかったか?という調査を行いました。

・
・・
・・・

見つけました

github.com

上記ページの「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 はその子プロセス にすればいいじゃん的なことも書いてあります。

f:id:ngzm:20170822140208p:plain

というか、"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” オプションで自動的にラップできます。

github.com

dumb-init

“Yelp” で使用している lightweight init

github.com

my_init

“baseimage-docker” という本来の Linux に近いプロセス環境を Docker で提供している。そのイメージで使用している my_init、こちらはマルチプロセスを管理できるようです。

github.com

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

あと、こんなシビアな問題があるなら、もっと目立つところで情報公開してください