シェルスクリプトのログ出力設計|ログレベル関数・タイムスタンプ・二重出力の実装パターン

宮崎智広 この記事の監修:宮崎智広(Linux実務・教育歴20年以上・受講者3,100名超)
HOMELinux技術 リナックスマスター.JP(Linuxマスター.JP)シェルスクリプト > シェルスクリプトのログ出力設計|ログレベル関数・タイムスタンプ・二重出力の実装パターン
「cronで動かしているスクリプトが何かおかしいのに、何が起きたか全然わからない」
そんな経験はないでしょうか。スクリプトを手動実行するとターミナルに出力が流れてくるので問題箇所をすぐ把握できますが、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つだけ/30秒/解除は3秒 / 詳細はこちら

なぜシェルスクリプトにログ設計が必要なのか

シェルスクリプトのログ設計を疎かにすると、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}" }

注意点が1つあります。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] ローテーション完了

PIDが付いているので、ログが混在しても 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 "バックアップ正常終了"

cronに登録する際は、標準エラーを /dev/null に捨てる設定を しない ことが重要です。ログ関数の出力はすべてstderr経由でログファイルに書き込まれます。

# crontab -e で設定 SHELL=/bin/bash LOG_LEVEL=INFO # 毎日午前2時にバックアップ実行(2>/dev/null は付けない) 0 2 * * * /usr/local/bin/backup.sh

ログ設計をさらに発展させたい方は、シェルスクリプト実践講座(Linux Master Pro)もご覧ください。現場で使えるシェルスクリプト設計を体系的に学べます。

トラブルシュート

「ログが書き込まれない」の原因を切り分ける

ログファイルに何も書き込まれない場合、次の手順で原因を絞り込みます。

ログディレクトリが存在するか: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_loglog_info / log_error の3関数だけをコピーしてスクリプト冒頭に貼ってみてください。それだけで「どこで失敗したか」の手がかりが飛躍的に増えます。

rsyncバックアップの世代管理や定期監視スクリプトと組み合わせることで、無人運用でも安心できる自動化基盤が構築できます。
現場で通用する安全なLinuxサーバー構築の「型」を体系的に身につけたい方へ、20年以上の運用経験を持つ現役エンジニアが基礎から教えます。
シェルスクリプト講座を見る >>

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

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

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

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

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

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

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

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

この記事を書いた人

宮崎 智広(みやざき ともひろ)

株式会社イーネットマーキュリー代表。現役のLinuxサーバー管理者として20年以上の実務経験を持ち、これまでに累計3,100名以上のエンジニアを指導してきたLinux教育のプロフェッショナル。「現場で本当に使える技術」を体系的に伝えることをモットーに、実践型のLinuxセミナーの開催や無料マニュアルの配布を通じてLinux人材の育成に取り組んでいる。

趣味は、キャンプにカメラ、トラウト釣り。好きな食べ物は、ラーメンにお酒。休肝日が作れない、酒量を減らせないのが悩み。最近、ドラマ「フライトエンジェル」を観て涙腺が崩壊しました。