「ちょっとした修正を加えるたびに別の箇所が壊れる」
「コードをコピペしまくって、同じバグを10箇所直さないといけない」
こういう経験は、Linuxサーバーをある程度触ったことのあるエンジニアなら一度は必ず通る道です。シェルスクリプトは手軽に始められる分、設計を意識しないとあっという間に「読めないコード」になります。
この記事では、現場で実際によく見かける「if地獄」「グローバル変数汚染」「コピペ地獄」の3つのアンチパターンを取り上げ、それぞれを関数・ローカル変数・共通ライブラリで改善するリファクタリング設計を解説します。RHEL 9.4 / Ubuntu 24.04 LTSで動作確認済みです。
この記事のポイント
・if地獄はearly return(早期終了)パターンとガード節で解消できる
・local宣言で変数スコープを関数内に閉じてグローバル汚染を防ぐ
・共通処理はsourceで外部ライブラリ化して複数スクリプトで再利用する
・リファクタリングはshellcheckで問題を可視化してから段階的に進める
でも安心してください。プロのエンジニアはコマンドを暗記していません。
「現場で使える型」を効率よく使いこなしているだけです。
なぜシェルスクリプトはすぐに「負債」になるのか
シェルスクリプトには「とりあえず動けばOK」の文化が根強くあります。コマンドを順番に並べれば動くため、設計を後回しにしやすいのが構造的な問題です。現場でよく見かける「負債スクリプト」には、共通した特徴があります。
・ifのネストが5段以上ある(読むだけで脳が疲れる)
・変数が全てグローバル(関数内の変更が思わぬ箇所に波及する)
・同じ処理が3箇所以上コピペされている(バグ修正を全箇所に反映し忘れる)
・コメントがない・変数名がa、b、tmp(書いた本人でも読めなくなる)
これらは技術力の問題ではなく、「設計の型」を知らないことが原因です。型を知れば、同じ機能でも「読める・直せる・テストできる」スクリプトに生まれ変わります。
if地獄を関数とearly returnで解消する
1. ネストが深くなる原因
典型的な「if地獄」の例を見てみましょう。ファイルのバックアップ処理で、よくある書き方です。# NG: ネストが深くて読みにくい backup() { if [ -f "$1" ]; then if [ -d "$BACKUP_DIR" ]; then if cp "$1" "$BACKUP_DIR/"; then if [ -f "$BACKUP_DIR/$(basename "$1")" ]; then echo "バックアップ成功: $1" else echo "コピー後のファイルが見つかりません" >&2 fi else echo "コピーに失敗しました" >&2 fi else echo "バックアップディレクトリが存在しません: $BACKUP_DIR" >&2 fi else echo "ソースファイルが存在しません: $1" >&2 fi }
fi がどの if に対応するのか、追いかけるだけで疲れます。2. 早期リターン(early return)パターン
早期リターンパターンでは、「NG条件が成立したら即座にreturnして終了する」ことで、ネストを平坦に保ちます。# OK: early returnで平坦に書く backup() { local src="$1" if [ ! -f "$src" ]; then echo "ソースファイルが存在しません: $src" >&2 return 1 fi if [ ! -d "$BACKUP_DIR" ]; then echo "バックアップディレクトリが存在しません: $BACKUP_DIR" >&2 return 1 fi if ! cp "$src" "$BACKUP_DIR/"; then echo "コピーに失敗しました" >&2 return 1 fi echo "バックアップ成功: $src" return 0 }
実際に実行した例です(RHEL 9.4 実機)。
$ BACKUP_DIR=/tmp/backup $ mkdir -p /tmp/backup $ backup /etc/hostname バックアップ成功: /etc/hostname $ backup /etc/nonexistent ソースファイルが存在しません: /etc/nonexistent $ echo $? 1
3. ガード節で前提条件を宣言的に並べる
前提条件チェックをまとめて「ガード節」として関数の先頭に置くと、「この関数が動作するための条件」が一目で分かります。# ガード節パターン: 前提チェックを冒頭に集める process_log() { local log_file="$1" local output_dir="$2" # ガード節(前提条件が満たされていなければ即座に終了) [ -z "$log_file" ] && { echo "ログファイルを指定してください" >&2; return 1; } [ ! -f "$log_file" ] && { echo "ファイルが見つかりません: $log_file" >&2; return 1; } [ ! -d "$output_dir" ] && { echo "出力先ディレクトリが存在しません" >&2; return 1; } # ここまで来れば前提条件はすべて満たされている local out_file="$output_dir/errors_$(date +%Y%m%d).log" grep "ERROR" "$log_file" > "$out_file" echo "エラーログを抽出しました: $(wc -l < "$out_file") 件" }
グローバル変数汚染をlocal・readonlyで防ぐ
1. グローバル変数がもたらす問題
bashでは、関数内で宣言した変数もデフォルトでグローバルスコープです。これが予期しないバグの温床になります。# NG: 関数内の変数がグローバルに漏れる count=0 increment() { i=1 # local 宣言なし → グローバル変数として i が生成される count=$((count + i)) } i=100 # 別の場所で i を使っている increment echo "i = $i" # 期待: 100 / 実際: 1 (increment 内で上書きされた)
$ i=100 $ increment $ echo "i = $i" i = 1
i=1 が外側の i=100 を上書きしてしまいます。この手のバグは関数が増えるほど見つけにくくなります。2. local宣言で変数スコープを関数内に閉じる
# OK: local で変数スコープを閉じる count=0 increment() { local i=1 # 関数スコープ内だけで有効 count=$((count + i)) } i=100 increment echo "i = $i" # 100 のまま(関数内の local i に影響されない) echo "count = $count" # 1
$ i=100 $ increment $ echo "i = $i" i = 100 $ echo "count = $count" count = 1
local を付ける、と習慣化すれば、変数汚染のバグは大幅に減ります。「変数名が衝突しないか不安で関数を書けない」という状況も解消されます。3. readonlyとdeclare -rで定数を保護する
スクリプト全体で使う設定値(バックアップ先パス・リトライ回数など)は、定数として保護しましょう。誤って上書きしようとするとエラーを出して検知できます。#!/bin/bash # 設定値を定数として宣言 readonly BACKUP_DIR="/var/backup" readonly MAX_RETRY=3 declare -r LOG_FILE="/var/log/batch/$(date +%Y%m%d).log" # 誤って上書きしようとするとエラーになる BACKUP_DIR="/tmp/test" # ← これはエラーになる
$ ./script.sh ./script.sh: 行 8: BACKUP_DIR: 読み取り専用の変数です
readonly と declare -r はどちらも同じ効果ですが、declare は型オプションを組み合わせやすいため(例: declare -ri MAX_RETRY=3 で整数型の定数)、設定値の宣言には declare を使う流派もあります。どちらかに統一することが重要で、混在は避けましょう。コピペ地獄を共通ライブラリ化で解消する
1. 同じ処理が複数スクリプトに散らばる問題
ログ出力・エラーハンドリング・設定読み込みは、プロジェクト内の全スクリプトで必要になります。コピペで対応するとバグ修正を全件探して直す羽目になります。# NG: 同じログ関数が backup.sh / deploy.sh / cleanup.sh それぞれに存在する log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" }
2. sourceコマンドでライブラリを読み込む設計
共通処理をlib/common.sh として切り出し、source コマンドで読み込みます。# lib/common.sh(共通ライブラリ) # 二重読み込み防止 [ -n "${_COMMON_SH_LOADED:-}" ] && return 0 readonly _COMMON_SH_LOADED=1 readonly LOG_DIR="/var/log/batch" log_info() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] INFO $*" | tee -a "$LOG_DIR/batch.log"; } log_error() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] ERROR $*" | tee -a "$LOG_DIR/batch.log" >&2; } die() { log_error "$1" exit "${2:-1}" }
# backup.sh(利用側) #!/bin/bash set -euo pipefail # スクリプトのディレクトリ基準でライブラリを読み込む SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" source "${SCRIPT_DIR}/lib/common.sh" log_info "バックアップ開始" cp /etc/hostname /var/backup/ || die "バックアップに失敗しました" 2 log_info "バックアップ完了"
[2026-09-07 14:23:01] INFO バックアップ開始 [2026-09-07 14:23:01] INFO バックアップ完了
3. 共通ライブラリ設計の指針
ライブラリを作るときに守るべき設計上の指針です。・関数名にプレフィックスを付けて衝突を防ぐ(例:
log_info log_error lib_retry)・二重読み込み防止のガードを必ず入れる(複数スクリプトがsourceし合う環境で必須)
・パスはSCRIPT_DIRからの相対で指定する(
cd後に実行されても壊れない)・グローバル変数を定義するならreadonly にする(上書き禁止にして他スクリプトへの影響を明示)
・ライブラリ自体はshebang行を書かない(source専用ファイルとして扱い、誤って直接実行させない)
シェルスクリプト講座を見る >>
既存スクリプトを安全にリファクタリングする手順
1. shellcheckで問題を可視化する
リファクタリングを始める前に、まずshellcheck で静的解析を走らせます。設計上の問題を客観的に可視化してからリファクタリングに着手するほうが、作業漏れが減ります。$ shellcheck backup.sh In backup.sh line 4: DIR=$1 ^--^ SC2034: DIR appears unused. Verify use (or export if used externally). In backup.sh line 7: if [ -f $FILE ]; then ^---^ SC2086: Double quote to prevent globbing and word splitting. In backup.sh line 12: cp $FILE $BACKUP_DIR ^---^ SC2086: Double quote to prevent globbing and word splitting.
shellcheckの警告コードは重大度が高い順に直すのが基本です。SC2086(引用符なし)とSC2048(配列展開)は最優先で直しましょう。
2. ベースラインテストで安全網を張る
リファクタリング前に「現状の動作」を記録しておくことで、リファクタリング後に挙動が変わっていないかを確認できます。# リファクタリング前: 正常系の期待出力を記録する $ ./backup.sh /etc/hostname 2>&1 バックアップ成功: /etc/hostname $ echo "exit=$?" exit=0 # 異常系も記録する $ ./backup.sh /etc/nonexistent 2>&1 ソースファイルが存在しません: /etc/nonexistent $ echo "exit=$?" exit=1
3. 小さなコミットで段階的に改善する
「一度に全部直す」は最も失敗しやすいリファクタリング手法です。現場で推奨する順序は以下のとおりです。・Step 1:変数名のリネーム(
a → src_file 等)。挙動を変えない安全な変更・Step 2:引用符の追加。
$FILE → "$FILE"。shellcheckの指摘を1件ずつ潰す・Step 3:関数の抽出。コピペ箇所を特定して共通ライブラリに移す
・Step 4:local宣言の追加。関数内のグローバル変数を全てlocal化する
・Step 5:early returnの適用。深いネストを平坦化する
各ステップの後にベースラインテストを実行し、挙動が変わっていないことを確認してからGitにコミットします。「全部一気に直す → どこかで壊れる → 何が原因か分からない」という状況を防ぐための分割です。
まとめ:「読める・直せる・テストできる」スクリプトに
この記事で解説した3つのリファクタリングパターンをまとめます。| アンチパターン | 解決策 | キーワード |
|---|---|---|
| if地獄(深いネスト) | NG条件を先にチェックしてreturnする | early return / ガード節 |
| グローバル変数汚染 | 関数内変数にlocal・定数にreadonly | local / readonly / declare -r |
| コピペ地獄 | 共通処理を外部ファイル化してsource | source / 共通ライブラリ |
設計の型を身につけたシェルスクリプトは、半年後の自分でも読めます。それが、運用を長く続けられるスクリプトの条件です。
シェルスクリプト講座を見る >>
3,100名以上が実践した「型」を無料で公開中
プロのエンジニアはコマンドを暗記していません。
「現場で使える型」を効率よく使いこなしているだけです。
その「型」を図解60Pにまとめた入門マニュアルを、完全無料でプレゼントしています。
姓・名・メールの3つだけ/30秒/解除は3秒 / 詳細はこちら
- 前のページへ:シェルスクリプトでプロセスを監視して自動再起動する方法|kill -0チェックとwatchdog設計の実践
- この記事の属するカテゴリ:シェルスクリプトへ戻る

無料メルマガで学習を続ける
Linuxの実践スキルをメールで毎週お届け。
登録は30秒、解除もいつでも可。
登録無料・いつでも解除できます