Docker/Kubernetes で PID 1 問題を回避する

はじめに

PID 1 問題というのは、コンテナを実行した際にアプリケーションのプロセスが PID 1(プロセス番号が1番)で実行されることで、コンテナに対して SIGTERM などのシグナルを送信してもコンテナ内のプロセスが正常に終了しないというものです。ここでは2020年3月現在でこの PID 1 問題を回避する方法を Docker と Kubernetes のそれぞれで紹介します。

TL;DR

  • アプリケーションが「明示的にシグナルをハンドリングするようにする」、または「PID 1 で実行されないようにする」の2つの回避策がある
  • アプリケーションプロセスが PID 1 で実行されないようにする場合、Docker では Tini のような軽量 init を使う、もしくは Docker 1.13 以上の場合は docker run--init オプションを使うで問題を回避できる
  • Kubernetes では Pod shareProcessNamespace を使うことで問題を回避できる

PID 1 問題とは

そもそもなぜこの問題が起きるのかについては、2016年3月に開催された Docker Meetup Tokyo #6 の LT で紹介したスライドがあるため、詳細はそちらで見てください。簡単にいうと PID 1 のプロセスは Linux カーネルに特別扱いされていて、そのプロセス自身が明示的に送信されたシグナルをハンドリングしていない場合それを無視します。コンテナの場合、アプリケーションのプロセスが PID 1 で実行されることが多いため、アプリケーションが明示的にシグナルをハンドリングしていないとシグナルを送信しても無視されてしまい、コンテナに SIGTERM を送っても無視されて終了しないということが起こります。

PID 1 問題を実際に確認するために Node.js で実行される HTTP サーバを含むコンテナイメージを用意しました。次のようにコンテナを実行して、コンテナに対して SIGTERM を送ってみます。

docker run -d --rm --name node-hello docker.io/superbrothers/node-hello

docker exec node-hello ps aux
USER       PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
root         1  0.9  1.3 589700 28100 ?        Ssl  05:01   0:00 node index.js
root        13  0.0  0.1  36636  2600 ?        Rs   05:01   0:00 ps aux

docker kill -s TERM node-hello

docker ps
CONTAINER ID        IMAGE                      COMMAND                  CREATED             STATUS              PORTS                       NAMES
6ab8821e98b3        superbrothers/node-hello   "docker-entrypoint.s…"   23 seconds ago      Up 22 seconds                                   node-hello

SIGTERM を送信してもコンテナが終了していないことが確認できます(プロセスが SIGTERM を受け取った場合のデフォルトの挙動は終了です)。

では、PID 1 問題の回避策をみていきます。アプリケーションが「明示的にシグナルをハンドリングするようにする」、または「PID 1 で実行されないようにする」の2つです。

Docker で PID 1 問題を回避する

「明示的にシグナルをハンドリングするようにする」という回避策では、これは個々のアプリケーションでやってくださいということになるのですが、言語によって簡単に対処できることもあります。例えば Node.js の場合、SIGTERM をデフォルトではハンドリングしていないため、PID 1 問題が発生しますが、npm start 経由でアプリケーションを実行することでこの問題を回避できます。それは npm がシグナルを明示的にハンドリングするようになっているからです。

FROM node:13
...
CMD ["npm", "start"]

このようにアプリケーションが直接この問題に対処すればどこであっても問題が発生しません。このほかにアプリケーションで簡単に対処できない場合は、Tini のような一般に軽量 init と呼ばれるものが使用できます。使い方は簡単で、コンテナイメージをビルドする際にインストールして、ENTRYPOINT で Tini を実行するようにするだけです。

ENV TINI_VERSION v0.18.0
ADD https://github.com/krallin/tini/releases/download/${TINI_VERSION}/tini /tini
RUN chmod +x /tini
ENTRYPOINT ["/tini", "--"]

CMD ["/your/program", "-and", "-its", "arguments"]

これでアプリケーションが Tini 経由で実行され、Tini がシグナルをハンドリングしてくれるため、PID 1 問題を回避できます。また、Docker 1.13 以上では docker run コマンドの --init オプションを使用すると、Docker が勝手に Tini 経由でアプリケーションを実行してくれます。

docker run --rm -d --init --name node-hello docker.io/superbrothers/node-hello

docker exec node-hello ps aux
USER       PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
root         1  0.1  0.0   1052     4 ?        Ss   04:24   0:00 /sbin/docker-init -- docker-entrypoint.sh node index.js
root         7  0.7  1.4 589724 29068 ?        Sl   04:24   0:00 node index.js
root        15  0.0  0.1  36636  2716 ?        Rs   04:24   0:00 ps aux

docker kill -s TERM node-hello

docker ps
CONTAINER ID        IMAGE               COMMAND             CREATED             STATUS              PORTS               NAMES

docker run コマンドを直接使用してコンテナを利用することが分かっている場合は --init オプションを利用するのがもっとも簡単です。

Kubernetes で PID 1 問題を回避する

Kubernetes でこの問題を回避する場合、アプリケーションがそもそもシグナルを明示的にハンドリングしていれば何もする必要はありません。Node.js の場合は npm start を使うといった具合です。また、軽量 init を使うことももちろん有効です。しかし、自分たちで開発するアプリケーションならまだしも既存のコンテナイメージでこの問題がある場合にこれらの対処方法を取るのは面倒です。docker run コマンドの --init オプションが使えればよいのですが、Kubernetes はこのオプションをサポートしていません。

そこで Kubernetes で 1.17 から GA になった Share Process Namespace が利用できます。この機能は Pod に含まれる複数のコンテナで PID ネームスペースを共有し、Pod に含まれるコンテナのプロセス間でシグナルを送信できるようにするための機能ですが、PID 1 問題を回避するためにも利用できます。

まずは Share Process Namespace を使用せずに Pod を作成してみます。

$ kubectl version
Client Version: version.Info{Major:"1", Minor:"18", GitVersion:"v1.18.0", GitCommit:"9e991415386e4cf155a24b1da15becaa390438d8", GitTreeState:"clean", BuildDate:"2020-03-25T14:58:59Z", GoVersion:"go1.13.8", Compiler:"gc", Platform:"darwin/amd64"}
Server Version: version.Info{Major:"1", Minor:"18", GitVersion:"v1.18.0", GitCommit:"9e991415386e4cf155a24b1da15becaa390438d8", GitTreeState:"clean", BuildDate:"2020-03-25T20:56:08Z", GoVersion:"go1.13.8", Compiler:"gc", Platform:"linux/amd64"}

cat <<EOL | kubectl apply -f-
apiVersion: v1
kind: Pod
metadata:
  name: node-hello
spec:
  containers:
  - name: node-hello
    image: docker.io/superbrothers/node-hello
EOL

kubectl exec node-hello -- ps aux
USER       PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
root         1  0.8  1.4 589724 29044 ?        Ssl  04:40   0:00 node index.js
root        13  0.0  0.1  36636  2724 ?        Rs   04:41   0:00 ps aux

kubectl delete po node-hello

node index.js コマンドが PID 1 で実行されていることがわかります。次に Shared Process Namespace を有効にして作成してみます。この機能は Pod spec.shareProcessNamespacetrue にすることで有効になります。

cat <<EOL | kubectl apply -f-
apiVersion: v1
kind: Pod
metadata:
  name: node-hello
spec:
  shareProcessNamespace: true
  containers:
  - name: node-hello
    image: docker.io/superbrothers/node-hello
EOL

kubectl exec node-hello -- ps aux
USER       PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
root         1  0.0  0.0   1024     4 ?        Ss   04:44   0:00 /pause
root         8  0.3  1.4 589724 28968 ?        Ssl  04:44   0:00 node index.js
root        22  0.0  0.1  36636  2852 ?        Rs   04:45   0:00 ps aux

shareProcessNamespace を有効にした Pod は PID 1 に /pause というコマンドが実行されています。そのためアプリケーションコンテナのプロセスが PID 1 で実行されることを避けられることで、PID 1 問題を回避できます。この /pause がどこからやってきたのかというと、Kubernetes では Pod に含まれるコンテナを実行する際にゾンビプロセスの刈り取りとそれらコンテナの Network ネームスペースを共有するために pause というコンテナが必ず付いてくるようになっているのです。このコンテナのおかげで Pod に含まれるコンテナ同士は localhost で通信し合えるというわけです。

まとめ

PID 1 問題の回避策は、アプリケーションが「明示的にシグナルをハンドリングするようにする」、または「PID 1 で実行されないようにする」の2つです。アプリケーションプロセスが PID 1 で実行されないようにする場合、Docker では Tini のような軽量 init を使う、もしくは Docker 1.13 以上の場合は docker run--init オプションを使うで問題を回避できます。Kubernetes では Pod shareProcessNamespace を使うことで問題を回避できます。