CIの「Bats Tests」が、issue本文が長い回にだけ、しかも毎回ではなく稀に落ちる。エラーの中身はJSON出力のパース失敗で、同じテストをもう一度流すとあっさり通ってしまう。ローカルで同じ入力を使って再現させようとしても、なぜか再現しない。
これは、GitHub issueを解析して実装計画に使う自社の開発支援スクリプトで実際に起きた話です。原因を突き止めてみたら、シェルスクリプトを書く人なら一度は踏んでもおかしくない、地味だけど強烈な罠でした。Linuxのパイプには容量の上限があり、読み手が早めに読み切って抜けると、書き手はSIGPIPEで問答無用で殺される、というやつです。
この記事で学べること
- なぜ「小さい入力ではずっと動いていたコード」が、ある日突然(それも本番データでだけ)壊れるのか
- Linuxのパイプがなぜ
headのような早期終了リーダーと相性最悪なのか、プロセスとシグナルのレベルで理解する - 壊れた再現条件(入力サイズのしきい値)をテストにどう固定化するか
- パイプの代わりにbashのhere-stringを使うと何が変わるのか、最小の再現コード付きで
前提条件
- bash(
<<<here-string構文を使うため) - Linux環境でのパイプ動作の基礎知識があるとより理解しやすい(本記事内でも説明します)
何が起きていたか — 「小さい入力なら動く」という罠
問題のスクリプトは、GitHub issueの本文を受け取って、受入条件(AC)の抽出や、本文の先頭500文字プレビューの生成、深度がcomprehensiveのときは影響ファイルの抽出まで行うものです。issue本文は数百文字のこともあれば、詳細な仕様や長いログ貼り付けで数万文字に膨らむこともあります(ログを全文貼り付けたくなる気持ちはわかりますが、後工程はそのぶん静かに悲鳴を上げています)。
実装は素直に「変数をパイプでつないでいく」スタイルでした。本文プレビューを作る処理はこう書かれていました(修正後の実際のコード、コメント込み)。
# NOTE: uses here-strings (not pipes) for the same SIGPIPE-safety reason as
# breaking_keyword_scan above — a large $1 fed through a pipe into a
# downstream head -N that early-exits can SIGPIPE-kill the upstream writer.
extract_ac() {
grep -E '^\s*-\s*\[[ x]\]|^[0-9]+\.\s' <<<"$1" | head -20 | json_array
}
このコメントにある通り、これは修正後の姿です。直す前はextract_ac・extract_requirements・本文プレビュー生成・comprehensive深度の影響ファイル抽出が、揃って「変数をechoやprintfでパイプに流し込み、grepやheadで受ける」実装になっていました。実際のコミットメッセージにはこう書かれています。
breaking_keyword_scan のみ here-string 化されていたが、同じ大容量 body を処理する extract_ac / extract_requirements / body_preview / comprehensive depth の affected_files・components 抽出は echo|grep や printf|head のパイプ実装のままだった。
このスクリプトを使ったテストは、小さめのissue本文に対しては何度実行しても安定してパスする状態でした。CIで時々落ちるのは、たまたまissue本文が長い回だけ——それも毎回ではない。原因を知らない状態でこの現象に出会うと、正直かなり厄介です(「今回だけ環境がおかしいのかな」と疑いたくなる気持ち、よく分かります)。
なぜパイプは「早く抜けるリーダー」と相性が悪いのか
ここでLinuxのパイプの仕組みを思い出す必要があります。パイプは無限のバッファではありません。Linuxのデフォルトのパイプ容量は64KBです。書き手(echoやprintf)が64KB分書き込んでもまだ読み手が受け取らない場合、書き手はブロックされて待たされます。普段は意識すらしない地味な数字ですが、今回に限っては主犯としてしっかり名指しされることになります。
一方でhead -c 500やhead -Nは、指定した分だけ読んだらさっさとプロセスを終了します。読み終わったらパイプの読み込み端(read end)を律儀に開けたままにしておく義理はありません。ここが今回の急所です。
読み手が終了してパイプの読み込み端が閉じられた状態で、書き手側がまだ書き込もうとすると、カーネルはその書き込みプロセスにSIGPIPEシグナルを送ります。デフォルトのSIGPIPEハンドラはプロセスを即座に終了させるので、echoやprintfはエラーメッセージも残さず静かに死にます。パイプでつないだ後続のコマンド(今回で言えばjq -Rs .によるJSON文字列化)には、中途半端な——あるいは空の——出力しか渡らず、結果としてJSON全体の構文が壊れます。
ここで効いてくるのが入力サイズです。issue本文が数百文字程度なら、echo "$BODY"が書き込む量はパイプ容量の64KBに全然届かず、headが読み切るまで書き込みはブロックすら発生しません。つまり小さい入力では、そもそもこの競合状態が起こる条件が成立しないわけです。issue本文が育って64KBの壁を超えて初めて、書き込みがブロックされたままheadに先に抜けられる余地が生まれ、SIGPIPEが飛ぶ。「小さい入力ではずっと動いていた」のは実装が正しかったからではなく、壊れる条件にたまたま一度も到達していなかっただけでした。
要は、読み手だけ先にゴールテープを切って、書き手が宙に浮いたまま取り残される話です(駅伝なら美談ですが、プロセス間通信だとただの事故です)。再現の仕組みを図にするとこうなります。
最小の再現コードで確認する
言葉で説明するより、手元で試した方が納得できます。パイプ版と、修正後のhere-string版を並べてみます。
# パイプ版: 大きい入力で壊れうる
big_body=$(head -c 70000 </dev/zero | tr '\0' 'a')
echo "$big_body" | head -c 500 > /tmp/preview.txt
echo "exit status of echo (via PIPESTATUS): ${PIPESTATUS[0]}"
bashのPIPESTATUSでパイプの左側(echo)の終了ステータスを見ると、入力サイズや実行環境によってはSIGPIPE起因の異常終了(141 = 128+SIGPIPE)が観測できます。パイプの右側のhead自体は正常終了して0を返すため、set -o pipefailを入れていても「右端の終了コードだけ見て安心する」書き方だと見逃しやすい点にも注意が必要です(PIPESTATUS、存在は知っていても実際に覗いたことがある人は意外と少ないはずです)。
修正後はこうです。
# here-string版: 早期終了するheadでもSIGPIPEが起きない
big_body=$(head -c 70000 </dev/zero | tr '\0' 'a')
head -c 500 <<<"$big_body" > /tmp/preview.txt
here-string(<<<)は、シェルが文字列全体を一時ファイル(あるいは内部バッファ)にまず書き出してから、それを読み取り専用のfdとして渡す仕組みです。同時に動く2つのプロセスの間をライブなパイプでつなぐわけではないので、「読み手が先に抜けて書き手が宙に浮く」という競合状態自体が発生しません。読み手がどれだけ早く読み終えて抜けても、書き手は最初から「もう書き終わっている」状態なので、SIGPIPEの飛びようがないわけです。
直し方: 全部のパイプをhere-stringに統一する
今回の修正は、echo|grepやprintf|headのパイプ実装を、洗いざらいhere-stringに置き換えるというものでした。実際に残っている実装コメントにも、その意図がはっきり書かれています。
uses a here-string (not a pipe) so grep -q's early-exit on match cannot cause an upstream SIGPIPE / silent false negative on large bodies
本文プレビュー生成も同様にhere-string化されています。
"body_preview": $(head -c 500 <<<"$BODY" | jq -Rs .)
ここで面白いのは、もともとbreaking_keyword_scan(issueタイトルと本文にbreaking系キーワードが含まれるかを見る決定論スキャン)だけはすでにhere-string化されていたことです。同じ関数群の中で、なぜ足並みが揃っていなかったのか——単純に、後から追加されたextract_acや本文プレビュー生成のようなロジックが、既存のパイプ実装のスタイルをそのままコピーして増えていったからだと考えられます(コピペは仕事が早い。バグの伝染も同じくらい早いというだけの話です)。「1箇所直したから他も安全」という思い込みが一番危ない、という教訓でもあります。
再現条件をテストに固定化する
原因が分かって直しただけでは、いずれ誰かが「パイプの方が短く書けるから」と書き戻してしまうかもしれません。それを防ぐために、テストコードには70KB程度に膨らませたissue本文を使う専用のテストケースが追加されています。テストファイルの冒頭コメントには、このテストスイートが何をカバーしているかがまとめて書かれています。
Covers: breaking_keyword_scan determinism across all depths (minimal / standard / comprehensive), full-body scan beyond the 500-char body_preview boundary, Japanese keyword detection, and a >64KB body regression to pin the here-string (non-pipe) SIGPIPE-safe implementation.
そして実際のテストケースはこうです。
@test "standard depth: ~70KB body with leading keyword -> true (SIGPIPE regression)" {
PAD="$(printf '%*s' 70000 '')"
PAD="${PAD// /a}"
BODY="${AC_STUB}breaking change needed"$'\n'"${PAD}"
FIXTURE="$FIXTURE_DIR/large.json"
make_fixture "$FIXTURE" "Large body issue" "$BODY"
export GH_FIXTURE="$FIXTURE"
run "$SCRIPT" 5 --depth standard
[ "$status" -eq 0 ]
echo "$output" | jq -e '.breaking_keyword_scan == true'
}
70000個の空白を生成してからaで埋めるという、傍から見るとかなり地味な儀式です(傍から見なくても地味です)。64KBのパイプ容量よりわずかに余裕を持たせた70KBという本文サイズを、わざわざ意図的に組み立てているのがポイントです。小さい本文だけをテストしていた頃は、このバグは構造的にテストで検出不可能でした。パイプ容量の壁を明示的に超える入力サイズを用意して初めて、この種の回帰をテストで捕まえられます。
動作確認
修正が正しく効いているかどうかは、次のように確認できます。
- 70KB前後(63〜70KB程度)のダミー本文でスクリプトを実行し、出力が正しいJSONとしてパースできるか(
jq -eなどで検証) bashのPIPESTATUSや終了コードを見て、途中のコマンドがSIGPIPE相当(141)で落ちていないか- 修正対象の関数を全部grepし、
| headや| grepのような「変数をパイプに流し込む」パターンが残っていないか
注意点・Tips
- パイプの右側だけ見て安心しない —
cmd1 | cmd2の終了コードは、シェルではデフォルトで右側(cmd2)のものです。左側のcmd1がSIGPIPEで死んでいても、$?や単純なifチェックだけでは気づけません。パイプの各コマンドの終了コードを見たい場合はPIPESTATUS(bash)を使ってください head・grep -q・grep -m Nのように「必要な分だけ読んで即終了する」コマンドをパイプの右側に置くときは要注意 — 左側の書き込みが大きくなりうる(入力サイズが可変・上限を作っていない)場合、パイプ容量を超えた瞬間にこの罠が牙を剥きます- 小さい入力でずっとテストが通っていたことを「動作確認済み」の証拠にしない — 今回のケースが典型で、入力サイズがしきい値以下では競合状態自体が発生しません。境界値(今回なら64KB前後)を超える入力で意図的にテストしないと、この種のバグは潜在化したまま本番でだけ発火します
- here-stringは万能ではない — シェルが文字列全体をバッファに書き出してから渡すため、巨大すぎる入力(数百MB級)ではメモリ・一時ファイルのオーバーヘッドが無視できなくなります。今回のように数十KB〜数百KB程度の入力を安全に扱いたい場面での対策と捉えてください(何でも解決してくれる魔法の杖ではない、という当たり前の話です)
なお、シェルスクリプトのテストにまつわる別の罠——set -euo pipefailが失敗ケースのテストを静かに無力化してしまう問題や、合成テストが全部緑でも実データでは漏れているといった話は、テストは全部緑なのに現実では漏れていた話にまとめています。あちらはテストスイート側の盲点、こちらはシェルの低レベルな挙動(パイプ容量とシグナル配送)に起因するバグという、似ているようで原因のレイヤーが異なる話です。
こうした「本番データでだけ、しかも稀にしか再現しない」類の足場のバグは、issue解析やPR自動作成のようなAIエージェント向けの開発パイプラインを作り込んでいくと、思った以上によく顔を出します(このあたりの泥臭い足場づくりも含めてAI開発の支援でよく触れる領域です)。
まとめ
CIでだけ、しかもたまにしか再現しないシェルスクリプトの不具合は、疑うべき候補の一つとして「パイプ容量とシグナル配送」を持っておくと当たりを付けやすくなります。
- Linuxのパイプ容量は64KBが既定で、これを超える書き込みは読み手が受け取るまでブロックされる
headやgrep -qのように必要な分だけ読んで即終了するコマンドがパイプの右側にいると、左側の書き手がブロックされたまま取り残され、パイプが閉じられた瞬間にSIGPIPEで即死する- 小さい入力ではこの競合状態自体が発生しないため、「テストはずっと緑」という状態と「バグが存在しない」はイコールではない
- 対策は、変数を経由する処理を可能な限りbashのhere-string(
<<<)に統一すること。同時実行の2プロセス間のライブなパイプではなくなるため、早期終了リーダーによるSIGPIPEの余地がなくなる - 再発防止には、パイプ容量のしきい値を意図的に超える入力サイズでの回帰テストを固定化しておく
シェルスクリプトは「動いているからヨシ」で放置されがちですが、動いて見える範囲がたまたま罠の外側だっただけ、ということは普通にあります。



