一般的なBashスクリプト作成の落とし穴とその回避方法

より安全なエラーハンドリング、クォーティング、配列、トラップ、引数解析で、よくあるBashスクリプティングのバグを回避しましょう。

よくあるBashスクリプティングの落とし穴とその回避方法

Bashスクリプティングの落とし穴は、実際のファイル名、欠落した変数、失敗したコマンド、予期しない入力に遭遇したときに現れます。あなたのラップトップで動作するスクリプトが、緩いデフォルト設定に依存していると、CIや本番環境で壊れる可能性があります。

すべてのシェルスクリプトを複雑にする必要はありません。しかし、展開をクォートし、意図的に失敗をチェックし、スペースを含む名前でテストする必要があります。

より安全なデフォルトを慎重に設定する

多くのスクリプトは次のように始まります:

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

これは多くの自動化スクリプトにとって良いベースラインですが、各オプションには注意すべき点があります:

  • set -e は、単純なコマンドが失敗したときに終了しますが、if テスト、&&|| リストの一部、一部のコマンド置換などでは例外です。
  • set -u は、未設定の変数を展開しようとすると終了します。
  • set -o pipefail は、パイプライン内のいずれかのコマンドが失敗した場合、最後のコマンドだけでなく、パイプライン全体を失敗させます。

早期の失敗が継続よりも安全な場合にこれらのオプションを使用してください。失敗が予想されるコマンドについては、ステータスを明示的に処理します。

if ! grep -q "ready" status.txt; then
  echo "service is not ready yet"
  exit 1
fi

変数展開をクォートする

クォートされていない変数は、最も一般的なBashのバグです。Bashはクォートされていない展開に対して単語分割とグロブ展開を行うため、release notes/*.txt のようなパスが複数の引数になったり、意図しないファイルにマッチしたりする可能性があります。

file="release notes.txt"

# 悪い例:値が2つの単語に分割されるため壊れる。
rm $file

# 良い例:正確に1つの引数を渡す。
rm -- "$file"

コマンドがサポートしている場合は、ユーザー制御のファイル名の前に -- を使用してください。これにより、-rf のようなファイル名がオプションとして解釈されるのを防ぎます。

引数リストには配列を使用する

コマンドと引数を1つの文字列に格納して実行しないでください。クォーティングがすぐに脆弱になります。

# 悪い例
flags="-a --exclude node_modules"
rsync $flags "$src" "$dest"

# 良い例
flags=(-a --exclude "node_modules")
rsync "${flags[@]}" "$src" "$dest"

配列は引数の境界を保持します。これは、引数にスペース、ワイルドカード文字、またはダッシュで始まる値が含まれている場合に重要です。

バッククォートよりも $(...) を優先する

バッククォートはネストが難しく、読み間違えやすいです。コマンド置換には $(...) を使用してください。

current_branch="$(git rev-parse --abbrev-ref HEAD)"
echo "building branch: $current_branch"

意図的に単語分割を行いたい場合を除き、コマンド置換はクォートしたままにしてください。

データを失わずにファイルを読み込む

以下のパターンは無害に見えますが、スペースで壊れ、バックスラッシュを壊す可能性があります:

for line in $(cat hosts.txt); do
  echo "$line"
done

代わりに while IFS= read -r を使用してファイルを読み込んでください。

while IFS= read -r host; do
  echo "checking $host"
done < hosts.txt

IFS= は先頭と末尾の空白を保持します。-r はバックスラッシュエスケープが解釈されるのを防ぎます。

一時ファイルを mktemptrap で処理する

ハードコードされた一時パスは、別のプロセスと競合したり、古いファイルを残したりする可能性があります。一意のパスを作成し、終了時にクリーンアップします。

tmp_file="$(mktemp)"
cleanup() {
  rm -f "$tmp_file"
}
trap cleanup EXIT

printf '%s\n' "work data" > "$tmp_file"

ディレクトリの場合は、mktemp -d を使用し、クリーンアップ関数でディレクトリを削除してください。

オプションを getopts で解析する

手動の引数解析は、しばしばエッジケースを見落とします。短いオプションの場合、Bashの組み込み getopts で通常は十分です。

verbose=false
output=""

while getopts ":vo:" opt; do
  case "$opt" in
    v) verbose=true ;;
    o) output="$OPTARG" ;;
    :)
      echo "Option -$OPTARG requires an argument" >&2
      exit 2
      ;;
    \?)
      echo "Unknown option: -$OPTARG" >&2
      exit 2
      ;;
  esac
done
shift "$((OPTIND - 1))"

getopts-v-o file のような短いフラグを処理します。スクリプトに --output のような長いオプションが必要な場合は、注意深いパーサーを作成するか、より強力な引数解析ライブラリを持つ言語を使用してください。

失敗する可能性のあるコマンドをチェックする

コマンドが何かを出力したからといって、成功したと仮定しないでください。重要な操作の出力を使用する前に、その操作をチェックしてください。

if ! archive="$(tar -czf app.tar.gz app 2>&1)"; then
  echo "archive failed: $archive" >&2
  exit 1
fi

パイプラインの場合、パイプラインの途中の失敗がパイプライン全体を失敗させるべきときは、pipefail を有効にしてください。

set -o pipefail
journalctl -u api.service | grep -i "error"

pipefail がない場合、パイプラインのステータスは通常、最後のコマンドから取得されます。

移植性が重要な場合はBashを避ける

スクリプトが配列、[[ ... ]]mapfilepipefail を使用する場合、それはBashスクリプトです。次のように開始してください:

#!/usr/bin/env bash

POSIX sh の移植性が必要な場合は、Bashのみの機能を避け、ターゲットシステムで使用されるシェルでテストしてください。#!/bin/sh でBashスクリプトを書き、どこでも同じように動作することを期待しないでください。

まとめ

Bashスクリプトを改善する最も速い方法は、乱雑な入力(ファイル名のスペース、欠落した変数、空のファイル、失敗するコマンド)でテストすることです。展開をクォートし、引数リストには配列を使用し、trap で一時ファイルをクリーンアップし、失敗パスを明示的にしてください。将来の自分は、完璧な入力でのみ動作するスクリプトのデバッグに費やす時間が減るでしょう。