バッチ運用テンプレ|日次ローテ / ロック / 通知

設計パターン&テンプレ
スポンサーリンク

この記事の狙い

日次バッチを安全に回すためのスケルトンを提示します。
ポイントは ロック(重複防止)/日付ローテーション(入出力の切替)/失敗時通知set -Eeuo pipefail 前提、stdout=結果 / stderr=ログの原則で設計します。

TL;DR(“コピペ可”最小テンプレ)

#!/usr/bin/env bash
# shellcheck disable=SC2155
set -Eeuo pipefail
set -o pipefail

# ==== 設定(環境変数で上書き可) ====
APP_NAME="${APP_NAME:-daily-job}"
APP_LOG_LEVEL="${APP_LOG_LEVEL:-info}"              # info|warn|error
BASE_DIR="${BASE_DIR:-/var/app}"
KEEP_DAYS="${KEEP_DAYS:-7}"                         # ローテーション保持日数
LOCK_FILE="${LOCK_FILE:-/run/${APP_NAME}.lock}"     # /tmp でも可
SLACK_WEBHOOK_URL="${SLACK_WEBHOOK_URL:-}"          # 省略可(通知OFF)
MAIL_TO="${MAIL_TO:-}"                              # 省略可(通知OFF)
TZ="${TZ:-UTC}"                                     # ローテーション日付の基準TZ
export TZ

# ==== 日付ローテーション ====
today="$(date +%Y-%m-%d)"
yesterday="$(date -d 'yesterday' +%Y-%m-%d 2>/dev/null || date -v-1d +%Y-%m-%d)"
RUN_DIR="${BASE_DIR}/run/${today}"
IN_DIR="${BASE_DIR}/in/${today}"
OUT_DIR="${BASE_DIR}/out/${today}"
LOG_DIR="${BASE_DIR}/logs"
mkdir -p -- "$RUN_DIR" "$IN_DIR" "$OUT_DIR" "$LOG_DIR"

# ==== ログ ====
__log_ok(){ case "$APP_LOG_LEVEL:$1" in error:info|error:warn|warn:info) return 1;; esac; }
log(){ __log_ok "$1" || return 0
  printf '%s %-5s %s\n' "$(date +'%Y-%m-%dT%H:%M:%S%z')" "$1" "${*:2}" >&2; }
log_info(){ log info "$@"; } log_warn(){ log warn "$@"; } log_err(){ log error "$@"; }

# ==== 通知(必要に応じて有効化) ====
notify(){
  local level="$1" msg="$2"
  # Slack
  if [[ -n "$SLACK_WEBHOOK_URL" ]]; then
    curl -fsS -X POST -H 'Content-type: application/json' \
      --data "{\"text\":\"[$APP_NAME][$level] ${msg//$'\n'/'  '}\"}" \
      "$SLACK_WEBHOOK_URL" >/dev/null || log_warn "slack notify failed"
  fi
  # メール(mailx 等がある場合)
  if [[ -n "$MAIL_TO" ]]; then
    printf '%s\n' "$msg" | mail -s "[$APP_NAME][$level]" -- "$MAIL_TO" || log_warn "mail notify failed"
  fi
}

# ==== ロック(重複起動防止) ====
exec {lfd}>"$LOCK_FILE"
if ! flock -x -w 10 "$lfd"; then
  log_warn "lock busy: $LOCK_FILE"; notify WARN "lock busy: $LOCK_FILE"; exit 0
fi
trap 'st=$?; flock -u "$lfd"; rm -f "$LOCK_FILE"; exit "$st"' EXIT INT TERM

# ==== クリーンアップとトレース(任意) ====
TMPDIR="$(mktemp -d "${RUN_DIR}/${APP_NAME}.XXXXXX")"
trap 'r=$?; [[ -d "$TMPDIR" ]] && rm -rf -- "$TMPDIR"; exit "$r"' RETURN
# 軽量トレースを別ファイルへ吐きたい場合(任意)
# exec {XFD}>"${LOG_DIR}/${APP_NAME}_${today}.trace"; export BASH_XTRACEFD=$XFD; export PS4='+ [${BASH_SOURCE##*/}:${LINENO}] '

# ==== メイン処理 ====
rotate_old(){
  # 保持日数超過のディレクトリを削除
  find "$BASE_DIR/out" -mindepth 1 -maxdepth 1 -type d -mtime +"$KEEP_DAYS" -print0 \
    | xargs -0 -r -I{} sh -c 'echo "rm -rf {}" >&2; rm -rf -- "{}"'
}

prepare_inputs(){
  # 例:前日の成果物を今日の入力へコピー/リンク
  if [[ -d "${BASE_DIR}/out/${yesterday}" ]]; then
    find "${BASE_DIR}/out/${yesterday}" -type f -print0 \
      | xargs -0 -I{} ln -f "{}" "$IN_DIR"/
  fi
}

run_job(){
  # ここに本処理を書く。成功なら 0、失敗で非0
  # 例:ダミー変換(NUL安全サンプル)
  find "$IN_DIR" -type f -print0 \
    | xargs -0 -I{} sh -c '
        set -Eeuo pipefail
        in="$1"; out="$2/$(basename "$1").out"
        awk "{print NR \":\" \$0}" <"$in" >"$out"
      ' _ "{}" "$TMPDIR"
}

emit_outputs(){
  # 原子更新:tmp から out へ移動
  find "$TMPDIR" -type f -print0 \
    | xargs -0 -I{} bash -c 'mv -f -- "$0" "$1"/' {} "$OUT_DIR"
}

main(){
  log_info "start: today=$today in=$IN_DIR out=$OUT_DIR"
  rotate_old
  prepare_inputs
  if run_job; then
    emit_outputs
    log_info "done: out=$(find "$OUT_DIR" -type f | wc -l) files"
    notify INFO "success: $(basename "$OUT_DIR")"
    return 0
  else
    log_err "job failed"
    notify ERROR "job failed: see logs in $LOG_DIR"
    return 4
  fi
}

main "$@" || exit $?

設計の要点

  • ロックは最初に取得し、trap必ず解放。タイムアウト(-w 10)で待ち続けない。
  • ローテーションはディレクトリ単位で日付名(YYYY-MM-DD)に固定。削除は -mtime と NUL 安全で。
  • 出力は原子更新TMPDIR で生成 → mv で最終場所へ。途中を読まれない。
  • 通知は失敗時優先。成功通知は運用次第で抑制(ノイズ対策)。
  • ログ設計:行頭に時刻・レベル・本文。stdout は成果物のみ(パイプで次工程に渡る想定)。

失敗しやすい点(アンチパターン → 改善)

悪い例問題改善
同一時間に複数起動競合・破損flock で単一実行+タイムアウト
実行途中のファイルを読む中間物が露出tmp→mv の原子更新
改行区切りでファイル列挙空白/改行で破綻-print0 / -0 / -d ''
エラーでも成功コード0監視が検知できない非0で終了+通知・ログ
ずっと無言トラブル時の解析困難stderr ログに最低限の進捗を出す

運用レシピ

cron の例(UTC 02:10 実行・出力は syslog に委譲)

# /etc/cron.d/daily-job
10 2 * * * root /usr/local/bin/daily-job.sh >>/var/log/daily-job.out 2>&1

systemd timer の例(推奨:依存や環境を管理しやすい)

# /etc/systemd/system/daily-job.service
[Unit]
Description=Daily batch

[Service]
Type=oneshot
Environment="APP_LOG_LEVEL=info" "BASE_DIR=/var/app"
ExecStart=/usr/local/bin/daily-job.sh
# /etc/systemd/system/daily-job.timer
[Unit]
Description=Daily batch timer

[Timer]
OnCalendar=02:10
Persistent=true

[Install]
WantedBy=timers.target
systemctl enable --now daily-job.timer

“コピペ可”テストブロック(最小)

#!/usr/bin/env bash
set -Eeuo pipefail
TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
export BASE_DIR="$TMP" APP_NAME="test-batch" KEEP_DAYS=0 APP_LOG_LEVEL=info

# 入力準備
today="$(TZ=UTC date +%Y-%m-%d)"
mkdir -p "$BASE_DIR/in/$today" "$BASE_DIR/out" "$BASE_DIR/run" "$BASE_DIR/logs"
printf 'a\nb\n' >"$BASE_DIR/in/$today/input.txt"

# 実行
script_path="./daily-job.sh"   # ← 上の本体のパスに合わせる
bash "$script_path" 2>"$TMP/err.log" || { echo "run failed"; exit 1; }

# 成果物検証
out="$(find "$BASE_DIR/out/$today" -type f | wc -l)"
[[ "$out" -ge 1 ]] || { echo "no outputs"; exit 1; }
grep -q '1:a' "$BASE_DIR/out/$today/input.txt.out" || { echo "content NG"; exit 1; }

echo "PASS"

伸ばし方(必要に応じて)

  • 並列化:入力をシャーディングして xargs -P$(nproc)、終了コード集約は wait/pipefail
  • ロック粒度:ジョブ全体ロックに加え、キー別(顧客IDなど)ロックで部分並列
  • 観測性BASH_XTRACEFDPS4 で区間トレース。失敗時のみトレースファイル案内を通知。
  • メトリクス:処理件数・時間を echo key=value で採取 → 監視に取り込み。

参考リンク

スポンサーリンク
Bash玄

はじめまして!Bash玄です。

エンジニアとしてシステム運用に携わる中で、手作業の多さに限界を感じ、Bashスクリプトを活用して業務を効率化したのがきっかけで、この道に入りました。「手作業は負け」「スクリプトはシンプルに」をモットーに、誰でも実践できるBashスクリプトの書き方を発信しています。

このサイトでは、Bashの基礎から実践的なスクリプト作成まで、初心者でもわかりやすく解説しています。少しでも「Bashって便利だな」と思ってもらえたら嬉しいです!

# 好きなこと
- シンプルなコードを書くこと
- コマンドラインを快適にカスタマイズすること
- 自動化で時間を生み出すこと

# このサイトを読んでほしい人
- Bashに興味があるけど、何から始めればいいかわからない人
- 定型業務を自動化したい人
- 効率よくターミナルを使いこなしたい人

Bashの世界に一歩踏み出して、一緒に「Bash道」を極めていきましょう!

Bash玄をフォローする