Docker上のサーバーを強制終了ではなく正常に終了する PID1になるプロセスとは?を理解してみた
はじめに
こんにちは、SHIFT の開発部門に所属している Katayama です。
Dockerfile でサーバーを作成した際に、docker stop を実行した際に正常終了ではなく、以下のように強制終了になってしまうパターンがあります。
[user@localhost docker-kubernetes]# docker logs process-confirm
2022/03/23 10:28:10 [notice] 8#8: using the "epoll" event method
2022/03/23 10:28:10 [notice] 8#8: nginx/1.14.0 (Ubuntu)
2022/03/23 10:28:10 [notice] 8#8: OS: Linux 3.10.0-1160.el7.x86_64
2022/03/23 10:28:10 [notice] 8#8: getrlimit(RLIMIT_NOFILE): 1048576:1048576
2022/03/23 10:28:10 [notice] 8#8: start worker processes
2022/03/23 10:28:10 [notice] 8#8: start worker process 9
2022/03/23 10:28:10 [notice] 8#8: signal 28 (SIGWINCH) received
2022/03/23 10:28:10 [notice] 9#9: signal 28 (SIGWINCH) received
今回は正常に終了する(安全にサーバーを止める)ためにはどうすればいいのか?について、"exec 形式"と"シェル形式"の 2 つの違いと、Linux の系統(RHEL 系・Debian 系)で挙動が違う事も含めて、見ていきたいと思います(「おまけ」に「docker killとは?」もありますので、docker stopと何が違うの?と疑問に思う方はそちらも参照ください)。
※正常に終了する場合、以下のように"SIGTERM"を受信して安全に終了する事が確認できます。
[user@localhost docker-kubernetes]# docker logs process-confirm
2022/03/23 10:30:48 [notice] 1#1: using the "epoll" event method
2022/03/23 10:30:48 [notice] 1#1: nginx/1.14.0 (Ubuntu)
2022/03/23 10:30:48 [notice] 1#1: OS: Linux 3.10.0-1160.el7.x86_64
2022/03/23 10:30:48 [notice] 1#1: getrlimit(RLIMIT_NOFILE): 1048576:1048576
2022/03/23 10:30:48 [notice] 1#1: start worker processes
2022/03/23 10:30:48 [notice] 1#1: start worker process 7
2022/03/23 10:30:48 [notice] 1#1: signal 28 (SIGWINCH) received
2022/03/23 10:30:48 [notice] 7#7: signal 28 (SIGWINCH) received
2022/03/23 10:30:53 [notice] 1#1: signal 15 (SIGTERM) received, exiting
2022/03/23 10:30:53 [notice] 7#7: exiting
2022/03/23 10:30:53 [notice] 7#7: exit
2022/03/23 10:30:53 [notice] 1#1: signal 17 (SIGCHLD) received from 7
2022/03/23 10:30:53 [notice] 1#1: worker process 7 exited with code 0
2022/03/23 10:30:53 [notice] 1#1: exit
結論|どうすればいいか?
今回は Ubuntu18.04 上に nginx を立てる場合を例に見ていく。
Dockerfile を作成する際に、ENTRYPOINT(CMD)を利用してそのコンテナ内のサーバーを起動すると思うが、その際の記述方式を"exec 形式"にする。以下は nginx をコンテナ内で起動するような Dockerfile の例だが、ENTRYPOINT の部分が"["nginx", "-g", "daemon off;"]"となっているように、"exec 形式"で記述している部分がポイントになる。
FROM ubuntu:18.04
RUN apt-get update; \
apt-get install -y curl gnupg2 ca-certificates lsb-release ubuntu-keyring; \
curl https://nginx.org/keys/nginx_signing.key | gpg --dearmor \
| sudo tee /usr/share/keyrings/nginx-archive-keyring.gpg >/dev/null; \
echo "deb [signed-by=/usr/share/keyrings/nginx-archive-keyring.gpg] \
http://nginx.org/packages/ubuntu `lsb_release -cs` nginx" \
| sudo tee /etc/apt/sources.list.d/nginx.list; \
echo -e "Package: *\nPin: origin nginx.org\nPin: release o=nginx\nPin-Priority: 900\n" \
| sudo tee /etc/apt/preferences.d/99nginx; \
apt-get update; \
apt-get install -y nginx;
ADD nginx.conf /etc/nginx/
ENTRYPOINT ["nginx", "-g", "daemon off;"]
# nginx.conf
error_log /dev/stderr notice;
events {}
上記について少し補足をする。
・"apt-get update;"
"E: Unable to locate package "というエラーが出るのでまず update を実行する
・"apt-get install -y curl ..." ~ "apt-get install -y nginx;"
Ubuntu に対する nginx のインストール方法に則り nginx をインストールしている部分(インストール方法は公式を参照)
・"error_log /dev/stderr"
標準エラー出力に nginx のエラーログを出力させるための設定。このようにする事で"docker logs "で nginx のエラーログが確認できる(詳細は公式を参照)。
・"notice"
ログレベルの設定をしている(詳細は公式を参照)。
・"events {}"
nginx: [emerg] no "events" section in configuration というエラーが出るで空で定義している(eventsを参照)。
nginx.conf の詳細については公式を参照。
※Docker で nginx を利用する際には、 nginx の Docker イメージがあるのでそれを使えばいいのだが、今回はコンテナ内にサーバーを立ち上げる事で、実行モジュールのプロセスがどうなるか?を確認するためにわざとこのような構成にしている。
※Dockerfile における CMD・ENTRYPOINT とは?といった Dockerfile の構成についてはDockerイメージを1から作成し、Docker Hubへの登録までやってみたを参照。
exec 形式でなければならない理由を理解するために、シェル形式で記述した場合どうなるか?を見ていく
Ubuntu で nginx を立てる Dockerfile を作成する
Dockerfile は以下のようになる(「結論 どうすればいいか?」との差分のみを書き出しており、FORM・RUN については「結論 どうすればいいか?」と全く同じ)。
...
ENTRYPOINT nginx -g 'daemon off;'
実際にコンテナを起動して docker stop でコンテナを停止する
上記の Dockerfile に基づいてイメージを作成し、コンテナを起動後停止してみる。
## ターミナルA
[user@localhost docker-kubernetes]# docker build -t test .
...
Successfully tagged test:latest
[user@localhost docker-kubernetes]# docker run -it --name process-confirm test
## ターミナルB
[user@localhost docker-kubernetes]# time docker stop process-confirm
process-confirm
real 0m10.234s
user 0m0.037s
sys 0m0.036s
[user@localhost docker-kubernetes]# docker logs process-confirm
2022/03/23 10:28:10 [notice] 8#8: using the "epoll" event method
2022/03/23 10:28:10 [notice] 8#8: nginx/1.14.0 (Ubuntu)
2022/03/23 10:28:10 [notice] 8#8: OS: Linux 3.10.0-1160.el7.x86_64
2022/03/23 10:28:10 [notice] 8#8: getrlimit(RLIMIT_NOFILE): 1048576:1048576
2022/03/23 10:28:10 [notice] 8#8: start worker processes
2022/03/23 10:28:10 [notice] 8#8: start worker process 9
2022/03/23 10:28:10 [notice] 8#8: signal 28 (SIGWINCH) received
2022/03/23 10:28:10 [notice] 9#9: signal 28 (SIGWINCH) received
するとなぜか docker stop のコマンドが応答するまでに約 10 秒かかっている事が分かる。また、docker logs で nginx のログを確認すると exit する事なく途中で nginx が急に止まっている事もわかる。「結論 どうすればいいか?」の Dockerfile(ENTRYPOINT を"exec 形式"で記述)でコンテナを起動・停止すると以下のように直に応答があり、そして nginx のログを確認すると"SIGTERM"の受信後に exit している事が確認できる。
[user@localhost docker-kubernetes]# time docker stop process-confirm
process-confirm
real 0m0.258s
user 0m0.042s
sys 0m0.059s
[user@localhost docker-kubernetes]# docker logs process-confirm
2022/03/23 10:30:48 [notice] 1#1: using the "epoll" event method
2022/03/23 10:30:48 [notice] 1#1: nginx/1.14.0 (Ubuntu)
2022/03/23 10:30:48 [notice] 1#1: OS: Linux 3.10.0-1160.el7.x86_64
2022/03/23 10:30:48 [notice] 1#1: getrlimit(RLIMIT_NOFILE): 1048576:1048576
2022/03/23 10:30:48 [notice] 1#1: start worker processes
2022/03/23 10:30:48 [notice] 1#1: start worker process 7
2022/03/23 10:30:48 [notice] 1#1: signal 28 (SIGWINCH) received
2022/03/23 10:30:48 [notice] 7#7: signal 28 (SIGWINCH) received
2022/03/23 10:30:53 [notice] 1#1: signal 15 (SIGTERM) received, exiting
2022/03/23 10:30:53 [notice] 7#7: exiting
2022/03/23 10:30:53 [notice] 7#7: exit
2022/03/23 10:30:53 [notice] 1#1: signal 17 (SIGCHLD) received from 7
2022/03/23 10:30:53 [notice] 1#1: worker process 7 exited with code 0
2022/03/23 10:30:53 [notice] 1#1: exit
この違いは何か?だが、結論からいうとコンテナ内のプロセスが正常終了ではなく、強制終了している事に違いがある。
まず、事象を理解するために docker stop コマンド実行時の内部の動きについてみていく。
docker stop コマンドが実行されると、Docker がコンテナ内のプロセスに対し"SIGTERM"を送り、コンテナ内のプロセスはそれを受信する事で終了する。通常はこれで終わりだが、コンテナ内のプロセスが終了しない場合には一定時間経過後に"SIGKILL"が送られ、プロセスを強制終了させるという動きをする(以下、公式(日本語はここ)からの引用)。
この一定時間はデフォルトだと公式に書かれている通り 10 秒に設定されている。
上記が docker stop コマンド実行時の内部の動きだが、先ほど見たようにおり、ENTRYPOINT を"シェル形式で記述した場合には約 10 秒の間があったが、まさにこの 10 秒は、強制終了までの一定時間であり、10 秒後に docker stop の応答があったという事はコンテナ内のプロセスが強制終了になったという事(実際には応答までの時間だけでなく、nginx のログからも強制終了になっている事は確認できる)。
シェル形式で記述するとなぜ強制終了になったのか
強制終了になった事を理解するには、コンテナ内の PID1 のプロセスが何であるか?が重要になるので、実際に先ほどのコンテナ内の PID1 のプロセスを確認してみると、以下の通り"/bin/sh -c nginx -g 'daemon off;'"になっている事が確認できる。
## docker runを実行したターミナルとは別ターミナル
[user@localhost docker-kubernetes]# docker exec -it process-confirm ps ax
PID TTY STAT TIME COMMAND
1 pts/0 Ss+ 0:00 /bin/sh -c nginx -g 'daemon off;'
8 pts/0 S+ 0:00 nginx: master process nginx -g daemon off;
9 pts/0 S+ 0:00 nginx: worker process
10 pts/0 S+ 0:00 nginx: worker process
11 pts/1 Rs+ 0:00 ps ax
上記のように PID1 のプロセスが nginx でない場合、docker stop 時にサーバー(実行モジュール)である nginx はシグナル"SIGTERM"を受信しないので正常終了せずずっと残り続け、タイムアウトの後にシグナル"SIGKILL"により強制的に終了するという事になる(nginx は"/bin/sh -c"のサブコマンドなのでシグナル"SIGTERM"を受信する事はない)。
そのため根本的な原因としては PID1 のプロセスが nginx になっていない事で、シェル形式で記述している場合にはこのような事象が発生するので"exec 形式"で ENTRYPOINT(CMD)を記述する必要がある。
※"exec 形式"で ENTRYPOINT(CMD)を記述した場合の PID1 が何になるか?を確認してみると以下のように nginx になっている事が確認できる。
[user@localhost docker-kubernetes]# docker exec -it process-confirm ps ax
PID TTY STAT TIME COMMAND
1 pts/0 Ss+ 0:00 nginx: master process nginx -g daemon off;
8 pts/0 S+ 0:00 nginx: worker process
9 pts/0 S+ 0:00 nginx: worker process
10 pts/1 Rs+ 0:00 ps ax
※Linux のシグナルとは?や PID1 とは?などについては本記事の範疇を超えるので扱わない(参考に示しているものを参照ください)。
・参考:Linux の「シグナル」って何だろう?
・参考:Shell form ENTRYPOINT example(日本語はここ)
・参考:-c で exec するかどうか、子か孫かどうかで何が問題に?
PID1 のプロセスの exec 形式とシェル形式での違い
上記の「exec 形式でなければならない理由を理解するために、シェル形式で記述した場合どうなるか?を見ていく」の章で見たように、シェル形式だとプロセスが強制終了になってしまう。PID1 のプロセスが nginx ではない事が根本的な原因だったが、では、"exec 形式"と"シェル形式"でどんな違いがあるかについてみると以下の通り。
・"exec 形式":コマンドシェルを起動せずにコマンドを実行(以下、公式からの引用)
・"シェル形式":"/bin/sh -c"の中でコマンドを実行(以下、公式からの引用)
上記のような違いがあるため、シェル形式で ENTRYPOINT(CMD)を記述すると、PID1 が実行モジュール(サーバー)にならず、"/bin/sh -c"になり、実際のサーバーはその"/bin/sh -c"のサブコマンドになってしまう。逆に、"exec 形式"であればコマンドシェルが起動することなくコマンドが実行されるので、PID1 が実行モジュール(サーバー)になり、docker stop 時のシグナル"SIGTERM"はサーバーが受信する。そのため"exec 形式"で記述する事で正常に終了できる。
※Dockerfile のリファレンスにも、"exec 形式"で記述するのが推奨と書かれている(以下、公式からの引用)。
まとめとして
今回は Docker 上のサーバーを強制終了ではなく正常に終了するための注意として、"exec 形式"で ENTRYPOINT(CMD)を記述すべき事をみてきた。単にサーバーを終了するだけであれば上記のポイントだけを気を付ければいいが、こればバッチ処理のサーバーだったりすればまた考慮すべき事は増えてくる。今後はそういったアプリケーション側の事情も加味した正常終了をさせる方法についても理解を深めてみたいと思った。
※ちなみに、CentOS7.9 だとどうなるか?については「おまけ」の「CentOS7.9 ではどうなるか?」に書いているのでそちらを参照。CentOS7.9 では Ubuntu18.04 とは違う結果になり、シェル形式でも PID1 がちゃんと実行モジュール(サーバー)のプロセスになる。
おまけ
CentOS7.9 ではどうなるか?
上記では Ubuntu18.04 を例に見てきたが、Centos7.9 だと同じ Linux 系でも系統が違う(CentOS は RHEL 系)ので少し事情が違う。それについて以下で見ていきたいと思う。
まず、CentOS7.9 における"/bin/sh"は"bash"へのシンボリックリンクなので実際に/bin/sh で起動するのは bash。この事は以下のコマンドで確かめられる。
[user@localhost docker-kubernetes]# ls -l /bin/sh
lrwxrwxrwx. 1 root root 4 2月 24 10:05 /bin/sh -> bash
そしてbash(4.2.x)の仕様書には以下のように記載されており、"sh -c"に渡された文字列が単独コマンドとして実行できる場合は、exec するような実装になっているようである。そのため、例えば、CentOS7.9 で"/bin/sh -c sleep 100"が実行されると、PID1 は"/bin/sh"ではなく(exec によりプロセスが置き換えられるので)PID1 は"sleep 100"になる(CentOS7.9 における"/bin/sh -c sleep 100"は実質、"exec sleep 100"という事)。
上記のような仕様になっているので、CentOS7.9 では Dockerfile を作成する時に ENTRYPOINT をシェル形式で記述しても、PID1 が nginx になるので"docker stop"でコンテナを正常に終了できる。実際、以下の Dockerfile で docker build をし、コンテナを起動後に"docker exec -it process-confirm ps ax"で PID1 を確認してみると、確かに nginx になっている事が確認できる。
FROM ubuntu:18.04
COPY nginx.repo /var/tmp
# http://nginx.org/en/linux_packages.html#RHEL-CentOS
RUN yum install -y yum-utils; \
mv /var/tmp/nginx.repo /etc/yum.repos.d; \
chmod +x /etc/yum.repos.d/nginx.repo; \
yum-config-manager --enable nginx-mainline; \
yum install -y nginx; \
systemctl enable nginx;
ENTRYPOINT nginx -g 'daemon off;'
## nginx.repoの中身は以下
[nginx-stable]
name=nginx stable repo
baseurl=http://nginx.org/packages/centos/$releasever/$basearch/
gpgcheck=1
enabled=1
gpgkey=https://nginx.org/keys/nginx_signing.key
module_hotfixes=true
[nginx-mainline]
name=nginx mainline repo
baseurl=http://nginx.org/packages/mainline/centos/$releasever/$basearch/
gpgcheck=1
enabled=0
gpgkey=https://nginx.org/keys/nginx_signing.key
module_hotfixes=true
[user@localhost docker-kubernetes]# docker exec -it process-confirm ps ax
PID TTY STAT TIME COMMAND
1 pts/0 Ss+ 0:00 nginx: master process nginx -g daemon off;
7 pts/0 S+ 0:00 nginx: worker process
8 pts/0 S+ 0:00 nginx: worker process
9 pts/1 Rs+ 0:00 ps ax
そして docker logs で nginx のログを見ると、確かに"SIGTERM"を受信後に exit している事が確認できる(正常に終了している)。
[user@localhost docker-kubernetes]# docker logs process-confirm
2022/03/23 10:54:15 [notice] 1#1: using the "epoll" event method
2022/03/23 10:54:15 [notice] 1#1: nginx/1.21.6
2022/03/23 10:54:15 [notice] 1#1: built by gcc 4.8.5 20150623 (Red Hat 4.8.5-44) (GCC)
2022/03/23 10:54:15 [notice] 1#1: OS: Linux 3.10.0-1160.el7.x86_64
2022/03/23 10:54:15 [notice] 1#1: getrlimit(RLIMIT_NOFILE): 1048576:1048576
2022/03/23 10:54:15 [notice] 1#1: start worker processes
2022/03/23 10:54:15 [notice] 1#1: start worker process 7
2022/03/23 10:54:20 [notice] 1#1: signal 15 (SIGTERM) received, exiting
2022/03/23 10:54:20 [notice] 7#7: exiting
2022/03/23 10:54:20 [notice] 7#7: exit
2022/03/23 10:54:20 [notice] 1#1: signal 17 (SIGCHLD) received from 7
2022/03/23 10:54:20 [notice] 1#1: worker process 7 exited with code 0
2022/03/23 10:54:20 [notice] 1#1: exit
※とはいえ、わざわざシェル形式で記述する場面はそうそう多くはないと思われるので基本的には exec 形式で書くべきと思われる。
## exec形式
ENTRYPOINT ["nginx", "-g", "daemon off;"]
※確かに exec 形式だとコマンドシェルが起動しないのでシェルの変数を使いたい場合には困るが、公式(日本語はここで以下の図)に書かれている通り、以下のようにすれば解決できるので、シェルの変数を使いたいだけでシェル形式を採用する事はないと思う。
...
RUN [ "sh", "-c", "echo $HOME" ]
...
※ちなみに、Ubuntuの"/bin/sh"はdashへのシンボリックリンクになっている
$ ls -l /bin/sh
lrwxrwxrwx. 1 root root 4 2月 24 10:15 /bin/sh -> dash
・参考:「分かりそう」で「分からない」でも「分かった」気になれる IT 用語辞典 シンボリックリンク
・参考:sh -c で呼び出したコマンドが bash だと孫プロセスにならないことがある
・参考:【 exec 】 現行のジョブに置き換えてコマンドを続行する
docker killとは?
docker stopがコンテナ内の主プロセス(PID1)にシグナル"SIGTERM"を送信するのに対し、docker killはシグナル"SIGKILL"を送信し強制的にプロセスを終了させようとする。
※ただし、docker killの場合には、"--signal"オプションで送信するシグナルを変えることもできる。
・参考:docker kill(日本語はここ)
《この公式ブロガーの記事一覧》
お問合せはお気軽に
https://service.shiftinc.jp/contact/
SHIFTについて(コーポレートサイト)
https://www.shiftinc.jp/
SHIFTのサービスについて(サービスサイト)
https://service.shiftinc.jp/
SHIFTの導入事例
https://service.shiftinc.jp/case/
お役立ち資料はこちら
https://service.shiftinc.jp/resources/
SHIFTの採用情報はこちら
https://recruit.shiftinc.jp/career/