「条件が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 ライブラリとして複数スクリプトから再利用する設計パターンも取り上げます。さらに、サブコマンドが増えてきたときの代替設計として「連想配列ディスパッチテーブル」パターン、case文を関数に切り出すときの変数スコープ設計、そして set -euo pipefail 環境で local とコマンド置換を組み合わせるときの注意点も取り上げます。この記事のポイント
・case文は「1変数 vs 複数の固定値」の分岐でif-elifより圧倒的に読みやすい
・コマンド引数解析・OS種別判定・拡張子別処理の3パターンが頻出ユースケース
・ID_LIKEでRocky Linux等の互換ディストリも正確に判定できる
・OS判定をdetect_os.sh関数に集約すると複数スクリプトから再利用できる
・*)は必ず末尾に置き、想定外入力を exit 1 でエラー終了させる設計が堅牢
・set -euo pipefail 環境では local 宣言とコマンド置換を2行に分けて set -e を有効にする
でも安心してください。プロのエンジニアはコマンドを暗記していません。
「現場で使える型」を効率よく使いこなしているだけです。
なぜ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
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
[ "$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以降なので実務では問題なく使えます。コマンド引数解析に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
# 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つ消化します。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}"
$ 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/"
$ 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
・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}"
$ bash detect-family.sh OS系統: rhel インストールコマンド: dnf install -y
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 [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 "完了しました"
$ 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
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
# バックアップ(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]
・
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個を超えて今後も増える見込みがある場合は「連想配列ディスパッチテーブル」設計が保守性で上回ります。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
$ ./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"
・サブコマンドが5個前後・今後増える予定がない → case文(シンプルで読みやすい)
・サブコマンドが10個以上・今後も追加が見込まれる → ディスパッチテーブル(拡張コストが低い)
・処理が複雑で関数に切り出したい → ディスパッチテーブル(関数との相性がよい)
ディスパッチテーブルには
declare -A(bash 4.0以降)が必要です。macOS のデフォルト bash(3.2)では動作しない点に注意してください。トラブルシュート|よくある落とし穴と対処法
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}" }
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}")}"
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; ;;& |
;;& でマッチ後も次を試す |
| サブコマンドが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 を有効にする |
・変数は必ず
"$var" とクォートする(スペース・グロブによる誤マッチを防ぐ)・
*)(デフォルト節)は必ず末尾に配置し、想定外入力を exit 1 で終了させる・
${1:-} で set -u 環境でも安全に引数を受け取る・
;; の書き忘れによるフォールスルーに注意する・意図的なフォールスルーは
;&、パターン継続は ;;& を使いコメントで理由を明記する・
${var,,} との組み合わせでユーザー入力の大文字小文字の表記揺れを吸収できる・OS判定は
/etc/os-release の ID を基本とし、ID_LIKE グロブで互換ディストリを補足する・OS判定ロジックは
detect_os.sh に集約して複数スクリプトから source で再利用する・サブコマンドが10個を超えたら連想配列ディスパッチテーブルへの移行を検討する
・
set -euo pipefail 環境では local とコマンド置換を同じ行に書かず、宣言と代入を2行に分ける・case文を関数に切り出すときは
local / local -r / declare -r で変数スコープを明確にするcase文を使いこなすと「1変数 × 複数パターン」の分岐をすっきり設計できます。if-elif が5行以上連続したら case文への書き換えを検討してください。コードの可読性と保守性が大幅に向上します。
シェルスクリプト講座を見る >>
<PR>手元に置いて学びを深める1冊
まず無料メルマガで case 文の実践パターンをつかんでから、手元の参考書として本書を置いておくと、bash の構文を体系的に振り返れます。
3,100名以上が実践した「型」を無料で公開中
プロのエンジニアはコマンドを暗記していません。
「現場で使える型」を効率よく使いこなしているだけです。
その「型」を図解60Pにまとめた入門マニュアルを、完全無料でプレゼントしています。
姓・名・メールの3つだけ/30秒/解除は3秒 / 詳細はこちら
- 次のページへ:シェルスクリプトの冪等設計|何度実行しても安全な初期設定スクリプトをステートファイルと条件分岐で実現する方法
- 前のページへ:シェルスクリプトの起動前チェック設計|依存コマンド・環境変数・ディレクトリを自動検証する実装パターン
- この記事の属するカテゴリ:シェルスクリプトへ戻る

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