アルアカ - Arcadia Academia

Arcadia Academiaは「エンジニアリングを楽しむ」を合言葉に日本のデジタル競争力を高めることをミッションとするテックコミュニティです。

bash の grep -q が pipefail で誤判定する|存在チェックが毎回失敗する原因と対処

Featured image of the post

「起動ガードを入れたはずなのに、実行するたびにプロセスが増えていく」「grep は確かにヒットしているのに、if の判定だけが毎回『無い』になる」「開発機では再現しないのに、本番のサーバでだけおかしい」——set -euo pipefail を付けたシェルスクリプトで、パイプの先に grep -q を置いたときに起きる現象です。

やっかいなのは、エラーメッセージが一切出ないことです。スクリプトは正常終了し、ログにも何も残らないまま、存在チェックだけが静かに壊れます。この記事では、なぜそうなるのか、自分のスクリプトに同じ穴が空いていないかをどう確認するのか、そして安全な書き換え方を扱います。

💡
30秒で要点
grep -q は最初のマッチで即座に終了する。その瞬間にパイプの読み口が閉じ、まだ書いている途中の前段は SIGPIPE で殺される(終了ステータス141)
set -o pipefail はその141をパイプライン全体のステータスにするため、マッチしたのに失敗として扱われる
症状は「毎回作り直す」「毎回スキップする」だけで、エラーは出ない
前段の出力が小さいと発生しない。データが増えた日に突然壊れ始めるのはこのため
直し方は「パイプの中で早期終了させない」。変数に受けてから判定するか、grep -c で件数を取る

目次を開く

症状:ガードが毎回すり抜ける

典型はこの形です。「無ければ作る」という起動時のガード。

#!/bin/bash
set -euo pipefail

# 目的のユニットが登録されていなければ作る
if ! systemctl list-units --all --no-pager | grep -q 'myapp.service'; then
  create_unit
fi

出力に myapp.service は確かに含まれています。手で叩けば見つかります。にもかかわらず create_unit が毎回走り、二重登録・二重起動が積み上がっていきます。

痕跡が残らないのが厄介な点です。set -e を付けていても止まりません。if の条件部で失敗したコマンドは set -e の対象外だからです。ログには「作りました」と出るだけで、それが2回目・3回目だとは書かれません。

原因:SIGPIPE と pipefail の噛み合わせ

3つの独立した振る舞いが重なって起きます。

grep -q は最初のマッチで即座に終了する

-q(quiet)は「マッチが1つでもあれば成功として抜ける」オプションです。最後まで読む必要がないので、見つけた瞬間に終了します。これは意図された高速化であって、それ自体は正しい動きです。

② 読み口が閉じると、書き手は SIGPIPE で殺される

パイプの受け手が終了したあと、書き手が次に書き込もうとした時点で SIGPIPE が送られます。既定の動作はプロセスの即時終了で、終了ステータスは 141(128 + シグナル番号13)になります。

つまり前段と grep -q の組み合わせは、マッチしたときに限って前段が異常終了するという、直感に反した構造を持っています。

pipefail がその141を拾い上げる

set -o pipefail は、パイプライン全体の終了ステータスを「0以外を返した最も右のコマンドの値」に変えるオプションです。これが無ければ最後のコマンド(grep)の 0 がそのまま採用されますが、有効にしていると前段の141が優先されます。

結果を表にすると、両方の場合が同じ判定に潰れているのが分かります。

状態 grep のステータス 前段のステータス パイプライン全体 if ! の判定
目的の文字列が無い 1(不一致) 0(最後まで出力できた) 1 真 → 作る(正しい)
目的の文字列がある 0(一致) 141(SIGPIPE) 141 真 → また作る(誤り)

「ある」ときも「ない」ときも if ! が真になるので、ガードは常にすり抜けます。

出力が小さいと再現しない

この不具合には再現条件があります。パイプにはカーネル側のバッファ(Linux の既定で64KiB)があり、前段の出力がそこに収まりきる場合、書き手はブロックせずに書き終えて正常終了できます。このとき SIGPIPE は発生せず、パイプライン全体も 0 で返ります。

そのため、次のような現れ方をします。

  • 手元の小さなテストデータでは正しく動く
  • 本番でリストが育ち、出力が64KiBを超えた日に突然壊れ始める
  • 出力サイズが境界付近だと、実行ごとに通ったり通らなかったりする

「昨日まで動いていたのに」「あの環境でだけ再現する」の正体はこれです。コードは1行も変わっていません。

自分のスクリプトを点検する

疑わしいパイプラインは、各段のステータスを個別に見れば一発で判定できます。bash なら PIPESTATUS に配列で入っています。

set -o pipefail

journalctl -u myapp --no-pager | grep -q 'started'; parts=("${PIPESTATUS[@]}")
echo "前段=${parts[0]} grep=${parts[1]}"

前段=141 grep=0 と出れば確定です。grep は成功しているのに、パイプライン全体は失敗している状態が目で見えます。

PIPESTATUS は次のコマンドを実行した時点で上書きされる点に注意してください。先に echo $? を挟むと消えるので、上のように同じ行のうちに配列へコピーします。

既存のスクリプトをまとめて洗うなら、危険な組み合わせを機械的に列挙します。

grep -rn -E '\|[[:space:]]*(grep -[a-zA-Z]*q|head |read )' --include='*.sh' .

ヒットしたうち、戻り値を判定に使っているものだけが対象です。単に出力を表示しているだけなら実害はありません。

直し方:3つの選択肢

方法 向いている場面 代償
A. 変数に受けてから判定 新規・書き換えできるコード全般 出力を丸ごとメモリに載せる
B. grep -c で件数を取る 正規表現での照合が必要なとき 入力を最後まで読むぶん遅い
C. その行だけ pipefail を外す 既存コードに手を入れたくないとき 前段の本当の失敗も見逃す

A. 出力を変数に受けてから判定する

set -euo pipefail

units="$(systemctl list-units --all --no-pager)"
case "$units" in
  *myapp.service*) echo "登録済み" ;;
  *)               create_unit ;;
esac

パイプそのものを無くすので、SIGPIPE が起きる余地がありません。case によるパターン照合は外部コマンドを呼ばないぶん速く、単純な部分一致ならこれが最も素直です。出力が巨大でメモリに載せたくない場合は、いったんファイルへ落としてから読みます。

B. 件数を数える

n="$(systemctl list-units --all --no-pager | grep -c 'myapp.service' || true)"
test "$n" -gt 0 || create_unit

grep -c は入力を最後まで読むので、前段が SIGPIPE で死ぬことはありません。ただしマッチ0件のとき `grep` は終了ステータス1を返すため、|| true で受けないと set -e で落ちます。

C. そのパイプラインだけ pipefail を外す

set +o pipefail
systemctl list-units --all --no-pager | grep -q 'myapp.service'
found=$?
set -o pipefail

test "$found" -eq 0 || create_unit

変更量は最小ですが、そのパイプラインでは前段の本当の失敗(コマンドが無い・権限が無い)も見逃すようになります。書き換えの余地が無いときの最後の手段と考えてください。

なお ... | grep -q ... || true で握りつぶすのは誤りです。141 も 1 も両方 0 に潰れるため、今度は常に「ある」と判定されるようになり、症状が反転するだけです。

同じ型の罠:head と read

原因は「早期終了するコマンドをパイプの受け手に置いたこと」なので、grep -q に限った話ではありません。pipefail 下では次のすべてが同じ構造を持ちます。

  • cmd | head -n 1 — 1行読んだら終了する
  • cmd | grep -m 1 'pat' — 1件マッチしたら読むのをやめる
  • cmd | read var — 1行読んで終わる

いずれも「戻り値を判定に使う」場合だけが問題です。存在チェックにパイプ+早期終了コマンドを使わない、を規則として持っておくと、この一族をまとめて避けられます。

まとめ

  • set -o pipefailgrep -q の組み合わせは、マッチしたときにパイプライン全体が141で失敗する
  • 原因は grep -q の早期終了で読み口が閉じ、前段が SIGPIPE を受けること
  • エラーは一切出ず、「毎回作り直す」「毎回スキップする」という動作だけが症状として出る
  • 前段の出力が64KiBに収まると発生しないため、データが増えた日に突然壊れる
  • 点検は PIPESTATUS を配列で取り、前段が141・grep が0 になっていないかを見る
  • 直し方は変数に受けて case で照合するのが基本。grep -c で件数を取る方法もある
  • || true での握りつぶしは判定を反転させるだけで、直したことにならない

よくある質問(FAQ)

Q. set -o pipefail を使うのをやめれば解決しますか?

症状は消えますが、勧められません。pipefail はパイプの前段が失敗したことを検知するための安全装置で、外すとこのパイプライン以外のすべての箇所で前段の失敗を見逃すようになります。壊れているのは判定の書き方の側なので、そちらを直してください。POSIX sh(dash など)には pipefail が無いため、シェバンを #!/bin/sh に変えると直ったように見えますが、これも安全装置を捨てているだけです。

Q. grep -q の代わりに grep -m 1 を使えば直りますか?

直りません。-m 1 も1件マッチした時点で読むのをやめるので、前段が SIGPIPE を受ける条件は変わりません。「読むのを途中でやめるかどうか」が分かれ目なので、最後まで読む grep -c に替えるか、パイプ自体をやめる必要があります。手元の版の挙動は grep --help で確認してください。

Q. どれくらいの出力サイズから再現しますか?

Linux のパイプバッファは既定で64KiBなので、それがおおよその目安です。ただし前段がどこまで書き進んだ時点で受け手が終了するかはタイミング次第なので、境界付近では実行ごとに結果が変わります。確実に再現させたいときは、明らかに大きな出力(大量のログや全件ダンプ)で試してください。逆に言えば、小さな出力で書いたテストはこの不具合を検出できません。

Q. 終了ステータス141はどこで確認できますか?

パイプラインの直後に PIPESTATUS を配列でコピーして表示します。echo $? はパイプライン全体の値しか返さず、しかも PIPESTATUS を上書きしてしまうため、段ごとの内訳を見るには配列を先に確保する必要があります。141 は「128 + シグナル番号」の形式で、13番の SIGPIPE を表します。他のシグナルで殺された場合も同じ形式で読めます(例:130 は SIGINT)。

プログラミング学習でお悩みですか?

現役エンジニアがあなたの学習をマンツーマンでサポートします。

  • 学習の進め方がわからない
  • ポートフォリオの作り方を知りたい
  • 現場で使える技術を学びたい
まずは30分の無料相談

相談は完全無料・オンラインで気軽に

あなたを爆速で成長させるメンタリングプログラムはこちら

メンタープログラムバナー

学習・開発のお悩みは現役エンジニアに相談

メンタープログラムの詳細を見る

エンジニアの基礎学習ゲーム

プログラミング学習支援

無料相談はこちら