lsofをRust製「Sniper」ボタンに置き換えた理由
目次
ローカル開発で localhost:3000 や localhost: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を覚える問題ではなく、文脈切り替えの問題
lsof と kill は難しいコマンドではない。
でも、作業中に何度も挟まると邪魔になる。
かなちゃん更新のように、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では sysinfo と lsof を使うと説明されている。
実装上の役割分担はだいたいこうなる。
| 領域 | 使っているもの | 役割 |
|---|---|---|
| UI | React, Tailwind CSS, Lucide | ポート一覧、検索、Killボタン |
| デスクトップ統合 | Tauri v2 | トレイアプリ、アップデータ、OS連携 |
| プロセス情報 | Rust, sysinfo | PIDからプロセス名やCPU・メモリを取る |
| 待受ポート検出 | lsof | TCP LISTEN中のポートを列挙する |
| 終了処理 | kill -9 | PIDに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だけでなくプロセス名と起動引数が見えるか | node や python だけでは区別しにくい |
| 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、各種プロキシが奪い合う。
ローカルでよく被る番号を並べるとこうなる。
| ポート | デフォルトで使うサービス | 備考 |
|---|---|---|
| 3000 | Next.js, CRA, Rails, Grafana | 最激戦区 |
| 4321 | Astro | 比較的平和 |
| 5000 | Flask, uvicorn | macOS AirPlayと衝突 |
| 5173 | Vite | Vite専用でほぼ安全 |
| 5432 | PostgreSQL | Homebrew自動起動で気づかず占有 |
| 6379 | Redis | 同上 |
| 8000 | Django, FastAPI, python http.server | 汎用すぎて常に危険 |
| 8080 | Spring Boot, Jenkins, プロキシ各種 | Java + CI系で衝突 |
| 8188 | ComfyUI | AI画像生成で固定 |
| 8888 | Jupyter Notebook | データ分析で固定 |
| 11434 | Ollama | ローカル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 process | parent / child | zombie | orphan |
| 日本語 | プロセスを殺す | 親 / 子 | ゾンビ | 孤児 |
| 中国語 | 杀死进程 | 父进程 / 子进程 | 僵尸进程 | 孤儿进程 |
| フランス語 | tuer un processus | père / fils | zombie | orphelin |
| ドイツ語 | Prozess töten | Elternprozess / Kindprozess | Zombie | verwaist |
| スペイン語 | matar un proceso | padre / hijo | zombi | hué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を押しながら、ちょっとだけ気になっている。