docker --init (Tini) が PID 1 問題を解決している様子を調べてみた

## はじめに

`docker --init` は PID 1 として動作させることを想定してないプロセスを適切に処理するために、軽量の init を PID 1 で実行する Docker 1.13 で追加されたオプションです。内部的には [Tini](https://github.com/krallin/tini) が使用されています。Tini の大きな役目は次の2つです。

- デフォルトのシグナルハンドラを機能させる
- ゾンビプロセスを刈り取る（reaping）

この記事では Tini を使用しない場合にどのような問題が発生し、Tini を使用することでそれらが解決する様子を確認します。

## 準備

まず動作確認を行うためのファイルを準備します。作業用のディレクトリを作り以下のファイルを作成します。

```Dockerfile:Dockerfile
FROM debian
RUN apt-get update && apt-get install -y procps tini
# ENTRYPOINT ["/usr/bin/tini", "-vvv", "--"]
COPY ./script.sh ./script.sh
CMD [ "sleep", "30" ]
```

※ `--init` では Tini のオプションを指定できないようなのでパッケージでインストールします。最初に Tini を使わない状態での問題を確認するため `ENTRYPOINT` はコメント状態にしています。

```bash:script.sh
#!/bin/bash

i=0
echo "parent pid: $BASHPID"
(
  echo "child pid: $BASHPID"
  while [ $i -lt 100000 ]; do i=$((i+1)); done # 長いsleepの代わり
  exit 0
) &

while [ $i -lt 1000 ]; do i=$((i+1)); done # 短いsleepの代わり
exit 0
```

※ `sleep` を使用すると新たにプロセスが作成されて混乱のもとになるので `while` を使用しています。

## Tini を使わない場合の問題を確認

以下のコマンドを実行し Docker コンテナを起動します。（Docker コンテナは 30 秒後に停止します。）

```sh
docker build -t tini-test . && docker run --name tini-test --rm -it tini-test
```

### デフォルトのシグナルハンドラが機能しないことの確認

確認用の Docker コンテナは `sleep` コマンドを PID 1 として起動しています。`sleep`（明示的にシグナルをハンドルしてないプロセス）は INT シグナルや TERM シグナルを受け取ったときにデフォルトの処理としてプロセス停止を行いますが PID が 1 の場合はプロセス停止を行いません。（PID 1 はプロセスのルートにあたる存在なので通常は終了する必要はないし終了されたら困るからでしょう。）

Docker コンテナを起動したら CTRL-C を押してみてください。コンテナを停止することができません。これは PID 1 のプロセスである `sleep` が INT シグナルに反応していないためです。

```console
$ docker build -t tini-test . && docker run --name tini-test --rm -it tini-test
Sending build context to Docker daemon  8.192kB
Step 1/5 : FROM debian
 ---> 6d6b00c22231
Step 2/5 : RUN apt-get update && apt-get install -y procps tini busybox bash ksh mksh posh yash zsh ruby
 ---> Using cache
 ---> 9e8992a067c9
Step 3/5 : COPY ./script.sh ./script.sh
 ---> Using cache
 ---> 6b89b83578b8
Step 4/5 : COPY ./sleep.sh ./sleep.sh
 ---> Using cache
 ---> 1e51be59d454
Step 5/5 : CMD [ "sleep", "30" ]
 ---> Using cache
 ---> 29964877f137
Successfully built 29964877f137
Successfully tagged tini-test:latest
^C^C^C^C
```

Docker コンテナを起動したら以下のように `docker stop` を実行してみてください。停止するのに 10 秒ほど時間がかかります。これは `docker stop` で TERM シグナルを `sleep` に送信したものの反応しないために 10 秒後にタイムアウトして代わりに KILL シグナルでコンテナが強制的に停止しているためです。（※ `docker stop` で送信するシグナルは　Dockerfile の `STOPSIGNAL` または `docker run --stop-signal` で変更することができます。）

```sh
$ docker stop tini-test
# （10秒後）
tini-test
```

### ゾンビプロセスが刈り取られないことの確認

Docker コンテナを起動したら、別の端末から以下のように `docker exec` でコンテナに入り `./script.sh` スクリプトを実行してみてください。バックグラウンドプロセスとして起動したサブシェル（ `(...) &` 部分）は、プロセスが終了したにも関わらず誰も `wait` していない（= PID 1 で起動している `sleep` が回収していない）ためゾンビプロセス（STAT: `Z`, `<defunct>`）として残っていることがわかります。

このゾンビプロセスは親プロセス (PPID) が 1 に変更されてる点にも注目してください。PID 15 の`[script.sh]` は 親プロセスである PID 14 から起動しているので、本来の PPID は 14 ですが親プロセスが先に停止し、孤児プロセスとなった PID 15 の PPID は 1 に振り返られています（リペアレンティング）。そのため新たに親プロセスとなった PID 1 が PID 15 を `wait` してプロセスを刈り取らなければいけません。これが PID 1 が行わなければいけない特殊な処理です。しかし`sleep` コマンドや多くのプログラムは（そもそもPID 1 として動かすことを想定してないので）そのような処理を行いません。

```console
$ docker exec -it tini-test /bin/bash
root@3561d6cf51de:/# ps axjf
 PPID   PID  PGID   SID TTY      TPGID STAT   UID   TIME COMMAND
    0     7     7     7 pts/1       13 Ss       0   0:00 /bin/bash
    7    13    13     7 pts/1       13 R+       0   0:00  \_ ps axjf
    0     1     1     1 pts/0        1 Ss+      0   0:00 sleep 30

root@3561d6cf51de:/# ./script.sh
parent pid: 14
child pid: 15

root@3561d6cf51de:/# ps axjf
 PPID   PID  PGID   SID TTY      TPGID STAT   UID   TIME COMMAND
    0     7     7     7 pts/1       16 Ss       0   0:00 /bin/bash
    7    16    16     7 pts/1       16 R+       0   0:00  \_ ps axjf
    0     1     1     1 pts/0        1 Ss+      0   0:00 sleep 30
    1    15    15     7 pts/1       16 Z        0   0:00 [script.sh] <defunct>
```

## Tini を使って問題が解決することを確認

では次に Tini を使用することで、これらの問題が解決することを確認します。Dockerfile の `ENTRYPOINT` のコメントを外して有効化してください。（もしくは `docker run` に `--init` オプションを指定することでも解決します。）

### デフォルトのシグナルハンドラが機能することの確認

確認手順は同じですので詳細は省きます。CTRL-C を押すとすぐにコンテナが停止し `docker stop` を実行するとすぐにコンテナが停止することが確認できるはずです。

### ゾンビプロセスが刈り取られていることの確認

Docker コンテナを起動したら、別の端末から以下のように実行してみてください。先程と違ってゾンビプロセスは残りません。

```console
$ docker exec -it tini-test /bin/bash
root@8734c3957af5:/# ps axjf
 PPID   PID  PGID   SID TTY      TPGID STAT   UID   TIME COMMAND
    0     9     9     9 pts/1       15 Ss       0   0:00 /bin/bash
    9    15    15     9 pts/1       15 R+       0   0:00  \_ ps axjf
    0     1     1     1 pts/0        8 Ss       0   0:00 /usr/bin/tini -vvv -- sleep 30
    1     8     8     1 pts/0        8 S+       0   0:00 sleep 30

root@8734c3957af5:/# ./script.sh
parent pid: 16
child pid: 17

root@8734c3957af5:/# ps axjf
 PPID   PID  PGID   SID TTY      TPGID STAT   UID   TIME COMMAND
    0     9     9     9 pts/1       18 Ss       0   0:00 /bin/bash
    9    18    18     9 pts/1       18 R+       0   0:00  \_ ps axjf
    0     1     1     1 pts/0        8 Ss       0   0:00 /usr/bin/tini -vvv -- sleep 30
    1     8     8     1 pts/0        8 S+       0   0:00 sleep 30
root@8734c3957af5:/# exit
exit
```

Tini を使用した場合は `/usr/bin/tini` が PID 1 として起動しており `sleep` は子プロセスとして実行されています。そして `script.sh` を実行すると先程の例でゾンビプロセスとなっていた `[script.sh]` は PID 1 である Tini によって刈り取られます。

このとき、Docker コンテナを起動した端末では次のような Tini のログが出力されているはずです。（※ 起動するプロセスが増えているため PID 番号は変わっています。）

```console
$ docker build -t tini-test . && docker run --name tini-test --rm -it tini-test
[INFO  tini (1)] Spawned child process 'sleep' with pid '8'
[TRACE tini (1)] No child to reap
[TRACE tini (1)] No child to reap
[DEBUG tini (1)] Received SIGCHLD
[DEBUG tini (1)] Reaped child with pid: '17'
[TRACE tini (1)] No child to reap
[TRACE tini (1)] No child to reap
[DEBUG tini (1)] Received SIGCHLD
[DEBUG tini (1)] Reaped child with pid: '8'
[INFO  tini (1)] Main child exited normally (with status '0')
[TRACE tini (1)] No child to wait
[TRACE tini (1)] Exiting: child has exited
```

Tini は一定間隔（0.5 秒？）で刈り取るべきプロセスがないかを確認し、対象となるプロセスがあれば刈り取ります。（`-vvv` オプションを指定している場合は上記の `Reaped child with pid: '17'` のようなログを出力を出力します。）

## 補足

さて Tini の動作確認としてはここまでなのですが、検証中に気づいた点について補足します。

### シェルもゾンビプロセスを刈り取ります

Dockerfile を次のように書き換えて Docker コンテナを起動してください。（Tini は使用しません）

```Dockerfile:Dockerfile
FROM debian
RUN apt-get update && apt-get install -y procps tini
# ENTRYPOINT ["/usr/bin/tini", "-vvv", "--"]
COPY ./script.sh ./script.sh
COPY ./sleep.sh ./sleep.sh
CMD [ "sh", "./sleep.sh" ]
```

```sh:sleep.sh
#!/bin/sh
sleep 30
```

そしてゾンビプロセスが残るかどうかを確認してみましょう。結果は以下の通り残りません。

```console
$ docker exec -it tini-test /bin/bash
root@69f6fc6d0e4c:/# ps axjf
 PPID   PID  PGID   SID TTY      TPGID STAT   UID   TIME COMMAND
    0     8     8     8 pts/1       15 Ss       0   0:00 /bin/bash
    8    15    15     8 pts/1       15 R+       0   0:00  \_ ps axjf
    0     1     1     1 pts/0        1 Ss+      0   0:00 sh ./sleep.sh
    1     7     1     1 pts/0        1 S+       0   0:00 sleep 30

root@69f6fc6d0e4c:/# ./script.sh
parent pid: 16
child pid: 17

root@69f6fc6d0e4c:/# ps axjf
 PPID   PID  PGID   SID TTY      TPGID STAT   UID   TIME COMMAND
    0     8     8     8 pts/1       18 Ss       0   0:00 /bin/bash
    8    18    18     8 pts/1       18 R+       0   0:00  \_ ps axjf
    0     1     1     1 pts/0        1 Ss+      0   0:00 sh ./sleep.sh
    1     7     1     1 pts/0        1 S+       0   0:00 sleep 30
```

これは Tini と同じように PID 1 で動作しているシェル（`sh`）がゾンビプロセスの刈り取りを行っているからです。シェルがゾンビプロセスの刈り取りを行うのは POSIX シェル の要件ではない（間違っていたら教えて下さい）と思いますが、少なくとも dash、bash、ksh、posh、yash、zsh、busybox ash はゾンビプロセスの刈り取りを行っているようです。そして mksh はゾンビプロセスの刈り取りを行いませんでした。

この事実から PID 1 のプロセスとして dash や bash を使っている場合はゾンビプロセスの刈り取りについては Tini に頼る必要はありません。しかしデフォルトのシグナルハンドラが機能しない問題が残っており、シェルスクリプトは明示的に処理を書かない限り TERM シグナルに反応しません。（CTRL-C には反応します。）`trap` コマンドで明示的に TERM シグナルのシグナルハンドラを書くことで対応できますが `docker stop` は PID 1 のプロセスにしか TERM シグナルを送信しないので、子プロセスがいる場合はプロセスグループに対して `kill` する等の対応が必要になることがあります。場合によっては先に子プロセスを終了してから自分自身を終了するなど面倒な処理となる場合もあるでしょう。

### Tini を使うべきかどうか

プログラムが正しく INT や TERM シグナルに対応しており終了処理をきちんと行っていれば Tini を使う必要はありません。それが理想だと思いますがプログラムの修正が困難な場合もあります。例えば少し複雑なシェルスクリプトを TERM シグナルに正しく対応するのは意外と大変です。Tini は本来存在しているはず init の代替に過ぎないので Tini を使用したとしてもプログラムに悪い影響を与えることはなくデメリットは殆ど無いでしょう。Docker イメージのサイズが多少増える程度です。なので可能ならプログラムを修正し、それが難しいなら躊躇せず Tini を使うという流れで良いと思います。

Docker compose の FAQ 「[Why do my services take 10 seconds to recreate or stop?](https://docs.docker.com/compose/faq/#why-do-my-services-take-10-seconds-to-recreate-or-stop)」（[［日本語］サービスの再作成や停止に10秒かかるのはどうして？](https://docs.docker.jp/compose/faq.html#id10)）でも同様のことが述べられています。

> To fix this problem, try the following:
>
> - Make sure you’re using the exec form of CMD and ENTRYPOINT in your Dockerfile.
> - If you are able, modify the application that you’re running to add an explicit signal handler for SIGTERM.
> - Set the stop_signal to a signal which the application knows how to handle:
> - If you can’t modify the application, wrap the application in a lightweight init system (like s6) or a signal proxy (like dumb-init or tini). Either of these wrappers takes care of handling SIGTERM properly.

### 参考

- [Docker and the PID 1 zombie reaping problem](https://blog.phusion.nl/2015/01/20/docker-and-the-pid-1-zombie-reaping-problem/)