シェルスクリプトが意図した動作をしない

シェルスクリプトはエラーメッセージを出さずに間違った結果を返す場合がある。

以下はログファイルが10個を超えたときに警告を出すスクリプトである。

#!/bin/bash

LOG_DIR=/var/log/app
COUNT=$(ls "${LOG_DIR}" | wc -l)

if [ ${COUNT} > 10 ]; then
  echo "too many log files: ${COUNT}"
fi

ログファイルは3個しかないにもかかわらず、警告が出力される。

$ ls /var/log/app
a.log  b.log  c.log
$ ./check.sh
too many log files: 3

条件式が正しく評価されていないと推測できるが、スクリプトの出力だけでは原因を特定できない。

bash -xで実行トレースを表示する

bash -xでスクリプトを実行すると、各コマンドを実行する直前に、実行内容が標準エラー出力へ表示される。

$ bash -x ./check.sh
+ LOG_DIR=/var/log/app
++ ls /var/log/app
++ wc -l
+ COUNT=3
+ '[' 3 ']'
+ echo 'too many log files: 3'
too many log files: 3

+ '[' 3 ']'の行に注目する。 スクリプトに書いた> 10が条件式から消えている。 bashが>をリダイレクトとして解釈し、[ 3 ]の標準出力を10という名前のファイルへ向けていた。 [ 3 ]は空文字列でない引数を真と判定するため、条件は常に成立する。

$ ls
10  check.sh

数値の比較には-gtを使う。

#!/bin/bash

LOG_DIR=/var/log/app
COUNT=$(ls "${LOG_DIR}" | wc -l)

if [ "${COUNT}" -gt 10 ]; then
  echo "too many log files: ${COUNT}"
fi

修正後のトレースでは、条件式が3 -gt 10として評価され、警告が出力されなくなる。

$ bash -x ./check.sh
+ LOG_DIR=/var/log/app
++ ls /var/log/app
++ wc -l
+ COUNT=3
+ '[' 3 -gt 10 ']'

行頭の+の数

トレースの行頭には+が表示される。 +は間接実行のネストの深さに応じて繰り返される。 コマンド置換やサブシェルの中で実行されるコマンドは++で表示される。

#!/bin/bash

echo "today is $(date +%Y)"
$ bash -x ./date.sh
++ date +%Y
+ echo 'today is 2026'
today is 2026

表示されるのは展開後のコマンド

トレースには変数やコマンド置換を展開した後のコマンドが表示される。 上記の例では${LOG_DIR}/var/log/appに置き換わっている。 変数へ実際に入った値を確認できるため、echoをスクリプト中に埋め込む必要がない。

一部分だけをトレースする

スクリプト全体のトレースは行数が多くなりやすい。 set -xset +xで囲むと、対象の範囲だけをトレースできる。

#!/bin/bash

APP=myapp

set -x
VERSION=$(echo "1.2.3")
echo "deploy ${APP} ${VERSION}"
set +x

echo "done"
$ ./deploy.sh
++ echo 1.2.3
+ VERSION=1.2.3
+ echo 'deploy myapp 1.2.3'
deploy myapp 1.2.3
+ set +x
done

set +x自体もトレース対象となるため、末尾に+ set +xが表示される。

PS4で行番号や関数名を表示する

トレースの行頭に表示される文字列はPS4変数で決まる。 デフォルト値は+ である。

PS4BASH_SOURCELINENOFUNCNAMEを含めると、ファイル名、行番号、関数名を表示できる。

#!/bin/bash
PS4='+ ${BASH_SOURCE}:${LINENO}:${FUNCNAME[0]:-main}: '
set -x

build() {
  local target=$1
  echo "building ${target}"
}

build myapp
$ ./ps4.sh
+ ./ps4.sh:10:main: build myapp
+ ./ps4.sh:6:build: local target=myapp
+ ./ps4.sh:7:build: echo 'building myapp'
building myapp

FUNCNAME[0]は関数の外では未定義になるため、${FUNCNAME[0]:-main}としてmainを表示している。

PS4はシングルクォートで囲む点に注意する。 ダブルクォートで囲むと代入時に展開され、常に同じ行番号が表示される。

処理時間を調べたい場合は時刻を含める。

PS4='+ [$(date +%H:%M:%S)] '
+ [02:05:12] sleep 1
+ [02:05:13] echo done
done

dateはコマンドの実行ごとに外部コマンドを起動する。 bash 5.0以降ではEPOCHREALTIMEを使うと、外部コマンドを起動せずにマイクロ秒単位の時刻を表示できる。

PS4='+ ${EPOCHREALTIME} '
+ 1786414095.104811 sleep 0.5
+ 1786414095.606494 echo done
done

トレースの出力先を分ける

トレースは標準エラー出力へ書かれる。 スクリプト本来の出力と混ざって読みにくい場合は、標準エラー出力だけをファイルへリダイレクトする。

#!/bin/bash

NAME=world
echo "hello ${NAME}"
$ bash -x ./app.sh 2> trace.log
hello world
$ cat trace.log
+ NAME=world
+ echo 'hello world'

スクリプト自身が標準エラー出力へログを書く場合は、BASH_XTRACEFDにファイルディスクリプタ番号を指定すると、トレースだけを別のファイルへ分離できる。

#!/bin/bash
exec 9> trace.log
BASH_XTRACEFD=9
set -x

NAME=world
echo "hello ${NAME}"
$ ./xfd.sh
hello world
$ cat trace.log
+ NAME=world
+ echo 'hello world'

環境変数でトレースを切り替える

スクリプトを書き換えずにトレースを有効にしたい場合、環境変数で切り替える。

#!/bin/bash
[ -n "${DEBUG:-}" ] && set -x

echo "run"

通常の実行ではトレースが出ない。

$ ./sw.sh
run

DEBUGを指定するとトレースが出力される。

$ DEBUG=1 ./sw.sh
+ echo run
run

呼び出し先のスクリプトもトレースする

bash -xのトレース対象は実行したスクリプトだけである。 別のスクリプトを呼び出す部分は、呼び出したコマンド行のみが表示される。

$ ./parent.sh
+ APP=myapp
+ ./child.sh myapp
child: myapp

SHELLOPTSをexportすると、呼び出し先のスクリプトへもオプションが引き継がれる。

#!/bin/bash
export SHELLOPTS
set -x

APP=myapp
./child.sh "${APP}"
$ ./parent.sh
+ APP=myapp
+ ./child.sh myapp
+ echo 'child: myapp'
child: myapp

PS4の設定も引き継ぎたい場合はexport PS4を併せて指定する。

機密情報の出力に注意する

トレースには展開後の値が表示される。 パスワードやトークンを変数へ入れている場合、値がそのまま出力される。

+ curl -H 'Authorization: Bearer eyJhbGciOi...' https://example.com/api

CIのログへbash -xの出力を残す場合は、機密情報を扱う区間をset +xで囲んで除外する。

set +x
TOKEN=$(cat /run/secrets/token)
curl -H "Authorization: Bearer ${TOKEN}" https://example.com/api
set -x

構文チェックと実行前の内容表示

-x以外にもデバッグに使えるオプションがある。

bash -nはコマンドを実行せずに構文だけをチェックする。 以下はfiが抜けているスクリプトである。

#!/bin/bash

if [ -f /etc/hosts ]; then
  echo yes
$ bash -n ./broken.sh
./broken.sh: line 4: syntax error: unexpected end of file
$ echo $?
2

bash -vは読み込んだ行をそのまま表示する。 -xが展開後のコマンドを表示するのに対し、-vはスクリプトに書いたままの内容を表示する。

$ bash -v ./app.sh
#!/bin/bash
NAME=world
echo "hello ${NAME}"
hello world

関連

参考