Dockerコンテナ停止とシグナル(SIGTERM)についての覚え書き

2年ちょっと前にDockerコンテナで「バックグラウンド・プロセスとして動作するデーモン」を起動/停止させる方法について書いた。

この時点では、シグナルまわりについて理解が浅い部分があった。今回は補足としてメモ書きを残しておこうと思う。

前提知識:その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シグナルには「受信時のデフォルトの動作」が存在する。SIGKILLSIGSTOPを除けば、Unixソフトウェアを書く際にシグナルハンドラを実装しない限り、シグナル受信時に「デフォルトの動作」が実行される。

SIGKILLSIGSTOPは、シグナルハンドラを用いてソフトウェア側でシグナル受信時の処理を上書きすることができない。いかなる場合にも、システムが定めた「デフォルトの動作」が実行される)

SIGTERM受信時の「デフォルトの動作」は「プロセスの異常終了」である。

ところでLinuxにおいては、PID=1のプロセスのみ、シグナル受信まわりの振る舞いが少し特殊である。具体的には、明示的にシグナルハンドラを実装したシグナルのみ、シグナル受信時に対応する処理が実行される。シグナルハンドラを実装していないシグナルでは、シグナル受信時に「デフォルトの動作」が実行されることはない。*1

つまり、例えばSIGTERMを受信した時、もしも対応するシグナルハンドラが実装されていないならば、「デフォルトの動作」は実行されず――実質的にSIGTERMが無視されたも同然の振る舞いとなる。

このため、「前提知識:その1」でも書いたように、PID=1のプロセスとして動作させるソフトウェアは、ちゃんとSIGTERMをハンドリングして後片付けを実施するように実装しておく必要がある。シグナル受信時に「デフォルトの動作」が実行されることを期待してはならない。

本題

以下の議論においては、docker run実行時にオプション--initを付与していないものとする。

コンテナを起動した時、特に何も指定しなければ、DockerfileのENTRYPOINTないしCMDに記述したコマンドが実行される。

ここで、ENTRYPOINTないしCMDexec形式でコマンドを記述していたならば、そのコマンドが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形式を使用した場合、SIGTERMENTRYPOINTないし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がシグナルに即応できる状態にしておくことだ。

こういう方法もあるにはあるのだが、ENTRYPOINTCMDの中身がごちゃごちゃしそうなので、実用レベルの技法としてはお勧めできないと考えている。無理にワンライナーで書くよりも、シェルスクリプトに独立させた上でexec形式で実行させた方が良いだろう。

実際のところ、上記の例ではバックグラウンド実行中のsleep infinityを放置したままSIGTERMexit 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までとなり、ENTRYPOINTCMDに記述したコマンドには到達しない。/bin/sh -cSIGTERMの「デフォルトの動作」に基づいて終了しようと試みるだろう*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を用意したように、独立したシェルスクリプトに処理を記述した方がメンテしやすいだろう。

まとめ

とりあえずこうしておけば安パイだと思う。

  1. DockerfileのENTRYPOINTCMDではexec形式を使用すること。
  2. Dockerコンテナのメインプロセスとして動作させるソフトウェアには、最低でもSIGTERMのシグナルハンドリング処理を実装すること。他にも対応すべきシグナルが存在するならば、それらのハンドリング処理も実装すること。
  3. 以下の場合にのみ、docker run実行時にオプション--initを付与することを検討すること。
    1. Dockerコンテナのメインプロセスとして動作させたいソフトウェアにSIGTERMを含むシグナルのハンドリング処理を実装することが困難な場合。
    2. (あまりよろしくない手法なので控えるべきだが)Dockerコンテナのメインプロセスとして動作させたいソフトウェアの「シグナル受信時の処理」が、各シグナルの「デフォルトの動作」で問題ない場合。

*1:2年前の時点では、この「LinuxのPID=1」固有の振る舞いは把握していなかった。

*2:ただしdocker run実行時にオプション等で実行するコマンドを入れ替えていない場合に限る。

*3:もちろんtrap文を仕込んだ場合は話が別だが。