この記事の狙い
日次バッチを安全に回すためのスケルトンを提示します。
ポイントは ロック(重複防止)/日付ローテーション(入出力の切替)/失敗時通知。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_XTRACEFD+PS4で区間トレース。失敗時のみトレースファイル案内を通知。 - メトリクス:処理件数・時間を
echo key=valueで採取 → 監視に取り込み。
参考リンク
- GNU Bash Manual — Redirections / Pipelines / Process Substitution
https://www.gnu.org/software/bash/manual/bash.html - flock(1) — util-linux
https://man7.org/linux/man-pages/man1/flock.1.html - systemd.timer(5) / systemd.service(5)
https://www.freedesktop.org/software/systemd/man/latest/ - Vixie cron(crontab(5))
https://man7.org/linux/man-pages/man5/crontab.5.html
