成功したジョブを失敗扱いする grep -q と pipefail の落とし穴

  • #シェルスクリプト
  • #launchd
  • #障害対応
成功したジョブを失敗扱いする grep -q と pipefail の落とし穴

今回やったこと / この記事について

launchd のジョブを入れ替えるスクリプトで、成功した処理を失敗と判定する問題を修正した。原因は set -o pipefail の有効なスクリプトで、launchctl list | awk | grep -qx を成功確認に使っていたことだった。grep -q は一致すると読み取りを早く終えるため、上流プロセスが SIGPIPE で終了し、パイプ全体が失敗になることがある。この記事では、確認コマンドをどう変えたかと、巻き戻しで状態を食い違わせないために加えた確認を記録する。

症状はラベルの位置で変わった

修正対象の scripts/ops/launchd_install.sh は、リポジトリ内の plist を各機の LaunchAgents に配置し、ジョブを再読み込みするスクリプトだ。plist は環境変数を展開しないため、ホームディレクトリのパスを各環境に合わせて書き換えてから配置する。実行中のジョブには触れず、入れ替えでは bootout の後に状態を確認して bootstrap する。

以前の成功確認では、launchctl list の出力を awk で処理して grep -qx に渡していた。grep -q は一致した時点で終了する。後続の読み取りを止められた上流が SIGPIPE(終了コード141)で終わると、pipefail はパイプライン内の失敗を全体の失敗として返す。つまり、探していたラベルが見つかっているのに、確認処理は失敗と判定されうる。

再現条件には出力順も関わった。実機で fleet-maintenance-watch は一覧の614行中62行目にあり失敗し、fleet-maintenance-run は462行目にあり通った。ジョブ自体の状態ではなく、対象ラベルが一覧のどこに現れるかで結果が変わった。成功確認の結果が表示順に依存するのは、監視や配備スクリプトにとって危険な兆候である。

確認対象一覧内の位置旧方式の結果
fleet-maintenance-watch614行中62行目偽の失敗が発生
fleet-maintenance-run614行中462行目通過

パイプをやめ、状態を直接確認する

修正後は、launchctl list の出力を絞り込むパイプではなく、対象サービスの状態を launchctl print で直接確かめる。実装では次のように、成功確認を1つの関数へまとめている。

loaded() { launchctl print "$DOMAIN/$1" >/dev/null 2>&1; }

この確認関数は、サービスが見つかれば成功、見つからなければ失敗を返す。パイプの途中で読み手が終了する構造をなくせるため、grep -q の早期終了による SIGPIPE を成功判定に持ち込まない。

ただし、コマンドが戻った直後に状態が必ず反映されているとは限らない。スクリプトは wait_state で loaded / unloaded の状態を最大約7秒確認する。bootout が完了する前に bootstrap を急ぐと EIO になることがあるため、外れたことを確かめてから新しい plist を読み込む。bootstrap が失敗を返した後に実際は読み込まれている場合もあるので、表示結果と実状態の両方を見る。

巻き戻しは「未ロード」を確認してから

もう一つの問題は、失敗時の復旧処理だった。新しい plist でジョブが読み込まれているのに、古い plist をディスクへ戻して再度 bootstrap すると、「ディスクは古い定義、launchd は新しい定義」という食い違いが残りうる。実際のスクリプトには、bootstrap 後の状態を確かめ、失敗時の巻き戻しをする処理がある。

そこで、古い plist に戻す前にサービスがロードされていないことを確認する。まだロードされているなら、ディスク上のファイルを上書きせず、状態と手動対応の情報を出す。bootout に失敗してジョブが外れなかった場合も、以前の plist があればディスク側を元の定義に揃え、新しい定義の bootstrap には進まない。

この順序は、失敗を隠さず、稼働中の定義を不用意に変えないために必要だ。単に「エラーなら古いファイルをコピーする」だけでは、ファイルと実行中サービスのどちらが新しいかを判別できない。復旧処理も通常処理と同じく、状態を観測してから操作する。

回帰テストで正常系と失敗系を分ける

scripts/test_launchd_install.sh は launchctl、plutil、sleep をスタブに置き換え、実際のOSに依存せず状態遷移を検証する。テストには、ラベルが一覧の先頭に出るケース、bootstrap直後の表示遅延、bootoutの遅延、エラーを返すが実際には読み込まれているケース、本当に読み込めないケースが含まれている。

この構成で確認したいのは、成功時に成功と判定することだけではない。失敗時に終了コード1で知らせること、古い定義へ戻すならディスクと launchd の状態を揃えること、実行中のジョブには触らないことも対象になる。回帰テストのコメントには、古い実装を LAUNCHD_INSTALL_SCRIPT で指定して比較できることも記録されている。

状態期待する動作
新規登録・置き換え成功exit 0、ディスクと launchd が新しい定義
bootstrap が遅れて反映待って確認し、誤って巻き戻さない
新しい定義を読み込めないexit 1で知らせ、可能なら旧定義へ戻す
実行中のジョブ既定では変更せず skip

まとめ

pipefail を有効にしたシェルでは、grep -q の早期終了が上流の SIGPIPE を招き、見つかった対象を失敗扱いすることがある。成功確認を launchctl print に置き換え、状態の反映を待ってから次の操作へ進むことで、一覧の並び順に依存する判定をなくした。さらに、失敗時は未ロードを確かめてから巻き戻すようにし、ファイルと実行中の定義がずれる危険を抑えた。

試験環境は launchctl 等をファイル状態のスタブに置き換えたシェルテストで、OSに依存しない。対象の実装・テストは scripts/ops/launchd_install.sh と scripts/test_launchd_install.sh。実機ごとの全ての launchd 状態を網羅するものではないため、運用時は出力された状態確認も追う。

更新履歴: 2026-10-17 初稿。