先に要点
- リストを1行ずつ処理するループが1件目だけで終わるなら、疑うのはループ本体ではなく、ループの中で呼んでいるコマンドです。
docker exec -i・ssh・mysqlのように標準入力を読むコマンドは、ループが次に読むはずだった行をまとめて飲み込みます。 - エラーは出ません。終了コードも0です。処理対象が1件のときは正しく動くので、動作確認では気づけません。複数件を処理する日、つまり一番困っている日にだけ壊れます。
- 筆者が20行のリストで測ったところ、
catを挟んだループは20回ではなく1回で終わりました。head -n 1をファイルから読ませた場合は10回です。半分だけ処理して正常終了するので、さらに気づきにくくなります。 - 直し方は4つあります。いちばん手軽なのは、中のコマンドに標準入力を渡さないことです。用途で選べるように、効き目と副作用を後半の表にまとめています。
複数のサーバーやコンテナに同じ処理を流すスクリプトで、こんな症状に出会うことがあります。対象は3件あるはずなのに、ログには1件しか残っていない。エラーは出ていない。終了コードも0。もう一度動かすと、やはり1件で終わる。
最初に疑いたくなるのはループの書き方や、リストを作っている側です。しかし原因は多くの場合、ループの中で呼んでいるコマンドが標準入力を読んでしまうことにあります。この記事では、その仕組みと、手元で実際に測った回数、そして4つの直し方を順番に書きます。
3行で再現できる
まず、手元のシェルにそのまま貼って試せる最小の形を出します。
printf "A\nB\nC\n" | while read -r x; do
echo "got: $x"
cat > /dev/null
done
期待するのは3行ぶんの出力です。実際に返ってきたのは次の1行だけでした。
got: A
cat の読み先を空にすると、期待どおりに動きます。
printf "A\nB\nC\n" | while read -r x; do
echo "got: $x"
cat < /dev/null > /dev/null
done
got: A
got: B
got: C
ここでの cat は「標準入力を読むコマンド」の代表として置いているだけです。実運用で同じことをするのは docker exec -i、ssh、mysql、psql、ffmpeg といった顔ぶれになります。
なお、ヒアストリング(while ... done の後ろに文字列を直接渡す形)でも、ファイルから読む形でも、症状は同じです。入力の与え方は関係ありません。
実際に ssh で20件を流して測る
作り話ではないことを確かめるため、20行のリストを用意して、ループの中から実際のサーバーへ ssh する形で回数を数えました。リモートで実行しているのは true だけで、何も出力しません。
seq 1 20 > list.txt
c=0
while read -r x; do
c=$((c+1))
ssh example-host "true"
done < list.txt
echo "ループが回った回数: $c"
結果をまとめます。いずれも入力は20行です。
| ループの中で呼ぶもの | リストの渡し方 | 20行のうち回った回数 |
|---|---|---|
| 何も呼ばない | パイプ | 20 |
cat |
パイプ | 1 |
head -n 1 |
パイプ | 1 |
head -n 1 |
ファイルから読む | 10 |
ssh(対策なし) |
ファイルから読む | 1 |
ssh -n |
ファイルから読む | 20 |
注目してほしいのは head -n 1 をファイルから読ませた行です。20件のうち10件だけが処理され、エラーもなく終わりました。1件で止まってくれるなら「さすがにおかしい」と気づけますが、ちょうど半分処理されると、件数を数えていない限り成功にしか見えません。
なぜ10なのかというと、入力がファイルのときは読み位置を戻せるからです。head は効率のためにまとめて読み込みますが、読み終わった後にファイルの読み位置を必要な分だけ戻します。結果として1周あたりきっちり1行だけを余分に消費します。ループ自身が読む1行と合わせて1周で2行、20行なら10周で尽きる計算です。入力がパイプのときは読み位置を戻せないため、まとめ読みしたぶんがそのまま失われ、1周で終わります。
パイプでループに渡すと、ループ全体がサブシェルで動きます。上の例をパイプ版で書くと、ループの中で増やしたカウンターはループを抜けた時点で消えるため、件数が常に0になります。件数を数えたいときは、パイプではなくファイルからの読み込みにしてください。
なぜこうなるのか
理由は単純で、ループも、ループの中のコマンドも、同じ標準入力を見ているからです。
Bash のマニュアルは read を「標準入力から1行を読む。ただし -u オプションでファイルディスクリプタを指定した場合はそこから読む」と説明しています。つまり read は専用の入力を持っているわけではなく、ふつうの標準入力を1行ずつ消費しているだけです。
一方、ループの中で起動したコマンドは、親プロセスから標準入力をそのまま引き継ぎます。cat のように終端まで読むコマンドなら、残りの行を全部持っていきます。奪い合いというより、1本の列に2人が並んで交互に取っている状態です。
docker exec の場合は -i が引き金になります。Docker の公式リファレンスは -i(--interactive)を「アタッチされていなくても標準入力を開いたままにする」と説明しています。これは、コンテナの中のプロセスに標準入力を渡すためのオプションです。渡された側が読めば、その行は当然消えます。
ssh も同じで、リモートで動かすコマンドに手元の標準入力を中継します。だからリモート側で標準入力を読むコマンドが動けば、手元のリストが吸い込まれます。
この不具合が厄介な3つの理由
エラーにならない
ループは最後まで実行された扱いで終わり、終了コードは0です。ログにも例外は残りません。監視で終了コードだけを見ていると、成功として記録されます。
1件のときは正しく動く
動作確認は対象1件で行いがちです。1件なら食われる行がないので通ります。本番で複数件になった日に初めて症状が出ます。
一番重要なときに壊れる
対象が増えるのは、障害がまとめて起きた日や、月次でまとめて処理する日です。落ち着いて全体を把握したい場面ほど、記録が欠けます。
とくに影響が出やすいのは、通知をまとめて送るスクリプトです。件名には3件と書いてあるのに本文が1件しかない、という形で表面化します。届いた通知の中身が正しいかどうかは受け取った人にしか確認できないため、長く残りやすい種類の不具合です。
直し方は4つ
1. 中のコマンドに標準入力を渡さない
いちばん手軽で、意図も読み手に伝わりやすい方法です。空の読み先を明示的に与えます。
while read -r x; do
docker exec -i mydb psql -c "select 1" < /dev/null
done < list.txt
2. コマンド側のオプションで止める
標準入力を読まないためのオプションを持つコマンドがあります。ssh なら -n です。docker exec でコンテナへデータを流し込む必要がないなら、-i を外すだけで済みます。
while read -r host; do
ssh -n "$host" "uptime"
done < list.txt
3. ループを別のファイルディスクリプタで回す
リストの読み込みだけを別の番号に移す方法です。中のコマンドが標準入力を読んでも、ループの読み先には影響しません。ループの中で対話的な入力を受け取りたい場合は、これが確実です。
while read -r x <&3; do
echo "got: $x"
cat > /dev/null
done 3< list.txt
3番という数字に意味はありません。0が標準入力、1が標準出力、2が標準エラー出力として予約されているため、空いている最小の番号として3を使うのが慣例です。
4. 先に全部読んでから回す
リストが巨大でなければ、配列に読み込んでから for で回すのがいちばん安全です。ループの実行中に標準入力を触るものが何もなくなります。
mapfile -t items < list.txt
for x in "${items[@]}"; do
echo "got: $x"
cat > /dev/null
done
どれを選ぶか
| 方法 | 向いている場面 | 注意点 |
|---|---|---|
| 空の読み先を与える | 中のコマンドを1つだけ直したいとき | コマンドを足すたびに書き足す必要がある。書き漏らすと再発する |
| コマンドのオプション | そのコマンドに専用の指定があるとき | オプション名がコマンドごとに違う。docker exec は逆に指定を外す形になる |
| 別のディスクリプタで回す | 中で複数のコマンドを呼ぶとき | ループの中と外で番号の対応を取る必要がある。外側の指定を書き忘れやすい |
| 先に配列へ読む | リストが数千行までのとき | 全件をメモリに載せる。巨大なリストやストリーム処理には向かない |
実務でいちばん事故が少ないのは、4番の「先に全部読む」です。ループの中に何を書いても壊れなくなるため、後から処理を足す人が同じ罠を踏みません。リストが大きくてストリームで処理したい場合だけ、3番を選ぶのがよいと思います。
自分のスクリプトを点検する
同じ形がほかにも潜んでいないかは、機械的に洗い出せます。まず、ループの中で標準入力を読みうるコマンドを呼んでいる箇所を探します。
grep -rn -A 10 "while read" --include="*.sh" . |
grep -E "docker exec .*-i|ssh |mysql |psql |ffmpeg |cat |head |tail |sort |xargs "
引っかかった箇所について、確認するのは次の2点だけです。
- そのコマンドに空の読み先、またはオプションでの抑止が付いているか
- 付いていないなら、リストが2件以上になったときに何件処理されるか
確認は実際に動かすのがいちばん早いです。処理本体を echo に置き換えて、出力行数がリストの行数と一致するかを見ます。
wc -l < list.txt
while read -r x; do echo "$x"; done < list.txt | wc -l
この2つの数が食い違うなら、その時点で原因は確定です。
検知を仕組みにする
一度直しても、後からループの中に別のコマンドを足せば再発します。件数が減ったら落ちるようにしておくのが、確実な歯止めになります。
total=$(wc -l < list.txt)
done_count=0
while read -r x <&3; do
done_count=$((done_count+1))
# ここで実際の処理
done 3< list.txt
if [ "$done_count" -ne "$total" ]; then
echo "処理件数が入力件数と一致しません: $done_count / $total" >&2
exit 1
fi
数行ですが、これを入れておくと「静かに件数が減る」系の不具合はまとめて検知できます。終了コードだけを見る監視でも拾えるようになるのが大きい点です。ログを人が読みに行かないと気づけない状態から抜けられます。
似た形で起きる別のケース
同じ「標準入力が食われる」形は、シェルスクリプト以外でも起きます。
- データの取り込み処理:取り込み対象を1行ずつ読むループの中で、データベースのクライアントを素で呼んでいる。取り込み件数が1件になるが処理は成功で終わるため、実行時間が異様に短いことでしか気づけない
- cron から動かすバッチ:手元で動かすと標準入力が端末につながっているため、中のコマンドが入力待ちで止まり、明らかな異常として見えます。ところが cron では標準入力が空なので待たずに進んでしまい、症状が「止まる」から「件数が減る」へ変わります。手元で再現しない理由がこれです
- CI のジョブ:ステップごとに標準入力の扱いが違うため、手元では再現せず、CI の上でだけ件数が合わない
いずれも「エラーを出さずに件数だけが減る」という同じ顔をしています。件数が合わない症状を見たら、まず標準入力を疑うと覚えておくと早く着地できます。
関連して、Docker本番運用でよくある事故と確認チェックリストでは、コンテナ運用で件数や状態がずれる別のパターンも整理しています。監視側の設計は小規模サイトの監視は何から始めるかが参考になります。
よくある質問
なぜ1件のときは問題が起きないのですか
食われる行が存在しないからです。ループは1行目を読み、中のコマンドが残りを読もうとしますが、もう何も残っていません。したがって1周で正常に終わり、これは期待どおりの動作です。動作確認を1件だけで済ませると必ず見逃します。
終了コードを見ていれば検知できますか
できません。ループ自体も、中のコマンドも正常に終了します。検知したいなら処理した件数を数えて入力の件数と突き合わせる必要があります。前の章のように、件数が一致しなければ異常終了させるのが確実です。
docker exec から -i を外しても大丈夫ですか
コンテナの中のプロセスへ標準入力でデータを流し込んでいないなら、外して問題ありません。-i は標準入力を開いたままにするためのオプションなので、コマンドを引数で渡すだけの使い方では不要です。逆に、ファイルの中身をパイプでコンテナへ送っている場合は -i が必要なので、その場合はループ側を別のディスクリプタに移してください。
xargs を使えば避けられますか
多くの場合は避けられますが、万能ではありません。xargs は自分が標準入力を読んで引数として渡す形になるため、ループ変数を経由する必要がなくなります。ただし xargs が起動したコマンドの標準入力をどう扱うかは実装によって差があるため、対話的なコマンドを呼ぶ場合は結局オプションでの制御が必要になります。並列実行と組み合わせるなら、まず件数の一致を確認できる形にしてから移行してください。
シェル以外の言語なら起きませんか
起きます。1つのプロセスに標準入力が1本しかないという性質は言語によりません。外部コマンドを起動するときに標準入力を継承する設定になっていれば、同じことが起こります。多くの言語の標準ライブラリには、子プロセスの標準入力を閉じる、あるいは空にする指定があるので、外部コマンドを繰り返し呼ぶ処理では必ず指定してください。
まとめ
リストを回すループが途中で終わるのに、エラーも異常終了も出ない。この形を見たら、ループの中で標準入力を読むコマンドを呼んでいないかを最初に確認してください。
- 原因はループ本体ではなく、ループと中のコマンドが標準入力を共有していること
docker exec -i・ssh・mysqlなどが代表格- 1件では再現せず、複数件のときだけ壊れる
- 半分だけ処理して正常終了することもある(筆者の計測では20件中10件)
- 恒久的に潰すなら、先に配列へ読んでから
forで回すのが安全
そのうえで、スクリプトの最後で処理件数と入力件数を突き合わせるのが、この種の不具合に対する唯一確実な歯止めです。件数が合わなければ落とす。それだけで、静かに減る系の不具合はまとめて検知できるようになります。