kill のバグ十年史

kill コマンドの打ち間違い一つでマシン上の全プロセスが落ちた。原因は 2013 年の getopt 書き換えで、負の pid の欠陥が 4 つの Ubuntu LTS 版とともにリリースされた。

日本語
コピー

2026年9月24日の午前、Endcrawl で短時間の障害が起きた。そのとき、私はちょうどこれを打ち込んだところだった:

sudo kill -TERM -1490947

すべてのサービスが即座に落ちた。

最初は自分のせいだと思った。数字を打ち間違えたんじゃないか!コードにシグナル処理のバグがあるんじゃないか。Unix の理解は自分が思っているほど深くないんじゃないか。そもそも自分は愚か者なんじゃないか!

ところが、この障害は kill(1) のバグが原因だった。

まったく予想外だ。kill は50年の歴史を持つプログラムで、仕様はシンプルで安定しており、1日に10億回ほど呼び出されている。

kill のバグはあまりに意外で、見つけたあと、そこで止まることができなかった。このバグがどうやって生まれたのか、何がプログラマーをここまで大きく誤らせたのかを知りたかった。最終的に根本原因を突き止めて、さらに驚いた。出てきたのは1つのバグではなく、kill のバグの一連の連鎖で、Ubuntu 14、16、18、22、24 LTS とともにリリースされていたのだ。

本稿はこの10年間の kill のバグを記録し、最後にいくつかの提言を示す。

正体のわからない障害はまた起きる

障害の話に戻る。私は慌てて火消しにあたった。消し終わったところで、予想外にも二つ目の火が上がった。すべてが終わったころにはかなり落ち込んでいて、自分の愚かなミスを認めて仕事に戻ろうとしていた。だが、どうにも引っかかるものがあった……

『バーン・アフター・リーディング』の最後のシーンを覚えているだろうか?

「俺たちは何を学んだ、Palmer?」 「知らん、長官」 「俺にもわからん。たぶん、もう二度とやるなってことだ」 「はい、長官」 「だが、俺たちが一体何をやったのか、俺にもさっぱりわからん……」

さっき一体何が起きたのか。また三度目の障害に遭うのではないか。どうすれば確信できるのか。

正体のわからない障害は、また起きる障害だ。あの引っかかりは、解明しろと私を急かしていた。ため息をついて他の仕事をすべて脇に置き、腰を据えて振り返りを始めた。

直接の原因

障害を引き起こしたコマンドを見てみよう:

sudo kill -TERM -1490947

ここで負の数はプロセスグループ識別子、略して pgid だ。pgid は pid に似ているが、関連するプロセスの集まりを識別する。慣例として、正の数を kill すると単一のプロセスにシグナルを送り、負の数を kill するとそのプロセスグループ内のすべてのプロセスにシグナルを送る。

私は当時、Endcrawl の大規模な 7k テストレンダリングをキャンセルしようとしていた。普通なら関連するプロセスの一つを殺し、失敗を他のプロセスへ連鎖させる。だが私には、プロセスグループごとまとめて殺すほうがきれいに見えた。

プロセスグループへのシグナルは珍しい操作ではない。誰もが使っている。^C でコマンドを実行するたび、tty はフォアグラウンドのプロセスグループに SIGINT を送っている。対話型シェルは最も単純なコマンド以外をすべて新しいプロセスグループで実行する。これもジョブ制御が成り立つ理由の一つだ。

プロセスグループへのシグナルは、実のところ Unix の優れた発明だ。私はそれが気に入りすぎてdaemontools に取り入れた。おかげで svc -+h は、複数プロセスのサービスを再起動するときに孤児プロセスを残さなくなった。

ともかく、私がどれほど驚いたか想像してほしい。このコマンドはレンダリングのプロセスだけでなく、マシン上の1つを除くすべてのプロセスを殺したのだ!

バグ

以下は procps 4.0.4 の該当するコード断片だ。Ubuntu 24.04 LTS の /bin/kill はここから来ている。バグがどこかわかるだろうか?

opterr=0; /* suppress errors on -123 */
while ((i = getopt_long(argc, argv, "l::Ls:hVq:", longopts, &optindex)) != -1)
  switch (i) {
    /* ... */
    case '?':
      if (!isdigit(optopt)) {
        xwarnx(_("invalid argument %c"), optopt);
        print_usage(stderr);
      } else {
        /* Special case for signal digit negative
         * PIDs */
        pid = (long)('0' - optopt);
        if (!execute_kill((pid_t) pid, signo, use_sigqueue, sigval))
          exitvalue = EXIT_FAILURE;
        exit(exitvalue);
      }
    /* ... */
  }

私の最初の推測は間違っていた。-1490947 にアンダーフローやオーバーフローの問題はない。

注目すべきは、認識できないオプションの switch 分岐に入っていることだ。getopt(3) は認識できないオプションについてこう述べている:

デフォルトでは、getopt() は標準エラーにエラーメッセージを出力し、問題のオプション文字を optopt に入れ、関数の結果として '?' を返す。

そう、これだ。optopt 変数にはハイフンのあとの最初の1文字しか入っておらず、負の pid 全体は入っていない。文字 0 から文字 1 を引くと、pid は -1 になる。pid -1 を kill するとどうなるか。結果は深刻だ:

pid が -1 に等しい場合、sig は呼び出しプロセスがシグナルを送る権限を持つすべてのプロセスに送られる。ただしプロセス1(init)は除く。

このコードは root として動いている。だから -1490947 を -1 と誤って解釈しただけで、init 以外のすべてのプロセスを殺してしまった。バグは見つかり、謎は解けた。

待て、だがなぜだ

しかし私には、むしろ疑問が増えた。なぜコードは、認識できないオプションを「signal [原文ママ] digit negative PID」と決めつけるのか。なぜ1桁の負の pid だけ特別扱いし、複数桁の負の pid という一般的なケースは誰が扱うのか。

しかも、こんなバグが2026年に、しかも Ubuntu LTS で出るとはどういうことか。ずっと前からあったのか。

いや、ありえない。私は以前プロセスグループを殺したことがある。kill -TERM -12345 の類は古くからある Unix の呪文だ。これは以前、確かに動いていたはずだ!

思い返せば、負の pid の kill がうまくいかなかった記憶がおぼろげにある。「ああ、目の前のものを全部殺してしまった」という類ではない。「おかしい、何も起きない」とか「あっ、シェルが消えた」といった程度だ。どれも小さなことで、ユーザーの操作ミスとして片づけやすかった。

調べてみると、思い違いではなかった。この10年、負の pid でプロセスを殺す側にとっては厳しい時代だった。負の pid をめぐるバグは少なくとも4つある。しかもそのすべてが Ubuntu LTS としてリリースされている。

歴史

4つのバグの話に入る前に、Unix の歴史をもう少しさかのぼっておく。

Unix には最初からプロセスがあった。1973年には kill(1) プログラムが登場している。だがプロセスグループが今のような形に落ち着くまでには10年かかった。1986年の Unix V9 でようやく、負の pid によるプロセスグループの kill ができるようになる。私が知る限り、BSD 系では負の pid での kill はずっと問題なく動いてきた。

90年代初頭、Linux というものが現れる。Linux のユーザーランドは当初 BSD のコードを大量に借用していたが、90年代の終わりにはそれが法的に危うくなった。すべてを書き直し、車輪をひとつ残らず再発明する必要があった。しかも急いで。Unix プログラマにとって胸躍る時代である。

こうして Linux の kill が再発明された。作者は Albert Calahan。Albert 自身が5つの子プロセスを fork して自分を DoS してしまったあと、Debian の下流メンテナである Craig Small が、誰にも顧みられなくなった procps を引き継いだ。

負の pid での kill は2013年まで問題なく動いていたように見えた。そこから厄介なことになる。

Bug 1:Ubuntu 14 では -1 を殺せない

Ubuntu 14.04 LTS には procps 3.3.9 が入っている。それ以前の kill のコードは BSD 系と同じく、ポインタ演算で argv を走査して解析していた。

個人的にはこのやり方が好きだ。Stallman は好きではなかった。GNU の世界では、コマンドラインの解析は getopt で標準化されるべきということになっている。一般論としてはおそらく良い方針だが、これから見るように kill(1) という具体的な場面では、ほぼ間違いなく悪い選択だった。

以下が getopt に置き換えたあとの該当コードだ:

opterr=0; /* suppress errors on -123 */
while (loop == 1 && (i = getopt_long(argc, argv, "l::Ls:hV", longopts, NULL)) != -1)
  switch (i) {
    /* ... */
    case '?':
      if (!isdigit(optopt)) {
        xwarnx(_("invalid argument %c"), optopt);
        kill_usage(stderr);
      }
      loop=0;
      break;
    /* ... */
  }

argc -= optind + sigopt;
argv += optind;

for (i = 0; i < argc; i++) {
  pid = strtol_or_err(argv[i], _("failed to parse argument"));
  if (!kill((pid_t) pid, signo))
    continue;
  exitvalue = EXIT_FAILURE;
  continue;
}

このコードは複数桁の負の pid を問題なく扱う。だが、折を見て kill -HUP -1 で全サービスを再起動するのが趣味という独創的な sysadmin なら、バグに気づくだろう。何も起きないのだ。

原因はオプションの結合にある。

  1. 複数桁の場合:kill -HUP -123 を渡すと、getopt は -1、-2、-3 を別々に渡したのと同じように振る舞う。認識できないオプションの分岐が3回ヒットし、-3 になってようやく optind をインクリメントして -123 引数を消費する。コードは -1 で getopt ループを早期に抜けるので、消費されなかった -123 引数は正しく pid killer に渡る。
  2. 1桁の場合:kill -HUP -1 を渡すと、getopt はループを抜ける前にこの引数を完全に消費し、optind をインクリメントしてしまう。pid killer には引数が届かず、何も起きない。

Bug 2:Ubuntu 16 では -1 を誤って殺す

何とかしないといけない。あいにく、薬は病よりたちが悪かった。

Ubuntu 16.04 LTS には procps 3.3.10 が入っている。kill -HUP -1 のバグは直ったが、その代償として複数桁の負の pid を殺せなくなった。kill -TERM -12345 がすべてを殺してしまう。

これが Ubuntu 24 で障害を引き起こしたバグそのもので、先ほど分析したとおりだ。不可解なのは一点だけ、Ubuntu 24 の時期の procps が、Ubuntu 16 の時点で問題と分かっていたコードをなぜか再び取り込んだことだ。以下のバグのほうがより深刻だと判断されたのだろう。

Bug 3:Ubuntu 18 では -1 を殺すとセグフォする

Ubuntu 18.04 LTS には procps 3.3.12 が入っている。

このバージョンは複数桁の負の pid の kill を直そうとした。新しいコードは optopt を解析せず(-123 を渡したとき、対応するのは -1 のような結合された1文字だけだ)、そこで止まって引数全体を解析しようとする:

opterr=0; /* suppress errors on -123 */
while (loop == 1 && (i = getopt_long(argc, argv, "l::Ls:hV", longopts, NULL)) != -1)
  switch (i) {
    /* ... */
    case '?':
      if (!isdigit(optopt)) {
        xwarnx(_("invalid argument %c"), optopt);
        kill_usage(stderr);
      } else {
        /* Special case for signal digit negative
         * PIDs */
        pid = atoi(argv[optind]);
        if (kill((pid_t)pid, signo) != 0)
        exitvalue = EXIT_FAILURE;
        exit(exitvalue);
      }
      loop=0;
      break;
    /* ... */
  }

複数桁の負の pid は正しく扱えるようになった。だが kill -TERM -1 のような1桁の負の pid を渡すと、pid 引数はすでに消費済みで、optind が argv[] の末尾を越えてしまう。セグメンテーションフォールトだ。

Bug 4:Ubuntu 22 ではシェルを殺す

Ubuntu 22.04 LTS には procps 3.3.17 が入っている。

このバージョンは optind が argv[] の末尾を越えないようにして、セグフォを直した:

opterr=0; /* suppress errors on -123 */
while ((i = getopt_long(argc, argv, "l::Ls:hVq:", longopts, NULL)) != -1)
  switch (i) {
    /* ... */
    case '?':
      if (!isdigit(optopt)) {
        xwarnx(_("invalid argument %c"), optopt);
        kill_usage(stderr);
      } else {
        /* Special case for signal digit negative
         * PIDs */
        pid = atoi(argv[optind-1]);
        if (kill((pid_t)pid, signo) != 0)
        exitvalue = EXIT_FAILURE;
        exit(exitvalue);
      }
      xerrx(EXIT_FAILURE, _("internal error"));
    /* ... */
  }

ここでもまた、getopt のオプション結合の仕組みと optind がどう噛み合うかを理解していない貢献者がいたらしい。ここまで読んだ読者なら、彼らにいくらか同情していることだろう。このパッチで kill -TERM -1 はセグフォせず動くようになった。だが複数桁の負の pid の kill は、今度はまったく別の形で二度目に壊れる。

kill -TERM -123 を実行したとしよう。オプション結合のせいで、認識できないオプションの分岐に来た時点では optind はまだインクリメントされていない。そこに後述するある奇妙な挙動が加わり、この時点で argv[optind-1] はすでに文字列 kill になっている。

atoi(3) は "kill" のような非数値文字列を渡されるとどうするか。0 を返す。

では、kill システムコールが 0 を受け取ると何をするのか?

pid が 0 なら、sig は呼び出し元プロセスが属するプロセスグループの全プロセスに送られる。

単純コマンドなので、shell は kill -TERM -123 を新しいプロセスグループには入れない。つまりこのバグは、あなたの shell を不意打ちで殺してしまう。ドカン。

何がわかったか、Palmer?

いくつか言っておきたいことがある。

狭い教訓

ひとつ目。getopt が optind を進めるのは、最後の結合オプションを処理するときだけだ。これがまったく直感的でない。この挙動を理解していたコントリビューターがいた証拠は見つからなかった。私自身も最初はわからず、慎重に調べて初めて理解した。

これは本当に予想外だ。こうした驚きを生む挙動は避けるべきである。

ただ、この教訓は狭すぎると思う。そもそも人がこの手の予想外の getopt 挙動を避けられるのか? オプションのマージは無効にできない。

広い教訓

Getopt は有害だ。

まあ、少なくともこの具体的な場面では成り立つ。kill(1) というプログラム自体が異端なのだ。受け付けるオプションは実はシグナル名で、たとえば -KILL。同じシグナルは番号でも指定できる。たとえば -9。そして pgid は負の数で、これがまたオプションそっくりに見える。

kill -1 -9 のようなコマンドラインはどう解釈すべきか?

  • pgid 9 に SIGHUP を送る?
  • pgid 1(つまりすべて)に SIGKILL を送る?
  • pgid 1 と 9 にデフォルトの SIGTERM を送る?

argv の各要素がオプションにも pid にもなりうる限り、この三つの解釈はすべて成り立つ。

解決策は argv を位置ベースで解析することだ。ここで getopt は使わない。オプションの結合や並べ替えといった機能は捨てる。この場面では、そうした機能がもたらす落とし穴のほうが利便性よりはるかに大きい。POSIX 仕様では、送るシグナルを指定できるのは argv[1] だけで、それ以降の引数はすべて pid と pgid だと定めている。この単純化された前提がよく効く。

昔ながらの Unix や BSD のやり方が正しいと思う。FreeBSD の kill.c を見てほしい。70 年代の先祖伝来の Unix と同じく、argv を位置ベースで解析している。発想が単純で、コードも単純だ。対して Linux の kill にはコマンドライン解析のバグがあり、FreeBSD の kill には一つもない。

getopt の教条こそが、この障害の真の根本原因だと私は本気で思っている。

もう半分まで来ている

getopt を捨てて「原始的な」argv 解析に切り替えることにまだ罪悪感があるなら、procps が getopt_long を呼ぶ前に何をしているかを見てほしい。

if (argc < 2)
  print_usage(stderr);

signo = skill_sig_option(&argc, argv);
if (signo < 0)
  signo = SIGTERM;

opterr=0; /* suppress errors on -123 */
while ((i = getopt_long(argc, argv, "l::Ls:hVq:", longopts, &optindex)) != -1)
/* ... */

skill_sig_option は何をするのか?

int skill_sig_option(int *argc, char **argv)
{
  int i;
  int signo = -1;
  for (i = 1; i < *argc; i++) {
    if (argv[i][0] == '-') {
      signo = signal_name_to_number(argv[i] + 1);
      if (-1 < signo) {
        memmove(argv + i, argv + i + 1,
            sizeof(char *) * (*argc - i));
        (*argc)--;
        return signo;
      }
    }
  }
  return signo;
}

argv を走査して、最初のシグナル指定を探す。-KILL や -9 のようなものを見つけると、その場で argv を書き換える(!)。getopt がそれを処理しなくて済むようにするためだ。

これはもう「原始的な」argv 解析そのものだ。すでに半分まで来ている。

実用的なアドバイス

ここまではソフトウェア開発者向けの結論だった。kill のユーザーには次のように勧める。

第一に、-- でオプションと pid・pgid を分ける。

kill -TERM -1490947     # no
kill -TERM -- -1490947  # yes

上で挙げたバグのある procps のバージョンでも、二番目の書き方なら正しく解析できる。

第二に、kill は最小限の権限で実行する。あのとき sudo -u render kill ではなく sudo kill を使っていれば、マシン上の全プロセスではなく、進行中のレンダリングプロセスだけが死んでいた。こうした防御的な使い方を勧めると自分が少し神経質に思えてくる。断っておくと、私たちが防いでいるのは kill のバグであって、kill は小さくて成熟した、広く使われている極めて重要なツールだ。それでも、事実は事実だ。

最後に、良い知らせとして、執筆時点で Ubuntu 24 の修正が進行中だ。それでも私は procps の kill を簡潔に書き直すべきだと考えている。

出典: alangrow.com← ホームへ戻る