32blogby StudioMitsu

シェルスクリプト実践ガイド:bash入門から実戦まで

シェルスクリプトの書き方を基礎から解説。shebang、変数、条件分岐、ループ、関数、set -euo pipefailによるエラー処理まで、現場で使えるパターンを網羅。

19 min read
目次

シェルスクリプトは、ターミナルで打つコマンドをファイルにまとめて一括実行するもの。毎日同じコマンドを手打ちしているなら、それはスクリプトにすべきサインだ。

シェルスクリプトとは、bashなどのシェルが実行するコマンドの集まり。#!/usr/bin/env bashset -euo pipefail をファイル冒頭に書き、変数は必ずクォートし、関数で構造化するのが堅牢なスクリプトの基本。

なぜ今シェルスクリプトなのか

PythonもGoもある時代に、なぜシェルスクリプトを書くのか。

答えはシンプルで、依存関係ゼロでOSレベルの操作ができる から。ランタイムのインストールもパッケージマネージャの設定も不要。ターミナルがあれば動く。これが効くのは:

  • サーバー構築 — VPSにユーザー追加、パッケージインストール、ファイアウォール設定
  • CI/CDパイプライン — ビルドステップ間のつなぎ、テスト実行、デプロイトリガー
  • cronジョブ — ログローテーション、DBバックアップ、深夜3時の定期処理
  • 開発ツール — プロジェクト初期化スクリプト、gitフック、ローカル環境セットアップ

僕は32blogのデプロイ前チェックをシェルスクリプトで回している。ビルド検証、リント、リンク切れチェックを一発で実行する。TypeScriptで書けないわけじゃないけど、40行で依存関係ゼロ、ここ数ヶ月一度も修正していない。それがシェルスクリプトの良さだ。

使い分けの目安:「コマンドを順番に実行して、ちょっとした分岐がある」ならシェルスクリプト。データ構造やHTTPクライアント、複雑なエラーリカバリが必要ならPythonやGoを選ぶ。

基本構造:shebang・権限・実行

すべてのスクリプトはここから始まる:

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

echo "Hello from $(hostname) at $(date)"

shebang行

1行目の #!/usr/bin/env bash は、このファイルをどのインタプリタで実行するか指定する。古いスクリプトで見かける #!/bin/bash でも動くが、#!/usr/bin/env bash のほうがポータブル。システム上のどこにbashがインストールされていても見つけてくれる。

安全ネット:set -euo pipefail

この1行でバグの大半を防げる:

bash
set -euo pipefail

各フラグの効果:

フラグ効果
-e(errexit)コマンドが失敗した時点で即座にスクリプトを終了
-u(nounset)未定義の変数を参照するとエラー(空文字として扱わない)
-o pipefailパイプの途中で失敗しても検知する(最後のコマンドだけ見ない)

これがないとどうなるか。以前バックアップスクリプトで、tar コマンドが静かに失敗したのに後続の「古いバックアップ削除」ステップが走ってしまい、1週間分のデータを失ったことがある。set -euo pipefail があれば最初のエラーで止まっていた。

ファイル権限と実行方法

スクリプトには実行権限が必要:

bash
# 実行権限を付与
chmod +x deploy-check.sh

# 実行
./deploy-check.sh

実行権限なしでもインタプリタを直接指定すれば動く:

bash
bash deploy-check.sh

ただし chmod +x が標準的。shebang行が正しいインタプリタを選んでくれる。権限管理の詳細はchmod/chownガイドを参照。

ファイル名

.sh 拡張子が一般的。$PATH に置くスクリプトは拡張子なしにすることもある。実行に使われるのはshebang行であって拡張子ではない。

変数・引数・ユーザー入力

変数の基本

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

# 代入 — イコールの前後にスペースを入れない
project_name="32blog"
build_dir="./dist"
max_retries=3

# 参照 — 変数は必ずクォートする
echo "Building ${project_name} into ${build_dir}"

${} の波括弧は省略できるが、つけたほうが安全。${project_name}_backup は明確だが、$project_name_backup だと project_name_backup という変数を探しに行く。

変数は必ずダブルクォートで囲む。 シェルスクリプトのバグで一番多いのがこれ:

bash
# ダメ — ファイル名にスペースがあると壊れる
file=my report.txt
cat $file  # "my" と "report.txt" を別々にcatしようとする

# 正解 — どんなファイル名でも安全
file="my report.txt"
cat "$file"

コマンド置換

コマンドの出力を変数に格納する:

bash
current_date=$(date +%Y-%m-%d)
git_branch=$(git rev-parse --abbrev-ref HEAD)
file_count=$(find . -name "*.mdx" | wc -l)

echo "ブランチ ${git_branch} に ${file_count} 個のMDXファイル(${current_date}時点)"

バッククォートではなく $() を使う。ネストできるし読みやすい:

bash
# モダン — 明確でネスト可能
files=$(find "$(pwd)" -name "*.log")

# レガシー — 避ける
files=`find \`pwd\` -name "*.log"`

スクリプト引数

位置パラメータでアクセスする:

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

# $0 = スクリプト名, $1 = 第1引数, $2 = 第2引数
# $# = 引数の数, $@ = 全引数

if [[ $# -lt 1 ]]; then
    echo "Usage: $0 <environment>" >&2
    exit 1
fi

environment="$1"
echo "Deploying to ${environment}"

オプションが複数あるなら case でパースする:

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

verbose=false
output_dir="./build"

while [[ $# -gt 0 ]]; do
    case "$1" in
        -v|--verbose)
            verbose=true
            shift
            ;;
        -o|--output)
            output_dir="$2"
            shift 2
            ;;
        -h|--help)
            echo "Usage: $0 [-v] [-o dir]"
            exit 0
            ;;
        *)
            echo "Unknown option: $1" >&2
            exit 1
            ;;
    esac
done

echo "Verbose: ${verbose}, Output: ${output_dir}"

ユーザー入力の読み取り

bash
read -rp "プロジェクト名を入力: " project_name
echo "プロジェクト作成中: ${project_name}"

-r はバックスラッシュのエスケープを無効化する(ほぼ常に必要)。-p はプロンプトを表示する。

条件分岐とループ

if文

テスト構文は [ ](POSIX)と [[ ]](Bash固有)の2種類。[[ ]] を使うべき。ワード分割やグロブ展開を安全に処理してくれる:

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

file="content/cli/ja/cli-shell-script.mdx"

# ファイルテスト
if [[ -f "$file" ]]; then
    echo "ファイルが存在する"
fi

if [[ ! -d "./dist" ]]; then
    echo "ビルドディレクトリがない。作成中..."
    mkdir -p ./dist
fi

# 文字列比較
environment="${1:-}"
if [[ "$environment" == "production" ]]; then
    echo "本番デプロイ — 追加チェック実行"
elif [[ "$environment" == "staging" ]]; then
    echo "ステージングデプロイ"
else
    echo "不明な環境: ${environment}" >&2
    exit 1
fi

# 数値比較
file_count=$(find . -name "*.mdx" | wc -l)
if [[ "$file_count" -gt 100 ]]; then
    echo "記事が100本超えてる"
fi

よく使うテスト演算子:

演算子意味
-f fileファイルが存在し、通常ファイルである
-d dirディレクトリが存在する
-z "$var"文字列が空
-n "$var"文字列が空でない
==, !=文字列の一致・不一致
-eq, -ne, -lt, -gt数値比較

forループ

bash
# リストを回す
for env in staging production; do
    echo "Deploying to ${env}"
done

# ファイルを回す(グロブを使う。ls をパースしない)
for file in content/cli/ja/*.mdx; do
    echo "処理中: ${file}"
done

# コマンド出力を回す
for branch in $(git branch --format='%(refname:short)'); do
    echo "ブランチ: ${branch}"
done

# C言語スタイル
for ((i = 1; i <= 5; i++)); do
    echo "試行 ${i} 回目"
done

whileループ

bash
# ファイルを1行ずつ読む
while IFS= read -r line; do
    echo "行: ${line}"
done < config.txt

# コマンド出力を1行ずつ処理
git log --oneline -10 | while IFS= read -r line; do
    echo "コミット: ${line}"
done

# 条件を満たすまで待機
retries=0
max_retries=5
until curl -sf http://localhost:3000/health > /dev/null 2>&1; do
    ((retries++))
    if [[ "$retries" -ge "$max_retries" ]]; then
        echo "サーバー起動失敗(${max_retries}回試行済み)" >&2
        exit 1
    fi
    echo "サーバー待機中...(${retries}/${max_retries})"
    sleep 2
done
echo "サーバー起動完了"

case文

文字列のパターンマッチングには caseif/elif の連鎖よりすっきりする:

bash
case "$1" in
    start)
        echo "サービス起動中..."
        ;;
    stop)
        echo "サービス停止中..."
        ;;
    restart)
        echo "サービス再起動中..."
        ;;
    status)
        echo "ステータス確認中..."
        ;;
    *)
        echo "Usage: $0 {start|stop|restart|status}" >&2
        exit 1
        ;;
esac

関数とエラーハンドリング

関数

関数でスクリプトを構造化する:

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

log_info() {
    echo "[INFO] $(date +%H:%M:%S) $*"
}

log_error() {
    echo "[ERROR] $(date +%H:%M:%S) $*" >&2
}

check_dependency() {
    local cmd="$1"
    if ! command -v "$cmd" &> /dev/null; then
        log_error "${cmd} がインストールされていない"
        return 1
    fi
    log_info "${cmd} 確認OK: $(command -v "$cmd")"
}

# 使い方
check_dependency "node"
check_dependency "git"
log_info "依存関係チェック完了"

ポイント:

  • 関数内の変数は local をつける。グローバルスコープを汚さない
  • $* は全引数を1つの文字列に展開。"$@" は個々の引数を保持
  • 関数の戻り値は終了コード(0=成功、1-255=失敗)。値を返すには echo とコマンド置換を使う
bash
get_version() {
    local package_json="$1"
    jq -r '.version' "$package_json"
}

version=$(get_version "package.json")
echo "現在のバージョン: ${version}"

エラーハンドリングパターン

set -euo pipefail の先にある、もう一歩踏み込んだエラー処理:

trapでクリーンアップ:

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

tmpdir=$(mktemp -d)

cleanup() {
    rm -rf "$tmpdir"
    echo "一時ディレクトリを削除"
}

trap cleanup EXIT

# スクリプトのロジック — 一時ファイルは$tmpdirに置く
# エラーで終了してもcleanupが自動で走る
cp important-data.txt "$tmpdir/backup.txt"

条件付き実行:

bash
# コマンド失敗を明示的に処理
if ! npm run build; then
    log_error "ビルド失敗"
    exit 1
fi

# コマンド失敗時のデフォルト値
git_hash=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown")

行番号付きエラーメッセージ:

bash
trap 'echo "エラー発生: ${LINENO}行目(終了コード: $?)" >&2' ERR

実務で使えるスクリプトパターン

パターン1:プロジェクトセットアップ

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

# 32blogのローカル開発環境セットアップ
log() { echo "[setup] $*"; }

log "依存関係チェック中..."
for cmd in node npm git; do
    if ! command -v "$cmd" &> /dev/null; then
        echo "未インストール: ${cmd}" >&2
        exit 1
    fi
done

node_version=$(node -v | tr -d 'v')
required_version="20"
if [[ "${node_version%%.*}" -lt "$required_version" ]]; then
    echo "Node.js ${required_version}以上が必要(現在: ${node_version})" >&2
    exit 1
fi

log "パッケージインストール中..."
npm ci

if [[ ! -f .env.local ]]; then
    log ".env.local をテンプレートから作成中..."
    cp .env.example .env.local
    echo ".env.local にAPIキーを設定してからdev serverを起動してください"
fi

log "セットアップ完了。'npm run dev' で起動。"

パターン2:バックアップとローテーション

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

backup_dir="/var/backups/32blog"
source_dir="/var/www/32blog/content"
max_backups=7
timestamp=$(date +%Y%m%d_%H%M%S)
backup_file="${backup_dir}/content_${timestamp}.tar.gz"

mkdir -p "$backup_dir"

echo "バックアップ作成中: ${backup_file}"
tar -czf "$backup_file" -C "$(dirname "$source_dir")" "$(basename "$source_dir")"

backup_size=$(du -h "$backup_file" | cut -f1)
echo "バックアップ完了: ${backup_size}"

# ローテーション — 最新のmax_backups個だけ残す
backup_count=$(find "$backup_dir" -name "content_*.tar.gz" | wc -l)
if [[ "$backup_count" -gt "$max_backups" ]]; then
    remove_count=$((backup_count - max_backups))
    find "$backup_dir" -name "content_*.tar.gz" -printf '%T@ %p\n' \
        | sort -n \
        | head -n "$remove_count" \
        | cut -d' ' -f2- \
        | xargs rm -f
    echo "ローテーション: 古いバックアップ${remove_count}個を削除"
fi

パターン3:デプロイ前チェック

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

errors=0

check() {
    local description="$1"
    shift
    if "$@" > /dev/null 2>&1; then
        echo "✓ ${description}"
    else
        echo "✗ ${description}" >&2
        ((errors++)) || true
    fi
}

echo "=== デプロイ前チェック ==="

check "リント通過" npm run lint
check "ビルド成功" npm run build
check "未コミットの変更なし" git diff --quiet
check "mainブランチにいる" test "$(git branch --show-current)" = "main"
check "リモートと同期済み" git diff --quiet origin/main..HEAD

if [[ "$errors" -gt 0 ]]; then
    echo ""
    echo "${errors}個のチェックが失敗。修正してからデプロイしてください。" >&2
    exit 1
fi

echo ""
echo "全チェック通過。デプロイOK。"

パターン4:一括ファイル変換

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

# ディレクトリ内のPNG画像をすべてWebPに変換
input_dir="${1:-.}"
converted=0
skipped=0

for png in "${input_dir}"/*.png; do
    [[ -f "$png" ]] || continue  # グロブにマッチしなければスキップ

    webp="${png%.png}.webp"

    if [[ -f "$webp" ]] && [[ "$webp" -nt "$png" ]]; then
        ((skipped++))
        continue
    fi

    cwebp -q 80 "$png" -o "$webp" 2>/dev/null
    ((converted++))
    echo "変換: $(basename "$png")"
done

echo "完了: ${converted}個変換、${skipped}個スキップ(変換済み)"

FAQ

bashとshのどちらを使うべき?

bashを使う。POSIX shには配列、[[ ]]、文字列操作など便利な機能がない。shを使う理由があるとすればAlpine Dockerのような最小環境だけど、そこでも apk add bash で入る。#!/usr/bin/env bash と書いて、#!/bin/sh は避ける。

set -euo pipefailは具体的に何をする?

3つの安全フラグを有効にする。-e はコマンド失敗で即終了、-u は未定義変数をエラーに、-o pipefail はパイプ内のどのコマンドが失敗しても検知する。サイレントに壊れる最も一般的なパターンを防いでくれる。shebangの直後に必ず書く。

シェルスクリプトのデバッグ方法は?

set -x を追加すると、各コマンドを実行前に表示する。部分的にデバッグしたい場合は set -xset +x で囲む。ファイルを修正せずに bash -x script.sh で実行してもいい。PS4='+ ${BASH_SOURCE}:${LINENO}: ' を設定するとファイル名と行番号も表示される。

シェルスクリプトとPythonの使い分けは?

シェルスクリプトはファイル操作、コマンドの連携、システムタスクが得意。ターミナルで打つことを自動化するならシェルスクリプト。複雑なデータ構造、HTTPリクエスト、jqで対応しきれないJSON処理、「失敗したらこう、このエラーならこう」みたいな細かいエラー処理が必要ならPython。200行を超えたらリライトを検討する。

ファイル名にスペースがある場合の対処法は?

変数は必ずクォートする:"$file" であって $file ではない。引数を転送するときは "$@" を使う。ファイルのループにはグロブ(for f in *.txt)を使い、ls の出力をパースしない。findxargsを使う場合は、-print0-0 でヌル区切りにする。

$@$* の違いは?

"$@" は各引数を個別のワードとして展開する(ほぼ常にこちらを使う)。"$*" は全引数を1つの文字列に結合する。for arg in "$@" はスペースを含む引数でも正しくイテレートする。連結が必要な場合以外は "$@" を使う。

Linux/macOS間でスクリプトの互換性を保つには?

shebangに #!/usr/bin/env bash を使う。GNU固有のフラグを避ける(sed -i ''sed -i はmacOSとLinuxで違う)。GNU版とBSD版の両方でテストする。最大限の互換性が必要ならPOSIX機能に限定するか、ドキュメントでbash必須と明記する。

shellcheckは使うべき?

間違いなく使うべき。ShellCheckはクォート漏れ、よくある落とし穴、互換性の問題を見つけてくれる。apt install shellcheckbrew install shellcheck でインストールして、CIにも入れる。「これは大丈夫」と思ったスクリプトでバグを見つけてくれたことが何度もある。

まとめ

シェルスクリプトは地味だけど、書けば書くほど複利で効いてくるスキルだ。一度書いたスクリプトは次も、その次も時間を節約してくれる。

覚えることは少ない。#!/usr/bin/env bashset -euo pipefail で始めて、変数をクォートして、関数で構造化して、trap でクリーンアップ。これだけで本番環境でも壊れないスクリプトが書ける。

さらに深掘りするなら、ShellCheckでスクリプトを検証し、Bash Pitfallsでエッジケースを学び、GNU Bashマニュアルを公式リファレンスとして参照するといい。Advanced Bash-Scripting Guideもブックマークしておく価値がある。

他のガイドで扱ったツールと組み合わせると強力だ。cronでスケジューリング、findxargsでファイル処理、sedとawkでテキスト変換。外部依存なしの自動化ツールキットが完成する。