2-2. bash スクリプティング - 事故らないスクリプトの書き方

所要時間: 40-60分(がっつりなら2セッション分) ゴール: set -euo pipefail を体に染み込ませ、クォーティングと変数展開で事故らないスクリプトが書ける コミット内容: 自作スクリプト群を ~/learn/linux/day202/ に保存

この章が終わるとできること

  • スクリプトの骨格(#!/usr/bin/env bash + set -euo pipefail)を反射で書ける
  • "$@"$* の違いを実例で説明できる
  • ${VAR:-default} ${VAR:?msg} ${VAR%.ext} の3パターンを使い分けられる
  • クォートを 「迷ったら付ける」 の感覚で書ける
  • shellcheck / bash -x で自分のスクリプトをデバッグできる

Day 8 とのつながり

  • 昨日学んだ $VAR $PATH export の知識が、今日からは「スクリプトを書く側」で再登場する
  • 昨日「対話シェルの設定ファイル」だった .zshrc と、今日書く .sh ファイルは 書き味は似ているが実行モデルが違う(サブシェルか source か)
  • 昨日の「#!/usr/bin/env bash が推奨される理由」が、今日のスクリプト1行目で活きる

これができると何が嬉しいか

  • 動くけど事故るスクリプト」を書かなくなる ─ 本番デプロイで自分のスクリプトが原因で全停止する確率がほぼ0に
  • rm -rf "$VAR/*" のような典型事故パターンを、書く前に 指が止まる
  • Day 10 の制御構文・Day 11 の systemd(ExecStart で .sh を呼ぶ)の前提が揃う

大前提: シェルスクリプトは「世界一事故の多い言語」

シェルスクリプトは Linux 運用の至るところで使われている:

  • デプロイスクリプト
  • バックアップ・ログローテーション
  • Docker の ENTRYPOINT / CMD
  • CI/CD の Pipeline
  • システム起動時の init / systemd の ExecStart

にも関わらず、シェルスクリプトには罠が異様に多い。実例:

  • 2017年 GitLab 障害: バックアップスクリプトの引数ミスで本番DBが消滅、300GB相当のデータが消えた
  • 2020年 SteamのMac版インストーラ: rm -rf "$STEAMROOT/"*$STEAMROOT が空になり、ユーザーのホーム全消去事件
  • AWSデプロイで rm -rf /usr 事故: ハードコードしたパスが本番で異なり、システムファイルを破壊

これらは全部「クォーティング・変数展開・エラーハンドリングの基本」を守れば防げた。本章では「プロダクション品質のシェルスクリプト」を書けるようになる。


セッション①: スクリプトの骨格と引数(30分)

0. 録画と作業ディレクトリ

mkdir -p ~/log ~/learn/linux/day202
cd ~/learn/linux/day202
script ~/log/linux_day202.log

1. スクリプトの最小骨格

プロが書く bash スクリプトには 必ず先頭に2行のお決まり があります。これが無いスクリプトは「素人かレガシー」と判断されます。

  • 何を書くか: shebang (#!/usr/bin/env bash) と厳格モード (set -euo pipefail) をスクリプトの1-2行目に固定で書く
  • いつ使うか: 新しい bash スクリプトを書き始める時、毎回。エディタのスニペットに登録しておくのが王道
  • 解決する具体的な問題: 「途中でエラーが起きてもスクリプトが続行してしまい、後続コマンドが破壊的に動く」事故を予防。デプロイ・バックアップ・cron ジョブで致命的になる
cat > hello.sh <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
 
echo "Hello, $1!"
EOF
 
chmod +x hello.sh
./hello.sh world
# Hello, world!

スクリプト先頭の「2行セット」を覚える

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

この2行は 「プロのシェルスクリプトの目印」。これが無いスクリプトは「素人が書いたか、レガシー」と見ていい。

  • 1行目(shebang): どのインタプリタで実行するか
  • 2行目(set): エラー時に黙って続行しないように厳格モードを有効化(後で詳しくやる)

ファイル拡張子 .sh の慣習

慣習として .sh を付けるが、OSは拡張子を見ない。shebang さえあれば拡張子無しでも実行可能。

ただし:

  • チーム開発: .sh があると一目でシェルスクリプトと分かる → 推奨
  • コマンドとして PATH に置く: git-log-pretty のように 拡張子を付けない ことも多い(git log-pretty で呼べる)。GitHub CLI の gh などはバイナリだが拡張子なし

2. 引数の受け取り

シェルスクリプトは引数を $1 $2 … と 位置で受け取ります$@ $* $# などの特殊変数を使い分けるのが鍵です。

  • 何の構文か: bash の特殊変数($0 ~ $9, $@, $*, $#, $$, $?)で引数とプロセス情報を取り出す
  • いつ使うか: CLI ツールを作る時、デプロイスクリプトに環境名を渡す時、バックアップ先パスをコマンドライン経由で受け取る時
  • 解決する具体的な問題: 「引数にスペースが入ると分割される」「全引数を別スクリプトにそのまま渡したい」を 正しいクォート付き "$@" で安全に処理する方法を身につけます
cat > args.sh <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
 
echo "スクリプト名: $0"
echo "引数1: $1"
echo "引数2: $2"
echo "引数の数: $#"
echo "全引数(配列展開, クォート安全): $@"
echo "全引数(単一文字列, 罠あり): $*"
EOF
chmod +x args.sh
./args.sh apple banana cherry

引数を表す特殊変数

変数意味
$0スクリプト名(./args.sh
$1 $2$9各引数(10以上は ${10} のように {} 必須)
$#引数の個数
$@全引数を個別に(クォートすると安全に各引数を保持)
$*全引数を1つの文字列に(区切り文字は IFS の最初の文字)
$$スクリプト自身の PID
$?直前のコマンドの終了コード(0=成功)
$!直前のバックグラウンドプロセスの PID

ざっくり言うと

$@$*どっちも「全引数」 だけど、クォートした時の挙動が違う。 スペース入りの引数があると $* は壊れる。だから 必ず "$@" と書く。

まず普通のパターン

スクリプトに2つの引数を渡す:

./script.sh "hello world" "foo bar"

人間の意図:

引数1: "hello world"   ← 1つの引数
引数2: "foo bar"       ← 1つの引数

$* を使った時に何が起きるか

for arg in $*; do echo "[$arg]"; done

$* は「全引数を1つの文字列に結合」してから、IFS(デフォルトはスペース)で再分割:

"hello world" "foo bar"
  ↓ $* が全部つなげる
"hello world foo bar"
  ↓ IFS(スペース)で再分割
"hello" "world" "foo" "bar"   ← 4つに分裂!

出力:

[hello]
[world]
[foo]
[bar]

= 元の引数の境界が消えた。最悪。

"$@" を使った時に何が起きるか

for arg in "$@"; do echo "[$arg]"; done

"$@"各引数をそれぞれクォートで囲んで展開する 特殊な振る舞い:

"hello world" "foo bar"
  ↓ "$@" が展開
"hello world" "foo bar"   ← 元の境界を保ったまま
  ↓ for が1つずつ取り出す
arg = "hello world"
arg = "foo bar"

出力:

[hello world]
[foo bar]

= 境界が正しく保たれた

対比表

$*"$*"$@"$@"
クォート無しスペース分割される-スペース分割される-
クォート有り-1つの文字列に結合-各引数の境界を保つ
使いどころほぼ無いログ出力で全引数表示ほぼ無い引数の転送、ループ

「別スクリプトに引数を転送する」よくあるパターン

# wrap.sh: 引数をそのまま myapp に渡したい
#!/usr/bin/env bash
exec myapp "$@"      # ★ 必ず "$@" を使う

exec myapp $* だとスペース入り引数で壊れる。exec myapp "$@" なら絶対に壊れない。

一番覚えやすい説明

  • "$@" = 神。常にこれを使え
  • $* = 罠
  • クォートなし = 死
  • 引数の話が出たら反射で "$@" と書く

3. クォーティング: 3種類

bash の 事故の8割はクォート漏れ が原因。'...' "..." クォートなし、の3パターンを正確に使い分けるのがプロの第一歩です。

  • 何の構文か: シングルクォート(変数展開しない)、ダブルクォート(変数展開する、単語分割を抑制)、クォートなし(事故の温床)の3種類
  • いつ意識するか: 変数を使う すべての場面rm "$FILE" のように常にダブルクォートで囲むのが鉄則
  • 解決する具体的な問題: ファイル名にスペースが含まれていると cat $FILE が複数ファイル扱いになる、グロブが意図せず展開される、rm -rf $VAR/foo$VAR が空だと rm -rf /foo になる事故、すべてクォートで防げます
NAME="world"
 
# シングルクォート: 中身を「文字通り」扱う(変数展開も無し)
echo 'hello $NAME'    # hello $NAME
 
# ダブルクォート: 変数展開はする、ただし空白で単語分割はしない
echo "hello $NAME"    # hello world
 
# バッククォート: コマンド置換(古い書き方、非推奨)
echo "today is `date`"
 
# $() : 推奨されるコマンド置換
echo "today is $(date)"

ざっくり言うと

bash のクォートは3種類あって、それぞれ「変数を展開するか」「単語分割するか」が違う。 事故の8割は クォートなしで変数を書いた時 に起きる。

まず普通のパターン

ファイル名が my report.txt(スペース入り)のケース:

FILE="my report.txt"
cat $FILE        # クォートなし

シェルの頭の中:

cat $FILE が来た
  ↓
$FILE を展開
  ↓
cat my report.txt   ← 文字列が剥き出しに
  ↓
スペースで単語分割(IFS の仕事)
  ↓
cat my report.txt   ← 「my」「report.txt」2引数として解釈
  ↓
cat: my: No such file
cat: report.txt: No such file

= 本物の “my report.txt” は触られず、別物を探しに行く

ダブルクォートを付けると何が起きるか

cat "$FILE"
cat "$FILE" が来た
  ↓
"..." の中なので「変数は展開、でも単語分割はしない」
  ↓
cat "my report.txt"   ← 1引数として解釈
  ↓
正しく中身を表示

シングルクォートだと

cat '$FILE'
cat '$FILE' が来た
  ↓
'...' の中なので「文字通り扱う」
  ↓
cat '$FILE'   ← 変数展開すらされない
  ↓
cat: $FILE: No such file

= リテラルとして $FILE という名前のファイルを探しに行く。

3種類の対比

クォート変数展開 ($VAR)コマンド置換 ($(...))単語分割イメージ
'...'(シングル)しないしないしない文字通り(生の文字列)
"..."(ダブル)するするしない賢い展開(変数を読むが分割しない)
クォートなしするするする無防備(事故源)

rm -rf $VAR の恐怖

VAR=""        # ← 何かのバグで空になった
rm -rf $VAR/build
rm -rf $VAR/build
  ↓
$VAR が空文字に展開
  ↓
rm -rf /build   ← システムの /build を消しに行く!

クォートを付けていれば:

rm -rf "$VAR/build"

rm -rf "/build" 同じく危険、だが $VAR の空チェックを必須化する習慣がつく

さらに set -u を併用:

set -u
rm -rf "$VAR/build" VAR が未定義なら ここで止まる

一番覚えやすい説明

  • シングル '...' = 文字通り扱う(変数すら展開しない)
  • ダブル "..." = 変数は展開、でも単語分割はしない(ほぼいつもこれ
  • クォートなし = 無防備(事故の温床)
  • 鉄則: 変数を書いたら反射でダブルクォートを付ける

$() がバッククォートより推奨される理由

  • ネストできる: $(date +%Y-$(uname -m)) のように入れ子可能。バッククォートだと \ でエスケープ地獄
  • 読みやすい: 開きと閉じが視覚的に明確
  • POSIX準拠: 現代のシェルすべてでサポート

バッククォートは1970年代から残っている古い記法。新しいコードでは $() を使う

クォートなしの事故例

# ファイル名にスペースが含まれているケース
FILE="my report.txt"
 
# NG: クォートなし
cat $FILE
# → "my" と "report.txt" の2ファイルを開こうとしてエラー
 
# NG: 削除なら大惨事
rm $FILE
# → "my" と "report.txt" を削除(しかも本物の "my report.txt" は残る)
 
# OK: クォートあり
cat "$FILE"
rm "$FILE"

4. 変数展開のテクニック

bash の ${VAR:-default} ${FILE%.ext} などの 変数展開記法は地味だが超強力。Python や Go で書きたくなる文字列処理の多くがシェルだけで完結します。

  • 何の構文か: ${VAR:-X}(デフォルト値)、${VAR:?msg}(必須チェック)、${FILE%.gz}(末尾削除)、${PATH//:/\n}(置換)など、${} 内の特殊文法
  • いつ使うか: 環境変数のデフォルト設定、必須環境変数のバリデーション、ファイル名の拡張子操作、パス文字列の加工
  • 解決する具体的な問題: 「環境変数が未設定だとスクリプトが暴走する」を :? で防ぐ、外部コマンド(sed awk)を呼ばずに高速に文字列処理する、for f in *.log; do mv "$f" "${f%.log}.txt"; done のようなリネーム処理を簡潔に書く
NAME="alice"
 
# 基本
echo "${NAME}"           # alice
echo "${NAME}_extra"     # alice_extra (変数名の境界を明示)
 
# デフォルト値(変数が未定義/空の場合に使う)
echo "${UNDEFINED:-fallback}"   # fallback(UNDEFINED は変更されない)
echo "${UNDEFINED:=fallback}"   # fallback(UNDEFINED に fallback が代入される)
 
# エラー終了(必須変数の検証)
: "${REQUIRED_VAR:?REQUIRED_VAR が未設定です}"
# REQUIRED_VAR が未定義/空なら、メッセージを出して exit
 
# 文字列操作
FILE="report.tar.gz"
echo "${FILE%.gz}"        # report.tar     (末尾の .gz を削除)
echo "${FILE%.*}"          # report.tar     (末尾の最短マッチを削除)
echo "${FILEsuffix}` | 末尾の suffix を削除(最長) |
> | `${VAR#prefix}` | 先頭の prefix を削除(最短) |
> | `${VAR##prefix}` | 先頭の prefix を削除(最長) |
> | `${VAR/pattern/repl}` | 最初の pattern を repl に置換 |
> | `${VAR//pattern/repl}` | すべての pattern を repl に置換 |
 
> [!info] 実務での活用例
> 
> ```bash
> # 必須環境変数のチェック
> : "${DATABASE_URL:?DATABASE_URL を設定してください}"
> : "${API_KEY:?API_KEY を設定してください}"
>
> # デフォルト値を持つ設定
> LOG_LEVEL="${LOG_LEVEL:-info}"
> PORT="${PORT:-8080}"
>
> # 拡張子の抜き出し
> # ↓ for ... in ...; do ... done は「リストを順に処理する」ループ(詳しくは 2-3)
> for f in *.log; do
>   base="${f%.log}"
>   mv "$f" "${base}.txt"
> done
> ```
 
### 5. set -euo pipefail の鉄則
 
これが bash スクリプトを「事故るプログラム」から「プロダクション品質」に変える **最重要の1行** です。
 
- **何のコマンドか**: bash の挙動を「エラー時に黙って続行しない」厳格モードに切り替える組み込みコマンド
- **いつ書くか**: スクリプト先頭(shebang の直後)に **必ず**。1行書くだけで本番事故の多くを未然に防げる
- **解決する具体的な問題**: 「`cd $DIR` が失敗したのに気付かず `rm -rf *` が走ってシステムログを消した」「タイポした変数が空に展開されて `rm -rf /` 級の事故」「パイプ途中の失敗が握り潰されてバックアップが3ヶ月失敗していた」を3つのフラグで一気に防ぎます
 
```bash
cat > strict.sh <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'
 
# 何かする
echo "厳格モード有効"
EOF

set フラグを1つずつ理解する

以下の例で出てくる if cmd; thencmd1 || cmd2!(否定)は 次の 2-3 で扱う制御構文。ここでは「条件分岐の前置きとして雰囲気だけ掴む」のでOK。

-e (errexit): コマンドが非ゼロで終了したら 即座にスクリプト終了

set -e
rm nonexistent_file.txt   # エラー
echo "ここは実行されない"   # スキップされる

なぜ必要か: デフォルトのシェルは「エラー出ても次のコマンド実行する」。バックアップスクリプトで cp が失敗したのに気付かず rm 走らせて元データ消失、みたいな事故が起きる。

例外(-eが効かない場面):

  • if cmd; then ...cmd(条件式の中)
  • cmd1 || cmd2cmd1
  • ! を付けた否定
  • パイプの最後以外(pipefail で改善)

-u (nounset): 未定義の変数を使ったらエラー

set -u
echo "$UNDEFINED"   # → unbound variable エラーで終了

なぜ必要か: タイポした変数が空文字として展開されるのを防ぐ。rm -rf "$PROJECT_DIR/build"$PROJECT_DIR がタイポ → rm -rf /build 事故、これが防げる。

-o pipefail: パイプ全体の終了コードを 「途中で失敗したコマンドの終了コード」 にする

# pipefail なし: 最後の wc が成功すれば全体成功扱い
cat nonexistent | wc -l   # → 終了コード 0(cat の失敗が無視される)
 
set -o pipefail
cat nonexistent | wc -l   # → 終了コード 1(cat の失敗が伝播)

なぜ必要か: パイプ多用するシェルで「途中で死んでも気付かない」を防ぐ。

IFS=$'\n\t' (オプション): 単語分割の区切り文字を「改行とタブ」に限定(デフォルトはスペース・タブ・改行)。スペース入りファイル名を扱う際の事故予防。

アンチパターン: set なしスクリプト

#!/bin/bash
# set 無し
cd /var/log/myapp
rm -rf *.log

もし cd が失敗したら(ディレクトリが存在しない等)、カレントディレクトリのまま rm -rf *.log が走る。運が悪いとシステムログが消える

正解:

#!/usr/bin/env bash
set -euo pipefail
cd /var/log/myapp   # 失敗したらここで止まる
rm -rf -- *.log     # -- でファイル名が - で始まる場合の事故も予防

-e の罠: || で意図的にエラー無視できる

set -e
grep "pattern" file.txt   # マッチしないと終了コード 1 で全体停止
 
# マッチしなくても続けたい場合
grep "pattern" file.txt || true
# または
if grep -q "pattern" file.txt; then
  echo "found"
fi

6. IFS と単語分割の罠

bash がクォートなしの変数や $(...) の結果をどう「分割」するかは、IFS(Internal Field Separator)という変数で決まっています。これを知らないとループで事故ります。

  • 何の概念か: IFS は bash が変数展開後に単語分割するときの区切り文字。デフォルトはスペース・タブ・改行
  • いつ意識するか: ファイル一覧を for でループする時、CSV をフィールド分割する時、read で行を変数に分解する時
  • 解決する具体的な問題: 「for f in $(ls)hello world.txt を2ファイルとして処理してしまう」「改行入りファイル名で壊れる」を、glob 展開や while IFS= read -r でゼロから防ぎます
# デフォルトの IFS は " \t\n"(スペース、タブ、改行)
echo "$IFS" | cat -A   # " ^I$" のように表示
 
# ファイル名にスペースが含まれていると…
mkdir test_dir && cd test_dir
touch "hello world.txt" "foo bar.txt"
 
# NG: クォートなし
for f in $(ls); do echo "[$f]"; done
# [hello]
# [world.txt]
# [foo]
# [bar.txt]
 
# OK: glob と "$@" を使う
for f in *.txt; do echo "[$f]"; done
# [foo bar.txt]
# [hello world.txt]
 
cd .. && rm -r test_dir

鉄則: ls の出力をパースするな

# NG: 改行・スペース・特殊文字で壊れる
for f in $(ls *.txt); do
  process "$f"
done
 
# OK: glob 展開を使う
for f in *.txt; do
  process "$f"
done
 
# OK: find と -print0 + while read -d ''(ファイル名に何が入っててもOK)
find . -name "*.txt" -print0 | while IFS= read -r -d '' f; do
  process "$f"
done

Unix 名言: 「Parsing ls is wrong」BashFAQ#001)。

7. ヒアドキュメント

複数行の文字列を「そのままコマンドの標準入力に流し込む」のがヒアドキュメント (<<EOF)。設定ファイル生成、SQLクエリ、SSH先での複数コマンド実行などで多用します。

  • 何の構文か: cmd <<EOF ... EOF で、EOF までの複数行を cmd の標準入力に渡す
  • いつ使うか: cat > config.yml <<EOF で設定ファイルを動的生成、mysql <<SQL で複数行クエリを流す、ssh server <<EOF で SSH先で複数コマンド実行
  • 解決する具体的な問題: 複数行文字列を echo -e "line1\nline2" でゴリ押しする可読性の悪さを解消、変数展開の有無(<<EOF vs <<'EOF')を選んで「リテラルとして書きたい $ を保持」できる
# 通常のヒアドキュメント: 変数展開される
NAME="alice"
cat <<EOF
Hello, $NAME!
今日は $(date +%Y-%m-%d) です。
EOF
 
# クォート付き: 変数展開されない
cat <<'EOF'
$NAME はそのまま出力される
$(date) も展開されない
EOF
 
# -EOF: 行頭のタブを削除(Markdown的なインデントが可能)
cat <<-EOF
	タブインデント
	タブインデント
EOF

ヒアドキュメントの3パターン

記法変数展開用途
<<EOFするテンプレート的に変数を埋め込みたい時
<<'EOF'しないスクリプトやコードをそのまま埋め込みたい時
<<-EOFする行頭タブを削除(インデント揃え用)

実務ユースケース

  • SQLを変数化したクエリでDBに流す
  • 別のシェルスクリプトを生成する(クォート版で $ を展開させない)
  • SSH 越しに複数コマンドを実行: ssh server <<'EOF'

セッション②: 終了コードとアンチパターン総覧(25-30分)

8. 終了コードと $?

すべての Unix コマンドは終了時に 0(成功)または 1-255(失敗)の整数 を返します。これを意識的に使うのが CLI ツール設計の基本です。

  • 何の概念か: 終了コード (exit code, exit status) はプロセスが終了する際に親に伝える「結果」。$? で直前のコマンドの終了コードを取得
  • いつ使うか: 自作スクリプトでエラー時に exit 1 する、CI/CD で失敗を検知させる、if 文や || && で条件分岐させる
  • 解決する具体的な問題: 「スクリプトが失敗しても CI/CD が成功扱いになる」「grep でマッチしなかった時の挙動を判別したい」など、プロセス間の状態伝達を正確に行えるようになります
ls /tmp        # 成功
echo $?        # 0
 
ls /nonexistent 2>/dev/null
echo $?        # 1(または2)
 
# スクリプトで明示的に終了コードを返す
# ↓ if は条件分岐、[[ ]] は条件式、$# は引数の数、-lt は「<」(数値比較)
# ↓ いずれも 2-3 で本格的に扱うが、ここでは「引数が1個未満なら使用法表示」と読む
cat > exit_demo.sh <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
 
if [[ $#--lt-1-| -lt 1 ]]; then
  echo "Usage: $0 <name>" >&2
  exit 64    # 64 = EX_USAGE(コマンド使用法エラー、sysexits.h準拠)
fi
 
if ! grep -q "$1" /etc/passwd; then
  echo "User '$1' not found" >&2
  exit 1
fi
 
echo "User '$1' exists"
exit 0
EOF
chmod +x exit_demo.sh
./exit_demo.sh                    # Usage表示、終了コード 64
echo $?
./exit_demo.sh nonexistent_user   # not found、終了コード 1
./exit_demo.sh root               # exists、終了コード 0

終了コードの慣習

コード意味
0成功
1一般的なエラー
2コマンドの誤用(多くの組み込みコマンド)
64-78sysexits.h 準拠の標準コード(man sysexits 参照)
126コマンドは見つかったが実行不可(権限なし)
127コマンドが見つからない
128+Nシグナル N で終了(例: 130 = Ctrl+C = SIGINT(2))
255範囲外(多くは「不明なエラー」)

実務での設計

  • CI/CD: 終了コード非ゼロで「失敗」と判定される。エラー時は必ず exit 1 以上で抜ける
  • エラーメッセージは stderr に: echo "error" >&2。stdout を pipe する後続コマンドにエラーが混入しない
  • Usage 表示は終了コード 64 (EX_USAGE) で

exit 0 の暴走

エラー処理を全部 ... || true でごまかして exit 0 するスクリプトは CI/CD で絶対に失敗を検知できない。本番デプロイで「成功!」と表示されるのに実際は壊れている、という最悪のパターン。

9. アンチパターン総覧

以下のアンチパターン例には if/[ ]/[[ ]] などの条件分岐が頻繁に登場する。これらは 次章 2-3 の本題。ここでは「クォーティングの良し悪し」に注目して読む。

アンチパターン1: クォートなし変数展開

# NG
FILE=$1
if [ -f $FILE ]; then
  cat $FILE
fi
 
# OK
FILE="$1"
if [[ -f "$FILE" ]]; then
  cat "$FILE"
fi

ファイル名にスペースや特殊文字が入った瞬間に壊れる。

アンチパターン2: ls の出力をパース

# NG
for f in $(ls *.txt); do
  wc -l $f
done
 
# OK
for f in *.txt; do
  wc -l "$f"
done

アンチパターン3: シェルで複雑な集計処理

# NG: シェルで集計しようとして地獄
total=0
while read line; do
  value=$(echo "$line" | cut -d',' -f3)
  total=$((total + value))
done < data.csv
echo $total
 
# OK: awk / Python / 専用ツールを使う
awk -F',' '{ total += $3 } END { print total }' data.csv

シェルは「コマンドを組み合わせるための糊」。複雑なロジックは別言語で書く。

目安: 30行を超えたら別言語を検討(Python / Go)。

アンチパターン4: バッククォート

# NG
DATE=`date +%Y%m%d`
 
# OK
DATE="$(date +%Y%m%d)"

アンチパターン5: [ ... ] の中で && ||

# NG: POSIX sh では未定義動作
if [ "$a" = "1" -a "$b" = "2" ]; then ...
 
# OK: [[ ]] を使う(bash/zsh)
if [[ "$a" == "1" && "$b" == "2" ]]; then ...
 
# POSIX互換にしたいなら別の if で分ける
if [ "$a" = "1" ] && [ "$b" = "2" ]; then ...

アンチパターン6: パスワードを引数に渡す

# NG: ps aux で他のユーザーから見える
mysql -u root -pSECRET123
 
# OK: 環境変数 or 設定ファイル
export MYSQL_PWD=SECRET123   # それでも /proc から見える可能性あり
mysql --defaults-extra-file=~/.my.cnf  # 推奨

セキュリティ: シェルインジェクション

# NG: ユーザー入力をそのままシェルに渡す
read -r filename
eval "cat $filename"   # filename="; rm -rf / #" だったら大惨事
 
# OK: eval を使わない、変数はクォートする
read -r filename
cat -- "$filename"

Web アプリのバックエンドで「ユーザー入力をシェルに渡す」コードは 絶対にやらない。やるなら Go の exec.Command のように引数を配列で渡す方式に。

10. デバッグテクニック

bash スクリプトが「期待通りに動かない時」に 何が起こっているかを可視化 する標準テクニックです。

  • 何のコマンドか: bash -x (実行トレース)、set -x / set +x (部分トレース)、bash -n (構文チェックのみ)、shellcheck (静的解析)
  • いつ使うか: 変数展開が想定通りか確認したい時、どの行で止まっているか分からない時、本番投入前のレビュー
  • 解決する具体的な問題: 「変数の中身が分からない」「条件分岐がなぜか入らない」を -x で全コマンドを展開後の形で表示して特定、コードレビュー前に shellcheck でクォート漏れや典型ミスを機械的に検出
# 実行をトレースする
bash -x script.sh   # 各行を実行前に表示
 
# スクリプト内の一部だけトレース
cat > debug_demo.sh <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
 
echo "通常"
set -x   # ここから詳細表示
NAME="alice"
echo "hello $NAME"
set +x   # ここで終了
echo "また通常"
EOF
chmod +x debug_demo.sh
./debug_demo.sh
 
# 文法チェック(実行せずに)
bash -n script.sh

ShellCheck で静的解析

brew install shellcheck
shellcheck script.sh

シェルスクリプトの デファクト Linter。クォート漏れ、変数展開ミス、[[[ の罠など、ほとんどの落とし穴を指摘してくれる。CI に必ず組み込む


練習課題

この練習課題で先取りする構文

課題スクリプトには if/[[ ]]/||/&& などの条件分岐が混ざる。これらは次章 2-3 で本格的に扱う構文。ここでは以下のイメージで読んでOK:

  • [[ $#--eq-2-| -eq 2 ]] || usage → 「引数が2個でなければ usage() を呼ぶ」(|| = 左が失敗したら右)
  • [[ -d "$SRC" ]] || { ... } → 「ディレクトリでなければエラー終了」(-d = ディレクトリ判定)
# 課題1: 引数チェック付きのバックアップスクリプト
cat > backup.sh <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
 
# 使用法を表示する関数
usage() {
  cat <<USAGE >&2
Usage: $0 <source_dir> <backup_dir>
  source_dir : バックアップ元のディレクトリ
  backup_dir : バックアップ先のディレクトリ(無ければ作成)
USAGE
  exit 64
}
 
# 引数チェック
[[ $#--eq-2-| -eq 2 ]] || usage
 
SRC="$1"
DST="$2"
 
# 入力検証
[[ -d "$SRC" ]] || { echo "Error: $SRC is not a directory" >&2; exit 1; }
 
# バックアップ先を作成
mkdir -p "$DST"
 
# 日付付きでアーカイブ
TIMESTAMP="$(date +%Y%m%d_%H%M%S)"
ARCHIVE="${DST}/backup_${TIMESTAMP}.tar.gz"
 
echo "バックアップ作成中: $ARCHIVE"
tar czf "$ARCHIVE" -C "$(dirname "$SRC")" "$(basename "$SRC")"
 
echo "完了: $(ls -lh "$ARCHIVE" | awk '{print $5, $9}')"
EOF
chmod +x backup.sh
 
# 試す
mkdir -p data
echo "important" > data/file.txt
./backup.sh data ~/backups
./backup.sh          # 使用法表示で終了
 
# 課題2: shellcheck で自分のスクリプトを検査
shellcheck backup.sh   # (brew install shellcheck)
 
# 課題3: 環境変数の必須チェック
cat > deploy.sh <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
 
# 必須環境変数
: "${APP_NAME:?APP_NAME を設定してください}"
: "${ENV:?ENV を設定してください(dev/stg/prod)}"
: "${API_KEY:?API_KEY を設定してください}"
 
# デフォルト値
LOG_LEVEL="${LOG_LEVEL:-info}"
TIMEOUT="${TIMEOUT:-30}"
 
echo "Deploying $APP_NAME to $ENV (log=$LOG_LEVEL, timeout=$TIMEOUT)"
EOF
chmod +x deploy.sh
 
# 試す
./deploy.sh                          # → APP_NAME を設定してください
APP_NAME=myapp ENV=dev API_KEY=xyz ./deploy.sh

締め: git で証跡を残す

exit   # script 終了
 
cd ~/learn/linux/day202
git init -q 2>/dev/null
git add -A
git commit -m "feat(linux): bash scripting 厳格モードとクォーティング"

チェックリスト

  • スクリプト先頭に #!/usr/bin/env bashset -euo pipefail を書いた
  • "$@"$* の違いを実験で確認した
  • ${VAR:-default} ${VAR:?msg} ${VAR%.ext} を使った
  • クォートなし変数の事故を1つ再現した
  • 終了コードを意識して exit を使った(0/1/64 など)
  • エラーメッセージを >&2 で stderr に流した
  • shellcheck で自作スクリプトを検査した
  • bash -x でトレース実行した

詰まった時のチートシート

やりたいことコード
スクリプトの骨格#!/usr/bin/env bash
set -euo pipefail
引数チェック[[ $\# -eq 2 ]] || { echo "usage"; exit 64; }
必須変数: "${VAR:?msg}"
デフォルト値VAR="${VAR:-default}"
stderr に出力echo "error" >&2
コマンド置換$(date)
文字列の末尾削除${FILE%.ext}
文字列の先頭削除${FILE#prefix_}
配列の引数"$@"
デバッグ実行bash -x script.sh
文法チェックbash -n script.sh
Lintshellcheck script.sh
エラー無視cmd || true
ヒアドキュメント(展開なし)<<'EOF'

「実務OK」基準

  • set -euo pipefail を反射で書ける
  • 「クォートしない理由を説明できない時はクォートする」 が骨身に染みている
  • $@ を必ず "$@" と書く(クォート付き)
  • 30行超のロジックを書きそうになったら Go / Python を考える
  • shellcheck をCIに組み込んでいる
  • rm -rf "$VAR/foo" を書くとき必ず $VAR の検証を入れる

さらに深掘りするなら

  • 公式: man bash の PARAMETERS / EXPANSION / SHELL BUILTIN COMMANDS セクション
  • 書籍: 『Bash クックブック』(O’Reilly)/ 『詳解 シェルスクリプト』(Arnold Robbins)
  • 必読サイト: BashFAQ / BashPitfalls(事故事例集)
  • 静的解析: ShellCheck(ブラウザで貼り付けてチェックも可)
  • Google Shell Style Guide: styleguide.google/shellguide.html(Googleの社内基準)

アンチパターン / 初心者やらかし事例

NG 1: クォートなし変数で空白入りパスが壊れる

file="My Document.txt"
rm $file   # `rm "My" "Document.txt"` と解釈される → 別ファイル消すか「該当なし」エラー
rm "$file" # OK

→ 対策: 変数は基本クォートshellcheck が必ず指摘する代表パターン。

NG 2: set -e を信じて関数の戻り値を確認しない

set -e
get_id() { return 1; }
ID=$(get_id)   # `$(...)` の中で失敗しても、`-e` でスクリプトが止まらないケースがある

→ 対策: 重要分岐は明示的に if ! get_id; then ... fi で書く。set -e は万能ではない。

NG 3: [ ][[ ]] を混同して空文字判定が壊れる

if [ $VAR = "x" ]; then ...   # $VAR が空だと `[ = "x" ]` で構文エラー
if [[ "$VAR" = "x" ]]; then ...  # OK

→ 対策: bash なら [[ ]] を使う。POSIX 互換にこだわるなら [ "$VAR" = "x" ] のように両側クォート。

NG 4: rm -rf "$DIR/"*$DIR が未定義

rm -rf "$DIR/"*   # $DIR 未定義なら `rm -rf "/"*` → 大事故

→ 対策: set -u で未定義変数エラー化 + : "${DIR:?DIR not set}" で二重防御。


自己評価チェックリスト

  • set -euo pipefaile / u / o pipefail それぞれ の意味を答えられる
  • "$@"$* の代わりに使う理由を実例で言える
  • ${VAR:-default}${VAR:?msg} の使い分けができる
  • shellcheck を実行して指摘を1つ修正した
  • bash -x script.sh で実行トレースを読んだ

次のレッスン: Day 10 - 制御構文

明日は if / for / while / case / 関数定義に入る。

特に テスト演算子の罠[ ] vs [[ ]])、while read でのファイル読み込み(行末改行・空白の落とし穴)、関数の戻り値設計などを扱う。今日学んだ set -euo pipefail と組み合わせると、堅牢なロジックが書けるようになる。

Day 10: 制御構文