「パイプで繋いだコマンドの中で変数に代入したのに、後で参照したら空だった」
シェルスクリプトを書き始めると、必ずといっていいほどこの罠にハマります。原因はbashの「サブシェル」の仕様です。パイプや
()でくくったコマンドは、現在のシェルとは別のプロセス(サブシェル)として実行されるため、変数への変更が親シェルには伝わりません。この記事では、サブシェルの正体(何が起きているのか)から、
()と{}の違い、パイプ後の変数消失の回避策(process substitution・lastpipe・一時ファイル)まで、実際のコードで解説します。RHEL 9 / bash 5系で動作確認済みです。この記事のポイント
・パイプ(|)の右辺はサブシェルで動き変数変更が親に戻らない
・()はサブシェル生成、{}はカレントシェル実行で動作が全く異なる
・解決策は < <(...) のprocess substitutionが最もスマート
・bash 4.2以降はshopt -s lastpipeでパイプのまま対処できる
でも安心してください。プロのエンジニアはコマンドを暗記していません。
「現場で使える型」を効率よく使いこなしているだけです。
サブシェルとは何か|変数が消える原因の根っこ
bashでコマンドを実行するとき、現在動いているシェルプロセス(親シェル)がそのまま実行する場合と、別の子プロセス(サブシェル)を生成してそこで実行する場合があります。重要なのは、サブシェルは親シェルのコピーとして起動するという点です。起動時点の変数の値はコピーされますが、サブシェル内での変更は親シェルには一切反映されません。Linuxのプロセスはそれぞれ独立したメモリ空間を持っており、プロセス間で変数を「書き戻す」機能は標準ではないからです。
1. サブシェルが生まれる3つのケース
bashでサブシェルが自動的に生成される主なパターンは次の3つです。・パイプ(|)の各コマンド:デフォルトではパイプの左辺も右辺も別々のサブシェルで実行されます
・()でくくったコマンドグループ:
( cmd1; cmd2 ) 全体がサブシェルで実行されます・バックグラウンド実行(&):
cmd & はサブシェルで非同期実行されます逆にサブシェルにならないケースも知っておきましょう。
{}でくくったコマンドグループ、source(または.)で読み込んだスクリプト、通常の関数呼び出しはカレントシェルで実行されます。2. 変数の「継承」と「スコープ」の仕組み
サブシェルが親シェルから引き継ぐのは、exportした環境変数と普通のシェル変数の両方です。ただし、引き継ぐのは起動時点のスナップショットであり、その後サブシェル内で変更しても親には反映されません。# 変数の継承と変更の非反映を確認する msg="hello" export ENV_MSG="world" # サブシェル内では両方参照できる ( echo $msg ) # → hello ( echo $ENV_MSG ) # → world # サブシェル内での変更は親に伝わらない ( msg="changed" ) echo $msg # → hello(変わっていない) ( ENV_MSG="changed" ) echo $ENV_MSG # → world(変わっていない)
export済み)はサブシェルだけでなく、bashから呼び出した別コマンドやスクリプトにも引き継がれます。なお、cshやtcshではexportの代わりにsetenvを使います。詳しくはcsh 環境変数設定の解説も参考にしてください。()と{}の違い|カレントシェルで実行するか否か
()と{}はどちらも「複数のコマンドをまとめて実行する」ために使いますが、動作の本質が異なります。この違いを理解していないと、変数スコープで予期しないバグを生みます。3. ()はサブシェル・{}はカレントシェル(実証コード)
#!/bin/bash x=10 # ()はサブシェルで実行 → 変数変更は親に伝わらない ( x=99; echo "()内: $x" ) echo "()外: $x" # 出力: # ()内: 99 # ()外: 10 ← 変わっていない echo "---" # {}はカレントシェルで実行 → 変数変更が維持される # ※ {の直後にスペース、最後のコマンドの後に ; が必要 { x=99; echo "{}内: $x"; } echo "{}外: $x" # 出力: # {}内: 99 # {}外: 99 ← 変わっている!
{}を使う際の構文上の注意点が2つあります。開き中括弧 { の直後にスペースが必要なこと、最後のコマンドの末尾に ; が必要なことです。この2つを忘れると構文エラーになります。4. ()が便利なユースケース|cdを使う場合
サブシェルの「変更が親に伝わらない」特性は、うまく活用できます。cdコマンドを()の中で実行すれば、ディレクトリ移動後に元の場所に戻る処理が不要になります。# ()の中でcdしても親シェルのディレクトリは変わらない echo "作業前: $(pwd)" (cd /var/log && tar czf /tmp/logs_backup.tar.gz .) echo "作業後: $(pwd)" # 元のディレクトリのまま # 実行結果: # 作業前: /home/user # 作業後: /home/user ← 戻る処理が不要
{}で同じことをすると、cd後のディレクトリが親シェルにも反映されます。pushd/popdで元に戻すか、()を使うかを目的に応じて選んでください。パイプ後で変数が消える問題|原因と仕組み
bashスクリプトで最も多くのエンジニアがハマるのが、パイプを使ったループでの変数問題です。特に「ファイルを読んでカウントしたい」「条件に合う行を集計したい」といった処理で発生します。5. ハマりやすいパターン(失敗例)
#!/bin/bash # /etc/passwdの行数を数えようとしている(失敗例) count=0 cat /etc/passwd | while read line; do count=$((count + 1)) done echo "行数: $count" # 実行結果(RHEL 9 / bash 5.2): # 行数: 0
echo "行数: $count" で期待した行数ではなく 0 が出力されます。ループの中で確かに count を増やしているのに、なぜでしょうか。6. なぜ消えるのか|$BASHPIDで確認する
パイプ| で繋いだとき、bashのデフォルト動作では右辺のコマンドがサブシェルで実行されます。つまり上の例では while read line; do ... done ブロック全体がサブシェル内で動いています。#!/bin/bash # $BASHPID は現在のプロセスの実際のPIDを返す(bash 4.0以降) echo "親シェル BASHPID: $BASHPID" # パイプ経由の場合(whileがサブシェルになる) echo "=== パイプ経由 ===" echo "" | while read line; do echo "while内 BASHPID: $BASHPID" done # リダイレクト経由の場合(whileはカレントシェルのまま) echo "=== リダイレクト経由 ===" while read line; do echo "while内 BASHPID: $BASHPID" done < /dev/null # 実行結果例: # 親シェル BASHPID: 12345 # === パイプ経由 === # while内 BASHPID: 12346 ← 別プロセスになっている! # === リダイレクト経由 === # while内 BASHPID: 12345 ← 同じプロセス
count への変更はサブシェル内だけで完結します。サブシェルが終了するとその変数はすべて消え、親シェルの count=0 だけが残るのです。
シェルスクリプト講座を見る >>
3つの解決策|変数をパイプ後も保持する
7. process substitution(< <(...))を使う【推奨】
最もスマートで可搬性が高い解決策が、プロセス置換(process substitution)を使う方法です。< <(コマンド) という構文で、コマンドの出力を「ファイルへの入力リダイレクト」として while に渡します。この形では while はリダイレクト入力を受け取るだけなので、サブシェルになりません。#!/bin/bash # process substitution を使った正解版 count=0 while read line; do count=$((count + 1)) done < <(cat /etc/passwd) echo "行数: $count" # 実行結果(RHEL 9 / bash 5.2): # 行数: 33
< <(cat /etc/passwd) の読み方は「cat /etc/passwd の出力をプロセス置換でファイル相当として while の標準入力に渡す」です。< と <( の間にスペースが必要な点に注意してください(<< と連続で書くとヒアドキュメント扱いになってしまいます)。なお、process substitution は
#!/bin/bash が必須です。#!/bin/sh でdashなどが起動する環境では使えません。8. shopt -s lastpipeを使う(bash 4.2以降)
bash 4.2以降では、shopt -s lastpipe を設定するとパイプの最後のコマンドをサブシェルではなくカレントシェルで実行するようになります。パイプの書き方を変えたくない場合に有効です。#!/bin/bash # シェルスクリプト実行時(ジョブコントロールが無効の環境)でのみ有効 shopt -s lastpipe count=0 cat /etc/passwd | while read line; do count=$((count + 1)) done echo "行数: $count" # 実行結果(RHEL 9 / bash 5.2): # 行数: 33
lastpipe はジョブコントロールが無効のとき(シェルスクリプト実行時)にのみ有効です。ターミナルでの対話操作(インタラクティブシェル)ではジョブコントロールが有効になっているため、この設定は効きません。シェルスクリプト専用の対処法として覚えておいてください。9. 一時ファイルを使う古典的方法
bash 3.x系への対応が必要な場合や、/dev/fdが存在しない環境でprocess substitutionが使えない場合は、一時ファイルを使う方法が最も確実です。#!/bin/bash # mktemp で安全な一時ファイルを作成する TMPFILE=$(mktemp) cat /etc/passwd > "$TMPFILE" count=0 while read line; do count=$((count + 1)) done < "$TMPFILE" rm -f "$TMPFILE" echo "行数: $count" # 実行結果: # 行数: 33
mktemp コマンドで安全な一時ファイルパスを生成します。/tmp/work_$$ のように PID で命名する方法より、mktemp の方がファイル名衝突のリスクが低いため推奨します。スクリプト終了時に必ず rm -f "$TMPFILE" で削除してください。トラブルシュート|よくある疑問と対処
Q. process substitutionで構文エラーになるbash: syntax error near unexpected token が出る場合、スクリプト冒頭のシェバンが #!/bin/sh になっていないか確認してください。/bin/sh が dash 等の場合、process substitution は使えません。#!/bin/bash に変更してください。Q. ()の中でcdしたのに元のディレクトリに戻ってこない
() の中の cd は親シェルに影響しません。元ディレクトリに戻れていない場合は、() ではなく {} や pushd/popd を使っていないか確認してください。Q. 関数内で変数を変更したが外に出ない場合と出る場合がある
bashの関数はデフォルトでカレントシェルで実行されます。関数内での変数変更は親スクリプトに反映されます。ただし、関数内で
local 変数名 を宣言した場合はローカルスコープになり、関数を出ると消えます。local を使っているかどうかを確認してください。Q. パイプを複数段繋げた後の変数を取り出したい
cmd1 | cmd2 | while read のように複数段のパイプでも、process substitution は有効です。while read ... done < <(cmd1 | cmd2) の形に書き換えてください。Linuxの基本的なコマンド連携についてはLinux 基本コマンドの解説も合わせて参照してください。本記事のまとめ
| 状況 | 原因 | 解決策 |
|---|---|---|
| パイプ後のwhileで変数が0のまま | whileがサブシェルで実行される | while ... done < <(コマンド) に変える |
| ()内の変数変更が外に伝わらない | ()はサブシェルを生成する | カレントシェルで実行したい場合は{}を使う |
| パイプの書き方を変えたくない | デフォルトではパイプ右辺がサブシェル | shopt -s lastpipe(bash 4.2以降・スクリプト専用) |
| 古い環境・高い可搬性が必要 | process substitution非対応の環境 | 一時ファイル(mktemp)を使う |
| と () がサブシェルを生み出すことを理解していれば、パイプ後の変数問題も落ち着いて対処できます。まずは process substitution(
< <(...))を一つ覚えるだけで、現場でよくあるケースの大半に対応できます。シェルスクリプト講座を見る >>
3,100名以上が実践した「型」を無料で公開中
プロのエンジニアはコマンドを暗記していません。
「現場で使える型」を効率よく使いこなしているだけです。
その「型」を図解60Pにまとめた入門マニュアルを、完全無料でプレゼントしています。
登録10秒/合わなければ解除3秒 / 詳細はこちら
- 次のページへ:bashのexecとファイルディスクリプタ操作|スクリプト全体のログをリダイレクトする設計
- 前のページへ:シェルスクリプトのエラー通知設計|mailx・sendmail・Webhookで障害をすぐ知らせる仕組みの作り方
- この記事の属するカテゴリ:シェルスクリプトへ戻る

無料メルマガで学習を続ける
Linuxの実践スキルをメールで毎週お届け。
登録は1分、解除もいつでも可。
登録無料・いつでも解除できます