一般的なBashスクリプト設定問題のトラブルシューティング

Bashスクリプトにおける設定問題のトラブルシューティング技術を習得します。このガイドでは、環境依存、不適切な引用や単語分割などの一般的な構文上の落とし穴、および重大な実行失敗に焦点を当てた、必須のデバッグ手法を詳しく説明します。堅牢なフラグ(`set -euo pipefail`)の使用方法、引数解析エラーの処理、DOS改行コードや誤ったPATH変数などの一般的な問題の解決方法を学び、自動化スクリプトがどの環境でも確実に動作するようにします。

一般的なBashスクリプト設定問題のトラブルシューティング

Bashの設定問題は、通常、漠然とした形で現れます。ターミナルでは動作するスクリプトがcronでは失敗する、デプロイスクリプトがkubectlを見つけられない、スペースを含む設定ファイルパスが特定の顧客でのみ壊れる、などです。バグは多くの場合、メインロジックにはありません。環境、引数、引用、パーミッション、または実際にファイルを実行したシェルに関する前提にあります。

Bashスクリプトをトラブルシューティングするとき、私は最初に4つの質問に答えようとします。どのシェルが実行しているか?どの環境を受け取ったか?どの入力を解析したか?どのコマンドが最初に失敗したか?この順序で、症状を追いかけることを防げます。

シェルと実行コンテキストを確認する

Bash構文で始まるスクリプトがshの下で実行されると、奇妙な方法で失敗する可能性があります。配列、[[ ... ]]source、プロセス置換、set -o pipefailはBashの機能です。ファイルがこれらを使用する場合、シバンはBashを指定する必要があります。

#!/usr/bin/env bash

次に、自動化と同じ方法で実行します。これらは同等ではありません。

./deploy.sh
bash deploy.sh
sh deploy.sh

./deploy.shはシバンを使用します。bash deploy.shはBashを強制します。sh deploy.shは、システムに応じてdash、BusyBox ash、または別のシェルを使用する場合があります。本番環境がsh deploy.shを呼び出す場合、完璧なBashシバンは役に立ちません。

Cron、systemd、CIランナー、SSH強制コマンド、Dockerエントリポイントはすべて異なる環境を提供します。インタラクティブに動作するスクリプトが失敗するのは、ログインシェルが実行前にPATHAWS_PROFILENVM_DIR、または言語バージョンマネージャーを設定したためかもしれません。

上部近くに一時的な診断ブロックを追加します。

printf 'shell=%s\n' "$BASH_VERSION" >&2
printf 'user=%s pwd=%s\n' "$(id -un)" "$PWD" >&2
printf 'PATH=%s\n' "$PATH" >&2

答えがわかったら、これを削除するかゲートします。診断は便利ですが、環境値をログに漏洩させると秘密が露出する可能性があります。

厳格モードを注意深く、盲目的に使用しない

set -euo pipefailは多くの自動化スクリプトにとって強力なデフォルトですが、エッジケースがあります。set -uは欠落した変数をキャッチします。pipefailはパイプラインの失敗を可視化します。set -eは多くのコマンド失敗後に停止しますが、条件文、パイプライン、複合コマンド内では、新しいBashユーザーが期待するのとは異なる動作をします。

実用的な出発点は次のとおりです。

set -Eeuo pipefail
trap 'printf "Error on line %s: %s\n" "$LINENO" "$BASH_COMMAND" >&2' ERR

失敗したコマンドがスクリプトを停止すべき場合に使用します。意図的にコマンドをプローブして続行するスクリプトで軽率に使用しないでください。予想される失敗については、条件を明示的に記述します。

if ! grep -q '^enabled=true$' "$config_file"; then
  printf 'Feature is disabled.\n'
fi

これは、set -eの下でgrepが失敗し、なぜスクリプトが終了したのか疑問に思うよりも明確です。

ファイルを読む前に引数を検証する

一般的な設定バグは、$1が存在しないときに存在するものとして扱うことです。set -uの下では、欠落した$1を参照すると即座に終了します。set -uがない場合、空の文字列になります。

小さな使用法ブロックを使用します。

usage() {
  printf 'Usage: %s <config-file> [environment]\n' "${0##*/}" >&2
}

if (( $# < 1 )); then
  usage
  exit 2
fi

config_file=$1
environment=${2:-dev}

if [[ ! -r $config_file ]]; then
  printf 'Config file is not readable: %s\n' "$config_file" >&2
  exit 1
fi

environmentのデフォルトに注意してください。ただし、config_fileにはありません。デフォルトはオプションの値には役立ちますが、必須の値には危険です。スクリプトは、本番デプロイメントで非常に意図的でない限り、静かに./config.ymlにフォールバックすべきではありません。

設定からのパスと値を引用符で囲む

ほとんどのBashスクリプトは、最終的に設定ファイルまたは環境変数からパスを読み取ります。その値が引用符で囲まれていない場合、Bashは単語分割とグロブ展開を実行します。

backup_dir="/mnt/backups/May reports"

# 壊れている: 複数の引数になる。
cp $backup_dir/latest.tar.gz /restore/

# 正しい。
cp "$backup_dir/latest.tar.gz" /restore/

同じルールがコマンド置換にも適用されます。

release_name=$(git describe --tags --always)
printf 'Deploying %s\n' "$release_name"

意図的に複数の引数が必要な場合は、文字列の代わりに配列を使用します。

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

これにより、opts="-a --delete"の後にrsync $opts ...という脆弱なパターンを回避できます。

PATHと外部コマンドの依存関係を確認する

command not foundは通常、コンテキストの問題です。ターミナルは/opt/homebrew/bin/awsawsを見つけるかもしれませんが、cronは/usr/bin:/binしか持っていません。

起動時に、必要なツールを確認します。

require_cmd() {
  command -v "$1" >/dev/null 2>&1 || {
    printf 'Required command not found: %s\n' "$1" >&2
    exit 127
  }
}

require_cmd docker
require_cmd jq
require_cmd aws

重要なシステムユーティリティの場合、絶対パスで問題ない場合があります。異なる場所にインストールされた開発者ツールの場合、明確なエラーメッセージ付きの依存関係チェックの方が通常は保守が容易です。

スクリプトがsystemdによって起動される場合、ユーザーの.bashrcに依存する代わりに、ユニットまたは環境ファイルで環境を設定します。非インタラクティブシェルは、ターミナルと同じスタートアップファイルを必ずしも読み取りません。

環境変数を明示的に解析する

環境駆動の設定は便利ですが、空と未設定は常に同じではありません。Bashのパラメータ展開を使用すると、正確に指定できます。

: "${APP_ENV:?APP_ENV must be set}"
log_level=${LOG_LEVEL:-INFO}

${APP_ENV:?message}は、変数が未設定または空の場合に失敗します。${LOG_LEVEL:-INFO}は、未設定または空の場合にデフォルトを使用します。空の文字列がスクリプトで意味を持つ場合は、コロンなしの形式(${VAR-default}など)を使用します。

トラブルシューティング中に環境全体をログにダンプすることは避けてください。トークン、データベースパスワード、クラウド認証情報を印刷するのは簡単すぎます。

CRLF改行と不可視文字に注意する

Windowsで編集されたスクリプトにはCRLF改行が含まれている可能性があります。典型的な症状は、^Mを含むエラー、またはインタプリタが存在しないように見えるシバンの失敗です。

以下で確認します。

file deploy.sh
sed -n 'l' deploy.sh | head

次のいずれかで修正します。

dos2unix deploy.sh
# または、dos2unixが利用できない場合:
sed -i 's/\r$//' deploy.sh

また、コピーされた設定値に末尾のスペースがないか確認します。prodのように見えるが実際はprod である変数は、caseブランチを見逃し、迷走させる可能性があります。

最初に失敗するコマンドをデバッグする

set -xは展開後のコマンドを表示します。これはまさに引用と設定のバグに必要なものです。

PS4='+ ${BASH_SOURCE}:${LINENO}: '
set -x
# 失敗するセクションここ
set +x

秘密の周りでxtraceを有効にしないでください。スクリプトがパスワード、トークン、署名付きURL、秘密鍵を扱う場合、必要な狭いセクションのみをトレースします。

設定ファイルの場合、解決された値と適用しようとしているテストを印刷します。

printf 'Using config_file=%q\n' "$config_file" >&2
[[ -r $config_file ]] || exit 1

%qはデバッグに便利です。シェルフレンドリーな方法で空白を可視化するからです。

パーミッションも設定として扱う

スクリプト自体は正しいが、実行アカウントが設定を読み取れない、ヘルパーを実行できない、出力ディレクトリに書き込めない場合があります。

実際のユーザーを確認します。

id
namei -l "$config_file"

namei -lは特に便利です。パス内のすべてのディレクトリに実行権限が必要だからです。アクセス不可能な親ディレクトリ内の読み取り可能なファイルは、依然としてアクセス不可能です。

実行可能スクリプトの場合、パッケージングまたはイメージビルド中にパーミッションと改行を一緒に設定します。

chmod 0755 /usr/local/bin/deploy

スクリプトがsudoでのみ動作する場合、特権が必要なファイルまたはコマンドを特定します。1つの所有権設定の誤りを隠すためだけに、スクリプト全体をrootとして実行しないでください。

信頼性の高いトラブルシューティングパス

Bashの設定問題が不明確な場合、次の順序でパスを実行します。

  1. スクリプトがBash機能を使用する場合、Bashの下で実行されていることを確認します。
  2. 失敗したコンテキストの作業ディレクトリ、ユーザー、PATHを印刷します。
  3. メインロジックの前に必須の引数と設定ファイルを検証します。
  4. 意図的に分割したい場合を除き、すべての展開を引用符で囲みます。
  5. command -vで必要な外部コマンドを確認します。
  6. 秘密を保護しながら、失敗するセクションの周りでのみset -xを使用します。
  7. ビジネスロジックを変更する前に、パーミッションと改行を確認します。

このシーケンスは、スクリプトをミステリー小説に変えることなく、ほとんどの現実世界の失敗をキャッチします。Bashは小さいですが、その実行コンテキストは大きいです。最初にコンテキストをトラブルシューティングします。

設定の読み込みと実行を分離する

設定の読み込みが独自のステップである場合、スクリプトのトラブルシューティングは容易です。ファイルを読み取り、変数をエクスポートし、ディレクトリを作成し、サービスを再起動することをすべて1つの長いブロックで行わないでください。最初に値を解決します。次にそれらを検証します。その後、作業を実行します。

load_config() {
  local file=$1
  [[ -r $file ]] || {
    printf 'Cannot read config: %s\n' "$file" >&2
    return 1
  }

  # 意図的にシンプルなKEY=VALUEファイルの例。
  # 完全に信頼していないファイルをsourceしないでください。
  while IFS='=' read -r key value; do
    [[ -z $key || $key == \#* ]] && continue
    case $key in
      APP_PORT) APP_PORT=$value ;;
      APP_ENV) APP_ENV=$value ;;
      *) printf 'Ignoring unknown config key: %s\n' "$key" >&2 ;;
    esac
  done < "$file"
}

. config.envで設定ファイルをsourceするのは一般的ですが、シェルコードを実行します。これは、ファイルが信頼され、コードのように所有されている場合にのみ許容されます。ユーザーが編集可能な設定の場合は、サポートするキーのみを解析します。

次のオペレーターにとって実行可能な失敗にする

良いエラーメッセージは、何が失敗したか、どの値が原因かを示します。これらを比較してください。

printf 'Error\n' >&2

および:

printf 'Cannot write backup directory: %s\n' "$backup_dir" >&2

2番目のメッセージは、次の人に確認すべき何かを提供します。これはDevOpsスクリプトで重要です。なぜなら、失敗を見る人が作者ではない可能性があるからです。彼らはオンコールで、半分眠っていて、失敗したデプロイメントからのCIログを見ているかもしれません。

終了コードも意味を持たせることができます。使用法の問題には2、一般的なランタイム失敗には1、文書化された理由がある場合はツール固有のコードを使用します。分類法を発明するのに一日中費やさないでください。ただし、スクリプトが警告を印刷したという理由だけで、検証失敗後に成功を返さないようにしてください。

お気に入りのコンテキストではなく、失敗するコンテキストをテストする

systemdがスクリプトを実行する場合、systemdでテストします。cronが実行する場合、 stripped環境でテストします。簡単な近似は次のとおりです。

env -i HOME="$HOME" PATH=/usr/bin:/bin bash ./script.sh config.env

これにより、インタラクティブシェルの快適なブランケットが取り除かれます。欠落したエクスポートとPATHの前提がすぐに現れます。

Dockerエントリポイントスクリプトの場合、可能な限り本番環境と同じ環境とマウントでイメージを実行します。

docker run --rm --env-file app.env -v "$PWD/config:/config:ro" my-image:tag

CIでのみ失敗する場合、CIランナーの作業ディレクトリと正確なコマンドラインを印刷します。多くのCI Bash失敗は、チェックアウト後の単なる間違った相対パスであり、深いシェルの問題ではありません。

出荷前の現実世界のレビューパス

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

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

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

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

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