投稿

ssh-add -l の終了コード 1 と 2 を区別せず、ssh-agent が 2,250 個たまった

シェル起動時に ssh-add -l の失敗で ssh-agent を立て直す書き方は、鍵が無いだけの状態でも agent を作り直し、前の agent を孤児として残す。

ssh-add -l の終了コード 1 と 2 を区別せず、ssh-agent が 2,250 個たまった

まとめ

  • 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
fi

ssh-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 で確かめてから、そのコードだけで分岐する。作り直しのたびに前のものを片付けない処理は、条件を少し誤るだけで、この件のように静かにたまっていく。