ssh-add -l の終了コード 1 と 2 を区別せず、ssh-agent が 2,250 個たまった
シェル起動時に ssh-add -l の失敗で ssh-agent を立て直す書き方は、鍵が無いだけの状態でも agent を作り直し、前の agent を孤児として残す。
まとめ
ssh-add -lの終了コードは 0(鍵あり)、1(agent は動いているが鍵なし)、2(agent に繋がらない)の 3 つあるif ! ssh-add -lで agent を立て直すと、1 のときも作り直してしまう。前の agent はソケットだけ消されて残る- シェルを起動するたびに 1 個ずつ漏れ、テストで zsh を大量に起動したら 2,250 個まで増えて、プロセス数の上限に当たった
- 立て直すのは 2 のときだけにし、1 のときは鍵を足すだけにする
何が起きたか
dotfiles のテストを回していたら、途中から fork failed: resource temporarily unavailable が出始めた。テストどころか、シェルの起動すら失敗する。プロセスを名前ごとに数えると、ほとんどが ssh-agent だった。
ps -A -o comm= | sort | uniq -c | sort -rn | head -3
# 2250 ssh-agent
# 52 Google Chrome Helper (Renderer)
# 20 /usr/sbin/distnoted
sysctl -n kern.maxprocperuid
# 2666残っていた agent はすべて親が PID 1(親を失った孤児)で、引数はどれも ssh-agent -a ~/.ssh/agent.sock だった。起動時刻は、テストをまとめて回した日ごとに数百個ずつ固まっていた。
原因は .zshenv の起動条件
以前 Claude Code で SSH_AUTH_SOCK が見えない問題 を直したときに、.zshenv へ次の処理を入れていた。
export SSH_AUTH_SOCK="$HOME/.ssh/agent.sock"
if ! ssh-add -l &>/dev/null 2>&1; then
rm -f "$SSH_AUTH_SOCK"
eval "$(ssh-agent -a "$SSH_AUTH_SOCK")" >/dev/null 2>&1
ssh-add --apple-use-keychain ~/.ssh/id_ed25519 2>/dev/null
fissh-add -l が失敗したら、ソケットを消して agent を立て直す。問題は「失敗」に 2 種類あることだった。man ssh-add の EXIT STATUS には「指定したコマンドが失敗したら 1、agent に接続できなければ 2」とある。-l の場合、agent に鍵が 1 つも無いと 1 になる。
表1: ssh-add -l の終了コードと、元の書き方の動き
| 終了コード | 状態 | 元の書き方の動き |
|---|---|---|
| 0 | agent に鍵が入っている | 何もしない |
| 1 | agent は動いているが鍵が無い | ソケットを消して agent を新しく立てる |
| 2 | agent に繋がらない | ソケットを消して agent を新しく立てる |
1 のときに立て直すのが漏れの原因になる。rm -f で消えるのはソケットのファイルだけで、前の agent のプロセスは動き続ける。誰からも接続できない agent が 1 個残る。新しい agent の鍵の追加が何らかの理由で通らなければ、次に起動したシェルも 1 を見て、また 1 個残す。シェルを開くたびに agent が 1 個ずつ増えていく。
再現と修正
鍵の無い agent を一時ディレクトリの HOME に立て、その HOME で zsh を 3 回起動して、ssh-agent の数を比べた。
表2: 鍵の無い agent がある状態で zsh を 3 回起動したときの増え方
| 版 | ssh-agent の増加 |
|---|---|
| 元の書き方 | +3 |
| 修正後 | +0 |
修正後は終了コードで分岐する。
ssh-add -l &>/dev/null
case $? in
1)
ssh-add --apple-use-keychain ~/.ssh/id_ed25519 2>/dev/null
;;
2)
rm -f "$SSH_AUTH_SOCK"
eval "$(ssh-agent -a "$SSH_AUTH_SOCK")" >/dev/null 2>&1
ssh-add --apple-use-keychain ~/.ssh/id_ed25519 2>/dev/null
;;
esac残っていた孤児は、ソケットを今持っている agent(lsof -t ~/.ssh/agent.sock の中で一番新しく起動したもの)以外を止めて片付けた。
修正後も残っている漏れ
修正後にテストを並列で回したところ、ssh-agent はまた 100 個以上に増えた。複数のシェルが同時に起動し、どれもソケットが無い状態(終了コード 2)を見ると、それぞれが agent を立てて取り合いになるためだと考えている。ただし原因は確かめておらず、ロックを入れるといった対策もまだしていない。
持ち帰り
コマンドの失敗を if ! cmd でまとめて受けると、終了コードが持つ区別を捨てることになる。とくに「作り直す」処理をつなげるときは、作り直しが必要な終了コードを man で確かめてから、そのコードだけで分岐する。作り直しのたびに前のものを片付けない処理は、条件を少し誤るだけで、この件のように静かにたまっていく。