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$PATHexportの知識が、今日からは「スクリプトを書く側」で再登場する - 昨日「対話シェルの設定ファイル」だった
.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.log1. スクリプトの最小骨格
プロが書く 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/buildrm -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}(置換)など、${}内の特殊文法 - いつ使うか: 環境変数のデフォルト設定、必須環境変数のバリデーション、ファイル名の拡張子操作、パス文字列の加工
- 解決する具体的な問題: 「環境変数が未設定だとスクリプトが暴走する」を
:?で防ぐ、外部コマンド(sedawk)を呼ばずに高速に文字列処理する、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 "厳格モード有効"
EOFset フラグを1つずつ理解する
以下の例で出てくる
if cmd; then、cmd1 || cmd2、!(否定)は 次の 2-3 で扱う制御構文。ここでは「条件分岐の前置きとして雰囲気だけ掴む」のでOK。
-e(errexit): コマンドが非ゼロで終了したら 即座にスクリプト終了set -e rm nonexistent_file.txt # エラー echo "ここは実行されない" # スキップされるなぜ必要か: デフォルトのシェルは「エラー出ても次のコマンド実行する」。バックアップスクリプトで
cpが失敗したのに気付かずrm走らせて元データ消失、みたいな事故が起きる。例外(-eが効かない場面):
if cmd; then ...のcmd(条件式の中)cmd1 || cmd2のcmd1!を付けた否定- パイプの最後以外(
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" doneUnix 名言: 「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"でゴリ押しする可読性の悪さを解消、変数展開の有無(<<EOFvs<<'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.shShellCheck で静的解析
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 bashとset -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 |
| Lint | shellcheck 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 pipefailのe/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: 制御構文 へ