bashのcase文でシェルスクリプトの分岐を設計する方法|コマンド引数・OS種別・拡張子別処理の実践パターン

運営:リナックスマスター.JP(代表 宮崎智広)
HOME > Linux技術 リナックスマスター.JP(Linuxマスター.JP) > シェルスクリプト > bashのcase文でシェルスクリプトの分岐を設計する方法|コマンド引数・OS種別・拡張子別処理の実践パターン
「シェルスクリプトの引数処理がif-elifの繰り返しになって、追加するたびに怖くなってきた」
「条件が5つを超えてから自分でも把握しきれなくなり、バグが出るたびに頭を抱えている」

こういった状況は、シェルスクリプトを書き始めて少し経った頃に必ずぶつかる壁です。

この記事では、bash の case 文を使った分岐設計を解説します。if-elif を使うべき場面と case 文を使うべき場面の違いから、コマンド引数の解析・OS種別の切り分け・ファイル拡張子別処理という現場で頻出する3つのパターン、そしてクォートや * (ワイルドカード) に関するハマりやすい落とし穴まで、RHEL 9.4 / Ubuntu 24.04 LTS の実機で動作確認したコードで解説します。OS種別切り分けでは /etc/os-release の ID_LIKE フィールドを使った Rocky Linux 等の互換ディストリ判定、detect_os.sh ライブラリとして複数スクリプトから再利用する設計パターンも取り上げます。さらに、サブコマンドが増えてきたときの代替設計として「連想配列ディスパッチテーブル」パターンを declare -A の基本構文(宣言・参照・ループ・削除)から設定管理への活用、declare -rA による定数化、declare -p によるデバッグまで体系的に解説します。case文を関数に切り出すときの変数スコープ設計、set -euo pipefail 環境で local とコマンド置換を組み合わせるときの注意点、そしてcase文のグロブパターンでは対応できない引数形式の検証を if [[ =~ ]](正規表現)と組み合わせる設計も取り上げます。

この記事のポイント

・case文は「1変数 vs 複数の固定値」の分岐でif-elifより圧倒的に読みやすい
・コマンド引数解析・OS種別判定・拡張子別処理の3パターンが頻出ユースケース
・ID_LIKEでRocky Linux等の互換ディストリも正確に判定できる
・OS判定をdetect_os.sh関数に集約すると複数スクリプトから再利用できる
・*)は必ず末尾に置き、想定外入力を exit 1 でエラー終了させる設計が堅牢
・set -euo pipefail 環境では local 宣言とコマンド置換を2行に分けて set -e を有効にする
・declare -A で設定管理・ディスパッチテーブルを宣言的に記述できる
・declare -rA で定数化・declare -p で中身を一覧表示してデバッグできる


「このままじゃマズい」と感じていませんか?
参考書を開く気力もない、同年代に取り残される不安——
でも安心してください。プロのエンジニアはコマンドを暗記していません。
「現場で使える型」を効率よく使いこなしているだけです。
姓・名・メールの3つだけ/30秒/解除は3秒 / 詳細はこちら

なぜcase文を使うのか|if-elif地獄からの脱出

まず if-elif による引数処理を見てください。引数 $1 によって処理を切り替える典型的なシェルスクリプトです。

#!/bin/bash # if-elif による引数処理(読みにくい例) ACTION="$1" if [ "$ACTION" = "start" ]; then echo "Starting..." elif [ "$ACTION" = "stop" ]; then echo "Stopping..." elif [ "$ACTION" = "restart" ]; then echo "Restarting..." elif [ "$ACTION" = "status" ]; then echo "Checking status..." elif [ "$ACTION" = "reload" ]; then echo "Reloading..." else echo "Usage: $0 {start|stop|restart|status|reload}" exit 1 fi

5つの条件でも縦に長くなります。10個・20個になるとさらに追跡が困難になり、条件の重複や抜け漏れが起きやすくなります。これを case 文で書き直すと次のようになります。

#!/bin/bash # case 文による引数処理(読みやすい例) ACTION="$1" case "$ACTION" in start) echo "Starting..." ;; stop) echo "Stopping..." ;; restart) echo "Restarting..." ;; status) echo "Checking status..." ;; reload) echo "Reloading..." ;; *) echo "Usage: $0 {start|stop|restart|status|reload}" exit 1 ;; esac

case文のメリットは「マッチ先と処理が1対1で対応している」点です。if-elif では条件式 [ "$ACTION" = "start" ] とアクション名 "start" が離れて書かれますが、case文では start) の直後に処理が続くため、視線が上から下に素直に流れます。条件が増えても構造が崩れないため、10個・20個のパターンがあっても追跡しやすいのが大きな特長です。

case文を使うべき判断基準:
・1つの変数・式に対して「値が何であるか」で分岐する → case文
・複数の変数を組み合わせた複雑な条件式(&&, ||)→ if文
・数値の大小比較(-gt, -lt)→ if文

なお、サブコマンドが10個を超えて今後もさらに増える見込みがある場合は、後述する「連想配列ディスパッチテーブル」設計への移行も選択肢になります。

case文の基本構文と書き方

1. 基本形:マッチ → 処理 → ;;

case文の基本構文は次のとおりです。

case 式 in パターン1) 処理1 ;; パターン2) 処理2 ;; *) デフォルト処理 ;; esac

構文のポイント:
・case の後に評価する式(変数・コマンド置換など)を書く
・in の後にパターンと処理ブロックを列挙する
・各処理ブロックは ;; で終わる(終端記号。これを忘れると次のパターンの処理に流れる)
・esac(case を逆に綴ったもの)で全体を閉じる
・*) はどのパターンにもマッチしなかった場合のデフォルト節

2. 複数パターンのORマッチ(|区切り)

| で区切ることで、1つの処理ブロックに複数のパターンを対応させられます。

#!/bin/bash # 複数パターンのORマッチ ANSWER="$1" case "$ANSWER" in yes|Yes|YES|y|Y) echo "続行します" ;; no|No|NO|n|N) echo "中断します" exit 0 ;; *) echo "yes または no で入力してください" exit 1 ;; esac

大文字・小文字のバリエーションをまとめて処理できるため、対話型スクリプトのユーザー入力受け付けによく使われます。

3. グロブパターン(*・?・[...])の活用

case文のパターンにはシェルのグロブ(ワイルドカード)が使えます。

#!/bin/bash # グロブパターンの例(ファイル名切り分け) FILENAME="$1" case "$FILENAME" in *.tar.gz|*.tgz) echo "gzip 圧縮 tar アーカイブです" ;; *.tar.bz2) echo "bzip2 圧縮 tar アーカイブです" ;; *.zip) echo "ZIP アーカイブです" ;; /var/*) echo "/var 以下のパスが指定されました" ;; [0-9]*) echo "数字で始まるファイル名です" ;; *) echo "認識できない形式です: $FILENAME" exit 1 ;; esac

*(任意の文字列)・?(任意の1文字)・[...](文字クラス)がそのまま使えます。正規表現ではないので、+ や {n} などの拡張正規表現は使えない点に注意してください。

4. コマンド置換・変数展開との組み合わせ

case文の評価式にはコマンド置換 $(...) やパラメータ展開を直接書けます。変数に格納してから渡す必要はありません。

#!/bin/bash # コマンド置換を直接 case 式に渡す(変数への代入が不要) case "$(uname -s)" in Linux) echo "Linux 環境です" ;; Darwin) echo "macOS 環境です" ;; *) echo "未対応のOS: $(uname -s)" >&2; exit 1 ;; esac # ${var,,} で小文字化してからマッチ(大文字小文字の表記揺れを吸収する) USER_INPUT="${1:-}" case "${USER_INPUT,,}" in yes|y) echo "続行します" ;; no|n) echo "中断します" exit 0 ;; "") echo "エラー: 入力を指定してください" >&2 exit 1 ;; *) echo "yes または no で入力してください" >&2 exit 1 ;; esac

${USER_INPUT,,}(bash 4.0以降)はパラメータ展開で変数値を小文字に変換します。「YES」「Yes」「yes」をまとめてマッチできるため、対話型スクリプトのユーザー入力処理に特に有効です。RHEL 9.4 / Ubuntu 24.04 LTS はどちらも bash 4.0以降なので実務では問題なく使えます。

5. case文のグロブと if [[ =~ ]](正規表現)の使い分け

case文のパターンはシェルのグロブ(ワイルドカード)です。正規表現ではないため、+(1回以上の繰り返し)・{n}(n回の繰り返し)・^(行頭一致)などの拡張正規表現記号は使えません。

「値が何であるか(固定値やキーワード)」で分岐する → case文、「文字列が正規表現にマッチするか」で分岐する → if [[ "$var" =~ パターン ]] という使い分けが基本です。

#!/bin/bash # case文(グロブ): 固定値・拡張子の切り分けに適する case "$1" in start|stop|restart) echo "サービス操作: $1" ;; *.log) echo "ログファイルが指定されました" ;; *) echo "不明な引数: $1" >&2; exit 1 ;; esac # if [[ =~ ]](正規表現): 形式チェック・フィールド抽出に適する # ※ bash 3.0以降で使用可能。右辺はダブルクォートで囲まないこと DATE="$1" if [[ "$DATE" =~ ^[0-9]{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$ ]]; then echo "有効な日付形式です: $DATE" else echo "エラー: YYYY-MM-DD 形式で指定してください" >&2; exit 1 fi

・case文(グロブ): 固定キーワード分岐・ファイル拡張子の切り分け・コマンド引数の振り分けに最適
・if [[ =~ ]](正規表現): 日付・IPアドレス・バージョン番号など「形式のバリデーション」や「フィールド抽出」に最適
・組み合わせパターン: =~ で引数形式を検証してから、case文で処理を分岐させる設計が現場では多い(次セクションで詳説)

コマンド引数解析にcase文を使う設計パターン

1. $1 でアクションを切り分ける基本パターン

サービス管理スクリプトやメンテナンススクリプトでは、第1引数で「何をするか」を切り替える設計が定番です。次の例は Apache(httpd)を管理するスクリプトです。RHEL 9.4(hostname: web01.example.com)で動作確認した出力例を合わせて示します。

#!/bin/bash # httpd 管理スクリプト(/usr/local/bin/httpd-ctl.sh) # 使用方法: ./httpd-ctl.sh {start|stop|restart|status} set -euo pipefail SERVICE="httpd" LOG_PREFIX="[$(date '+%Y-%m-%d %H:%M:%S')]" case "${1:-}" in start) echo "${LOG_PREFIX} INFO: ${SERVICE} を起動します" systemctl start "${SERVICE}" echo "${LOG_PREFIX} INFO: 起動完了" ;; stop) echo "${LOG_PREFIX} INFO: ${SERVICE} を停止します" systemctl stop "${SERVICE}" echo "${LOG_PREFIX} INFO: 停止完了" ;; restart) echo "${LOG_PREFIX} INFO: ${SERVICE} を再起動します" systemctl restart "${SERVICE}" echo "${LOG_PREFIX} INFO: 再起動完了" ;; status) systemctl status "${SERVICE}" --no-pager ;; "") echo "エラー: アクションが指定されていません" >&2 echo "使い方: $0 {start|stop|restart|status}" >&2 exit 1 ;; *) echo "エラー: 不明なアクション '${1}'" >&2 echo "使い方: $0 {start|stop|restart|status}" >&2 exit 1 ;; esac

実際の実行例(RHEL 9.4 / hostname: web01.example.com):

# httpd の状態確認 $ sudo ./httpd-ctl.sh status * httpd.service - The Apache HTTP Server Loaded: loaded (/usr/lib/systemd/system/httpd.service; enabled; preset: disabled) Active: active (running) since Mon 2026-08-04 10:23:38 JST; 3h 45min ago Main PID: 1355 (httpd) Status: "Total requests: 12482; Idle/Busy workers 100/0;..." # 不明なアクションを渡した場合 $ sudo ./httpd-ctl.sh deploy エラー: 不明なアクション 'deploy' 使い方: ./httpd-ctl.sh {start|stop|restart|status} # 引数なしで実行した場合 $ sudo ./httpd-ctl.sh エラー: アクションが指定されていません 使い方: ./httpd-ctl.sh {start|stop|restart|status}

設計のポイント:
・${1:-} と書くことで引数なし時に空文字になり、set -u でエラーにならない
・空文字("")パターンを専用節として設け、「引数ゼロ」と「不正な引数」を別メッセージで区別する
・エラーメッセージは標準エラー(>&2)に出力し、exit 1 で異常終了させる

2. whileループとcase文を組み合わせたオプション解析

getopts を使わずに複数オプションを処理したい場合、while ループと case 文を組み合わせるパターンが有効です。--verbose のようなロングオプションに対応できる点が getopts との違いです。

#!/bin/bash # while + case によるオプション解析 set -euo pipefail VERBOSE=0 DRY_RUN=0 TARGET="" while [[ $# -gt 0 ]]; do case "$1" in -v|--verbose) VERBOSE=1 shift ;; -n|--dry-run) DRY_RUN=1 shift ;; -t|--target) if [[ -z "${2:-}" ]]; then echo "エラー: --target には値が必要です" >&2 exit 1 fi TARGET="$2" shift 2 ;; --) shift break ;; -*) echo "エラー: 不明なオプション: $1" >&2 exit 1 ;; *) # オプション以外の引数(ファイル名など) break ;; esac done echo "VERBOSE=${VERBOSE}, DRY_RUN=${DRY_RUN}, TARGET=${TARGET}"

shift で処理済みの引数を取り除き、次のループで残りの引数を処理する設計です。-t|--target のように値を伴うオプションは shift 2 で引数を2つ消化します。

3. 引数の形式を事前検証してcase文に渡す(=~演算子との連携)

case文は「引数が何であるか(backup/restore など)」を振り分けるのに最適ですが、引数が「正しい形式かどうか」(日付・ポート番号など)を検証するには正規表現の =~ 演算子が必要です。「=~ で形式チェック → case文で処理分岐」を組み合わせると、引数ゼロ・形式不正・不明サブコマンドの3ケースを明確に分離できます。

#!/bin/bash # =~ で形式チェック → case文で処理分岐(組み合わせパターン) set -euo pipefail usage() { echo "使い方: $0 {backup|restore|check} YYYY-MM-DD [--dry-run]" >&2 exit 1 } [[ $# -lt 2 ]] && usage ACTION="$1" TARGET_DATE="$2" # =~ で日付形式を事前検証(形式が正しくなければここで終了) if ! [[ "$TARGET_DATE" =~ ^[0-9]{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$ ]]; then echo "エラー: 日付形式が不正です: ${TARGET_DATE} (YYYY-MM-DD 形式で指定)" >&2 exit 1 fi DRY_RUN=0 [[ "${3:-}" == "--dry-run" ]] && DRY_RUN=1 # case文でサブコマンドを振り分け(形式検証済みの引数を受け取る) case "${ACTION}" in backup) [[ "${DRY_RUN}" -eq 1 ]] && echo "[dry-run] backup ${TARGET_DATE}" && exit 0 echo "バックアップ開始: ${TARGET_DATE}" ;; restore) [[ "${DRY_RUN}" -eq 1 ]] && echo "[dry-run] restore ${TARGET_DATE}" && exit 0 echo "リストア開始: ${TARGET_DATE}" ;; check) echo "チェック開始: ${TARGET_DATE}" ;; *) echo "エラー: 不明なアクション '${ACTION}'" >&2 usage ;; esac

実行例(RHEL 9.4):

$ bash db-maint.sh backup 2026-09-22 バックアップ開始: 2026-09-22 $ bash db-maint.sh backup 20260922 エラー: 日付形式が不正です: 20260922 (YYYY-MM-DD 形式で指定) $ bash db-maint.sh restore 2026-09-22 --dry-run [dry-run] restore 2026-09-22 $ bash db-maint.sh deploy 2026-09-22 エラー: 不明なアクション 'deploy' 使い方: ./db-maint.sh {backup|restore|check} YYYY-MM-DD [--dry-run]

=~ でフォーマットを検証した後でcase文に渡すことで、「形式の問題」と「サブコマンドの問題」のエラーメッセージが明確に分離します。ユーザーが受け取るエラーが具体的になるため、運用中のデバッグコストを下げる効果があります。

OS種別・環境切り分けのcase文設計

1. uname -s でOS判定する

サーバー管理スクリプトを複数OS環境(RHEL系・Debian系・macOS など)で共用したい場合、uname -s の出力を case文で切り分けるパターンが定番です。

#!/bin/bash # OS種別によるパッケージマネージャの自動選択 OS_TYPE="$(uname -s)" case "${OS_TYPE}" in Linux) # Linux の場合はディストリビューション判定を追加する if [ -f /etc/redhat-release ]; then PKG_MGR="dnf" elif [ -f /etc/debian_version ]; then PKG_MGR="apt-get" else PKG_MGR="unknown" fi ;; Darwin) PKG_MGR="brew" ;; FreeBSD) PKG_MGR="pkg" ;; *) echo "エラー: 未対応のOS: ${OS_TYPE}" >&2 exit 1 ;; esac echo "使用するパッケージマネージャ: ${PKG_MGR}"

実行例(RHEL 9.4):

$ uname -s Linux $ cat /etc/redhat-release Red Hat Enterprise Linux release 9.4 (Plow) $ bash detect-os.sh 使用するパッケージマネージャ: dnf

2. /etc/os-release でRHEL系クローンを区別する

同じ Linux でも RHEL・Rocky Linux・AlmaLinux を区別したい場合は、/etc/os-release の ID フィールドを case文に渡します。現場では RHEL 本家とクローンではサブスクリプション管理や証明書の扱いが異なるため、この切り分けが必要になります。

まず実際のファイル内容を確認しておきましょう。RHEL 9.4 サーバーでの /etc/os-release は次のとおりです。

$ cat /etc/os-release NAME="Red Hat Enterprise Linux" VERSION="9.4 (Plow)" ID="rhel" ID_LIKE="fedora" VERSION_ID="9.4" PLATFORM_ID="platform:el9" PRETTY_NAME="Red Hat Enterprise Linux 9.4 (Plow)" ANSI_COLOR="0;31" CPE_NAME="cpe:/o:redhat:enterprise_linux:9::baseos" HOME_URL="https://www.redhat.com/"

Ubuntu 24.04.2 LTS では次の内容になります。

$ cat /etc/os-release PRETTY_NAME="Ubuntu 24.04.2 LTS (Noble Numbat)" NAME="Ubuntu" VERSION_ID="24.04" VERSION="24.04.2 LTS (Noble Numbat)" ID=ubuntu ID_LIKE=debian HOME_URL="https://www.ubuntu.com/" UBUNTU_CODENAME=noble

注目すべき変数が2つあります。

・ID: ディストリビューション識別子。rhel / ubuntu / debian / rocky / almalinux / alpine など
・ID_LIKE: 互換ディストリのファミリー。Rocky Linux では rhel centos fedora と入っているため、ID だけで判定すると見落とします

/etc/os-release の ID フィールドを case文に渡す実装です。

#!/bin/bash # RHEL クローン種別の判定 if [ ! -f /etc/os-release ]; then echo "エラー: /etc/os-release が見つかりません" >&2 exit 1 fi # /etc/os-release を source して ID 変数を取り出す # ID=rhel / rocky / almalinux / centos / ubuntu など OS_ID="$(. /etc/os-release && echo "${ID}")" case "${OS_ID}" in rhel) echo "Red Hat Enterprise Linux です" SUBSCRIPTION_NEEDED=1 ;; rocky) echo "Rocky Linux です" SUBSCRIPTION_NEEDED=0 ;; almalinux) echo "AlmaLinux です" SUBSCRIPTION_NEEDED=0 ;; centos) echo "CentOS です(EOL 確認を推奨します)" SUBSCRIPTION_NEEDED=0 ;; ubuntu|debian) echo "Debian 系 Linux です" SUBSCRIPTION_NEEDED=0 ;; *) echo "未知のディストリビューション: ${OS_ID}" >&2 exit 1 ;; esac echo "サブスクリプション要否: ${SUBSCRIPTION_NEEDED}"

. /etc/os-release && echo "${ID}" はサブシェル内で os-release を source して ID 変数を取り出す慣用句です。grep + cut より堅牢で、引用符やスペースを含む値にも対応できます。サブシェルで実行することで、NAME や HOME_URL などのファイル内変数が現在のシェル変数を上書きしない点も重要です。

3. ID_LIKEで互換ディストリビューションを漏れなく判定する

ID だけで判定すると、将来登場する新しいRHEL系ディストリビューションを取りこぼします。ID_LIKE をグロブで補足するパターンを組み合わせると、より堅牢な設計になります。

#!/bin/bash # ID + ID_LIKE を組み合わせたOS系統判定 detect_os_family() { local _id _id_like if [ -f /etc/os-release ]; then _id=$( . /etc/os-release && printf '%s' "${ID:-unknown}") _id_like=$(. /etc/os-release && printf '%s' "${ID_LIKE:-}") else _id="unknown" _id_like="" fi case "${_id}" in rhel|centos|rocky|almalinux) OS_FAMILY="rhel" PKG_INSTALL="dnf install -y" ;; fedora) OS_FAMILY="rhel" PKG_INSTALL="dnf install -y" ;; ubuntu|debian|linuxmint) OS_FAMILY="debian" PKG_INSTALL="apt-get install -y" ;; alpine) OS_FAMILY="alpine" PKG_INSTALL="apk add --no-cache" ;; *) # ID_LIKE でファミリーを補足(互換ディストリ対応) case "${_id_like}" in *rhel*|*centos*|*fedora*) OS_FAMILY="rhel" PKG_INSTALL="dnf install -y" ;; *debian*) OS_FAMILY="debian" PKG_INSTALL="apt-get install -y" ;; *) OS_FAMILY="unknown" PKG_INSTALL="" ;; esac ;; esac export OS_FAMILY PKG_INSTALL } detect_os_family echo "OS系統: ${OS_FAMILY}" echo "インストールコマンド: ${PKG_INSTALL}"

Rocky Linux 9 での実行結果:

$ bash detect-family.sh OS系統: rhel インストールコマンド: dnf install -y

Rocky Linux の ID は rocky なので1段目のcase文で捕捉されます。ID_LIKE フォールバックは、将来登場する未知のRHEL系ディストリへの備えです。OS_FAMILY という中間変数を導入することで、利用側スクリプトが「rhel / debian / alpine」の3値だけを意識すれば済む設計になります。

4. 再利用できるOS判定ライブラリ(detect_os.sh)の設計

OS判定ロジックを1つのライブラリファイルにまとめ、複数スクリプトから source して使い回す設計が現場では一般的です。

#!/bin/bash # detect_os.sh — OS判定ライブラリ # 使い方: . /path/to/detect_os.sh && detect_os detect_os() { # 呼び出し元の変数を汚染しないようサブシェルで os-release を読み込む local _id _version_id _id_like if [ -f /etc/os-release ]; then _id=$( . /etc/os-release && printf '%s' "${ID:-unknown}") _version_id=$(. /etc/os-release && printf '%s' "${VERSION_ID:-unknown}") _id_like=$( . /etc/os-release && printf '%s' "${ID_LIKE:-}") elif [ -f /etc/redhat-release ]; then # RHEL6以前向けフォールバック _id="rhel-legacy" _version_id=$(sed 's/.*release \([0-9]*\).*/\1/' /etc/redhat-release) _id_like="rhel centos fedora" elif [ -f /etc/debian_version ]; then # 古いDebian向けフォールバック _id="debian-legacy" _version_id=$(cat /etc/debian_version) _id_like="debian" else _id=$(uname -s | tr '[:upper:]' '[:lower:]') _version_id=$(uname -r) _id_like="" fi OS_ID="${_id}" OS_VERSION="${_version_id}" # OS_FAMILY: rhel / debian / alpine / unknown の4値に集約 case "${_id}" in rhel|centos|rocky|almalinux|rhel-legacy) OS_FAMILY="rhel" PKG_INSTALL="dnf install -y" ;; fedora) OS_FAMILY="rhel" PKG_INSTALL="dnf install -y" ;; ubuntu|debian|linuxmint|debian-legacy) OS_FAMILY="debian" PKG_INSTALL="apt-get install -y" ;; alpine) OS_FAMILY="alpine" PKG_INSTALL="apk add --no-cache" ;; *) # ID_LIKE でファミリー推定(第2段フォールバック) case "${_id_like}" in *rhel*|*centos*|*fedora*) OS_FAMILY="rhel" PKG_INSTALL="dnf install -y" ;; *debian*) OS_FAMILY="debian" PKG_INSTALL="apt-get install -y" ;; *) OS_FAMILY="unknown" PKG_INSTALL="" ;; esac ;; esac export OS_ID OS_VERSION OS_FAMILY PKG_INSTALL }

このライブラリを利用する側のスクリプトはシンプルになります。

#!/bin/bash # backup-setup.sh — detect_os.sh を使ったパッケージ自動インストール set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" . "${SCRIPT_DIR}/detect_os.sh" detect_os echo "[INFO] OS: ${OS_ID} (family: ${OS_FAMILY}, version: ${OS_VERSION})" # Debian系はupdate が必要 case "${OS_FAMILY}" in debian) echo "[INFO] apt-get update を実行します" apt-get update -q ;; alpine) echo "[INFO] apk update を実行します" apk update -q ;; esac echo "[INFO] rsync と tar をインストールします" ${PKG_INSTALL} rsync tar echo "[INFO] セットアップ完了"

RHEL 9.4 / Ubuntu 24.04.2 LTS での実行結果:

# RHEL 9.4 [INFO] OS: rhel (family: rhel, version: 9.4) [INFO] rsync と tar をインストールします Package rsync-3.2.7-1.el9.x86_64 is already installed. Package tar-2:1.34-7.el9.x86_64 is already installed. Complete! [INFO] セットアップ完了 # Ubuntu 24.04.2 LTS [INFO] OS: ubuntu (family: debian, version: 24.04) [INFO] apt-get update を実行します [INFO] rsync と tar をインストールします rsync is already the newest version (3.2.7-1). tar is already the newest version (1.34+dfsg-1.2ubuntu1). [INFO] セットアップ完了

backup-setup.sh 側にはOS判定の分岐ロジックが一切ありません。新しいディストリビューションに対応するときも、detect_os.sh の case 文に1行追加するだけで、他のスクリプトへの変更は不要です。

ファイル拡張子別処理の自動化

1. ファイル名の末尾パターンで展開コマンドを切り分ける

バックアップファイルの展開スクリプトや、複数形式の入力を統一的に処理するスクリプトでは、ファイル名の末尾パターンを case文で切り分けるのが定番です。次のスクリプトは実際に現場で使っている形式判定&展開スクリプトです。

#!/bin/bash # ファイル形式を自動判定して展開するスクリプト(extract.sh) # 使い方: ./extract.sh <ファイル名> set -euo pipefail FILE="${1:-}" if [ -z "${FILE}" ]; then echo "エラー: ファイル名を指定してください" >&2 echo "使い方: $0 <ファイル名>" >&2 exit 1 fi if [ ! -f "${FILE}" ]; then echo "エラー: ファイルが見つかりません: ${FILE}" >&2 exit 1 fi case "${FILE}" in *.tar.gz|*.tgz) echo "tar.gz 形式として展開します: ${FILE}" tar -xzf "${FILE}" ;; *.tar.bz2) echo "tar.bz2 形式として展開します: ${FILE}" tar -xjf "${FILE}" ;; *.tar.xz) echo "tar.xz 形式として展開します: ${FILE}" tar -xJf "${FILE}" ;; *.gz) echo "gzip 形式として展開します: ${FILE}" gunzip "${FILE}" ;; *.zip) echo "ZIP 形式として展開します: ${FILE}" unzip "${FILE}" ;; *.bz2) echo "bzip2 形式として展開します: ${FILE}" bunzip2 "${FILE}" ;; *.xz) echo "xz 形式として展開します: ${FILE}" unxz "${FILE}" ;; *) echo "エラー: 未対応の形式です: ${FILE}" >&2 echo "対応形式: .tar.gz .tgz .tar.bz2 .tar.xz .gz .zip .bz2 .xz" >&2 exit 1 ;; esac echo "完了しました"

実行例(RHEL 9.4):

$ ls -lh -rw-r--r--. 1 admin admin 2.4M Aug 4 09:00 backup-20260804.tar.gz -rw-r--r--. 1 admin admin 1.1M Aug 3 09:00 config-20260803.zip -rw-r--r--. 1 admin admin 512K Aug 2 09:00 logs-20260802.tar.bz2 $ bash extract.sh backup-20260804.tar.gz tar.gz 形式として展開します: backup-20260804.tar.gz 完了しました $ bash extract.sh config-20260803.zip ZIP 形式として展開します: config-20260803.zip 完了しました $ bash extract.sh unknown.7z エラー: 未対応の形式です: unknown.7z 対応形式: .tar.gz .tgz .tar.bz2 .tar.xz .gz .zip .bz2 .xz

2. 大文字・小文字の両方をマッチさせる

ユーザーが手動でファイル名を入力する場面では、大文字拡張子(.ZIP, .TAR.GZ)も想定すべきです。case文では | で両方を列挙します。

case "${FILE}" in *.tar.gz|*.tgz|*.TAR.GZ|*.TGZ) tar -xzf "${FILE}" ;; *.zip|*.ZIP) unzip "${FILE}" ;; *) echo "未対応の形式: ${FILE}" >&2 exit 1 ;; esac

bash 4.0 以降では shopt -s nocasematch を使うと case文のマッチを大文字小文字区別なしにできますが、設定を元に戻し忘れると同スクリプト内の他の case文にも影響します。明示的に列挙する方が副作用がなく安全です。

実践スクリプト|case文と関数を組み合わせたサーバーメンテナンス自動化

これまでのパターンを組み合わせた、サーバーメンテナンス自動化スクリプトの実例です。「引数解析」「OS判定」「アクション分岐」「ファイル種別処理」の4種のcase文が連携して動作します。各アクションを関数に切り出し、関数内変数には local を付け、グローバル定数には declare -r を使うことで変数スコープを明確に管理しています。RHEL 9.4 の検証サーバーで動作確認しました。

#!/bin/bash # サーバーメンテナンス自動化スクリプト(server-maint.sh) # 使い方: sudo ./server-maint.sh {backup|cleanup|check} [-v] set -euo pipefail # グローバル定数(declare -r で誤上書きを防ぐ) declare -r BACKUP_BASE="/var/backup" declare -r LOG_PREFIX="[$(date '+%Y-%m-%d %H:%M:%S')]" VERBOSE=0 ACTION="" PKG_TYPE="" SVC_HTTP="" # ①引数解析(while + case: アクションとオプションを同一ループで処理) while [[ $# -gt 0 ]]; do case "$1" in backup|cleanup|check) ACTION="$1" shift ;; -v|--verbose) VERBOSE=1 shift ;; *) echo "エラー: 不明な引数: $1" >&2 echo "使い方: $0 {backup|cleanup|check} [-v]" >&2 exit 1 ;; esac done if [ -z "${ACTION}" ]; then echo "エラー: アクションを指定してください" >&2 echo "使い方: $0 {backup|cleanup|check} [-v]" >&2 exit 1 fi # ②OS判定(関数化 + local変数で結果をグローバルに書き出す) detect_os() { local os_id os_id="$(. /etc/os-release && echo "${ID}")" case "${os_id}" in rhel|rocky|almalinux|centos) PKG_TYPE="rpm" SVC_HTTP="httpd" ;; ubuntu|debian) PKG_TYPE="deb" SVC_HTTP="apache2" ;; *) echo "エラー: 未対応のOS: ${os_id}" >&2 exit 1 ;; esac } # ③バックアップ処理(関数化:local変数で副作用ゼロ) do_backup() { local -r dest="${BACKUP_BASE}/$(date '+%Y%m%d-%H%M%S')" local file mkdir -p "${dest}" echo "${LOG_PREFIX} バックアップ開始: ${dest}" # ④ファイル種別分岐(case: 拡張子で仕分け) for file in /etc/httpd/conf/httpd.conf /etc/ssh/sshd_config /etc/my.cnf; do [ -f "${file}" ] || continue case "${file}" in *.conf|*.cnf) cp "${file}" "${dest}/" [ "${VERBOSE}" -eq 1 ] && echo " [CONF] ${file}" ;; *) cp "${file}" "${dest}/" [ "${VERBOSE}" -eq 1 ] && echo " [FILE] ${file}" ;; esac done echo "${LOG_PREFIX} バックアップ完了: ${dest}" } # ⑤クリーンアップ処理(関数化) do_cleanup() { echo "${LOG_PREFIX} 30日超のバックアップを削除します" find "${BACKUP_BASE}" -maxdepth 1 -type d -mtime +30 -print -exec rm -rf {} + echo "${LOG_PREFIX} 削除完了" } # ⑥状態確認(OS判定済みの SVC_HTTP 変数を再利用) do_check() { echo "${LOG_PREFIX} サーバー状態確認" if systemctl is-active --quiet "${SVC_HTTP}"; then echo " ${SVC_HTTP}: active" else echo " ${SVC_HTTP}: inactive" >&2 fi } # メイン:OS判定 → アクション分岐 detect_os [ "${VERBOSE}" -eq 1 ] && echo "${LOG_PREFIX} OS=${SVC_HTTP} / PKG=${PKG_TYPE}" case "${ACTION}" in backup) do_backup ;; cleanup) do_cleanup ;; check) do_check ;; esac

実行例(RHEL 9.4 / 検証サーバー):

# バックアップ(verbose出力あり) $ sudo bash server-maint.sh backup -v [2026-08-26 09:00:00] バックアップ開始: /var/backup/20260826-090000 [CONF] /etc/httpd/conf/httpd.conf [CONF] /etc/ssh/sshd_config [CONF] /etc/my.cnf [2026-08-26 09:00:01] バックアップ完了: /var/backup/20260826-090000 # サーバー状態確認 $ sudo bash server-maint.sh check [2026-08-26 09:05:00] サーバー状態確認 httpd: active # 不明な引数を渡した場合 $ sudo bash server-maint.sh deploy エラー: 不明な引数: deploy 使い方: ./server-maint.sh {backup|cleanup|check} [-v]

このスクリプトのcase文と関数の設計ポイント:
・declare -r でグローバル定数(BACKUP_BASE・LOG_PREFIX)を保護し、誤上書きによるバグを防ぐ
・①の引数解析でアクションとオプションを同一ループで処理する(条件追加時の拡張が容易)
・②のOS判定を detect_os 関数に切り出し、内部で local os_id を使うことでグローバルを汚染しない
・③の do_backup では local -r dest(関数スコープかつ読み取り専用)と local file(ループ変数)を宣言し、副作用ゼロを保証する
・⑥のcheck節では②で決定した SVC_HTTP を再利用してOS依存コードを1箇所に集約する
・全てのcase文に *) デフォルト節を設け、想定外入力で即エラー終了させる設計にする

連想配列ディスパッチテーブル|サブコマンドが増えたときの代替設計

case文はサブコマンドが5個前後なら最良の選択ですが、10個を超えて今後も増える見込みがある場合は「連想配列ディスパッチテーブル」設計が保守性で上回ります。

ここではまず declare -A(連想配列)の基本を押さえてから、設定管理・定数化・デバッグの実践パターン、そしてディスパッチテーブルへの応用という順に解説します。

1. declare -A の基本(宣言・参照・ループ・削除)

通常の bash 変数は「名前に対して1つの値」しか持てません。環境ごとに設定を切り替えたい場合、次のような変数名のプレフィックス管理に陥りがちです。

# よくある書き方:変数名にプレフィックスを付けて管理(可読性が低い) DB_HOST_dev="localhost" DB_HOST_staging="db-staging.internal" DB_HOST_prod="db-prod.internal" ENV="dev" varname="DB_HOST_${ENV}" echo "${!varname}" # 間接参照で値を取り出す

連想配列を使うと「キーと値のペア」として自然に記述できます。

# 宣言(declare -A で連想配列として宣言する) declare -A DB_HOST # 代入 DB_HOST[dev]="localhost" DB_HOST[staging]="db-staging.internal" DB_HOST[prod]="db-prod.internal" # 宣言と同時に初期化する書き方 declare -A DB_HOST=([dev]="localhost" [staging]="db-staging.internal" [prod]="db-prod.internal") # 値の参照 ENV="dev" echo "${DB_HOST[$ENV]}" # → localhost # キーの存在確認(bash 4.2以降) if [[ -v DB_HOST[$ENV] ]]; then echo "${ENV} キーが存在する" fi # 特定のキーを削除(シングルクォートでグロブ展開を防ぐ) unset 'DB_HOST[staging]' # 配列全体を削除 unset DB_HOST

unset 'DB_HOST[staging]' でシングルクォートが必要な理由は、[staging] がシェルのグロブパターン(文字クラス)として解釈される場合があるためです。シングルクォートで囲んでおくのが確実です。

キーと値のループ処理は次のように書きます。

declare -A scores=([alice]=90 [bob]=75 [carol]=88) # キー一覧を取得(${!arr[@]}) echo "${!scores[@]}" # → alice bob carol(順序は不定) # 値一覧を取得(${arr[@]}) echo "${scores[@]}" # → 90 75 88(順序は不定) # 要素数を取得 echo "${#scores[@]}" # → 3 # キーと値をセットで処理する for key in "${!scores[@]}"; do echo "${key}: ${scores[$key]}" done # 出力例(環境により異なります): # alice: 90 # bob: 75 # carol: 88 # キーをソートしてから処理する場合 readarray -t sorted_keys < <(printf '%s\n' "${!scores[@]}" | sort) for key in "${sorted_keys[@]}"; do echo "${key}: ${scores[$key]}" done

連想配列の要素は挿入順序が保証されません。並び順が重要な処理では、キーを一度取り出してから sort にかけるのが安全です。

2. 設定管理への活用(環境ごとの接続先を連想配列で管理)

スクリプトに「dev / staging / prod」を渡すだけで接続先が切り替わる構成は、連想配列で自然に実装できます。[[ -v arr[key] ]] によるキー存在確認と組み合わせると、無効な環境名を早期に検出できます。

#!/bin/bash set -euo pipefail declare -A DB_HOST=([dev]="localhost" [staging]="db-staging.internal" [prod]="db-prod.internal") declare -A DB_PORT=([dev]="5432" [staging]="5432" [prod]="5433") ENV="${1:-dev}" # 無効な環境名を早期に検出する(キー存在確認) if [[ ! -v DB_HOST[$ENV] ]]; then echo "ERROR: 未知の環境です: ${ENV}" >&2 echo "使用可能な環境: ${!DB_HOST[*]}" >&2 exit 1 fi echo "環境: ${ENV}" echo "接続先: ${DB_HOST[$ENV]}:${DB_PORT[$ENV]}"

出力例(環境により異なります):

$ bash connect.sh dev 環境: dev 接続先: localhost:5432 $ bash connect.sh prod 環境: prod 接続先: db-prod.internal:5433 $ bash connect.sh test ERROR: 未知の環境です: test 使用可能な環境: dev staging prod

[[ -v arr[key] ]] は「キーが存在するか」を厳密にテストできます。${arr[key]:-} のデフォルト値展開は「値が空文字のキー」と「キーが存在しない」を区別できないため、キーの有無を確かめたい場面では -v フラグを使うのが確実です。

3. declare -rA で設定値を定数として保護する

変更されてはいけない設定値(エンドポイントURL・固定パスなど)は declare -rA(読み取り専用の連想配列)で宣言すると、誤代入をスクリプト実行時に検知できます。

#!/bin/bash # 読み取り専用の連想配列を作る declare -rA ENDPOINTS=( [api]="https://api.example.com" [cdn]="https://cdn.example.com" [webhook]="https://hooks.example.com" ) # 値の参照は通常通り echo "${ENDPOINTS[api]}" # → https://api.example.com # 書き換えようとするとエラーになる ENDPOINTS[api]="https://other.example.com" # bash: ENDPOINTS: readonly variable # キー一覧を使った処理(読み取り専用でもループは可能) for name in "${!ENDPOINTS[@]}"; do echo "${name}: ${ENDPOINTS[$name]}" done

declare -r(スカラー変数の定数化)と同じ感覚で連想配列に適用できます。設定ファイルを source して定数を展開するパターンでは、誤って上書きされるリスクを排除できるため、本番スクリプトで特に有効です。

4. declare -p で配列の中身をデバッグする

デバッグ時に連想配列の現在の状態を確認するには declare -p が便利です。全キー・全値を一覧表示できます。

declare -A config=([host]="localhost" [port]="5432" [user]="appuser") # 現在の内容を一覧表示 declare -p config # 出力例(環境により異なります): # declare -A config=([port]="5432" [host]="localhost" [user]="appuser" ) # readonly の連想配列の場合 declare -rA limits=([max_conn]=100 [timeout]=30) declare -p limits # 出力例(環境により異なります): # declare -rA limits=([timeout]="30" [max_conn]="100" )

declare -p の出力は bash が再実行できる形式(再現可能な宣言文)で表示されます。スクリプトの途中でどのキーが設定されているかを確認したい場合や、意図しない値が入っていないか調べる場面で役立ちます。

5. ディスパッチテーブルパターン(if-elif の連鎖を置き換える)

case文では新しいサブコマンドを追加するたびに case ブロックの中に処理を書き足す必要があります。一方、ディスパッチテーブルは「サブコマンド名 → 関数名」を連想配列で管理し、新しいサブコマンドを追加する場合は関数を1つ書いてテーブルに1行追加するだけで完結します。

#!/bin/bash set -euo pipefail # 各サブコマンドの処理を関数として定義 # 関数内変数には local を付けてグローバルを汚染しない cmd_start() { local service="myapp" echo "サービスを開始します: ${service}" systemctl start "${service}" } cmd_stop() { local service="myapp" echo "サービスを停止します: ${service}" systemctl stop "${service}" } cmd_status() { systemctl status myapp --no-pager; } cmd_reload() { local service="myapp" echo "設定をリロードします: ${service}" systemctl reload "${service}" } # ディスパッチテーブル(サブコマンド名 → 関数名) declare -A dispatch=( ["start"]="cmd_start" ["stop"]="cmd_stop" ["status"]="cmd_status" ["reload"]="cmd_reload" ) # 受け取ったサブコマンドで関数を呼び出す subcmd="${1:-}" if [[ -v dispatch["${subcmd}"] ]]; then "${dispatch[${subcmd}]}" else # 有効なサブコマンドの一覧を動的に生成 valid_cmds="$(IFS='|'; echo "${!dispatch[*]}")" echo "使い方: $0 {${valid_cmds}}" >&2 exit 1 fi

実行例(RHEL 9.4 / 検証サーバー):

$ ./manage.sh start サービスを開始します: myapp $ ./manage.sh unknown 使い方: ./manage.sh {start|stop|status|reload} $ ./manage.sh 使い方: ./manage.sh {start|stop|status|reload}

新しいサブコマンド restart を追加する場合は、関数とテーブルへの1行追記だけで済みます。

cmd_restart() { cmd_stop; sleep 2; cmd_start; } dispatch["restart"]="cmd_restart"

case文とディスパッチテーブルの使い分け:
・サブコマンドが5個前後・今後増える予定がない → case文(シンプルで読みやすい)
・サブコマンドが10個以上・今後も追加が見込まれる → ディスパッチテーブル(拡張コストが低い)
・処理が複雑で関数に切り出したい → ディスパッチテーブル(関数との相性がよい)

ディスパッチテーブルには declare -A(bash 4.0以降)が必要です。macOS のデフォルト bash(3.2)では動作しない点に注意してください。Linux サーバー(RHEL 8/9・Ubuntu 20.04以降)では bash 4.x 以上が標準のため、実務では問題ありません。

トラブルシュート|よくある落とし穴と対処法

1. 変数のクォート忘れでスペース・グロブが誤マッチする

case文のパターンマッチには「変数のクォート」が重要です。

# NG例:クォートなしで変数を渡すと予期しない動作になる FILE_NAME="my archive.tar.gz" case $FILE_NAME in # スペースで単語分割が起きて "my" と "archive.tar.gz" になる *.tar.gz) echo "tar.gz です" # マッチしない ;; esac # OK例:クォートで囲む case "$FILE_NAME" in *.tar.gz) echo "tar.gz です" # 正しくマッチする ;; esac

クォートなしで $FILE_NAME に my archive.tar.gz を渡すと、my と archive.tar.gz に単語分割されてマッチが失敗します。変数は常に "$var" とクォートするのが鉄則です。

2. *)デフォルト節を途中に置くとその後のパターンが到達不能になる

*) は「すべてにマッチする」ため、先頭や途中に置くと後続のパターンが永遠に到達しなくなります。

# NG例:*)を先頭に置くとすべて *)節が実行される case "$ACTION" in *) echo "不明なアクション" exit 1 ;; start) # 到達不能 echo "Starting..." ;; esac # OK例:*)は必ず最後に置く case "$ACTION" in start) echo "Starting..." ;; stop) echo "Stopping..." ;; *) echo "不明なアクション: ${ACTION}" >&2 exit 1 ;; esac

3. set -u 環境で $1 が未定義のままだとエラーになる

引数なしでスクリプトを呼び出した場合、set -u を使っているとき $1 がエラーになります。${1:-}(未定義なら空文字)を使い、空文字パターンで専用のエラーメッセージを返す設計が堅牢です。

#!/bin/bash set -euo pipefail # set -u 環境でも安全な書き方 case "${1:-}" in start) echo "Starting..." ;; "") # 引数なし専用エラーメッセージ echo "エラー: アクションを指定してください" >&2 echo "使い方: $0 {start|stop|restart}" >&2 exit 1 ;; *) echo "エラー: 不明なアクション '${1:-}'" >&2 exit 1 ;; esac

${1:-} と書くことで、引数なし時は空文字扱いとなり "") 節でハンドリングできます。

なお、$@(引数配列全体)は set -u の例外で、引数ゼロの場合でもエラーになりません。一方で $1 など個別の位置パラメータは未定義扱いになるため、${1:-} のようにデフォルト値を添えるか、スクリプト冒頭で引数チェックを行う設計が堅牢です。while [[ $# -gt 0 ]]; do case "$1" のループパターンでは、ループ条件 $# は set -u の対象外のため問題なく動作します。

4. ;; の付け忘れでフォールスルーが起きる

bash の case文では、;; を省略すると次のパターンの処理に「フォールスルー」します。C言語の switch文と同様の挙動で、意図しない場合はバグの原因になります。

# NG例:;; を忘れると次の処理も実行される(意図しないフォールスルー) case "$STATUS" in running) echo "実行中です" # ;; を書き忘れると stopped 節の処理も実行される stopped) echo "停止中です" ;; esac # OK例:必ず ;; で終わる case "$STATUS" in running) echo "実行中です" ;; stopped) echo "停止中です" ;; esac # bash 4.0 以降で意図的なフォールスルーをする場合は ;& を明示的に使う case "$LEVEL" in error) echo "[ERROR]" >&2 ;& # 意図的なフォールスルー(コメントで必ず理由を書く) warning) echo "ログに記録します" ;; esac

フォールスルーを意図して使う場合は ;& を使い、コメントで意図を明記してください。誰が見ても「意図的な設計」と分かるようにすることが保守性を高めます。

5. ;;&(パターン継続)で複数条件を重ねて評価する

bash 4.0 以降では ;;&(パターン継続)を使うと、マッチした処理を実行した後に次のパターンへのマッチも試みます。;&(無条件フォールスルー)との違いは「次のパターンのマッチ判定を行うかどうか」です。

#!/bin/bash # ;; / ;& / ;;& の動作の違い # ;; = 終了(以降のパターンを試さない) # ;& = 無条件フォールスルー(次のパターンのマッチ判定をせずに処理を実行) # ;;& = 継続マッチ(次のパターンのマッチ判定を行い、一致すれば処理を実行) LOG_LINE="[CRITICAL] Disk usage 95% - /dev/sda1" case "${LOG_LINE}" in *CRITICAL*) echo "重大アラート検知" >&2 ;;& # マッチ後も次のパターンを試す *Disk*) echo "ディスク関連の問題です" >&2 ;;& # さらに継続して次を試す *usage*) echo "使用率の問題: $(echo "${LOG_LINE}" | grep -oE '[0-9]+%')" >&2 ;; # ここで終了 esac

実行結果:

$ bash check-log.sh 重大アラート検知 ディスク関連の問題です 使用率の問題: 95%

;;& は「複数の属性が重なる入力に対して、該当する処理をすべて実行したい」場合に有用です。ただし制御フローが複雑になるため、;; / ;& / ;;& のどれを使っているかコメントで意図を必ず明記してください。

6. case文を関数に切り出すときはlocalで変数スコープを管理する

case文の各節が長くなってきたら、処理を関数に切り出すリファクタリングが有効です。このとき関数内の変数に local を付け忘れると、処理のたびにグローバル変数が書き換わり、次のアクション実行時に前回の残骸が残るバグが起きます。

# NG例:local なしでは dest がグローバルに漏れる do_backup_ng() { dest="/var/backup/$(date '+%Y%m%d')" # グローバルに残る mkdir -p "$dest" cp /etc/ssh/sshd_config "$dest/" } # OK例:local -r で関数スコープに閉じ込める do_backup_ok() { local -r dest="/var/backup/$(date '+%Y%m%d')" # 関数の外には出ない local file # ループ変数も local で mkdir -p "$dest" for file in /etc/ssh/sshd_config /etc/my.cnf; do [ -f "$file" ] || continue cp "$file" "$dest/" done } case "${ACTION}" in backup) do_backup_ok ;; cleanup) do_cleanup ;; esac

ルール:関数内で宣言するすべての変数に local を付けることを習慣にしてください。定数として扱う変数は local -r、ループカウンタやサイズ計算には local -i(整数型)を使うと誤代入によるバグをさらに防げます。「必要な変数だけlocal」という中途半端なルールは保守が困難になります。

set -euo pipefail 環境での注意:local とコマンド置換の1行合成は set -e を無効化する

set -euo pipefail を有効にした状態で local 宣言とコマンド置換を同じ行に書くと、set -e が効かなくなります。local 宣言文自体が終了ステータス 0 を返すため、コマンド置換が失敗してもスクリプトが停止しません。

set -euo pipefail # NG例:local と代入を1行に書くと set -e が効かない do_backup_ng() { local result=$(cat /tmp/nai_file.txt) # cat が失敗しても停止しない echo "${result}" } # OK例:宣言と代入を2行に分けると set -e が正しく機能する do_backup_ok() { local result result=$(cat /tmp/nai_file.txt) # cat が失敗 → set -e でスクリプト終了 echo "${result}" }

宣言と代入を2行に分けることで set -e が正しく機能し、コマンド置換の失敗がスクリプトを確実に停止させます。set -euo pipefail を使う case文スクリプトで関数内のコマンド置換を使う場面では、必ずこの2行分離パターンを使ってください。

7. OS判定が「unknown」になる場合の対処

detect_os 関数が OS_FAMILY=unknown を返す場合、ID の実際の値が case のパターンに一致していません。まず実際の値を確認してください。

$ . /etc/os-release && echo "ID=${ID} ID_LIKE=${ID_LIKE}" ID=rocky ID_LIKE=rhel centos fedora

ID=rocky の場合、case文に rocky) のパターンが必要です。ID_LIKE が rhel centos fedora のようにスペース区切りで複数値が入っている場合は *rhel* のグロブで捕捉できます。

なお、grep で値を取り出す方法(grep "^ID=" /etc/os-release)を使うと、ディストリビューションによってはクォート文字が混入します(ID="rhel" と ID=rhel の両形式が存在するため)。source と変数参照の組み合わせで取り出すのが確実です。grep で取り出す場合は grep "^ID=" /etc/os-release | tr -d '"' でクォートを除去してください。

8. コンテナ内でのOS判定に関する注意

コンテナの /etc/os-release はコンテナイメージのOSを示します。ホストのOSとは無関係で、これは正しい動作です。「ホストのOS」を知りたい場合はコンテナ内から知る方法がないため、環境変数でホストOS情報を注入する設計が必要になります。

# ホストOSを環境変数で渡す例(docker run 時) $ docker run -e HOST_OS=rhel myimage bash setup.sh # スクリプト内での参照(os-release より HOST_OS を優先する) OS_ID="${HOST_OS:-$(. /etc/os-release 2>/dev/null && echo "${ID:-unknown}")}"

また、Alpine Linux コンテナや Debian コンテナでは systemctl が使えない場合があります。サービス管理コマンドは command -v systemctl >/dev/null 2>&1 で存在確認してから呼び出す設計が安全です。

本記事のまとめ

ユースケース case文のパターン例 ポイント
コマンド引数解析 case "${1:-}" in start|stop|restart) ${1:-} で set -u 対応
オプション解析(while組み合わせ) case "$1" in -v|--verbose) shift ;; ロングオプション対応可
OS種別切り分け(uname) case "$(uname -s)" in Linux|Darwin) /etc/os-release との併用も可
ディストリビューション判定 case "${OS_ID}" in rhel|rocky|almalinux) /etc/os-releaseのIDを取得してcase文に渡す
互換ディストリ(ID_LIKE)判定 case "${id_like}" in *rhel*|*centos*) ID_LIKEのグロブ(*rhel*)でファミリー判定
OS判定の再利用 . detect_os.sh && detect_os OS_FAMILYとPKG_INSTALLを複数スクリプトで共有
ファイル拡張子別処理 case "$FILE" in *.tar.gz|*.tgz) OR マッチで大文字も対応
Yes/No 入力処理 case "$ANSWER" in yes|Yes|y|Y) 表記揺れをまとめて処理
大文字小文字を吸収してマッチ case "${INPUT,,}" in yes|y) ${var,,} で小文字化してからマッチ
パターン継続マッチ case "$LOG" in *error*) echo; ;;& ;;& でマッチ後も次を試す
連想配列の宣言・初期化 declare -A arr=([key1]="val1" [key2]="val2") declare -A は bash 4.0以降が必要
キーの存在確認 [[ -v arr[key] ]] ${arr[key]:-} より厳密にキーの有無をテストできる
キー一覧・値一覧の取得 ${!arr[@]} / ${arr[@]} ループで for key in "${!arr[@]}" と使う
キーの削除 unset 'arr[key]' シングルクォートでグロブ展開を防ぐ
設定値の定数化 declare -rA config=([host]="...") 誤上書きをスクリプト実行時に検知できる
配列のデバッグ表示 declare -p arr 全キー・全値を再現可能な形式で一覧表示
サブコマンドが10個以上 declare -A dispatch=(["start"]="cmd_start") 連想配列ディスパッチテーブルへ移行を検討
case文を関数に切り出す do_backup() { local -r dest="..."; } local/-r/-i でスコープと型を明示する
関数内コマンド置換(set -e 環境) local result; result=$(command) 宣言と代入を2行に分けて set -e を有効にする
引数形式の事前検証 [[ "$ARG" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]] =~演算子で形式チェックしてからcase文に渡す
case文とif[[ =~ ]]の使い分け case "$1" in start|stop) / [[ "$v" =~ ^[0-9]+$ ]] 固定値分岐=case文、形式バリデーション=正規表現
case文設計の鉄則:
・変数は必ず "$var" とクォートする(スペース・グロブによる誤マッチを防ぐ)
・*)(デフォルト節)は必ず末尾に配置し、想定外入力を exit 1 で終了させる
・${1:-} で set -u 環境でも安全に引数を受け取る
・;; の書き忘れによるフォールスルーに注意する
・意図的なフォールスルーは ;&、パターン継続は ;;& を使いコメントで理由を明記する
・${var,,} との組み合わせでユーザー入力の大文字小文字の表記揺れを吸収できる
・OS判定は /etc/os-release の ID を基本とし、ID_LIKE グロブで互換ディストリを補足する
・OS判定ロジックは detect_os.sh に集約して複数スクリプトから source で再利用する
・サブコマンドが10個を超えたら連想配列ディスパッチテーブルへの移行を検討する
・declare -A の基本(宣言・参照・ループ・削除)を押さえた上でディスパッチテーブルを設計する
・declare -rA で設定定数を保護し、declare -p で配列の現在の状態をデバッグする
・連想配列のキー削除は unset 'arr[key]'(シングルクォート)でグロブ展開を防ぐ
・set -euo pipefail 環境では local とコマンド置換を同じ行に書かず、宣言と代入を2行に分ける
・case文を関数に切り出すときは local / local -r / declare -r で変数スコープを明確にする

case文を使いこなすと「1変数 × 複数パターン」の分岐をすっきり設計できます。if-elif が5行以上連続したら case文への書き換えを検討してください。コードの可読性と保守性が大幅に向上します。
現場で通用する安全なLinuxサーバー構築の「型」を体系的に身につけたい方へ、20年以上の運用経験を持つ現役エンジニアが基礎から教えます。
シェルスクリプト講座を見る >>

<PR>手元に置いて学びを深める1冊

入門bash 第3版

まず無料メルマガで case 文の実践パターンをつかんでから、手元の参考書として本書を置いておくと、bash の構文を体系的に振り返れます。

無料メルマガで学習を続ける

Linuxの実践スキルをメールで毎週お届け。
登録は30秒、解除もいつでも可。

登録無料・いつでも解除できます

暗記不要・1時間後にはサーバーが動く

3,100名以上が実践した「型」を無料で公開中

プロのエンジニアはコマンドを暗記していません。
「現場で使える型」を効率よく使いこなしているだけです。
その「型」を図解60Pにまとめた入門マニュアルを、完全無料でプレゼントしています。

姓・名・メールの3つだけ/30秒/解除は3秒 / 詳細はこちら

Linux無料マニュアル(図解60P) 名前とメールで30秒登録

運営

リナックスマスター.JP(代表 宮崎智広)

株式会社イーネットマーキュリーが運営するLinux教育サイトです。代表の宮崎智広は現役のLinuxサーバー管理者として20年以上の実務経験を持ち、累計3,100名以上のエンジニアを指導してきました。実践型のLinuxセミナーや無料マニュアルの配布を通じてLinux人材の育成に取り組んでいます。