Bash変数展開の問題を効果的にトラブルシューティングする方法

Bashスクリプトは、微妙な変数展開エラーによってしばしば失敗します。この包括的なガイドでは、誤ったクォーティング、未初期化値の処理、サブシェルや関数内での変数スコープ管理など、一般的な問題を詳しく解説します。`set -u`や`set -x`などの重要なデバッグテクニックを学び、`${VAR:-default}`のような強力なパラメータ展開修飾子を習得して、堅牢で予測可能かつエラーのない自動化スクリプトを作成しましょう。謎の空文字列のデバッグに悩むのはもう終わりにして、自信を持ってスクリプトを作成しましょう。

Bash変数展開の問題を効果的にトラブルシューティングする方法

Bashの変数展開バグは、しばしばランダムな動作のように見えます。スペースを含むパスが2つのパスになったり、ファイル名の中のワイルドカードがディレクトリの半分に展開されたり、ループ内で設定した変数が消えたり、欠落した環境変数が静かに空文字列になったりします。シェルはランダムに動作しているわけではありません。スクリプトが本来行うべきタスクに集中しているときに忘れがちな展開ルールに従っているのです。

有用なメンタルモデルは次のとおりです。Bashは単に$nameをテキストに置き換えてコマンドを実行するわけではありません。変数を展開し、結果を単語に分割し、グロブを展開し、最後に結果の引数リストでコマンドを実行します。ほとんどの修正は、これらのステップを制御することから生まれます。

未設定の変数は、止めない限り空になる

デフォルトでは、このスクリプトは空の値を出力して続行します。

printf 'Deploying %s\n' "$APP_VERSION"

APP_VERSIONが必要だった場合、それはバグです。変数が必須の場合は、パラメータ展開を使用します。

: "${APP_VERSION:?APP_VERSION must be set}"
printf 'Deploying %s\n' "$APP_VERSION"

先頭の:は何もしないコマンドです。展開がチェックを行います。変数が未設定または空の場合、Bashはメッセージを表示し、非対話型シェルから終了します。

オプションの値の場合は、デフォルトを明確にします。

log_level=${LOG_LEVEL:-INFO}
retry_count=${RETRY_COUNT:-3}

コロンが重要です。${VAR:-default}は、VARが未設定または空の場合にデフォルトを使用します。${VAR-default}は、VARが未設定の場合のみデフォルトを使用します。この違いは、空文字列が有効な設定値である場合に重要です。

set -uも未設定の変数をキャッチできます。

set -u

多くのスクリプトで便利ですが、明確な検証の代わりにはなりません。また、オプションの位置パラメータ、配列、または存在を意図的にチェックする変数を扱う場合に驚くことがあります。引数が存在しない可能性がある場合は、${1:-}を使用します。

mode=${1:-help}

分割とグロブを意図しない限り、変数をクォートする

これが最も一般的な展開問題です。

file="Quarterly Report *.txt"
rm $file

クォートされていない場合、Bashは最初に$fileを展開し、次にスペースで分割し、その後*をワイルドカードとして扱います。コマンドは意図しない複数の引数を受け取る可能性があります。クォートすると、正確に1つの引数を受け取ります。

rm -- "$file"

--は、ダッシュで始まる値からコマンドを保護します。これは-rfのようなファイル名で重要です。

変数、コマンド置換、およびほとんどのパラメータ展開には二重引用符を使用します。

cp "$source_file" "$destination_dir/"
printf 'User: %s\n' "$user_name"

一重引用符は異なります。展開を完全に防ぎます。

printf 'Home is $HOME\n'   # リテラルテキストを表示
printf "Home is $HOME\n"   # 値を表示

'prefix-$value'のような文字列を構築しているスクリプトを見かけたら、それはバグの可能性があります。値が展開されるべき場合は二重引用符を使用します。

配列は多くの引数構築問題を解決する

壊れたBashの多くは、複数のコマンドオプションを1つの文字列に格納することから生じます。

opts="-a --delete --exclude *.tmp"
rsync $opts "$src/" "$dest/"

これは単語分割に依存しており、オプション引数にスペースが含まれると壊れる可能性があります。配列を使用します。

opts=(-a --delete --exclude '*.tmp')
rsync "${opts[@]}" "$src/" "$dest/"

"${opts[@]}"は各配列要素を独自の引数として展開します。これはほとんどのコマンド構築に必要なものです。

同じことがファイル名を収集する場合にも当てはまります。

files=("$report_dir"/*.txt)
for file in "${files[@]}"; do
  [[ -e $file ]] || continue
  process_report "$file"
done

[[ -e $file ]] || continueガードは、ファイルが一致せずグロブがリテラルのままになった場合を処理します(シェルオプションに依存します)。

コマンド置換は末尾の改行を削除する

$(command)はstdoutをキャプチャしますが、Bashは末尾の改行文字を削除します。これは通常、バージョン文字列では問題ありませんが、最終的な改行が重要なデータでは間違っています。

version=$(git describe --tags --always)
printf 'Version: %s\n' "$version"

行指向の出力には、配列が必要な場合はmapfileを推奨します。

mapfile -t names < <(find "$base_dir" -maxdepth 1 -type f -name '*.log' -printf '%f\n')
for name in "${names[@]}"; do
  printf 'log=%s\n' "$name"
done

for item in $(ls)は避けてください。空白、グロブ文字、および異常なファイル名で壊れます。グロブをループするか、注意深い区切り文字でfindを使用します。

パイプライン内の変数はサブシェルにある可能性がある

これは、ループが正しく実行されているように見えるため、人々を悩ませます。

count=0
printf '%s\n' a b c | while IFS= read -r line; do
  count=$((count + 1))
done
printf 'count=%s\n' "$count"

多くのBash設定では、パイプライン内のwhileループはサブシェルで実行されます。インクリメントは発生しますが、親シェルのcountは変更されません。

代わりにプロセス置換を使用します。

count=0
while IFS= read -r line; do
  count=$((count + 1))
done < <(printf '%s\n' a b c)
printf 'count=%s\n' "$count"

または、パイプラインに必要な値を生成させ、その値を直接キャプチャします。

ローカル変数は偶発的な上書きを防ぐ

Bash関数内の変数は、localと宣言しない限りグローバルです。これにより、ヘルパー関数が奇妙な展開バグの原因になる可能性があります。

env=prod

load_config() {
  env=dev
}

load_config
printf '%s\n' "$env"  # dev

一時的な値にはlocalを使用します。

load_config() {
  local env=dev
  printf 'loaded defaults for %s\n' "$env"
}

localはBashの機能です。Bashスクリプトでは問題ありませんが、スクリプトをshで実行すべきでないもう一つの理由です。

名前が他のテキストに触れる場合は中括弧を使用する

$prefix_fileは、$prefixの後に_fileが続くのではなく、prefix_fileという名前の変数を意味します。中括弧を使用して境界を明確にします。

prefix=app
printf '%s\n' "${prefix}_file"

中括弧は、多くのパラメータ展開操作でも必要です。

path=/var/log/nginx/access.log
printf 'dir=%s\n' "${path%/*}"
printf 'file=%s\n' "${path##*/}"

${path%/*}は最短の一致するサフィックスを削除します。${path##*/}は最長の一致するプレフィックスを削除します。これらは便利ですが、dirnamebasenameでスクリプトがチームにとって明確になる場合は、使いすぎないようにしてください。

実際の引数を表示して展開をデバッグする

set -xは展開後のコマンドを表示します。行番号でトレースを改善します。

PS4='+ ${BASH_SOURCE}:${LINENO}: '
set -x
mv $file $target_dir
set +x

トレースにより、コマンドがmv Quarterly Report *.txt /tmp/outになったのか、mv 'Quarterly Report *.txt' /tmp/outになったのかが明らかになります。機密情報からxtraceを遠ざけてください。

より安全な手動チェックには、%qで値を表示します。

printf 'file=%q\n' "$file" >&2
printf 'target_dir=%q\n' "$target_dir" >&2

%qはスペースや特殊文字を、単純なechoよりも読みやすい方法で可視化します。

実用的なチェックリスト

Bash変数が誤って展開された場合、次の順序で確認します。

  1. スクリプトはshではなくBashで実行されていますか?
  2. 変数は実際に設定されていますか?必須の値には${VAR:?message}を使用します。
  3. 分割が意図的でない限り、すべての展開はクォートされていますか?
  4. 複数の引数には配列を使用していますか?
  5. パイプラインによってループがサブシェルに入っていませんか?
  6. localが欠落しているため、関数がグローバル変数を上書きしていませんか?
  7. 変数名を近くのテキストから分離するために中括弧が必要ですか?

これらのチェックは、最も良い意味で退屈です。ほとんどの展開バグを「Bashは変だ」から、具体的で修正可能なルールに変えます。

間接展開とnamerefは特別な注意が必要

Bashは、別の変数に格納された名前の変数を展開できます。

name=APP_ENV
printf '%s\n' "${!name}"

これはAPP_ENVの値を表示します。強力ですが、スクリプトが読みにくくなり、変数名がユーザー入力から来る場合に安全でなくなる可能性があります。名前から値へのマッピングのみが必要な場合は、連想配列の方が明確です。

declare -A endpoints=(
  [dev]='https://dev.example.test'
  [prod]='https://api.example.com'
)

printf '%s\n' "${endpoints[$env]:?unknown environment}"

Bashにはdeclare -nを使用したnamerefもあり、ヘルパー関数でよく使用されます。ライブラリスタイルのスクリプトでは便利ですが、驚くべき副作用を生み出す可能性があります。配列や変数を参照渡しすることがコードを真に簡素化する場合にのみ使用してください。

パターン削除は正規表現マッチングではない

${file%.log}${path##*/}などのパラメータ展開演算子は、シェルパターンを使用し、正規表現は使用しません。この違いは重要です。

file='access.log'
printf '%s\n' "${file%.log}"

これは.logサフィックスを削除します。「正規表現に一致するものを削除する」という意味ではありません。正規表現チェックには、[[ ... =~ ... ]]を使用します。

if [[ $port =~ ^[0-9]+$ ]]; then
  printf 'numeric\n'
fi

そこでも、注意深くクォートしてください。=~の右辺は、正規表現として扱いたい場合、通常はクォートされません。左辺の変数は[[ ]]内ではクォートする必要はありません。なぜなら、[[ ]][ ]のように単語分割を行わないからです。

子プロセスが必要なものだけをエクスポートする

Bashで変数を設定しても、スクリプトが起動するコマンドで自動的に使用できるようにはなりません。

APP_ENV=prod
./run-app

run-appは、エクスポートされるかインラインで提供されない限り、APP_ENVを認識しません。

export APP_ENV=prod
./run-app

# または
APP_ENV=prod ./run-app

これは、スクリプトが正しい値を表示するが、子プロセスが値がないかのように動作する場合の一般的な混乱の原因です。変数はシェルに存在しますが、子プロセスの環境に配置されたことはありません。

逆もまた真です。子プロセスは親シェルの変数を変更できません。ヘルパースクリプトがexport TOKEN=...を表示する場合、通常の実行では呼び出し元は更新されません。ソースする必要がありますが、ソースは信頼できるシェルコードのために予約されるべきです。

出荷前の実際のレビューパス

スクリプトまたはコンテナのセットアップが完了したと呼ぶ前に、午前2時にデバッグしなければならない次の人のように一度読んでください。そうすると、気づくことが変わります。スクリプト作成時に意味があったプロンプトが、CIログに表示されると曖昧になるかもしれません。明白に思えたDockerサービス名が、アプリケーションの変数名と一致しないかもしれません。開発には安全だったBashのデフォルトが、本番環境では危険かもしれません。

私は、意図的に扱いにくい値を使用して短いドライランを行うのが好きです。スペースを含むパスを使用します。空のオプション値を使用します。ダッシュで始まるファイル名を試します。異なる作業ディレクトリからスクリプトを実行します。1つの期待される環境変数なしでコンテナを起動します。これらのテストは派手ではありませんが、通常最初に壊れる仮定をキャッチします。

また、失敗メッセージも確認してください。出力がfailedだけの場合、記事のアドバイスは実装に反映されていません。有用な失敗は、使用された値、失敗したチェック、およびオペレーターが変更できる内容を示します。それは、すべての環境変数をダンプしたり、機密情報を表示したりすることを意味しません。具体的な情報が役立つ場合に具体的であることを意味します。設定パス、欠落したコマンド名、ネットワーク名、サービスホスト名、またはプロセスがバインドしようとしたポートなどです。

最後の習慣は、システムが実際に実行される方法に近い例を維持することです。本番環境でComposeを使用する場合は、Composeでテストします。スクリプトがsystemdによって起動される場合は、systemdまたは同様に最小限の環境でテストします。コマンドがコピー&ペーストで安全であるべき場合は、例自体にクォーティング、--セパレーター、および検証を含めます。読者は、警告よりも動作するパターンをコピーすることが多いです。

そのレビューパスは官僚主義ではありません。それは、小さな自動化を退屈に保つ方法です。退屈は、シェルプロンプト、設定ローダー、変数展開、コンテナ診断、Dockerネットワーキングに求めるものです。動作が驚きにくいほど、次のオペレーターがそれを信頼しやすくなります。

特に変数展開については、そのレビューにもう1つの習慣を追加します。コマンドが異常に動作する場合、引数カウントを表示します。小さなヘルパーが目に見えないものを可視化できます。

show_args() {
  local i=1
  for arg in "$@"; do
    printf 'arg[%d]=%q\n' "$i" "$arg" >&2
    i=$((i + 1))
  done
}

show_args mv $file $target_dir
show_args mv "$file" "$target_dir"

最初の呼び出しは、壊れたコマンドが受け取るものを示します。2番目は修正されたバージョンを示します。引数リストを見ると、クォーティングのバグは神秘的ではなくなります。