入出力とプロセス制御|リダイレクト/パイプ/サブシェル

運用と安全設計

この記事の狙い

Bash の入出力(リダイレクト・パイプ)とプロセス制御(サブシェル・並列実行)の基本を、安全運用の観点で設計指針に落とし込みます。
「どこへ出すか」「どこから読むか」「どの文脈で実行するか」を明示し、副作用とスコープを可視化します。

前提と対象

Bash 4+(set -Eeuo pipefail 推奨)。GNU/BSD 差異は最小限に留め、POSIX 準拠に寄せつつ Bash 拡張も使用します。

TL;DR(最小実装・コピペ可)

#!/usr/bin/env bash
set -Eeuo pipefail

# 1) リダイレクトは「先にファイルを開く」→コマンド実行
out="/tmp/out.txt"
exec 3>"$out"                    # FD3 を出力用に開く
printf 'hello\n' >&3             # FD3 に出力
exec 3>&-                         # FD3 を閉じる

# 2) パイプは "set -o pipefail" 前提でエラー検知
set -o pipefail
grep -E '^[0-9]+$' input.txt | awk '{s+=$1} END{print s}'

# 3) サブシェルは ( ... ):カレントディレクトリ等の副作用を隔離
( cd dir && make )               # 親の CWD は変わらない

# 4) プロセス置換で「パイプのサブシェル落とし穴」回避
mapfile -t lines < <(cmd_that_outputs_lines)

# 5) 並列は xargs -P/&+wait:出力先は衝突させない
printf '%s\n' a b c | xargs -n1 -P4 ./worker.sh

設計の要点(原則)

  • ファイルを先に開くcmd >"$out" を散在させず、exec 3>"$out" で一度だけ開くと安全
  • stdout=結果 / stderr=ログ:パイプの下流へ混入させない
  • pipefail を有効:途中段の失敗を検知
  • サブシェルで副作用を閉じ込める( cd dir; ... )/必要がなければ使わない
  • プロセス置換 < <(...) > >(…):変数を上位スコープに残したい場合に有効
  • 並列は出力・一時ファイルの衝突回避を先に設計

リダイレクトの基礎

標準ストリーム

  • > filestdout をファイルへ
  • 2> filestderr をファイルへ
  • &> … stdout+stderr を同じ先へ(Bash 拡張)
  • >> … 追記

結果は stdout、メッセージは stderr の原則を徹底します。

printf '%s\n' "$result" >"$out"
printf 'WARN: %s\n' "$msg" >&2

FD(ファイル記述子)を明示する

複数の出力先がある時は FD を割り当て、一箇所で開いて一箇所で閉じる

exec 3>"/tmp/report.txt"   # 3を出力FDとして
gen_report >&3
exec 3>&-                  # 閉じる

入力を安全に開く

exec 4<"/etc/hosts"
while IFS= read -r line <&4; do
  printf '%s\n' "$line"
done
exec 4<&-

パイプラインとエラー検知

pipefail

デフォルトでは、パイプラインの最後のコマンドの終了コードだけが返ります。途中で失敗しても気づけません。
set -o pipefail で「どれかが失敗したら失敗」に。

set -o pipefail
gzip -dc big.gz | awk '{...}' | sponge out.txt   # 途中の失敗も検知

stderr を混ぜない

ログを stdout に混ぜると、下流のパースが壊れます。
ログは必ず >&2 に出し、必要なら 2>log に退避。

cmd_that_logs 2>cmd.log | awk '{...}'

明示的に中間結果を残すか判断する

可読性・デバッグ性が必要なら TMP を使う方が安全。

tmp="$(mktemp)"; trap 'rm -f "$tmp"' EXIT
grep -E 'pattern' in.txt >"$tmp"
awk -f transform.awk "$tmp" >out.txt

サブシェルとスコープ

サブシェル ( ... )

かっこで囲むと子プロセス。環境・変数・CWD の変更が親に影響しません

( cd assets && make )   # 親の CWD は変わらない

利点:副作用の封じ込め。
注意:パイプも各段がサブシェルになります(変数が外へ残らない)。

カレントディレクトリの扱い

親を汚さない書き方:

( cd "$dir" && run_stuff )
# もしくはパスを常に明示して実行する
run_stuff "$dir/input" >"$dir/out"

プロセス置換と「パイプの落とし穴」回避

変数を上位に残したい:プロセス置換 < <(...)

パイプ cmd | while read ...; doneループがサブシェルになり、ループ内で更新した変数が外に残りません。
プロセス置換なら親シェル側の whileで読めます。

count=0
while IFS= read -r _; do ((count++)); done < <(find . -type f)
echo "$count"

出力を別コマンドへ同時に流したい:> >(tee ...)

some_heavy_task > >(tee result.txt) 2> >(ts '[%H:%M:%S]' >&2)

tee は stdout をファイルと端末に複製できます。プロセス置換はBash拡張です。

並列実行とプロセス制御

xargs -P(手軽な並列)

printf '%s\n' a b c d | xargs -n1 -P4 ./worker.sh
  • -n1 で 1 引数ずつ渡す
  • 出力が混ざるので出力先(ファイル)で衝突しない設計

バックグラウンド実行 & + wait

jobs_pids=()
task A & jobs_pids+=($!)
task B & jobs_pids+=($!)
for p in "${jobs_pids[@]}"; do wait "$p"; done

wait の終了コードで個々の成否を判定可能。

if wait "$p"; then ...; else ...; fi

flock や一時ディレクトリで排他

並列時の競合を防ぐにはロックを設ける。

{
  flock -n 9 || { echo "busy"; exit 0; }
  critical_section
} 9>"/tmp/my.lock"

よくある I/O レシピ

1) 全出力をログに、結果だけをファイルに

{
  echo "start" >&2
  result="$(run)"              # run は stdout へ結果を出す実装
  printf '%s\n' "$result" >out.txt
  echo "done" >&2
} 2>run.log

2) 1 つの入力を複数の消費者に

producer | tee >(consumer_a >a.out) >(consumer_b >b.out) >/dev/null

3) FD を使った多重出力(ログ+コンソール)

exec 3> >(tee logfile)      # FD3 を tee に接続
log(){ printf '%s\n' "$*" >&3; }
log "hello"

失敗しやすい点(アンチパターン)

  • cmd | while read …; do count=$((count+1)); done; echo "$count"
    → 0 になる(サブシェル)。プロセス置換で回避。
  • set -e だけでパイプエラーを拾えると誤解
    set -o pipefail が必要。
  • stdout にログ
    → パイプ下流が壊れる。ログは stderr。
  • リダイレクトを散在
    → どこで開いたか分かりづらく、競合しやすい。FD で一箇所に集約
  • 並列で同じファイルに書く
    → 競合・破損。一時ファイルへ書いて最後に結合する。

“コピペ可”テストブロック(最小)

#!/usr/bin/env bash
set -Eeuo pipefail
set -o pipefail
TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT

# 1) pipefail 検証(途中失敗を検知)
( false | cat ) && { echo "pipefail NG"; exit 1; } || :

# 2) プロセス置換でカウントが残る
count=0
while IFS= read -r _; do ((count++)); done < <(printf 'a\nb\nc\n')
[[ $count -eq 3 ]] || { echo "proc-subst NG"; exit 1; }

# 3) FD で出力統一
exec 3>"$TMP/out"
printf 'X\n' >&3
exec 3>&-
[[ "$(cat "$TMP/out")" == "X" ]] || { echo "fd NG"; exit 1; }

echo "PASS"

運用メモ(実務)

  • I/O 設計を先に決める(どのストリームへ、どのタイミングで)
  • ログ粒度と回転を最初から決める(stderrjournald/syslog/ファイル)
  • 一時ファイルは mktemptrap で確実に掃除
  • 大規模パイプラインは中間成果物を残すモードを用意(デバッグしやすい)

互換性と移植性

  • process substitution&> は Bash 拡張。POSIX sh では不可。
  • tee, xargs, flock はプラットフォームによりオプション差あり(BSD 系 flock は別パッケージのことがある)。
  • ポータビリティ重視なら > file 2>err を基本に、拡張は条件付きで使用。

セキュリティと安全設計

  • 外部入力を コマンド名やリダイレクト先に使わない(固定ディレクトリ+検証)。
  • ログへ機密値を出さない。必要ならマスク、もしくは別ストレージへ。
  • 一時ファイルはパーミッションを意識(umask 077mktemp)。

パフォーマンスの勘所(短く)

  • 不要な外部プロセス(小さな sed/awk/cat 連鎖)を削ると体感が大きく改善。
  • 並列度は CPU/IO に合わせて xargs -P を調整。
  • 大量 I/O はまとめ書き(FD を開きっぱなしで printf)の方が速い。

参考リンク

Bash玄

はじめまして!Bash玄です。

エンジニアとしてシステム運用に携わる中で、手作業の多さに限界を感じ、Bashスクリプトを活用して業務を効率化したのがきっかけで、この道に入りました。「手作業は負け」「スクリプトはシンプルに」をモットーに、誰でも実践できるBashスクリプトの書き方を発信しています。

このサイトでは、Bashの基礎から実践的なスクリプト作成まで、初心者でもわかりやすく解説しています。少しでも「Bashって便利だな」と思ってもらえたら嬉しいです!

# 好きなこと
- シンプルなコードを書くこと
- コマンドラインを快適にカスタマイズすること
- 自動化で時間を生み出すこと

# このサイトを読んでほしい人
- Bashに興味があるけど、何から始めればいいかわからない人
- 定型業務を自動化したい人
- 効率よくターミナルを使いこなしたい人

Bashの世界に一歩踏み出して、一緒に「Bash道」を極めていきましょう!

Bash玄をフォローする