やってみた 2026年8月9日

プロセスの出自を追うwitrをコンテナで動かしたら、終了コードがドキュメントと食い違った

XECIN GoCLIコンテナトラブルシュート

GitHubのトレンドで pranshuparmar/witr を見かけて、説明文の一行で引っかかりました。「Why is this running?」。プロセスやポートを、それを起動した張本人まで遡って説明してくれる、と。

自分がこれを気にするのは、フロントの開発コンテナでポートが埋まっているときなんですよね。5173 が使えない、でも中に入って ss を叩こうとしたら ss: command not found。そこから apt-get update && apt-get install -y iproute2 を打つところまでが、もはや様式美になっていて。

その様式美を1コマンドで飛ばせるなら嬉しいなと思って、使い捨てのコンテナで実際に動かしてみました。結果としては「ポート周りは期待以上」「ただしCIに組み込む前に知っておくべき挙動が3つあった」という感じです。

試した環境

ホストを汚したくないので、検証は使い捨ての Docker コンテナの中だけで完結させています。以下の数値はすべてその中での実測です。

  • 対象: pranshuparmar/witr(commit dc4fa1d / 2026-08-08、VERSION0.3.3、Apache-2.0)
  • ベースイメージ: golang:1.25-bookworm(Debian GNU/Linux 12、go1.25.12 linux/amd64)
  • ビルド: go build ./cmd/witr が 12,061ms、できたバイナリは 11,125,164 バイト

go.modgo 1.25 を要求するので、そこだけ注意です。手癖で golang:1.22 を選ぶと最初のビルドで止まります。

# すべて使い捨てコンテナ内
git clone --depth 1 https://github.com/pranshuparmar/witr.git   # 1,023ms
cd witr && go build -o /usr/local/bin/witr ./cmd/witr           # 12,061ms
witr --version
# -> witr v0.3.3 (commit dc4fa1d, built 2026-08-08T03:33:54Z)

リリース済みバイナリを取ってくる curl -fsSL .../install.sh | bash の方が当然速いんですが、今回は「今日の main で何が起きるか」を見たかったので、ソースからにしました。

ss も lsof も無いコンテナで、ポートの主が一発で分かった

まずここが良かったところです。検証に使ったコンテナには、ネットワーク系のツールが本当に何も入っていませんでした。

ss       NOT-INSTALLED
lsof     NOT-INSTALLED
netstat  NOT-INSTALLED
fuser    NOT-INSTALLED

この状態で、自作の小さなHTTPサーバを 127.0.0.1:80810.0.0.0:8082 の2つで起動して、ポートから引いてみます。

$ witr --port 8082 --no-color
Target      : demosrv

Process     : demosrv (pid 2933) {forked}
User        : root
Container   : docker (d61dd6b073df)
Command     : demosrv 0.0.0.0:8082
Started     : just now (Sun 2026-08-09 12:08:08 +00:00)

Why It Exists :
  bash (pid 2927) → demosrv (pid 2933)

Source      : docker (container)

Working Dir : /go
Sockets     : 0.0.0.0:8082 (TCP | LISTENING)
              [::]:8082 (TCP6 | LISTENING)

Warnings    :
  • Process is listening on a public interface
  • Process is running as root

外部コマンドに頼らず /proc を直接読んでいるので、追加インストールなしで当ててくれます。0.0.0.0 で待ち受けている方にだけ「public interface で listen している」という警告が出て、127.0.0.1:8081 の方には出ませんでした。ここは素直に賢いです。

ファイル側から引くのも同じ感覚で、witr --file /tmp/p2.log と打つと、そのファイルを標準出力として掴んだままのプロセスが出てきました。カレントディレクトリがGitリポジトリの中にあると Git Repo : witr (main) の行が増えるのも、地味に効きます。

速度は、プロセスが19個しかいない小さな名前空間での話ですが、1回のルックアップが3〜4msでした。実機の数百プロセス環境でどうなるかまでは測れていないので、ここは自分の使い方次第だと思います。

終了コードが「契約」になっている、はずだった

witr が面白いのは、READMEに終了コードの表がきちんと定義されていることです。0=クリーン、1=警告あり、2=見つからない、3=権限不足、4=不正な入力、5=内部エラー。スクリプトやCIから使うことを明確に想定した設計で、実際READMEにも case $? のサンプルが載っています。

なので、ひととおり潰して測ってみました。ここで1つ自分がやらかしていて、最初は witr ... | head の後ろで $? を読んでいたんです。当然それは head の終了コードなので、全部0に見えていました。今思えば当たり前なんですが、危うく「終了コードが機能していない」と誤読するところでした。パイプを外して測り直したのが以下です。

実行したコマンド実測ドキュメントの意味
witr —pid 2999(一般ユーザのプロセス)0クリーン
witr —pid 1(rootのプロセス)1警告あり
witr definitely-no-such-proc2見つからない
witr demo(あいまい一致で複数ヒット)4不正な入力・あいまい一致
witr —container d61dd6b073df5内部エラー(表示は「見つからない」)
witr -i(TTYの無い環境)1警告あり(実際は起動失敗)

大半は表のとおりで、そこは気持ちよく揃っています。引っかかったのは下2行です。

--container の方は、画面には「見つかりませんでした」としか書いていないのに、返るのは 5 でした。

$ witr --container d61dd6b073df --no-color; echo "rc=$?"
no container found matching "d61dd6b073df"

No matching process or service found. Please check your query or try a different name/port/PID.
For usage and options, run: witr --help
rc=5

気になったのでソースを追ったら、原因がはっきり分かりました。終了コードは、エラーメッセージの文字列を部分一致で分類して決めています。

// internal/app/app.go
func classifyError(err error) int {
	msg := strings.ToLower(err.Error())
	switch {
	case strings.Contains(msg, "permission denied") || ...:
		return ExitPermission
	case strings.Contains(msg, "no matching") ||
		strings.Contains(msg, "no running process") ||
		strings.Contains(msg, "not found") ||
		strings.Contains(msg, "no process"):
		return ExitNotFound
	...

一方、コンテナ解決の失敗時に作られるメッセージは no container found matching %q です。no matching でも not found でもない、found matching という並び。一語ぶんズレているせいで、どの分岐にも当たらず既定の内部エラーに落ちる、というわけです。正直なところ、これは書いた人を責める気には全くならなくて、文字列で分類する仕組みを持つと必ずどこかで起きるやつだなと思いました。

もうひとつ、TTYの無い環境で -i(TUI)を叩くと、起動に失敗しているのに 1 が返ります。

$ witr -i --no-color; echo "rc=$?"
Error: error running tui: could not open a new TTY: open /dev/tty: no such device or address
rc=1

CIのログで 1 を見たら、普通は「対象は見つかったが警告があった」と読みます。ここが起動失敗と同じ番号なのは、自動判定には少し使いにくいところです。ちなみに docker exec -t で擬似端末を与えると今度は普通に起動して、タイムアウトで殺されるまで動き続けました(画面の取得はできていません)。

そしてもうひとつ、CIで使うなら効いてくる前提があります。Dockerコンテナの中は基本rootなので、警告「Process is running as root」が常に出て、終了コードは常に1になる。実際、上の表で 0 が返ったのは、わざわざ一般ユーザを作って起動したプロセスだけでした。if witr foo; then のような素朴な書き方をすると、コンテナ内では永遠に偽になります。

--json は環境変数を丸ごと吐き出す

これは知らずに使うと事故ると思ったので、独立して書いておきます。

人間向けの標準出力は、--env を付けない限り環境変数を一切表示しません。ところが --json は、--env の有無に関係なく環境変数を含めます。しかも対象プロセスだけでなく、Ancestry(祖先プロセス)の各要素にも入ります。

ダミーの秘密情報を持たせたプロセスで確かめました。

# 検証用のダミー値(実在しないキー)
$ witr --pid 3087 --json | grep -iE "AWS_SECRET|DB_PASSWORD"
      "DB_PASSWORD=hunter2",
      "AWS_SECRET_ACCESS_KEY=DUMMY-not-a-real-key-1234",
        "AWS_SECRET_ACCESS_KEY=DUMMY-not-a-real-key-1234",
        "DB_PASSWORD=hunter2",

sleep 1個を調べただけのJSONが7,224バイトになって、同じ値が計4か所(対象プロセス+祖先3つ)に平文で並びました。

--json はREADMEでもスクリプトやCI向けの出力として案内されている入口です。そこがそのままログに流れると、docker inspect を叩くまでもなく秘密が出てしまう。警告の実装を見ても、環境変数まわりで警告を出すのは LD_PRELOADDYLD_*(ライブラリインジェクション疑い)だけで、秘密っぽい名前のキーは検出対象になっていません。パイプの先には気をつけたいところです。

コンテナの中だと「誰が起動したか」の前提が崩れる

witr の中核は Why It Exists の行、つまり親をたどった因果の鎖です。ここはちゃんと動きます。3段の親子関係を作ってやると、きれいに出ました。

$ witr --pid 2129 --short --no-color
bash (pid 2122) → bash (pid 2128) → sleep (pid 2129)

ただ、この鎖を作るのに一手間かかったのが面白くて。最初 bash -c 'bash -c "sleep 9999"' で3段のつもりだったのに、ps で見たら sleep 1個しかいませんでした。bashが最後のコマンドをexecで置き換える最適化のせいですね。; true を足して初めて3プロセスに分かれました。witr は嘘をついていなくて、自分の作ったテストデータの方が間違っていた、という話です。

一方で、コンテナならではの崩れ方も2つ見えました。

ひとつ目。docker exec で起動したプロセスは、コンテナのPID名前空間から見ると親が外にいるので PPID が 0 になります。すると鎖は自分自身の1行だけになって、「なぜ動いているのか」に答えられません。名前空間の中から見える範囲では、これは仕方のないことだと思います。

ふたつ目。親が先に終了して孤児になったプロセスは、名前空間のPID 1に引き取られます。今回のコンテナのPID 1は sleep infinity だったので、こうなりました。

Why It Exists :
  sleep (pid 1) → bash (pid 2997) → sleep (pid 2999)

sleep infinity は何も起動していません。引き取っただけです。それでも鎖の先頭に立ってしまう。PPIDを辿る以上こうなるのは理解できるんですが、「原因」として提示されると一瞬信じそうになるので、コンテナ内で読むときは頭の隅に置いておきたいところです。

関連して、出力に出る {forked} の判定も気になって覗いてみました。

// internal/proc/process_linux.go
// Fork detection: if ppid != 1 and not systemd, likely forked; ...
if ppid != 1 && comm != "systemd" {
	forked = "forked"
} else {
	forked = "not-forked"
}

「PID 1 に親がいる=initに引き取られた=forkされたわけではない」というホスト前提のヒューリスティックです。これがコンテナの中では反転します。実際、ppid が 0 のコンテナのPID 1には {forked} が付き、逆にPID 1に引き取られた孤児のbashには付きませんでした。表示としては本来の意図と逆になっていて、ここは読み流すのが吉かなと思っています。

最後に細かい点をひとつ。--short は祖先だけを表示するのに対し、--tree は祖先に加えて直接の子まで出します。--help ではどちらも ancestry と説明されているので、最初は表示が食い違って見えて戸惑いました。

$ witr --pid 2122 --short   # 祖先のみ(自分が先頭)
bash (pid 2122)

$ witr --pid 2122 --tree    # 子が1段ぶん増える
bash (pid 2122)
  └─ bash (pid 2128)

試してみての所感

自分の当初の目的、つまり「ツールが何も入っていないコンテナで、ポートを掴んでいるやつを特定する」については、witr は完全に期待どおりでした。apt-get install iproute2 の様式美から解放されるだけでも、開発コンテナに1本入れておく価値はあると思います。単一の静的バイナリなので、置くのも捨てるのも楽です。

一方で、CIやスクリプトに組み込むのは、もう少し様子を見ます。終了コードが「契約」として整理されているのは良い設計だと思うんですが、その契約を実現している仕組みがエラーメッセージの部分一致であることと、コンテナ内では root 警告で常に1が返ることを踏まえると、case $? に素直に載せるのは今の自分には怖いです。当面は人間が読むための道具として使って、機械判定には使わない、という線引きにしておきます。

--json の環境変数まわりは、使う前に知っておいて本当に良かったところです。ここは好みが分かれるところですが、自分だったら既定では伏せて、--env を付けたときだけ出す方が安心して使えるかなと思いました。

もっと良い使い方があったら教えてください。特に、実機の数百プロセス環境での体感速度は誰かの実測を見てみたいです。