こういった要求が重なったとき、設定をスクリプト内にベタ書きしていると、毎回ファイルを編集するしかありません。環境変数だけに頼ると、設定ファイルで管理したい固定値との折り合いがつかなくなります。
この記事では、シェルスクリプトで設定値を「CLIオプション → 環境変数 → 設定ファイル → デフォルト値」の3層で優先管理する設計パターンを、実装コード付きで解説します。Docker・git・ansible-playbookが採用しているこの優先ルールを自作スクリプトに取り込めば、スクリプト本体を修正しなくても環境ごとの切り替えが柔軟にできます。
この記事のポイント
・CLIオプション > 環境変数 > 設定ファイル > デフォルト値 の順で解決する
・getoptsで受けた値は専用変数に保持し、後段の層を上書きできる仕組みにする
・${VAR:-default}演算子で環境変数の存在確認とデフォルト代入を1行で書ける
・設定ファイルはsource前にパーミッション確認を入れるのが安全設計の基本
でも安心してください。プロのエンジニアはコマンドを暗記していません。
「現場で使える型」を効率よく使いこなしているだけです。
設定値の優先順位が崩れると現場でどんな問題が起きるか
設定値の管理を場当たり的に実装すると、よくある落とし穴にはまります。・ハードコードの罠:スクリプト内にサーバー名をベタ書きすると、本番と開発で別ファイルを管理することになり、diff管理が崩壊します
・環境変数だけの罠:設定値が多いと環境変数の設定漏れが起きやすく、「環境変数が空のままデフォルトで動いていた」問題が本番障害になります
・設定ファイルだけの罠:一時的に値を変えたいときにファイルを書き換えるのは危険です。cronや並列実行中に別プロセスが古い値でファイルを読む競合も起きます
優先順位が明確な設計があれば、これらの問題はすべて回避できます。使い手は「どの方法で渡すか」を選ぶだけです。
3層優先順位の設計全体像
解決する順序は次のとおりです。・第1層(最優先):CLIオプション(-s server や --server=server 形式)
・第2層:環境変数(MYAPP_SERVER=prod01 のように実行前にexport済みのもの)
・第3層:設定ファイル(~/.myapp.conf に server=prod01 と書いたもの)
・第4層(最低優先):スクリプト内のデフォルト値
実装の核は「上位層が空でなければ使い、空なら次の層へ落ちる」というロジックです。bashのパラメータ展開
${VAR:-fallback} を組み合わせると、このロジックを1行で表現できます。# 解決ロジックの骨格(イメージ) FINAL_VALUE="${CLI_VALUE:-${ENV_VALUE:-${FILE_VALUE:-DEFAULT_VALUE}}}"
${CLI_VALUE:-...} は「CLI_VALUE が空なら次の式を評価する」という意味です。空文字列 "" も「空」と判定されるため、各層で値が見つからなかったときは必ず次の層へ落ちます。第1層の実装(getoptsでCLIオプションを受け取る)
1. getoptsで短オプションを解析する
CLIオプションはgetopts コマンドで解析します。受け取った値を「CLI専用変数」に格納することが重要です。第2層以降の変数と分けておくことで、優先順位の適用が明確になります。#!/bin/bash set -euo pipefail usage() { echo "Usage: $0 [-s server] [-p port] [-u user] [-c config_file]" >&2 exit 1 } # CLI専用変数(空文字 = 未指定) CLI_SERVER="" CLI_PORT="" CLI_USER="" CLI_CONFIG="" OPTIND=1 while getopts "s:p:u:c:h" opt; do case "$opt" in s) CLI_SERVER="$OPTARG" ;; p) CLI_PORT="$OPTARG" ;; u) CLI_USER="$OPTARG" ;; c) CLI_CONFIG="$OPTARG" ;; h|*) usage ;; esac done shift $((OPTIND - 1))
OPTIND=1 を先頭でリセットするのは、スクリプトを source で読み込んだ場合に前回の解析状態が残る問題を防ぐためです。2. 長オプション(--server=value 形式)に対応する場合
bashのgetopts は長オプションに対応していないため、case 文で手動解析します。# 長オプションを手動解析する例(getoptsと併用しない) for arg in "$@"; do case "$arg" in --server=*) CLI_SERVER="${arg#--server=}" ;; --port=*) CLI_PORT="${arg#--port=}" ;; --user=*) CLI_USER="${arg#--user=}" ;; --config=*) CLI_CONFIG="${arg#--config=}" ;; esac done
${arg#--server=} はパラメータ展開のプレフィックス削除です。--server=prod01 から prod01 だけを取り出せます。第2層の実装(環境変数を参照する)
シェルスクリプト講座を見る >>
MYAPP_ のようなプレフィックス付き名前にするのが慣習です。汎用的な名前(SERVER や HOST)だと他ツールの変数と衝突する可能性があります。# 環境変数専用変数(ENV_プレフィックスで層を明示する) ENV_SERVER="${MYAPP_SERVER:-}" ENV_PORT="${MYAPP_PORT:-}" ENV_USER="${MYAPP_USER:-}"
${MYAPP_SERVER:-} は「MYAPP_SERVER が未定義または空なら空文字を代入する」という意味です。set -u 環境でも未定義変数によるエラーを起こさずに参照できます。第3層の実装(設定ファイルを安全に読み込む)
1. key=value形式のファイルを手動パースする(推奨)
source コマンドで設定ファイルを読み込む方法は手軽ですが、ファイル内に任意のbashコードが書けてしまいます。key=value形式を while readループでパースする方が安全です。# ~/.myapp.conf の例 server=prod01.example.com port=5432 user=appuser # コメント行は # で始める
load_config() { local config_file="${1:-}" FILE_SERVER="" FILE_PORT="" FILE_USER="" [[ -z "$config_file" || ! -f "$config_file" ]] && return 0 # others への書き込み権限があれば拒否(改ざんリスク) local perm perm="$(stat -c '%a' "$config_file")" if [[ "${perm: -1}" != "0" && "${perm: -1}" != "4" ]]; then echo "[WARN] $config_file にothers書き込み権限があります。スキップします。" >&2 return 0 fi while IFS='=' read -r key value; do # コメント行と空行をスキップ [[ "$key" =~ ^[[:space:]]*(#|$) ]] && continue key="${key// /}" # キーの前後スペース除去 value="${value%% *}" # 値のインラインコメント(# 以降)を除去 case "$key" in server) FILE_SERVER="$value" ;; port) FILE_PORT="$value" ;; user) FILE_USER="$value" ;; esac done < "$config_file" }
2. 設定ファイルのデフォルトパスを決める
設定ファイルのパスは「CLIで指定 → XDG_CONFIG_HOME → HOME直下」の順で探すのが一般的です。resolve_config_path() { # CLIで明示指定されていればそちらを優先 [[ -n "$CLI_CONFIG" ]] && { echo "$CLI_CONFIG"; return; } # XDG標準パス → HOME直下の順で探す local xdg="${XDG_CONFIG_HOME:-$HOME/.config}" [[ -f "$xdg/myapp/config" ]] && { echo "$xdg/myapp/config"; return; } [[ -f "$HOME/.myapp.conf" ]] && { echo "$HOME/.myapp.conf"; return; } echo "" # 見つからなければ空文字を返す(設定ファイルなし) }
XDG_CONFIG_HOME はLinuxの標準的な設定ディレクトリ変数です。未設定なら ~/.config にフォールバックします。3層を組み合わせた完成形スクリプト
1. 完成形のコード
#!/bin/bash # myapp.sh — 3層優先順位設定管理の実装例 # 使用法: ./myapp.sh [-s server] [-p port] [-u user] [-c config] set -euo pipefail usage() { echo "Usage: $0 [-s server] [-p port] [-u user] [-c config]" >&2 exit 1 } # ---- 第1層: CLIオプション解析 ---- CLI_SERVER="" CLI_PORT="" CLI_USER="" CLI_CONFIG="" OPTIND=1 while getopts "s:p:u:c:h" opt; do case "$opt" in s) CLI_SERVER="$OPTARG" ;; p) CLI_PORT="$OPTARG" ;; u) CLI_USER="$OPTARG" ;; c) CLI_CONFIG="$OPTARG" ;; h|*) usage ;; esac done shift $((OPTIND - 1)) # ---- 第3層: 設定ファイル読み込み ---- load_config() { local config_file="${1:-}" FILE_SERVER="" FILE_PORT="" FILE_USER="" [[ -z "$config_file" || ! -f "$config_file" ]] && return 0 local perm; perm="$(stat -c '%a' "$config_file")" if [[ "${perm: -1}" != "0" && "${perm: -1}" != "4" ]]; then echo "[WARN] $config_file: others書き込み可。スキップ。" >&2; return 0 fi while IFS='=' read -r key value; do [[ "$key" =~ ^[[:space:]]*(#|$) ]] && continue key="${key// /}"; value="${value%% *}" case "$key" in server) FILE_SERVER="$value" ;; port) FILE_PORT="$value" ;; user) FILE_USER="$value" ;; esac done < "$config_file" } resolve_config_path() { [[ -n "$CLI_CONFIG" ]] && { echo "$CLI_CONFIG"; return; } local xdg="${XDG_CONFIG_HOME:-$HOME/.config}" [[ -f "$xdg/myapp/config" ]] && { echo "$xdg/myapp/config"; return; } [[ -f "$HOME/.myapp.conf" ]] && { echo "$HOME/.myapp.conf"; return; } echo "" } # ---- 第2層: 環境変数 ---- ENV_SERVER="${MYAPP_SERVER:-}" ENV_PORT="${MYAPP_PORT:-}" ENV_USER="${MYAPP_USER:-}" # ---- 設定ファイルを読む ---- config_path="$(resolve_config_path)" load_config "$config_path" # ---- 優先順位を適用して最終値を決定 ---- SERVER="${CLI_SERVER:-${ENV_SERVER:-${FILE_SERVER:-localhost}}}" PORT="${CLI_PORT:-${ENV_PORT:-${FILE_PORT:-5432}}}" USER="${CLI_USER:-${ENV_USER:-${FILE_USER:-$(whoami)}}}" # ---- デバッグ出力(DEBUG_CONFIG=1 で有効化) ---- if [[ "${DEBUG_CONFIG:-}" == "1" ]]; then echo "[DEBUG] CLI: SERVER='${CLI_SERVER}' PORT='${CLI_PORT}' USER='${CLI_USER}'" echo "[DEBUG] ENV: SERVER='${ENV_SERVER}' PORT='${ENV_PORT}' USER='${ENV_USER}'" echo "[DEBUG] FILE: SERVER='${FILE_SERVER}' PORT='${FILE_PORT}' USER='${FILE_USER}'" echo "[DEBUG] FINAL: SERVER='${SERVER}' PORT='${PORT}' USER='${USER}'" fi # ---- メイン処理 ---- echo "接続先: ${SERVER}:${PORT} (ユーザー: ${USER})"
2. 動作確認の実行例
RHEL 9.4 / Ubuntu 24.04 LTS で動作確認済みです。設定ファイルを作成し、各層が正しく上書きされることを確認します。# 設定ファイルを作成する $ cat ~/.myapp.conf server=db01.example.com port=5432 user=dbadmin # 第3層(設定ファイル)のみ使われるケース $ bash myapp.sh 接続先: db01.example.com:5432 (ユーザー: dbadmin) # 第2層(環境変数)で上書きするケース $ MYAPP_SERVER=db02.example.com bash myapp.sh 接続先: db02.example.com:5432 (ユーザー: dbadmin) # 第1層(CLIオプション)で全上書きするケース $ MYAPP_SERVER=db02.example.com bash myapp.sh -s db03.example.com -p 5433 接続先: db03.example.com:5433 (ユーザー: dbadmin) # 設定ファイルも環境変数もなし → デフォルト値が使われる $ bash myapp.sh 接続先: localhost:5432 (ユーザー: tomohiro) # デバッグモードで各層の値を確認する $ DEBUG_CONFIG=1 bash myapp.sh -s db03.example.com [DEBUG] CLI: SERVER='db03.example.com' PORT='' USER='' [DEBUG] ENV: SERVER='' PORT='' USER='' [DEBUG] FILE: SERVER='db01.example.com' PORT='5432' USER='dbadmin' [DEBUG] FINAL: SERVER='db03.example.com' PORT='5432' USER='dbadmin' 接続先: db03.example.com:5432 (ユーザー: dbadmin)
よくあるエラーとトラブルシュート
落とし穴1:空文字列と「未指定」を区別していない
CLI_SERVER="" は「未指定」を意味しますが、ユーザーが -s "" と明示的に空文字を渡した場合も同じになります。「空文字で接続先を消したい」という操作ができません。この場合はsentinel値(__UNSET__ など)を使うか、「空文字を渡すことは許可しない」と仕様で明示しましょう。落とし穴2:設定ファイルに引用符を書いてしまう
while IFS='=' read で読む場合、設定ファイルの server="db01.example.com" は引用符ごと取り込まれます。FILE_SERVER に "db01.example.com"(ダブルクォート付き)が入ってしまい、接続に失敗します。設定ファイルには引用符なしで記述するよう、コメントで明示しておきましょう。落とし穴3:set -eが設定ファイルの不在で止まる
set -eo pipefail の下では、コマンドが0以外で終了すると即停止します。load_config 内で [[ -f "$file" ]] || return 0 のように、ファイル不在は「正常終了」として扱うことを明示しないと、設定ファイルが存在しない初回実行でスクリプトが異常終了します。落とし穴4:export変数が子プロセスに漏れる
スクリプト内でexport SERVER=... と書くと、そのスクリプトから呼び出した子プロセスにも SERVER が引き継がれます。子プロセスが意図せず SERVER を参照してしまうと、予期しない動作を招きます。上位層の変数名に CLI_ENV_FILE_ プレフィックスを付けて、最終値だけをエクスポートする設計にすると安全です。本記事のまとめ
| 層 | 設定方法 | 主な用途 |
|---|---|---|
| 第1層(最優先) | ./myapp.sh -s server のようにオプション付き実行 |
テスト時・一時的な上書き |
| 第2層 | MYAPP_SERVER=xxx 環境変数 |
CI/CD・Dockerの実行環境制御 |
| 第3層 | ~/.myapp.conf 設定ファイル |
個人・環境ごとのベース設定 |
| 第4層(最低優先) | スクリプト内のデフォルト値 | 初期インストール・デモ環境 |
| 優先ロジック | ${A:-${B:-${C:-default}}} |
上位が空なら次の層に落ちる |
| 安全な読み込み | while IFS='=' readで手動パース | sourceより安全(任意コード実行を防ぐ) |
シェルスクリプト講座を見る >>
3,100名以上が実践した「型」を無料で公開中
プロのエンジニアはコマンドを暗記していません。
「現場で使える型」を効率よく使いこなしているだけです。
その「型」を図解60Pにまとめた入門マニュアルを、完全無料でプレゼントしています。
姓・名・メールの3つだけ/30秒/解除は3秒 / 詳細はこちら
- 前のページへ:シェルスクリプトの関数を子プロセスへ渡す設計|export -fで別プロセス側から自作処理を使えるようにする
- この記事の属するカテゴリ:シェルスクリプトへ戻る

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