急にinitが何をしているのか、何をすべきなのかが気になったので調べてみた。一緒に600行強のinit実装であるtiniのソースコードをざっくり読んだ。この場を借りてメモしていく。
the PID 1 problem
RubyコミュニティなどではPassengerで有名なPhusion社のブログに、Docker and the PID 1 zombie reaping problemという記事が掲載されている。
- ゾンビプロセスをreapしてくれないと困る
- SIGTERMなどでPID=1が先に死んだらその子プロセスを処理してくれないと困る
みたいな内容が書いてある。詳細は読んでみてほしい。
システムコンテナ(参考)と呼ばれる種類のコンテナを作る場合、任意のプログラムをコンテナ内部のPID=1とするのではなく、上記のような振る舞いをする軽量なinitプログラムを経由して呼び出すというプラクティスがある。tiniはそういった軽量initの一つで、dockerの --init
オプションで使われるコマンドとしても知られている。
“A tiny but valid init
for containers”と自称している。
tini のコードリーディング
2018/04/16現在、プログラムの主要部分である tini.c
は611行しかない。読み切るのもそんなに難しくないので目を通してみる。単にログを出したり便利計算をするだけのマクロやdefineによる分岐はざっくり省略している。
最初にmain関数(L543)に目を通す。ここでは、大きく以下のような処理を行う。
parse_args() でARGVをパースして子プロセスのARGVを組み立てる parse_env() で環境変数をパースする 構造体を確保して、 configure_signals() でシグナルの設定をする register_subreaper() で、 PR_SET_CHILD_SUBREAPER が実装された環境のみprctl()を呼ぶ。 \ その後 reaper_check() して自分が子プロセスをreapできるか確認する spawn() で管理すべき子プロセスを作る while (1) { wait_and_forward_signal() でシグナルを待ち構える シグナルが SIGCHLD の場合もあるので、 reap_zombies() でゾンビを処理したりする child_exitcode がセットされていればtiniを終了する }
前処理たち
parse_args()
では要するに引数をパースしていて、init特有の処理はないが、 child_args_ptr
という子プロセスに渡す *argv[]
相当を埋める処理が行われている。 型の宣言が読めない がそういうことなので、charのポインタの配列のポインタを宣言しているはず。
parse_env()
ではSUBREAPERに関する環境変数と、ログのVERBOSITYに関する環境変数を見ているのみ。
configure_signals()
が少しinit的で独特である。まず、以下のコードで、SIGSEGVを始めとした特定の、クリティカルなシグナルを除いたシグナルについて sigprocmask(2)
でブロックするような処理をしている。
sigfillset(parent_sigset_ptr); int signals_for_tini[] = {SIGFPE, SIGILL, SIGSEGV, SIGBUS, SIGABRT, SIGTRAP, SIGSYS, SIGTTIN, SIGTTOU}; for (i = 0; i < ARRAY_LEN(signals_for_tini); i++) { if (sigdelset(parent_sigset_ptr, signals_for_tini[i])) { PRINT_FATAL("sigdelset failed: '%i'", signals_for_tini[i]); return 1; } } if (sigprocmask(SIG_SETMASK, parent_sigset_ptr, sigconf_ptr->sigmask_ptr)) { PRINT_FATAL("sigprocmask failed: '%s'", strerror(errno)); return 1; }
なぜ sigprocmask(2)
でのブロックが必要かというと、致命的なシグナル以外は後ろの処理で sigtimedwait(2)
を用いてシグナルを待ち受け、子プロセスにフォワードする必要があるためだ。sigprocmask(2)とsigtimedwait(2)がセットで使われる見本。
で、後ろでSIGTTIN/SIGTTOUについては そもそも無視する ように設定がされるのだが、コメントにある通りtiniがttyをコントロールできなくなるエッジケースがあるようで、そのパターンをつぶしておく必要があるらしい。
シグナルの設定の後は、 PR_SET_CHILD_SUBREAPER
が定義された環境では自分にsubreaper属性をセットする(register_subreaper()
)。 孤児プロセスができた場合、一番近いsubreaper属性がある親に対してSIGCHLDを送る そうで、これを利用すれば PID Namespace などを分離していないコンテナでもプログラムをinit的に振るまわせることができるような雰囲気がある。試してはいない...。
reaper_check()
では自分がsubreaper属性を持っているか、またはPIDが1である事を検査している。
spawn()
でARGVを元に作った子プロセス向けARGVから、子プロセスを作っている。地味に setpgid(0, 0)
したりtty周りの始末(これはよくわからない...)をしたり、あとinit独特のシグナルハンドラを元に戻したりしていて勉強になる。
そしてここからは while(1)
ループ。
wait_and_forward_signal()
ここでは sigtimedwait(2)
でシグナルを待ち受ける。なお、この処理は1秒後、もしくは中断で一旦breakする。ここ、 timed で待つ必要があるのかもしれないが僕にはよくわからなかった...。
シグナルを受け取ることに成功した場合、まず SIGCHLD
であった場合そのままbreakして、終了したプロセスのハンドリングが後続の処理に委ねられる。
その他のシグナルであれば kill(2)
でそのまま子プロセスのPIDに再送する。なお、プロセスグループに送るオプションもある。
reap_zombies()
ここで、ゾンビのみなさんをキレイに刈り取っている。一方で正常に子プロセスが終了した場合の対応もある。
まず、 waitpid(-1, &status, WNOHANG)
して、ちゃんと終わったプロセスを残らずwait()してあげている。ここで、終了を看取ったプロセスがそもそも正規にtiniが待ち受けるべきプロセスかどうかを確認している。そうであった場合、終了ステータス、シグナルなどを正しくパースして、ログを吐いたりしている。wait()のステータスにある情報はたくさんのマクロを駆使しないと正確に取り出せないので冗長だけど、ここは割とおきまりのコードではあるように読めた。
で、待ち受けるべきプロセスの終了の場合は、mainの int current_status
のポインタである変数 int *child_exitcode
にステータスコードがセットされる。
その他のプロセスは本来tiniが直接は看取らないゾンビプロセスであるはずなので、設定によりログだけを吐いてそのままstatusを捨てている。
この waitpid(2)
のループを、その時点での終了したプロセスがいなくなるまで繰り返す。
int current_status
の確認
ループの終わりに int current_status
の中身をチェックして、 -1 でない正しいステータスコードが格納されていたらtini自体も終了する。
おまけ
PRを覗いたら prctl の PR_SET_PDEATHSIG
オプションを用いて、先に親が死んだ場合に子プロセスをkillしていく挙動を指定するパッチがあった。これはこれで便利そうなので入ったら良さそう。 :+1:
という感じで非常にシンプルなので勉強になった。というのと、複数のデーモンを待ち受けるinitは結構面倒そうだな... と思った。
今回コードリーディングをしたが、解釈に誤りなどがあればツッコミ何卒。特に、シグナルと仮想端末難しいっす...と改めて思ったので...。
あとこれくらいのinitをmrubyなどで再実装してみたい感じがした。
参考
nodeコンテナの場合の話など。 man 2 kill
へのリファレンスもある。