2年ちょっと前にDockerコンテナで「バックグラウンド・プロセスとして動作するデーモン」を起動/停止させる方法について書いた。
- Dockerのコンテナ起動時にバックグラウンド・プロセスとして動作するデーモンを起動させたい - 新・日々録 by TRASH BOX@Eel
- Dockerのコンテナ停止時にバックグラウンド・プロセスとして動作しているデーモンを適切に終了させたい - 新・日々録 by TRASH BOX@Eel
この時点では、シグナルまわりについて理解が浅い部分があった。今回は補足としてメモ書きを残しておこうと思う。
前提知識:その1
docker run
で起動したコンテナの中のメインプロセスはPID=1となる。docker stop
においては、コンテナ内のPID=1のプロセスにまずSIGTERM
を送信して、一定時間待機してもプロセスが終了しないならSIGKILL
を送信して強制終了させる。
(SIGTERM
の送信については、DockerfileのSTOPSIGNAL
や、docker run
のオプション--stop-signal
を使用することで、他のシグナルを送信するように変更することが可能である)
この仕組みに適切に対応するためにも、PID=1のプロセスとして動作させるソフトウェアは、ちゃんとSIGTERM
をハンドリングして後片付けを実施するように実装しておく必要がある(このあたりは、Unixデーモンを自作する際の基本だろう)。
ここで、仮にPID=1のプロセスの下に子プロセスや孫プロセスが生成される構成であるならば、シグナルハンドリング後の後片付けの過程において、子プロセスや孫プロセスを適切に終了するように実装しておくべきだろう。
前提知識:その2
Unixシグナルには「受信時のデフォルトの動作」が存在する。SIGKILL
とSIGSTOP
を除けば、Unixソフトウェアを書く際にシグナルハンドラを実装しない限り、シグナル受信時に「デフォルトの動作」が実行される。
(SIGKILL
とSIGSTOP
は、シグナルハンドラを用いてソフトウェア側でシグナル受信時の処理を上書きすることができない。いかなる場合にも、システムが定めた「デフォルトの動作」が実行される)
SIGTERM
受信時の「デフォルトの動作」は「プロセスの異常終了」である。
ところでLinuxにおいては、PID=1のプロセスのみ、シグナル受信まわりの振る舞いが少し特殊である。具体的には、明示的にシグナルハンドラを実装したシグナルのみ、シグナル受信時に対応する処理が実行される。シグナルハンドラを実装していないシグナルでは、シグナル受信時に「デフォルトの動作」が実行されることはない。*1
つまり、例えばSIGTERM
を受信した時、もしも対応するシグナルハンドラが実装されていないならば、「デフォルトの動作」は実行されず――実質的にSIGTERM
が無視されたも同然の振る舞いとなる。
このため、「前提知識:その1」でも書いたように、PID=1のプロセスとして動作させるソフトウェアは、ちゃんとSIGTERM
をハンドリングして後片付けを実施するように実装しておく必要がある。シグナル受信時に「デフォルトの動作」が実行されることを期待してはならない。
本題
以下の議論においては、docker run
実行時にオプション--init
を付与していないものとする。
コンテナを起動した時、特に何も指定しなければ、DockerfileのENTRYPOINT
ないしCMD
に記述したコマンドが実行される。
ここで、ENTRYPOINT
ないしCMD
にexec形式でコマンドを記述していたならば、そのコマンドがPID=1のプロセスとなる。
一方で、shell形式を使用した場合、そのコマンドは/bin/sh -c
のサブコマンドとして実行される。つまりPID=1にはならない。実際にはコンテナに同梱される/bin/shの種類によってPID=1になったりならなかったりするようだが、公式ドキュメントにはshell形式で記述したコマンドについて「実行ファイルはコンテナの PID 1 ではなく」と明記されているので、原則として「PID=1にはならない」と考えておくべきだろう。
さて、「前提知識:その1」にも書いたが、docker stop
に起因するSIGTERM
はPID=1のプロセスに送信される。
Dockerfileにてexec形式を使用した場合、SIGTERM
はENTRYPOINT
ないしCMD
に記述したコマンドにたいして送信される*2。なぜならPID=1のプロセスとして動作しているからだ。なので、当該コマンドの実装において、適切なシグナルハンドリングを行うようにしておけば、docker stop
にてトラブルが発生することはないだろう。
ところがshell形式を使用した場合、ENTRYPOINT
ないしCMD
に記述したコマンドはPID=1のプロセスにはならないので、SIGTERM
が直接届くことはない。
SIGTERM
は当該コマンドの親プロセスであるPID=1のプロセスに届く。仮にPID=1のプロセスが/bin/sh -c
だとすると、「前提知識:その2」で触れたが、SIGTERM
に対応するシグナルハンドラが用意されていない状態では、実質的にSIGTERM
は無視されるだろう。そして当然ながら、子プロセスである当該コマンドにSIGTERM
が伝播することもない。
公式ドキュメントにも、shell形式のケースでは「実行ファイルは SIGTERM シグナルを受信しません」と書かれている。つまるところ、何も考えずにshell形式を使用して「デーモンとして振る舞うソフトウェア」を実行した場合には、通常はdocker stop
でトラブルなくコンテナが即時終了するなんて期待しない方がよいだろう。ほぼ確実にタイムアウトによるSIGKILL
送信で終了することになるはずだ。
……一応、shell形式を用いる場合への対応策は、なくはない。
仮にPID=1のプロセスが/bin/sh -c
ならば、そのサブコマンド――Dockerfileにshell形式で記述するENTRYPOINT
ないしCMD
の中にtrap文を仕込むことで、/bin/sh -c
のレベルでSIGTERM
をハンドリングできる。
だから、例えば以下のDockerfileをビルドして実行したコンテナは、docker stop
にて即座に停止する。
FROM ubuntu:latest CMD trap 'exit 0' TERM; while :; do sleep infinity & wait $!; done
コツは2つある。1つ目は、trap文でシグナルを捉えることだ。2つ目は、時間がかかる処理(上記の例ではsleep(1))をバックグラウンドで実行させるようにして、/bin/sh -c
がシグナルに即応できる状態にしておくことだ。
こういう方法もあるにはあるのだが、ENTRYPOINT
やCMD
の中身がごちゃごちゃしそうなので、実用レベルの技法としてはお勧めできないと考えている。無理にワンライナーで書くよりも、シェルスクリプトに独立させた上でexec形式で実行させた方が良いだろう。
実際のところ、上記の例ではバックグラウンド実行中のsleep infinity
を放置したままSIGTERM
でexit 0
しているのだが、これは「sleep(1)なら放置して異常終了させても大丈夫だろう」という前提があるからだ。現実には、バックグラウンド実行中のプロセスを適切に終了させるのが筋な訳で、その辺の対応を付け加えようとした時、無理やりDockerfileにワンライナーで押し込むよりも、シェルスクリプトという「別のファイル」に分かりやすく記述した方が、メンテナンス性が向上するはずだ。
docker run
の--init
docker run
実行時にオプション--init
を付けた場合、PID=1のプロセスとして「init
として振る舞うコマンド」が動作するようになる。
この実体は、Dockerデーモンのシステムパス上で最初に見つかったdocker-init
という名前の実行可能ファイルである。この記事を書いている時点では、既定ではtini由来の実行ファイルが使用されるようだ。
この機能の使いどころは、正直なところちょっと難しい気がする。
というのも、基本的に「必要なシグナルハンドラを実装した上で、Dockerfileにてexec形式を使用してPID=1のメインプロセスとして動作させる」という風にしておけば、--init
は不要なのだ。
言い換えれば、諸々の事情によりメインプロセスとして動作させたいソフトウェアにシグナルハンドラを実装するのが困難な場合には、--init
は役に立つ。PID=1で動作するdocker-init
が、exec形式で指定した「メインプロセスとして動作させたかったソフトウェア」にシグナルを中継することで、中継されたシグナルの「デフォルトの動作」が実行される。
まあしかし、基本的にサーバ・サイドなどでデーモンとして振る舞うソフトウェアでは、シグナル受信時に「デフォルトの動作」に基づいて終了してもよいケースは少ない。大抵は、終了時に所定の手続きを実施する必要がある。ならば必要なシグナルハンドラを実装して、シグナルの受信をソフトウェア内部で検知して、終了処理を実行しなくてはならない。
そんな訳で、安易に--init
に頼るのは良くない気がする。どちらかと言えば、この機能は「いざという時の代替手段」だと思う。
ちなみにDockerfileでshell形式を使用した場合、仮にdocker-init
の子プロセスが/bin/sh -c
となるならば、シグナルが到達するのは/bin/sh -c
までとなり、ENTRYPOINT
やCMD
に記述したコマンドには到達しない。/bin/sh -c
はSIGTERM
の「デフォルトの動作」に基づいて終了しようと試みるだろう*3。
以前書いた記事についての補足
以前書いた「バックグラウンド・プロセスとして動作するデーモン」を起動/停止させるケースでは、諸々の制約より、件のデーモンはPID=1のプロセスではなく子プロセスとして動作する仕組みになっていた。
そこで、まずラッパーとなるシェルスクリプトを用意して、exec形式で実行させるようにした。こうすることで、ラッパースクリプトはPID=1のプロセスとして動作するので、docker stop
の際にSIGTERM
を受信することが可能になる。
あとはラッパースクリプト内にSIGTERM
に対応するシグナルハンドラを用意して、その中で子プロセスとして動作しているデーモンを正規の手段で終了させているだけである。
記事の蛇足として以下の記述をしたが:
なお、秘伝のデーモンがシグナル受信だけで適切に終了する(つまり本稿で言うなら「
/etc/init.d
のスクリプトで停止」しなくても問題ない)場合、もしかしたら冒頭に挙げたCMD /etc/init.d/magic-daemon start; tail -f /dev/null
のような書き方をした上で、docker run
の時にオプション--init
を付与するだけで事足りるかもしれないが、あまり試していないので真偽は不明である。
今考えてみれば、このケースでは--init
の有無に関係なく、シグナルが到達するのは/bin/sh -c
になる。だから/bin/sh -c
側でシグナルハンドラを用意するなどの対応が必須となる。
「本題」の後半に「shell形式を用いる場合への対応策」としてtrap文を用いる方法について述べたが、--init
付きでdocker run
するケースでも、やることは全く同じである。
あえてDockerfileに押し込むならこんな感じになるだろう。
FROM amazonlinux:latest # 色々と準備(省略) # コンテナ起動時に秘伝のデーモン magic-daemon を起動する。 # その後、コンテナを終了させないようにする。 CMD trap '/etc/init.d/magic-daemon stop; exit 0' TERM; /etc/init.d/magic-daemon start; while :; do sleep 1 & wait $!; done
しかし、こうやって無理やりワンライナーで書くのは少し無理がある。
結局は、以前の記事で最終的にラッパースクリプトdaemon-runner.shを用意したように、独立したシェルスクリプトに処理を記述した方がメンテしやすいだろう。
まとめ
とりあえずこうしておけば安パイだと思う。
- Dockerfileの
ENTRYPOINT
やCMD
ではexec形式を使用すること。 - Dockerコンテナのメインプロセスとして動作させるソフトウェアには、最低でも
SIGTERM
のシグナルハンドリング処理を実装すること。他にも対応すべきシグナルが存在するならば、それらのハンドリング処理も実装すること。 - 以下の場合にのみ、
docker run
実行時にオプション--init
を付与することを検討すること。- Dockerコンテナのメインプロセスとして動作させたいソフトウェアに
SIGTERM
を含むシグナルのハンドリング処理を実装することが困難な場合。 - (あまりよろしくない手法なので控えるべきだが)Dockerコンテナのメインプロセスとして動作させたいソフトウェアの「シグナル受信時の処理」が、各シグナルの「デフォルトの動作」で問題ない場合。
- Dockerコンテナのメインプロセスとして動作させたいソフトウェアに