初心者のためのBashシェルスクリプト入門:本番環境の混乱を自動化で乗り越えろ

Linux tutorial - IT technology blog
Linux tutorial - IT technology blog

午前2時、サーバーが炎上中

スマホにアラートが届く。本番サーバーのディスク使用量が94%に達している。眠い目をこすりながらSSH接続し、手作業でディレクトリを確認し、古いログファイルを一つずつ削除し、サービスを再起動する。1時間後にようやく解決――でも、来週また同じことが起きると分かっている。

その夜、自分に誓った。これを自動で処理するスクリプトを書こうと。怠けたいからじゃない。午前2時に人間はミスをするが、コンピューターはしないからだ。その瞬間、bashスクリプトは「いつか学ぶべきもの」から「今すぐ必要なもの」に変わった。

これが、あの頃あれば良かったと思うガイドだ。

基礎概念:bashスクリプトとは何か

bashスクリプトとは、シェルコマンドの一覧を上から下に実行するプレーンテキストファイルだ。それだけ。ターミナルで手入力するのと同じコマンドを、ファイルに保存して必要なときに実行するだけのことだ。

最初のスクリプト

hello.shというファイルを作成しよう:

#!/bin/bash
# これはコメントです
echo "サーバーは稼働中です: $(date)"
echo "稼働時間: $(uptime -p)"

最初の行#!/bin/bashはシェバンと呼ばれる。OSにどのインタープリターを使うかを伝える行だ――これがないと、システムがshや別のシェルでbashスクリプトを実行しようとして、思わぬ不具合が起きることがある。実行権限を付けて実行しよう:

chmod +x hello.sh
./hello.sh

変数

変数はスクリプト全体で再利用する値を格納する。鉄則が一つある:=の周囲にスペースを入れてはいけない。NAME="prod"は動く。NAME = "prod"はエラーになる。bashがNAMEをコマンドとして解釈し、実行しようとするからだ。

#!/bin/bash
SERVER_NAME="prod-web-01"
DISK_THRESHOLD=90

echo "サーバーを確認中: $SERVER_NAME"
echo "ディスク使用量がこれを超えたら警告: ${DISK_THRESHOLD}%"

変数の直後に別の文字が続く場合は${VAR}を使おう。$DISK_THRESHOLD_MBは黙って失敗する――bashが文字通りDISK_THRESHOLD_MBという名前の変数を探すからだ。${DISK_THRESHOLD}_MBと書けば、意図した通りになる。

コマンド置換

$()を使ってコマンドの出力を変数に直接格納しよう:

CURRENT_DISK=$(df / | awk 'NR==2 {print $5}' | tr -d '%')
echo "現在のディスク使用量: $CURRENT_DISK%"

条件分岐

条件分岐により、スクリプトは単純なコマンドの再生から、システムの実際の状態に応答できるものに変わる。値の比較、ファイルの存在確認、ロジックの分岐ができるようになる:

#!/bin/bash
DISK_USAGE=$(df / | awk 'NR==2 {print $5}' | tr -d '%')
THRESHOLD=85

if [ "$DISK_USAGE" -gt "$THRESHOLD" ]; then
    echo "警告: ディスク使用量 ${DISK_USAGE}% — ログをクリーニング中"
    find /var/log -name "*.log" -mtime +7 -delete
else
    echo "ディスク正常: ${DISK_USAGE}%"
fi

[ ]内のスペースは必須だ――省略できない。["$DISK_USAGE" -gt "$THRESHOLD"]と書くと、bashは謎の「command not found」エラーを出す。シェルはそのスペースを必要としており、括弧をファイル名ではなくテストコマンドとして解析するためだ。

ループ

ループはリストに対して、あるいは条件が満たされている間、アクションを繰り返す:

# forループ — 項目を反復処理
for SERVICE in nginx mysql redis; do
    systemctl is-active --quiet $SERVICE && echo "$SERVICE: 稼働中" || echo "$SERVICE: 停止中"
done

# whileループ — 条件が偽になるまで実行
COUNT=0
while [ $COUNT -lt 5 ]; do
    echo "試行 $COUNT 回目"
    COUNT=$((COUNT + 1))
done

関数

関数がなければ、10サービスのヘルスチェックは60行のコピペコードになる。関数を使えば20行で済む。一度定義すれば、必要な場所からいつでも呼び出せる:

#!/bin/bash

check_service() {
    local SERVICE_NAME=$1
    if systemctl is-active --quiet "$SERVICE_NAME"; then
        echo "[OK] $SERVICE_NAME は稼働中"
    else
        echo "[FAIL] $SERVICE_NAME が停止中 — 再起動を試みます"
        systemctl restart "$SERVICE_NAME"
    fi
}

check_service nginx
check_service mysql
check_service redis

localキーワードは変数のスコープを関数内に限定する。これがないと、SERVICE_NAMEがグローバルスコープに漏れ出し、他の変数を黙って上書きする可能性がある――テスト中ではなく、まさに午前2時に表面化するタイプのバグだ。

実践練習:実際の監視スクリプトを作る

これは、4GB RAMを搭載した本番のUbuntu 22.04サーバーでcronを使って毎晩実行しているスクリプトだ。このスクリプトができる前は、ディスク問題の調査に手作業で20分かかっていた。今は自動でログが記録され、真夜中ではなく朝に問題が分かる。

ヘルスチェックスクリプト

#!/bin/bash
# server-health.sh — 本番ヘルスチェック
# 使い方: ./server-health.sh [--notify]

set -euo pipefail

# 設定
HOSTNAME=$(hostname)
ALERT_EMAIL="[email protected]"
DISK_WARN=85
RAM_WARN=90
LOG_FILE="/var/log/health-check.log"

# ログ出力ヘルパー
log() {
    echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_FILE"
}

# ディスク使用量を確認
check_disk() {
    local USAGE
    USAGE=$(df / | awk 'NR==2 {print $5}' | tr -d '%')
    if [ "$USAGE" -gt "$DISK_WARN" ]; then
        log "警告: ディスク使用量 ${USAGE}%"
        return 1
    fi
    log "正常: ディスク使用量 ${USAGE}%"
    return 0
}

# RAM使用量を確認
check_ram() {
    local TOTAL FREE USAGE
    TOTAL=$(free | awk '/Mem:/ {print $2}')
    FREE=$(free | awk '/Mem:/ {print $4}')
    USAGE=$(( (TOTAL - FREE) * 100 / TOTAL ))
    if [ "$USAGE" -gt "$RAM_WARN" ]; then
        log "警告: RAM使用量 ${USAGE}%"
        return 1
    fi
    log "正常: RAM使用量 ${USAGE}%"
    return 0
}

# 重要サービスを確認
check_services() {
    local FAILED=0
    for SVC in nginx mysql; do
        if ! systemctl is-active --quiet "$SVC"; then
            log "緊急: $SVC が稼働していません"
            FAILED=$((FAILED + 1))
        else
            log "正常: $SVC 稼働中"
        fi
    done
    return $FAILED
}

# 全チェックを実行
log "=== $HOSTNAME のヘルスチェック ==="
ALL_OK=true

check_disk || ALL_OK=false
check_ram || ALL_OK=false
check_services || ALL_OK=false

if [ "$ALL_OK" = false ]; then
    log "ヘルスチェック失敗 — ログを確認してください"
    exit 1
fi

log "全チェック完了"

cronで自動実行する

スクリプトを保存し、実行権限を付けてからスケジュールに登録しよう:

chmod +x /opt/scripts/server-health.sh

# crontabエディタを開く
crontab -e

# 30分ごとに実行
*/30 * * * * /opt/scripts/server-health.sh >> /var/log/health-check.log 2>&1

問題発生時のデバッグ

スクリプトは失敗する。その原因を突き止める方法を紹介しよう:

# 詳細出力で実行 — 各コマンドの実行前に表示される
bash -x ./server-health.sh

# 直前のコマンドの終了コードを確認
echo $?

# 厳格なエラー処理のためスクリプトの先頭に追加:
set -euo pipefail
# -e: エラー時に終了
# -u: 未定義変数でエラー
# -o pipefail: パイプ内のエラーをキャッチ

set -euo pipefailはすべての本番スクリプトの先頭に書くべきだ。これがないと、スクリプトが途中で黙って失敗し、原因を説明するログエントリも残らないまま、システムが中途半端な壊れた状態になりかねない。

引数の受け取り

ハードコードされたパスはスクリプトを脆くする。位置パラメーターを使えば実行時に値を渡せ、同じスクリプトを異なるディレクトリや環境で再利用できる:

#!/bin/bash
# 使い方: ./backup.sh /var/www/html /backups

SOURCE_DIR=$1
DEST_DIR=$2
TIMESTAMP=$(date +%Y%m%d_%H%M%S)

if [ $# -ne 2 ]; then
    echo "使い方: $0 <コピー元> <コピー先>"
    exit 1
fi

tar -czf "${DEST_DIR}/backup_${TIMESTAMP}.tar.gz" "$SOURCE_DIR"
echo "バックアップ完了: ${DEST_DIR}/backup_${TIMESTAMP}.tar.gz"

$1$2は第1引数と第2引数だ。$#は引数の総数を返す。$0はスクリプト名そのもの――誰かが間違った呼び出し方をした場合に、正確なusageメッセージを表示するのに便利だ。

手作業をやめよう

bashスクリプトは頭の良さを誇示するためのものではない――同じことを二度しないためのものだ。手作業でこなしている繰り返し作業はすべて、書かれるのを待っているスクリプトだ。

小さく始めよう。毎週手作業でやっていることを一つ選んで自動化する。ディスク容量の確認、ログのローテーション、ディレクトリのバックアップ――何でも構わない。スクリプトを書き、本番以外のマシンでテストし、cronでスケジュールに登録する。

目標は完璧なコードではない。次にディスクが午前2時に満杯になったとき、スマホが鳴る前にスクリプトがすでに対処している――それが目標だ。

Share: