技術 約8分で読めます

lsofをRust製「Sniper」ボタンに置き換えた理由

いけさん目次

ローカル開発で localhost:3000localhost:8000 が埋まっていると、だいたい一回手が止まる。
前に起動した Vite、FastAPI、ComfyUI、何かの検証用サーバー、どれかが生き残っている。

いつもの流れはこうだ。

lsof -i :3000
kill -9 <PID>

DEV Communityで出ていた Why I Replaced lsof with a Rust-Based “Sniper” Button は、この面倒を Recoil というTauri製トレイアプリで潰す話だった。
原典は「lsofを置き換えた」と書いているが、GitHubの実装を見ると少し違う。
Recoilは lsof を完全に捨てているのではなく、Rust側から lsof -iTCP -sTCP:LISTEN -P -n を叩き、結果をGUIで扱いやすくしている。

つまり置き換わったのは lsof そのものではなく、ポート確認からPID指定、終了までの手作業だ。

lsofを覚える問題ではなく、文脈切り替えの問題

lsofkill は難しいコマンドではない。
でも、作業中に何度も挟まると邪魔になる。

かなちゃん更新のように、FastAPI、WebSocket、Tailscale Serve、CLIワーカーを組み合わせる環境だと、ローカルに複数の待受ポートが立つ。
かなチャット v2のアーキテクチャ変更点 でも localhost:8000 を Tailscale Serve 経由でHTTPS化していたが、この手の構成は「どれが今ポートを掴んでいるか」をすぐ見たい。

ComfyUIも同じで、API経由のヘッドレス実行やテスト起動を繰り返すと、前のプロセスが残っていて次の起動が失敗することがある。
WAI-Anima v1をRTX 4060 LaptopのComfyUIでAPI経由で動かすまでの記録 みたいな検証では、生成の中身より先に環境の足回りで詰まる時間がいちばん腹立つ。

Recoilが狙っているのは、この「またポートかよ」をトレイから潰す体験だ。
ポート、PID、プロセス名を一覧し、Sniper Buttonで終了する。

flowchart TD
    A["開発サーバー起動"] --> B{"port already in use"}
    B --> C["Recoilで待受ポートを見る"]
    C --> D["PIDとプロセス名を確認"]
    D --> E["Sniper Buttonで終了"]
    E --> F["開発サーバーを再起動"]

Rustが効いている場所と効いていない場所

Recoilの構成は、フロントエンドが React 19 + Vite、デスクトップ側が Tauri v2、バックエンドが Rust。
READMEでは sysinfolsof を使うと説明されている。

実装上の役割分担はだいたいこうなる。

領域使っているもの役割
UIReact, Tailwind CSS, Lucideポート一覧、検索、Killボタン
デスクトップ統合Tauri v2トレイアプリ、アップデータ、OS連携
プロセス情報Rust, sysinfoPIDからプロセス名やCPU・メモリを取る
待受ポート検出lsofTCP LISTEN中のポートを列挙する
終了処理kill -9PIDにSIGKILLを送る

ここは少し注意が必要だ。
「Rust製だから lsof より速い」という話ではない。
少なくとも現行の公開コードでは、ポート列挙は lsof を外部コマンドとして呼んでいる。

Rustが効いているのは、常駐アプリとしての薄さ、Tauri IPC、プロセス情報の整形、UIを固めない設計のほうだ。
Electronで150MB級の常駐アプリを立てるより、TauriでOSのWebViewを使うほうが小さく済む、という原典の判断は分かる。

この方向は以前書いた TauriでマルチペインSFTPクライアントは作れるのか調べた と近い。
ファイル転送でもポート監視でも、UIはWeb技術で作り、OSに近い処理だけRustへ寄せる。
ブラウザ内ツールでは触りにくい領域を、ネイティブアプリとして薄く包む使い方だ。

Sniper Buttonは便利だが、kill -9は強い

Recoilの「Sniper」は気持ちいい名前だが、現行実装では kill -9 を使っている。
これはSIGKILLなので、対象プロセスに終了処理の猶予を与えない。

開発サーバーを落とすだけなら問題になりにくい。
でも、DB、キュー、ファイル書き込み中のツール、学習や生成の途中プロセスを巻き込むと、後片付けが走らない。

自分の環境で使うなら、ここは見たい。

確認点理由
PIDだけでなくプロセス名と起動引数が見えるかnodepython だけでは区別しにくい
SIGTERMを先に送れるか正常終了できるプロセスはそちらが安全
ポート単位でフィルタできるか3000、5173、8000、8188あたりをすぐ見たい
管理者権限が必要なプロセスをどう扱うかmacOSでは他ユーザー所有プロセスを落とせない場合がある
テレメトリを切れるかv1.4.3ではAptabaseのイベント計測が入っている

Recoilの公式サイトでは v1.4.3 の変更点として、プライバシー配慮型分析、tokioによる非同期処理、起動成功・プロセス操作テレメトリが挙げられている。
個人のローカル開発ツールとしては、何を送っているかを確認してから入れたい。

CLIワンライナーで足りる場合も多い

RecoilみたいなGUIが必要かは、頻度で決まる。

たまに詰まるだけなら、シェル関数で十分だ。

portkill() {
  local port="$1"
  local pid
  pid=$(lsof -tiTCP:"$port" -sTCP:LISTEN)
  if [ -z "$pid" ]; then
    echo "no listener on port $port"
    return 0
  fi
  echo "$pid"
  kill "$pid"
}

まずSIGTERMを送り、それでも残ったら kill -9 にするほうが荒くない。
複数PIDが返る場合や、プロセス名を確認したい場合は、この関数を少し厚くする。

一方で、GUIがあると「今どのポートが空いていないか」を一覧できる。
かなちゃん、LLM APIプロキシ、ComfyUI、Vite、Astro、Playwrightのテストサーバーを行き来していると、問題は単発のPIDではなく、ローカル環境全体の視界になる。

Recoilはその視界をトレイに置くツールだ。
Rustで lsof を魔法のように消したわけではないが、毎回コマンドを打つ気力を消してくれるなら、それは十分に実用的だと思う。

固定ポートで被りやすいサービスたち

ポートが埋まる原因は「前のプロセスが残っている」だけではない。
異なるツールがデフォルトで同じ番号を使っているケースが結構ある。

いちばん激しいのは :3000 で、Next.js、Create React App、Ruby on Rails、Grafanaが全部ここをデフォルトにしている。
フロントエンドを2プロジェクト並行で触るだけで衝突する。

:5000 も厄介だ。
FlaskやuvicornのデフォルトだがmacOS Monterey(12.0)以降、AirPlay Receiverがこのポートを占有するようになった。
Flaskを起動して Address already in use が出て、lsof したら ControlCe が返ってくる、あの現象だ。
システム設定 → 一般 → AirDropとHandoff → AirPlayレシーバーをオフにすれば解放されるが、知らないと時間が溶ける。

:8000 も Django、FastAPI、python -m http.server と汎用すぎて常に衝突の候補だし、:8080 は Spring Boot、Jenkins、各種プロキシが奪い合う。

ローカルでよく被る番号を並べるとこうなる。

ポートデフォルトで使うサービス備考
3000Next.js, CRA, Rails, Grafana最激戦区
4321Astro比較的平和
5000Flask, uvicornmacOS AirPlayと衝突
5173ViteVite専用でほぼ安全
5432PostgreSQLHomebrew自動起動で気づかず占有
6379Redis同上
8000Django, FastAPI, python http.server汎用すぎて常に危険
8080Spring Boot, Jenkins, プロキシ各種Java + CI系で衝突
8188ComfyUIAI画像生成で固定
8888Jupyter Notebookデータ分析で固定
11434OllamaローカルLLMで固定

DB系は特に注意が要る。
PostgreSQLやRedisはHomebrewでインストールすると brew services でログイン時に自動起動される設定になることがある。
自分で起動した覚えがないのにポートが埋まっていたら、brew services list を確認する。
Docker Composeで同じDBを立てようとして、Homebrew側のサービスと衝突するのも定番のハマりポイントだ。

黙ってポートを変えるツールが一番厄介

衝突時の挙動はツールによって全然違う。

ViteやWebpack Dev Serverは、指定ポートが埋まっていると自動で次の番号を探す。
Viteなら5173、5174、5175と連番で上がっていく。
便利だが、前の検証を閉じ忘れると、気づかないうちにViteが3つ走っている。

Next.jsはターミナルで「別のポートで起動するか?」と聞いてくる。
Flask、Rails、Djangoは素朴に Address already in use で死ぬ。

一見、自動で避けてくれるほうが親切に見える。
でも厄介なのはこっちだ。
ポートが変わったことにブラウザ側は気づかないので、古いタブをリロードすると前のインスタンスが応答する。
「コード変えたのに反映されない」と悩む時間が発生して、原因が別ポートで起動していたことに気づくまでが遠い。

Recoilのポート一覧はこの場面で効く。
5173〜5175に3つNodeが立っているのが見えれば、古い2つをSniperで落として終わりだ。

各国のプログラマも「殺して」いるのか

プロセスの話をしていると、会話が物騒になる。
「親を殺す前に子を殺せ」「ゾンビが残ってる」「孤児になったプロセスをinitが引き取る」。
隣で聞いていたら通報されそうだが、UNIX系OSでは全部正式な用語だ。

POSIXがシグナル名に SIGKILL を定めた時点で、「殺す」は仕様に組み込まれた。
kill コマンド、親プロセス(parent)、子プロセス(child)、孤児プロセス(orphan)、ゾンビプロセス(zombie)。
英語圏の比喩というより、OSの設計用語として世界中に輸出されている。

各国の技術ドキュメントを見ると、例外なく直訳か音訳で「殺す」を使っている。

言語プロセスを殺す親 / 子ゾンビ孤児
英語kill a processparent / childzombieorphan
日本語プロセスを殺す親 / 子ゾンビ孤児
中国語杀死进程父进程 / 子进程僵尸进程孤儿进程
フランス語tuer un processuspère / filszombieorphelin
ドイツ語Prozess tötenElternprozess / KindprozessZombieverwaist
スペイン語matar un procesopadre / hijozombihuérfano
ロシア語убить процессродительский / дочернийзомбисирота
韓国語프로세스를 죽이다부모 / 자식좀비고아

全部殺している。

「殺す」以外にも物騒な語彙が溢れている。
fork bomb(プロセスを無限増殖させてシステムを食い潰す攻撃)、deadlock(全員動けなくなる膠着状態)、fatal error(致命的エラー)、reap(ゾンビプロセスを「刈り取る」)。
Recoilの「Sniper Button」もこの伝統の延長にある。

2020年前後に master/slave を primary/replica に置き換える動きが広がったのは記憶に新しい。
GitHubのデフォルトブランチ名、Pythonの内部用語、DBレプリケーションの呼称。
「変えようがない」と言われていた用語が、実際にかなりの範囲で書き換わった。

kill、zombie、orphan にはまだ誰も手をつけていない。
代替語が思いつかないのもあるし、POSIXのシグナル名を変えたら互換性が壊滅する。
でも master/slave だって同じことを言われていた。

果たしてこの先も、プログラマは使えないプロセスを「殺す」ことができるのか。
Sniper Buttonを押しながら、ちょっとだけ気になっている。

参照