そんな経験はないでしょうか。スクリプトを手動実行するとターミナルに出力が流れてくるので問題箇所をすぐ把握できますが、cron経由の自動実行では出力が残らず、どこで失敗したのかまったくわからないまま終わります。
「ログが残っていれば5分で解決できた障害調査が、ログがないせいで1時間かかった」——そんな経験を持つエンジニアは少なくありません。
この記事では、シェルスクリプトにINFO・WARN・ERRORのログレベル関数を組み込み、タイムスタンプとPIDを付けてファイルとターミナルに二重出力する設計を解説します。コピーしてすぐ使えるログ関数ライブラリから、古いログをfindで自動削除するローテーション設計まで、実務で使える実装パターンを紹介します。
実行環境:RHEL 9.4 / Ubuntu 24.04 LTS、bash 5.2(どちらでも同じ動作です)
この記事のポイント
・ INFOERRORのログレベル関数はbash組み込み機能だけで実装できる
・ exec 2>(teeでファイルとターミナルに同時出力できる
・ LOG_LEVEL環境変数1つでDEBUGと本番の出力を切り替えられる
・ findの-mtime +30で古いログを自動削除してディスクを守れる
でも安心してください。プロのエンジニアはコマンドを暗記していません。
「現場で使える型」を効率よく使いこなしているだけです。
なぜシェルスクリプトにログ設計が必要なのか
シェルスクリプトのログ設計を疎かにすると、3つの問題が発生します。・障害調査に時間がかかる:cronで動くスクリプトが失敗してもどこで失敗したか不明
・断続的な失敗を見逃す:毎日成功している日もあれば失敗している日もあるが、誰も気づかない
・作業証跡が残らない:本番環境での変更・削除が後追いできない
単純に
echo で出力するだけでは「いつ・どの処理で・何が起きたか」が記録されません。ログレベルを付けることで、grep '\[ERROR\]' /var/log/myapp/*.log だけで失敗箇所を素早く絞り込めるようになります。また、後述の
set -euo pipefail と組み合わせると、エラー発生時に適切なログを出力してからスクリプトを停止する設計が実現できます。ログレベル関数の実装(INFO・WARN・ERROR)
1. 基本のlog関数を作る
まずログレベルと日時を付けて標準エラー(stderr)に出力するシンプルな関数を実装します。#!/usr/bin/env bash # ---------- ログ関数ライブラリ ---------- LOG_LEVEL="${LOG_LEVEL:-INFO}" # DEBUG / INFO / WARN / ERROR SCRIPT_NAME="$(basename "$0")" PID="$$" _log_level_num() { case "$1" in DEBUG) echo 0 ;; INFO) echo 1 ;; WARN) echo 2 ;; ERROR) echo 3 ;; *) echo 1 ;; esac } log() { local level="$1"; shift local message="$*" local ts ts="$(date '+%Y-%m-%d %H:%M:%S')" if [ "$(_log_level_num "$level")" -lt "$(_log_level_num "$LOG_LEVEL")" ]; then return 0 fi echo "[${ts}] [${level}] [${SCRIPT_NAME}:${PID}] ${message}" >&2 } log_debug() { log DEBUG "$@"; } log_info() { log INFO "$@"; } log_warn() { log WARN "$@"; } log_error() { log ERROR "$@"; } # ----------------------------------------
$ LOG_LEVEL=DEBUG ./backup.sh [2026-08-19 14:23:45] [DEBUG] [backup.sh:12345] バックアップ設定を読み込みました [2026-08-19 14:23:45] [INFO] [backup.sh:12345] バックアップ開始 [2026-08-19 14:23:46] [INFO] [backup.sh:12345] rsync完了 [2026-08-19 14:23:46] [WARN] [backup.sh:12345] 転送ファイルが0件(空ディレクトリの可能性) [2026-08-19 14:23:46] [INFO] [backup.sh:12345] バックアップ正常終了
2. 標準エラー(stderr)へ出力する理由
ログ出力先を標準出力(stdout)ではなく標準エラー(stderr)にするのは、スクリプト本来の「結果」と「ログ」を分離するためです。たとえばファイルの行数を数えて後段のスクリプトに渡す処理を書く場合、
wc -l の結果だけをstdoutに出し、「処理開始」「処理完了」といったログはstderrに書きます。こうすることで、パイプや変数代入でstdoutだけを受け取れます。# ファイル行数を数えて返す関数(結果はstdout、ログはstderr) count_lines() { local file="$1" log_info "行数カウント開始: ${file}" # stderr へ wc -l < "${file}" # stdout へ(数値だけ) } # 呼び出し側ではstdoutだけを変数に受け取る line_count="$(count_lines /var/log/messages)" log_info "行数: ${line_count}"
3. DEBUGレベルとVERBOSEモードを追加する
開発・検証時はDEBUGログを出して処理の詳細を追い、本番はINFO以上だけ残す——という切り替えを環境変数1つで制御できると便利です。先ほどの関数はすでにこの仕組みを内蔵しています。# 開発時: DEBUGを含む全ログを出力 $ LOG_LEVEL=DEBUG ./backup.sh # 本番時: INFO以上のみ出力(デフォルト) $ ./backup.sh # 警告・エラーだけを見たい場合 $ LOG_LEVEL=WARN ./backup.sh # スクリプト内での使い方 log_debug "変数の値: SRC=${SRC}, DEST=${DEST}" # DEBUGなので本番では無視 log_info "バックアップ開始" # INFOなので通常は出力 log_warn "転送ファイルが0件" # WARNは必ず出力 log_error "rsync失敗 (exit: $?)" # ERRORは必ず出力
ログファイルへの二重出力設計
1. teeコマンドで標準エラーとファイルに同時出力
スクリプト冒頭でセットアップ関数を1回呼ぶだけで、以降のstderr出力がターミナルと指定ファイルの両方に書き込まれます。#!/usr/bin/env bash LOG_DIR="/var/log/backup" LOG_FILE="${LOG_DIR}/backup_$(date +%Y%m%d).log" setup_log() { mkdir -p "${LOG_DIR}" # 以降のstderr出力をファイルとターミナルに二重出力 exec 2> >(tee -a "${LOG_FILE}" >&2) log_info "ログファイル: ${LOG_FILE}" }
exec 2> >(tee ...) の構文は bash専用 です。#!/bin/sh のスクリプトには使えません。シバンを #!/usr/bin/env bash にしてから設定してください。実際のサーバーで実行した場合のログファイル確認例を示します。
# ログファイルの内容を確認 $ cat /var/log/backup/backup_20260819.log [2026-08-19 14:23:45] [INFO] [backup.sh:12345] ログファイル: /var/log/backup/backup_20260819.log [2026-08-19 14:23:45] [INFO] [backup.sh:12345] バックアップ開始 [2026-08-19 14:23:46] [INFO] [backup.sh:12345] rsync完了 [2026-08-19 14:23:46] [INFO] [backup.sh:12345] バックアップ正常終了 # ERRORだけ絞り込む $ grep '\[ERROR\]' /var/log/backup/*.log
2. スクリプト名とPIDをログに自動付与する
複数のスクリプトが同じログディレクトリを使う場合や、同一スクリプトが並列実行された場合に備えて、スクリプト名とPIDをすべてのログ行に自動で付与します。先ほどの関数でSCRIPT_NAME="$(basename "$0")" と PID="$$" を定義しているのがその実装です。# 複数スクリプトが同時に動いている場合の出力例 [2026-08-19 02:00:01] [INFO] [backup.sh:23456] バックアップ開始 [2026-08-19 02:00:01] [INFO] [logrotate.sh:23457] ローテーション開始 [2026-08-19 02:00:03] [ERROR][backup.sh:23456] rsync失敗 (exit: 23) [2026-08-19 02:00:05] [INFO] [logrotate.sh:23457] ローテーション完了
grep 'backup.sh' で該当スクリプトの行だけ抽出できます。ログローテーションの組み込み方
1. 日付付きログファイル名で自動切替
ログファイル名に日付を埋め込むことで、スクリプトを修正せずに日別でファイルが自動的に切り替わります。# 日付付きログファイル名(スクリプト起動時の日付で固定) LOG_FILE="${LOG_DIR}/$(basename "$0" .sh)_$(date +%Y%m%d).log" # 結果例 # /var/log/backup/backup_20260819.log (今日) # /var/log/backup/backup_20260820.log (翌日は自動で切替)
2. findコマンドで古いログを自動削除する
ログを溜め込むとディスクを圧迫するため、一定日数より古いログを自動削除する関数を用意します。rotate_logs() { local deleted deleted="$(find "${LOG_DIR}" -name "*.log" -mtime +30 -print)" if [ -n "${deleted}" ]; then find "${LOG_DIR}" -name "*.log" -mtime +30 -delete log_info "30日超過ログを削除しました" else log_debug "削除対象のログなし" fi }
-mtime +30 は「最終更新日時が30日より古い」ファイルを対象にします。削除前に -print で対象ファイル一覧を確認してから本番に適用するとより安全です。実践:バックアップスクリプトへのログ関数組み込み例
ここまでのログ関数をすべて組み込んだ、実務で使えるバックアップスクリプトの完成例を示します。#!/usr/bin/env bash set -euo pipefail # ===== ログ設定 ===== LOG_DIR="/var/log/backup" LOG_FILE="${LOG_DIR}/backup_$(date +%Y%m%d).log" LOG_LEVEL="${LOG_LEVEL:-INFO}" SCRIPT_NAME="$(basename "$0")" PID="$$" _log_level_num() { case "$1" in DEBUG) echo 0;; INFO) echo 1;; WARN) echo 2;; ERROR) echo 3;; *) echo 1;; esac } log() { local l="$1"; shift [ "$(_log_level_num "$l")" -ge "$(_log_level_num "$LOG_LEVEL")" ] && echo "[$(date '+%Y-%m-%d %H:%M:%S')] [${l}] [${SCRIPT_NAME}:${PID}] $*" >&2 || true } log_debug() { log DEBUG "$@"; } log_info() { log INFO "$@"; } log_warn() { log WARN "$@"; } log_error() { log ERROR "$@"; } setup_log() { mkdir -p "${LOG_DIR}" exec 2> >(tee -a "${LOG_FILE}" >&2) log_info "ログファイル: ${LOG_FILE}" } rotate_logs() { find "${LOG_DIR}" -name "*.log" -mtime +30 -delete && log_info "30日超過ログを削除しました" || true } # ===== メイン処理 ===== SRC="/var/www/html" DEST="/backup/html" setup_log log_info "バックアップ開始: ${SRC} -> ${DEST}" if [ ! -d "${SRC}" ]; then log_error "バックアップ元が存在しません: ${SRC}" exit 1 fi if rsync -av --delete "${SRC}/" "${DEST}/"; then log_info "rsync完了" else log_error "rsync失敗 (exit: $?)" exit 1 fi rotate_logs log_info "バックアップ正常終了"
/dev/null に捨てる設定を しない ことが重要です。ログ関数の出力はすべてstderr経由でログファイルに書き込まれます。# crontab -e で設定 SHELL=/bin/bash LOG_LEVEL=INFO # 毎日午前2時にバックアップ実行(2>/dev/null は付けない) 0 2 * * * /usr/local/bin/backup.sh
トラブルシュート
「ログが書き込まれない」の原因を切り分ける
ログファイルに何も書き込まれない場合、次の手順で原因を絞り込みます。・ログディレクトリが存在するか:
ls -ld /var/log/myapp/ で確認。なければ mkdir -p で作成する・書き込み権限があるか:cronの実行ユーザーがディレクトリに書き込めるか
ls -la で確認・bashで実行しているか:
exec 2> >(tee ...) はbash専用。shシェルでは動作しない# ログディレクトリの存在と権限を確認 $ ls -ld /var/log/myapp/ drwxr-xr-x. 2 ec2-user ec2-user 4096 8月 19 14:00 /var/log/myapp/ # ディレクトリが存在しない場合 $ ls -ld /var/log/myapp/ ls: cannot access '/var/log/myapp/': No such file or directory # 実行ユーザーを確認してから作成する $ whoami ec2-user $ mkdir -p /var/log/myapp/
cronでのログ出力が空になる原因と対処
cronでスクリプトを動かしてもログファイルが空のままになる場合、cronのデフォルトシェルが/bin/sh になっていることが原因のケースが多くあります。exec 2> >(tee ...) はbash特有の構文のため、sh環境では無視されてログファイルが空になります。# crontab の先頭でSHELLを明示する SHELL=/bin/bash LOG_LEVEL=INFO # 毎日午前2時に実行 0 2 * * * /usr/local/bin/backup.sh # または、フルパスでbashを指定して呼び出す方法 0 2 * * * /bin/bash /usr/local/bin/backup.sh
LOG_LEVEL 環境変数をcron内で設定する場合は、crontabの先頭行に LOG_LEVEL=INFO のように記述してください。export コマンドはcrontabでは不要です。本記事のまとめ
シェルスクリプトのログ出力設計をまとめます。| やりたいこと | 実装方法 |
|---|---|
| タイムスタンプ付きログを出力する | log_info / log_warn / log_error 関数を実装 |
| ファイルとターミナルに同時出力 | exec 2> >(tee -a "${LOG_FILE}" >&2) |
| DEBUGログを本番で抑制する | LOG_LEVEL=INFO 環境変数で制御 |
| 古いログを自動削除する | find "${LOG_DIR}" -name "*.log" -mtime +30 -delete |
| cronでbashを強制する | crontab の先頭に SHELL=/bin/bash を記述 |
setup_log と log_info / log_error の3関数だけをコピーしてスクリプト冒頭に貼ってみてください。それだけで「どこで失敗したか」の手がかりが飛躍的に増えます。rsyncバックアップの世代管理や定期監視スクリプトと組み合わせることで、無人運用でも安心できる自動化基盤が構築できます。
シェルスクリプト講座を見る >>
3,100名以上が実践した「型」を無料で公開中
プロのエンジニアはコマンドを暗記していません。
「現場で使える型」を効率よく使いこなしているだけです。
その「型」を図解60Pにまとめた入門マニュアルを、完全無料でプレゼントしています。
姓・名・メールの3つだけ/30秒/解除は3秒 / 詳細はこちら
- 前のページへ:シェルスクリプトのwhileループとポーリング設計|サービス起動待ち・ファイル生成待ち・条件成立まで繰り返す実装パターン
- この記事の属するカテゴリ:シェルスクリプトへ戻る

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