UNIXシグナルとプロセスのライフサイクル - fork/exec/waitからゾンビ、PID 1問題、graceful shutdownまで

UNIXシグナルとプロセスのライフサイクル - fork/exec/waitからゾンビ、PID 1問題、graceful shutdownまで

作成日:
読了:45
更新日:
この記事を読む人におすすめPR / Amazonアソシエイト

当サイトは Amazon.co.jp を宣伝しリンクすることで紹介料を得る手段を提供する、Amazonアソシエイト・プログラムの参加者です。価格・在庫はリンク先の最新情報をご確認ください。

Ctrl+Cでプログラムを止める、killでプロセスを終わらせる、docker stopでコンテナを止める、Kubernetesでローリングアップデートをする。これらはすべて、シグナルという同じ仕組みの上に乗っています。そしてシグナルを正しく扱えるかどうかは、プロセスが「どう生まれ、どう終わり、誰に後始末されるか」というプロセスのライフサイクルの理解と切り離せません。

docker stopすると毎回10秒待たされる」「コンテナの中にゾンビプロセスが溜まる」「デプロイのたびに数件だけ502エラーが出る」。こうした現場のトラブルの多くは、fork/exec/wait、シグナルのデフォルト動作、PID 1の特殊性という基礎を知ると原因が説明できます。この記事では、プロセスの誕生から刈り取りまでをたどり、シグナルの意味とハンドラの書き方、コンテナとKubernetesでの終了フロー、そしてNode.jsやPythonでのgraceful shutdownの実装までを、manページや公式ドキュメントを確認しながら整理します。

以下の説明はLinuxを前提とします。シグナル番号はアーキテクチャやOSによって異なり、たとえばmacOSとLinuxではSIGCHLDなどの番号が違います。コードでは番号を直書きせず、必ずSIGTERMのような名前を使ってください。なお、記事中のC、Node.js、Python、シェルの実行例は筆者の手元(macOS)で実行した結果で、PIDなどの値は環境によって変わります。

プロセスのライフサイクル全体像

まず、1つのプロセスが生まれてから消えるまでの流れを図にします。

  親プロセス
     |
     | fork()           自分の複製(子プロセス)を作る
     v
  子プロセス(親と同じプログラム)
     |
     | execve()         自分の中身を別のプログラムに置き換える
     v
  子プロセス(新しいプログラムとして実行中)
     |
     | exit() / _exit() または シグナルによる終了
     v
  ゾンビ(終了したが、終了ステータスが親に回収されていない)
     |
     | 親が wait() / waitpid() で終了ステータスを回収
     v
  完全に消滅(PIDとカーネル内の情報が解放される)

ポイントは、プロセスの作成が「複製(fork)」と「置き換え(exec)」の2段階に分かれていることと、終了したプロセスは親に回収(wait)されるまで完全には消えないことの2つです。シェルでコマンドを実行するたびに、この流れが1回まわっています。

プロセスとスレッドの違いがあやふやな場合は、先に並行と並列・プロセスとスレッドの記事を読むと以降の話が入りやすくなります。また、forkやexecはいずれもシステムコールで、ユーザー空間からカーネルへの呼び出しの仕組みはシステムコールの記事で扱っています。

fork、exec、wait

fork: 自分を複製する

fork(2)は、呼び出したプロセスの複製を子プロセスとして作ります。1回の呼び出しから2回戻ってくる、という独特の関数で、親には子のPIDが、子には0が返ります。

fork(2)のmanページには、子プロセスが親から受け継がないものとして次のような項目が挙げられています(一部抜粋)。

  • 子プロセスの保留中シグナルの集合は空で始まる
  • prctl(2)PR_SET_PDEATHSIGの設定はリセットされる
  • 子プロセスはスレッドを1つだけ持った状態で作られる(forkを呼んだスレッドの複製)

最後の点は重要で、マルチスレッドのプログラムでforkすると、他のスレッドがロックを握ったまま子プロセスには存在しない、という状態が起こりえます。そのため、マルチスレッドのプログラムでforkした子プロセスでは、すぐにexecするのが定石です。

また、同じmanページによれば、Linuxのforkはcopy-on-writeで実装されています。親のメモリを丸ごとコピーするのではなく、ページテーブルを共有しておき、どちらかが書き込んだページだけを後からコピーします。仕組みの詳細は仮想メモリとページングの記事を参照してください。

exec: 中身を置き換える

execve(2)は、呼び出したプロセスのプログラムを別のものに置き換えます。PIDは変わらず、メモリの中身だけが新しいプログラムになります。成功すると呼び出し元には戻ってきません

execで新しいプログラムのELFファイルが読み込まれ、動的リンカが共有ライブラリを解決していく流れは、リンカとローダの記事で詳しく扱っています。

wait: 子の終了ステータスを回収する

親はwait(2)waitpid(2)で子の終了を待ち、終了ステータスを受け取ります。受け取った整数値はそのままでは使わず、wait(2)のmanページにあるマクロで解釈します。

マクロ意味
WIFEXITED(wstatus)子が正常に終了した(exitやmainからのreturn)なら真
WEXITSTATUS(wstatus)終了コード。exitに渡した値の下位8ビット。WIFEXITEDが真のときだけ使う
WIFSIGNALED(wstatus)子がシグナルで終了させられたなら真
WTERMSIG(wstatus)子を終了させたシグナルの番号。WIFSIGNALEDが真のときだけ使う
WCOREDUMP(wstatus)コアダンプが生成されたなら真。manページではPOSIX.1-2024で標準化されたと注記されている

終了コードが下位8ビットしか残らないため、exit(256)は親からは0に見えます。終了コードには0から255の範囲の値を使いましょう。

Cで一連の流れを試す

fork、exec、kill、waitpidを1つのプログラムで試してみます。1人目の子はexecでsh -c 'exit 3'に置き換わって終了コード3で終わり、2人目の子は親からSIGTERMを送られて終了します。

#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/wait.h>
#include <unistd.h>
 
static void report(pid_t pid, int wstatus)
{
    if (WIFEXITED(wstatus)) {
        printf("pid %d: exited, status=%d\n", pid, WEXITSTATUS(wstatus));
    } else if (WIFSIGNALED(wstatus)) {
        printf("pid %d: killed by signal %d\n", pid, WTERMSIG(wstatus));
    }
}
 
int main(void)
{
    /* 1人目: exec で別プログラムに置き換わり、終了コード3で終わる */
    pid_t a = fork();
    if (a == 0) {
        execlp("sh", "sh", "-c", "exit 3", (char *)NULL);
        perror("execlp");      /* exec が成功すればここには来ない */
        _exit(127);
    }
 
    /* 2人目: 眠り続けるので、親が SIGTERM を送る */
    pid_t b = fork();
    if (b == 0) {
        pause();
        _exit(0);
    }
    sleep(1);
    kill(b, SIGTERM);
 
    for (int i = 0; i < 2; i++) {
        int wstatus;
        pid_t pid = waitpid(-1, &wstatus, 0);
        report(pid, wstatus);
    }
    return EXIT_SUCCESS;
}

実行結果の例です。

$ cc -Wall -o status status.c && ./status
pid 5410: exited, status=3
pid 5411: killed by signal 15

2人目の子はSIGTERMにハンドラを設定していないので、デフォルト動作で終了し、親からは「シグナル15で終了した」と見えています。

終了コード143と137の正体

シェルで$?を見ると、シグナルで終了したプロセスの終了コードが128 + シグナル番号になっているのをよく見かけます。手元のbash 5.3で確かめると次のようになります。

$ bash -c 'kill -TERM $$'; echo $?
143
$ bash -c 'kill -KILL $$'; echo $?
137

143は128にSIGTERMの15を、137は128にSIGKILLの9を足した値です。これはカーネルが返す値そのものではなく、waitで得た「シグナルで終了した」という情報を、シェルなどが1つの数値で表すための慣習です。Node.jsのドキュメントにも、シグナルで終了した場合の終了コードは128 + シグナル番号になると書かれています。コンテナの終了コードに137が出ていたら、「SIGKILLで殺された(OOM Killerや猶予期間切れの強制終了など)」を疑う手がかりになります。

ゾンビプロセスと孤児プロセス

ゾンビ: 回収されない死体

wait(2)のmanページは、終了したがまだwaitされていない子プロセスを「ゾンビ」と呼んでいます。カーネルは、親が後からwaitできるように、PID、終了ステータス、リソース使用量といった最小限の情報を保持し続けます。

ゾンビ自体はメモリをほとんど使いませんが、PIDを1つ占有し続けます。親がwaitを怠るバグがあると、ゾンビが溜まり続け、最終的にPIDが枯渇して新しいプロセスを作れなくなります。

実際にゾンビを作ってみます。子はすぐに終了しますが、親は30秒間waitしません。

#include <stdio.h>
#include <stdlib.h>
#include <sys/wait.h>
#include <unistd.h>
 
int main(void)
{
    pid_t pid = fork();
    if (pid == 0) {
        _exit(0);              /* 子はすぐ終わる */
    }
    printf("child pid=%d (parent will not wait for 30s)\n", pid);
    fflush(stdout);
    sleep(30);                 /* この間、子はゾンビとして残る */
    waitpid(pid, NULL, 0);     /* ここで刈り取られる */
    return EXIT_SUCCESS;
}

別の端末からpsで状態を見ると、STATZ(ゾンビ)になっています。以下は手元のmacOSでの出力で、Linuxのpsでも状態はZ、コマンド名には<defunct>が付いて表示されます(細かな表示形式はpsの実装によって異なります)。

$ ./zombie &
child pid=5438 (parent will not wait for 30s)
$ ps -o pid,ppid,stat,comm -p 5438
  PID  PPID STAT COMM
 5438  5428 Z    <defunct>

ゾンビはすでに終了しているので、kill -9を送っても消えません。消す方法は、親にwaitさせるか、親を終了させることだけです。

孤児: 親を失ったプロセス

逆に、子より先に親が終了すると、子は孤児プロセスになります。wait(2)のmanページによれば、親が終了した場合、その子(ゾンビを含む)はinit、またはprctl(2)PR_SET_CHILD_SUBREAPERで指定された最も近いサブリーパー(subreaper)の養子になり、initは自動的にwaitしてゾンビを取り除きます。

  init (PID 1)                        init (PID 1)
    |                                   |
    +-- 親 (PID 100)       親が終了      +-- 子 (PID 101)  <- initの養子になる
          |              ---------->         終了したら init が wait する
          +-- 子 (PID 101)

PR_SET_CHILD_SUBREAPERのmanページによれば、孤児はまだ生きている最も近い祖先のサブリーパーに付け替えられ、以後その孤児でgetppid(2)を呼ぶとサブリーパーのPIDが返り、孤児が終了したときのSIGCHLDもサブリーパーが受け取ります。後述するtiniのサブリーパーモードは、この仕組みを使うものです。

つまり通常のLinuxでは、「親が死ねばinitが後始末してくれる」という安全網があります。この安全網がコンテナの中では働かないことがある、というのが後で扱うPID 1問題の核心です。

SIGCHLDを無視するとゾンビにならない

親がいちいちwaitしたくない場合の方法もあります。wait(2)のmanページによれば、POSIX.1-2001では、SIGCHLDの処理をSIG_IGNに設定するか、sigaction(2)SA_NOCLDWAITフラグを付けると、終了した子はゾンビにならないと規定されています。ただしこの場合、終了ステータスは受け取れず、waitは全ての子が終了するまでブロックしたあとECHILDで失敗します。

シグナルの基本

シグナルとは何か

シグナルは、プロセスに対して非同期に届く「通知」です。送り手はカーネル自身(不正なメモリアクセスや、子の終了など)のこともあれば、他のプロセス(kill(2))や端末(Ctrl+C)のこともあります。

signal(7)のmanページでは、シグナルを受け取ったときのデフォルト動作を次の5種類に分けています。

動作意味
Termプロセスを終了する
Ignシグナルを無視する
Coreプロセスを終了し、コアダンプを生成する
Stopプロセスを一時停止する
Cont停止中のプロセスを再開する

プロセスは、シグナルごとにこの動作を「デフォルトのまま」「無視する」「自分で用意したハンドラ関数を呼ぶ」のいずれかに変更できます。これをディスポジション(disposition)と呼びます。

よく使うシグナル

signal(7)の表から、実務でよく出会うシグナルを抜き出します。番号はx86とARMでの値です。

シグナル番号デフォルト意味と主な用途
SIGHUP1Term制御端末のハングアップ、または制御プロセスの終了
SIGINT2Termキーボードからの割り込み(Ctrl+C)
SIGQUIT3Coreキーボードからの終了(Ctrl+\)
SIGKILL9Term強制終了。捕捉も無視もできない
SIGUSR110Termユーザー定義
SIGSEGV11Core不正なメモリ参照
SIGUSR212Termユーザー定義
SIGPIPE13Term読み手のいないパイプへの書き込み
SIGALRM14Termalarm(2)のタイマ
SIGTERM15Term終了要求。killコマンドのデフォルト
SIGCHLD17Ign子プロセスの停止、終了、再開
SIGCONT18Cont停止していれば再開する
SIGSTOP19Stop一時停止。捕捉も無視もできない
SIGTSTP20Stop端末からの停止(Ctrl+Z)

graceful shutdownの観点で押さえておきたいのは次の3つの違いです。

  • SIGTERMは「終わってください」というお願いです。プロセスはハンドラで受け取り、後片付けをしてから終了できます
  • SIGINTも終了の要求ですが、本来は端末で人間がCtrl+Cを押したときに届くものです
  • SIGKILLは交渉の余地がない強制終了です。signal(7)には、SIGKILLSIGSTOPは捕捉も、ブロックも、無視もできないと明記されています。後片付けの機会はありません

したがって、止める側の正しい手順は「まずSIGTERMを送り、一定時間待っても終わらなければSIGKILL」になります。後で見るように、docker stopもKubernetesも、まさにこの手順で動いています。

fork、execをまたいだときの引き継ぎ

signal(7)によれば、シグナルに関する設定は次のように引き継がれます。

項目forkで作った子execve後
ハンドラを設定したシグナル親のコピーを引き継ぐデフォルトに戻る
無視(SIG_IGN)に設定したシグナル親のコピーを引き継ぐ無視のまま残る
シグナルマスク(ブロック中のシグナル)親のコピーを引き継ぐ保持される
保留中のシグナル空で始まる保持される

ハンドラ関数のアドレスはexec後のプログラムでは意味を持たないため、デフォルトに戻るのは自然です。一方で「無視」とシグナルマスクはexecを越えて残るので、親がSIGTERMをブロックしたままexecすると、起動したプログラムがいつまでもSIGTERMを受け取れない、という事故が起こりえます。

標準シグナルはキューイングされない

signal(7)には、標準シグナルはキューイングされないと書かれています。あるシグナルがブロックされている間に同じシグナルが何回生成されても、保留として記録されるのは1回分だけで、ブロック解除時に1回だけ配送されます。

これはSIGCHLDの扱いで特に重要です。子が3つほぼ同時に終了しても、SIGCHLDのハンドラが1回しか呼ばれないことがあります。そのため、SIGCHLDを受けたら「1回のwaitで1人を回収する」のではなく、「回収できる子がいなくなるまでループでwaitする」必要があります。

/* SIGCHLD ハンドラの例(抜粋) */
static void on_sigchld(int sig)
{
    int saved_errno = errno;
    (void)sig;
    /* WNOHANG: 回収できる子がいなければ即座に0を返す */
    while (waitpid(-1, NULL, WNOHANG) > 0) {
        /* 1人ずつ回収する */
    }
    errno = saved_errno;
}

システムコールの中断とEINTR

プロセスがreadなどでブロックしている最中にシグナルハンドラが実行されると、signal(7)によれば、そのシステムコールは「ハンドラから戻った後に自動で再開される」か「EINTRエラーで失敗する」かのどちらかになります。どちらになるかは、ハンドラを設定するときにSA_RESTARTフラグを付けたかどうかで決まります。

ただし、同じmanページには、SA_RESTARTを付けても再開されない呼び出し(スリープ系の関数、selectpollなど)も列挙されています。シグナルを扱うプログラムでは、ブロッキング呼び出しがEINTRで戻ってくる可能性を常に考慮する必要があります。

シグナルハンドラとasync-signal-safe

ハンドラの中でprintfを呼んではいけない理由

シグナルハンドラは、プログラムのどこで割り込まれるか分からないという点で、普通の関数とまったく異なります。

signal-safety(7)のmanページは、printfを例に危険性を説明しています。stdioの関数は、バッファリングのために静的に確保したデータバッファと、そのカウンタやインデックスを持っています。printfの実行途中にシグナルで割り込まれ、ハンドラの中でもう一度printfを呼ぶと、2回目の呼び出しは不整合なデータを操作することになり、結果は予測できません。mallocのような内部状態を持つ関数も同様です。

ハンドラの中から安全に呼べる関数をasync-signal-safeな関数と呼びます。signal-safety(7)の一覧には、たとえば次のものが含まれています。

一覧に含まれる(安全)一覧に含まれない(ハンドラ内で使わない)
write_exitwaitpidkillsignalsigactionprintfmallocexit

exitが含まれず_exitが含まれているのは、exitatexit(3)で登録された関数の実行やstdioバッファのフラッシュを行うためです。

また、errnoについて、signal-safety(7)は、ハンドラが入口でerrnoを保存し、戻る前に元の値に戻すことを条件にしています。ハンドラ内で呼んだwriteなどがerrnoを書き換え、割り込まれた側のエラー判定を壊してしまうのを防ぐためです。

定石: ハンドラではフラグを立てるだけ

安全なハンドラの定石は、volatile sig_atomic_t型のフラグを立てるだけにして、実際の後片付けはメインループで行うことです。

#include <errno.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
 
static volatile sig_atomic_t stop_requested = 0;
 
static void on_signal(int sig)
{
    int saved_errno = errno;
    stop_requested = sig;                       /* フラグを立てるだけ */
    const char msg[] = "signal received\n";
    write(STDERR_FILENO, msg, sizeof(msg) - 1); /* write は async-signal-safe */
    errno = saved_errno;
}
 
int main(void)
{
    struct sigaction sa;
    memset(&sa, 0, sizeof(sa));
    sa.sa_handler = on_signal;
    sigemptyset(&sa.sa_mask);
    sa.sa_flags = 0;                            /* SA_RESTART なし: sleep を中断させる */
    sigaction(SIGTERM, &sa, NULL);
    sigaction(SIGINT, &sa, NULL);
 
    printf("pid=%d working...\n", getpid());
    fflush(stdout);
    while (!stop_requested) {
        sleep(10);                              /* 仕事の代わり */
    }
    /* ここはハンドラの外なので printf も使える */
    printf("got signal %d, flushing and exiting\n", (int)stop_requested);
    return EXIT_SUCCESS;
}

別の端末からSIGTERMを送ると、10秒のsleepが中断され、メインループがフラグに気付いて後片付けに入ります。

$ ./graceful &
pid=5444 working...
$ kill -TERM 5444
signal received
got signal 15, flushing and exiting
$ wait 5444; echo "exit=$?"
exit=0

signal(2)ではなくsigaction(2)を使っているのは、signal(2)のmanページ自身が、signal()の挙動はUNIXのバージョンによって異なるので使用を避け、sigaction(2)を使うよう警告しているためです。

後で見るように、Pythonなどの言語ランタイムは、この「低レベルのハンドラでは記録だけして、後で安全な場所で処理する」方式を内部で採っています。

コンテナのPID 1問題

PID 1は特別扱いされる

コンテナは独立したPID namespaceを持つため、コンテナ内で最初に起動したプロセスがPID 1になります(namespaceそのものはLinuxコンテナの記事で扱っています)。

pid_namespaces(7)のmanページには、次のように書かれています。

Only signals for which the "init" process has established a signal handler can be sent to the "init" process by other members of the PID namespace.

つまり、PID namespaceのinitプロセスには、自分でハンドラを登録したシグナルしか、同じnamespaceの他のメンバーから送れません。この制限は特権プロセスにも適用され、initを誤って殺してしまうことを防ぐためのものです。

普通のプロセスなら、SIGTERMのハンドラを書いていなくてもデフォルト動作(Term)で終了します。ところがPID 1になると、「デフォルト動作に任せる」ことができなくなります。ここから2つの問題が生まれます。

問題1: SIGTERMで止まらない

ハンドラを持たないアプリケーションがPID 1として動いていると、docker stopが送ったSIGTERMでは終了しません。Dockerのドキュメントによれば、docker stopはコンテナのメインプロセスにSIGTERMを送り、猶予期間を過ぎても終了しなければSIGKILLを送ります。デフォルトの猶予期間はLinuxコンテナで10秒、Windowsコンテナで30秒です。「docker stopすると毎回10秒待たされる」のは、SIGTERMが効かずにSIGKILLまで待っている典型的な症状です。

さらに見落としやすいのが、DockerfileのCMDやENTRYPOINTをシェル形式で書いた場合です。Dockerfileのリファレンスには、シェル形式のENTRYPOINTは/bin/sh -cのサブコマンドとして起動され、/bin/sh -cはシグナルを中継しないため、実行ファイルはコンテナのPID 1にならず、Unixシグナルも受け取らない、と書かれています。

# シェル形式: /bin/sh -c "node server.js" として起動される
# PID 1 は sh になり、node に SIGTERM が届かない
CMD node server.js
 
# exec形式: node 自身が PID 1 になる
CMD ["node", "server.js"]

どうしてもシェルスクリプトで前処理をしたい場合は、最後にexecで本体のプログラムに置き換えます。execによってシェル自身が本体に置き換わるので、本体がそのままPID 1を引き継ぎます。

#!/bin/sh
set -eu
# 環境変数からの設定ファイル生成などの前処理
./render-config.sh
# シェルを本体に置き換える(PID はそのまま)
exec "$@"

問題2: ゾンビが刈り取られない

もう1つの問題はゾンビです。前述のとおり、孤児になったプロセスはPID 1の養子になり、通常のinitは養子を自動でwaitします。しかしコンテナのPID 1がWebサーバーやアプリケーションそのものだと、自分が起動した覚えのない養子をwaitするコードを持っていません

たとえば、アプリケーションが外部コマンドを起動し、そのコマンドがさらに孫プロセスを作ってから先に終了すると、孫は孤児としてPID 1に付け替えられます。孫が終了してもPID 1がwaitしないので、ゾンビとして残り続けます。

シェルのtrapにも落とし穴がある

「シェルスクリプトをPID 1にしてtrapでSIGTERMを受ければよい」と考えるかもしれませんが、ここにも注意点があります。bashは、フォアグラウンドのコマンドの完了を待っている間に届いたシグナルのtrapを、そのコマンドが終わるまで実行しません。手元のbash 5.3で確かめてみます。

#!/usr/bin/env bash
# fg.sh: フォアグラウンドで sleep している間に SIGTERM を受ける
trap 'echo "trap: TERM"; exit 0' TERM
sleep 3
$ s=$(date +%s); ./fg.sh & sleep 0.5; kill -TERM $!; wait $!; echo "elapsed=$(( $(date +%s) - s ))s"
trap: TERM
elapsed=4s

0.5秒後にSIGTERMを送ったのに、trapが動いたのはsleep 3が終わった後でした。子をバックグラウンドで起動してwaitで待つと、waitはシグナルで中断されるので、すぐにtrapが動きます。

#!/usr/bin/env bash
# entry.sh: 子をバックグラウンドで起動し、SIGTERM を中継する
cleanup() {
  echo "trap: forwarding TERM to child $child"
  kill -TERM "$child" 2>/dev/null
  wait "$child"
  echo "trap: child exited with $?"
  exit 0
}
trap cleanup TERM
 
sleep 1000 &
child=$!
wait "$child"
$ ./entry.sh & sleep 0.5; kill -TERM $!; wait $!; echo "exit=$?"
trap: forwarding TERM to child 5927
trap: child exited with 143
exit=0

とはいえ、シグナルの中継とゾンビの刈り取りを毎回シェルで正しく書くのは大変です。そこで使われるのが専用の小さなinitです。

解決策: tiniとdocker run --init

tiniは、コンテナのPID 1として動くことを目的とした小さなinitです。GitHubのREADMEでは、役割として次の2点を挙げています。

  • 誤ってゾンビプロセスを作ってしまうソフトウェアから守る(ゾンビが溜まると、時間とともにシステム全体のPIDが枯渇しうる)
  • Dockerイメージ内で動かすソフトウェアで、デフォルトのシグナルハンドラが機能するようにする。たとえばtiniを使えば、明示的にハンドラを設定していなくてもSIGTERMでプロセスが正しく終了する

tiniはPID 1として起動し、指定されたプログラムを子として起動します。受け取ったシグナルは子に中継し、養子を含む終了した子はwaitで刈り取ります。アプリケーションはPID 1ではなくなるので、SIGTERMのデフォルト動作も通常どおり効きます。

  tini なし                          tini あり
  PID 1: node (ハンドラなし)          PID 1: tini
    SIGTERM -> 無視される               |  SIGTERM を中継、ゾンビを wait
    養子のゾンビ -> 溜まる              +-- PID 7: node
                                              SIGTERM のデフォルト動作で終了できる

READMEによれば、Docker 1.13以降にはtiniが組み込まれており、docker run--initフラグを付けるだけで使えます。docker runのリファレンスでも、--initは「シグナルを中継し、プロセスを刈り取るinitをコンテナ内で実行する」オプションと説明されています。

# Docker に組み込まれた init を使う
docker run --init my-image

イメージ側に組み込む場合は、READMEの例のようにENTRYPOINTでtiniを起動します。

ENV TINI_VERSION=v0.19.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"]

READMEに記載されている主なオプションは次のとおりです。

オプション説明
-sサブリーパーモード。tiniがPID 1として動けない環境で、Linux 3.4以降のサブリーパー機能を使ってゾンビを刈り取る
-gシグナルを直接の子だけでなく、子のプロセスグループ全体に送る
-e指定した終了コードを0にマッピングする(JavaアプリケーションがSIGTERMで143を返すような場合に使う)

TINI_VERSIONの値はREADMEの例に書かれているもので、最新のリリースは執筆時点で確認していません。導入時はGitHubのリリースページで確認してください。

Kubernetesの終了フロー

Podが削除されるときに起きること

Kubernetesでは、Podの削除(ローリングアップデートやスケールインを含む)のたびに、各コンテナにシグナルが送られます。Pod Lifecycleのドキュメントに沿って流れを整理すると、次のようになります。

  kubectl delete pod / ローリングアップデート
      |
      v
  PodがTerminatingになり、猶予期間(terminationGracePeriodSeconds)のカウントが始まる
      |
      +----------------------------------+
      |                                  |
      v                                  v
  kubelet: preStopフックがあれば実行      コントロールプレーン:
      |                                  PodをServiceのEndpointSliceから
      v                                  外すかどうかを評価(同時進行)
  kubelet: 各コンテナのメインプロセスに
           停止シグナル(デフォルトはTERM)
      |
      v
  猶予期間を過ぎても残っていれば KILL

ドキュメントで確認できた要点は次のとおりです。

  • 猶予期間terminationGracePeriodSecondsのデフォルトは30秒
  • preStopフックはTERMシグナルより前に実行され、フックの完了を待ってからTERMが送られる
  • 猶予期間が切れた時点でpreStopフックがまだ動いていると、kubeletは1回限り2秒の延長を要求する。フックにそれ以上の時間が必要なら、terminationGracePeriodSeconds自体を調整する
  • 猶予期間が切れると、残っているプロセスにKILLシグナルが送られる
  • 停止シグナルは、コンテナイメージのSTOPSIGNAL命令で変更できる

preStopの実行時間も猶予期間の30秒に含まれる点に注意してください。preStopで25秒待つと、アプリケーションがTERMを受けてから使える時間は残りの5秒ほどしかありません。

デプロイ時に502が出る理由とpreStopのsleep

ドキュメントには、kubeletがPodのgraceful shutdownを始めるのと同時に、コントロールプレーンがそのPodをEndpointSliceから外すかどうかを評価する、と書かれています。つまり「Serviceの振り分け先から外れてから、SIGTERMが届く」という順序は保証されていません。

EndpointSliceの更新が各ノードのkube-proxyやロードバランサに反映されるまでには時間がかかります。その間にアプリケーションがSIGTERMを受けて即座にリスナーを閉じると、まだ振り分けられてくる新しいリクエストが接続を拒否され、デプロイのたびに数件の502エラーとして見えます。

よく使われる対策が、preStopで数秒sleepして、振り分け先から外れるのを待ってからSIGTERMを受けることです。Kubernetesの公式ブログによれば、preStopとpostStartのフックで使えるsleepアクションはv1.29で導入され、v1.30からデフォルトで有効になっています。コンテナイメージにsleepコマンドが入っていない(distrolessなど)場合でも使えます。

apiVersion: v1
kind: Pod
metadata:
  name: web
spec:
  terminationGracePeriodSeconds: 45
  containers:
    - name: app
      image: example/web:1.0
      lifecycle:
        preStop:
          sleep:
            seconds: 5   # 振り分け先から外れるのを待つ

sleepの秒数は環境(kube-proxyの反映速度やクラウドのロードバランサ)によって適切な値が変わるため、ここでの5秒はあくまで例です。「preStopのsleep」「アプリケーションの後片付け」「予備」の合計がterminationGracePeriodSecondsに収まるように設計します。

同じブログでは、v1.33からsleepの秒数に0を指定できるようになったことと、コンテナのlifecycle.stopSignalフィールドでPod仕様から停止シグナルを指定できる機能が、ContainerStopSignalsフィーチャーゲートによるalpha機能としてv1.33で追加されたことが紹介されています(この機能はspec.os.nameの指定を必要とします)。stopSignalがその後のバージョンでbetaやGAに昇格したかどうかは、この記事では未確認です。

言語ごとのgraceful shutdown実装

アプリケーション側でやるべきことは、どの言語でもほぼ同じです。

  1. SIGTERM(と、ローカル開発用にSIGINT)のハンドラを登録する
  2. シグナルを受けたら、新しい仕事の受け付けを止める(リスナーを閉じる、キューからの取得をやめる)
  3. 処理中の仕事を終わらせる(リクエストの応答、トランザクションのコミット)
  4. データベース接続やファイルなどのリソースを閉じる
  5. 猶予期間より短い上限時間を設け、それを超えたら諦めて終了する

Node.js

Node.jsのドキュメントによれば、Windows以外のプラットフォームではSIGTERMSIGINTにデフォルトのハンドラがあり、端末モードをリセットしてから128 + シグナル番号の終了コードで終了します。そしてリスナーを1つでも登録すると、そのデフォルト動作は取り除かれます。つまり、ハンドラを登録したら、終了処理の最後に自分でprocess.exit()を呼ぶか、イベントループを空にして自然に終了させる責任が生じます。

同じドキュメントには、次の点も書かれています。

  • SIGPIPEはデフォルトで無視される
  • SIGKILLSIGSTOPにはリスナーを登録できない
  • Windowsはシグナルをネイティブにサポートしておらず、SIGINTSIGTERMSIGKILLを送ると無条件に終了する

HTTPサーバーのgraceful shutdownの例です。

const http = require('node:http');
 
const server = http.createServer((req, res) => {
  // 時間のかかるリクエストを模擬する
  setTimeout(() => res.end('done\n'), 3000);
});
 
server.listen(3456, () => {
  console.log(`pid=${process.pid} listening on :3456`);
});
 
let shuttingDown = false;
 
function shutdown(signal) {
  if (shuttingDown) return;
  shuttingDown = true;
  console.log(`${signal} received, closing server`);
 
  // 新規接続の受付を止め、処理中のリクエストが終わったらコールバックが呼ばれる
  server.close((err) => {
    if (err) {
      console.error(err);
      process.exit(1);
    }
    console.log('all connections closed, bye');
    process.exit(0);
  });
 
  // アイドルなkeep-alive接続は待たずに閉じる
  server.closeIdleConnections();
 
  // 猶予期間より短い上限を設け、それでも終わらなければ諦める
  setTimeout(() => {
    console.error('timeout, forcing exit');
    process.exit(1);
  }, 10_000).unref();
}
 
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));

Node.js v24.11.0で、3秒かかるリクエストの処理中にSIGTERMを送った結果です。リクエストへの応答を返し終えてから終了していることが分かります。

$ node server.js &
pid=5737 listening on :3456
$ curl -s localhost:3456 &     # 3秒かかるリクエスト
$ kill -TERM 5737              # その処理中に SIGTERM
SIGTERM received, closing server
done                           # curl が応答を受け取る
all connections closed, bye
$ wait 5737; echo "exit=$?"
exit=0

比較のため、ハンドラを登録していないNode.jsプロセスにSIGTERMを送ると、デフォルトのハンドラによって終了コード143で終了しました。

$ node -e 'setInterval(() => {}, 1000)' &
$ kill -TERM $!; wait $!; echo "exit=$?"
exit=143

注意: npm startのように、npmなどのラッパー経由でNode.jsを起動すると、PID 1がラッパーになり、シグナルがアプリケーションに届くかどうかがラッパーの実装に依存します。コンテナではCMD ["node", "server.js"]のように直接起動するのが確実です。

Python

Pythonのsignalモジュールのドキュメントによれば、SIGINTは親プロセスが変更していなければKeyboardInterrupt例外に変換されます。一方でSIGTERMにはPythonとしてのハンドラが設定されていないため、OSのデフォルト動作で終了し、finally節やコンテキストマネージャの後片付けは実行されません。

また、同じドキュメントには、Pythonのシグナルハンドラの実行方式について次の点が書かれています。

  • Pythonのシグナルハンドラは、低レベル(C)のシグナルハンドラの中では実行されない。低レベルのハンドラはフラグを立てるだけで、Pythonの仮想マシンが後で(たとえば次のバイトコード命令で)Pythonのハンドラを実行する
  • Pythonのシグナルハンドラは、シグナルが別のスレッドで受信された場合でも、常にメインインタプリタのメインスレッドで実行される

前者は、Cの章で説明した「ハンドラではフラグを立てるだけ」という定石を、ランタイムが代わりにやってくれているということです。そのため、Pythonのハンドラではprintなども使えます。

import os
import signal
import threading
import time
 
stop = threading.Event()
 
def handle(signum, frame):
    print(f"received {signal.Signals(signum).name}", flush=True)
    stop.set()
 
signal.signal(signal.SIGTERM, handle)
signal.signal(signal.SIGINT, handle)
 
print(f"pid={os.getpid()} working...", flush=True)
while not stop.is_set():
    # 1件分の仕事をしてから、停止要求を確認する
    time.sleep(0.5)
 
print("cleanup done, exiting", flush=True)

Python 3.14.7での実行結果です。

$ python3 worker.py &
pid=5526 working...
$ kill -TERM 5526
received SIGTERM
cleanup done, exiting
$ wait 5526; echo "exit=$?"
exit=0

ハンドラはメインスレッドで実行されるため、メインスレッドが長時間のC拡張の処理などでブロックしていると、ハンドラの実行が遅れる点に注意が必要です。

実務チェックリスト

最後に、ここまでの内容を運用上のチェックリストにまとめます。

  • 止めるときは、まずSIGTERM、待ってからSIGKILL。いきなりkill -9を使うと、バッファのフラッシュや一時ファイルの削除などの後片付けが行われません
  • アプリケーションはSIGTERMを受けたら、受け付け停止、処理中の完了、リソース解放の順で終了する。上限時間を設け、猶予期間より先に自分で終わらせます
  • DockerfileのCMDとENTRYPOINTはexec形式で書く。前処理にシェルスクリプトを使うなら、最後はexec "$@"で本体に置き換えます
  • コンテナのPID 1には、シグナルの中継とゾンビの刈り取りができるものを置く。自分で子プロセスを起動するアプリケーションなら、docker run --initやtiniを使います
  • Kubernetesでは、preStopのsleepとterminationGracePeriodSecondsをセットで設計する。preStopの時間も猶予期間に含まれます
  • 終了コード137と143を読み分ける。143はSIGTERMで終了、137はSIGKILLで終了です。137なら、OOM Killerか猶予期間切れを疑います
  • シグナルハンドラの中ではasync-signal-safeな関数だけを使う。Cではフラグを立てるだけにし、errnoを保存して戻します
  • 子プロセスを起動したら必ずwaitする。SIGCHLDは複数回分がまとめて1回になりうるので、WNOHANG付きでループします

まとめ

  • UNIXのプロセスは、forkで複製され、execで中身を置き換え、終了後は親にwaitで回収されるまでゾンビとして残ります
  • 親が先に終了した子はinit(またはサブリーパー)の養子になり、通常はinitが自動でwaitして後始末します
  • シグナルにはTerm、Ign、Core、Stop、Contのデフォルト動作があり、SIGTERMは後片付けのできる終了要求、SIGKILLは捕捉できない強制終了です
  • 標準シグナルはキューイングされず、ハンドラはどこで割り込むか分からないため、ハンドラ内ではasync-signal-safeな関数だけを使い、フラグを立てて本処理はメインループで行うのが定石です
  • コンテナのPID 1には、ハンドラを登録したシグナルしか届かず、養子のゾンビも自動では刈り取られません。exec形式での起動と、docker run --initやtiniで対処します
  • Kubernetesでは、preStopの後にTERMが送られ、デフォルト30秒の猶予期間を過ぎるとKILLされます。EndpointSliceからの除外は同時進行なので、preStopのsleepで振り分け停止を待つのが有効です
  • Node.jsはSIGTERMのリスナーを登録するとデフォルトの終了動作が外れ、PythonのシグナルハンドラはメインスレッドでPythonのコードとして後から実行されます

参考リンク

CPUスケジューラの仕組み - 古典アルゴリズムからCFS、EEVDF、sched_extまで

CPUスケジューラの仕組み - 古典アルゴリズムからCFS、EEVDF、sched_extまで

46

CPUスケジューラの仕組みを、スループット・レイテンシ・公平性のトレードオフ、FIFOやMLFQなどの古典アルゴリズム、LinuxのCFSからEEVDFへの移行、sched_ext、スケジューリングクラス、cgroupのcpu.maxによるスロットリング、観測方法まで一次ソース付きで解説します。

ファイルシステムの仕組み 徹底解説 - inode・ジャーナリング・ページキャッシュ

ファイルシステムの仕組み 徹底解説 - inode・ジャーナリング・ページキャッシュ

63

ファイルを開いて書き込んで閉じるまでにカーネルとディスクで何が起きているのかを、VFS・inode・ディレクトリエントリ・エクステント・ページキャッシュ・ext4のジャーナリング・CoWファイルシステムまで順に追います。man7.orgとkernel.orgの一次情報に加え、実機のext4とmacOSで測った出力例を添えて解説します。