シェルスクリプトで大量のファイルやレコードを処理するバッチ処理は、単純なforループで書けてしまうだけに、設計が後回しにされがちです。しかし「動くスクリプト」と「現場で安定稼働するスクリプト」の差は、進捗の可視化・チェックポイントによる途中再開・エラーの集約という3つの設計があるかどうかです。
この記事では、シェルスクリプトのバッチ処理に必要な設計パターンを順を追って解説します。ゼロから始めて最終的には、中断しても数分で再開できる実用スクリプトを組み上げます。動作確認はRHEL 9.4 / Ubuntu 24.04 LTSで行っています。
この記事のポイント
・find -print0 とprocess substitutionでスペース含むファイル名も安全に処理できる
・進捗カウンター(N/総数)を追加するだけで処理中の見通しが大幅に改善する
・チェックポイントファイルで中断後も処理済みをスキップして再開できる
・エラーを一時ファイルに収集して処理完了後に一括報告する設計が安全
でも安心してください。プロのエンジニアはコマンドを暗記していません。
「現場で使える型」を効率よく使いこなしているだけです。
なぜバッチ処理に専用の設計が必要なのか
よくある失敗パターンは次のようなものです。#!/bin/bash for file in /var/data/logs/*.log; do gzip -c "$file" > "/mnt/archive/$(basename "$file").gz" done
・進捗が見えない:1万件のファイルがあっても何件処理したか分からない。あと何分かかるかも不明
・途中で止まると最初から:エラーや強制終了で中断すると、処理済みの分も含めてゼロから再実行する羽目になる
・エラーが埋もれる:ループの途中で失敗してもスクリプトが終わった後にはどのファイルが失敗したのか分からない
バッチ処理が1件や10件なら問題になりません。しかし対象が1万件・10万件に増えたとき、これらの設計がなければ運用コストが爆発的に増えます。実際、私がセミナーで指導してきた受講生の中でも、バッチスクリプトの途中再開設計を知らなかったために数時間の作業をゼロに戻した経験を持つ方は少なくありません。
基本構造:findとprocess substitutionで一括処理する
まず、スペースを含むファイル名でも安全に動く基本構造を確認します。よく見かける次の書き方には落とし穴があります。# 危険な書き方(スペース含むファイル名で壊れる) for file in $(find /var/data/logs -name "*.log"); do echo "$file" done
$(find ...) の結果はスペースで単語分割されるため、「access log.log」のようなスペース含みのファイル名が「access」と「log.log」に分かれてしまいます。現場ではほとんどのサーバーでそのようなファイル名を使わないとしても、設計段階で安全側に倒しておくべきです。1. find -print0 とprocess substitutionで安全に処理する
#!/bin/bash set -uo pipefail SRC_DIR="/var/data/logs" DST_DIR="/mnt/archive" mkdir -p "${DST_DIR}" while IFS= read -r -d '' file; do echo "処理中: $(basename "${file}")" gzip -c "${file}" > "${DST_DIR}/$(basename "${file}").gz" done < <(find "${SRC_DIR}" -name "*.log" -print0 | sort -z)
・read -d '':ヌルバイトを区切りとして読み込む
・IFS=:先頭・末尾の空白を除去しない設定
・sort -z:ヌルバイト区切りのまま辞書順にソート
・<(...):process substitutionでfindの出力をwhileの標準入力に渡す
実行結果:
処理中: access_2026-01-01.log 処理中: access_2026-01-02.log 処理中: access_2026-01-03.log ...
2. 進捗カウンターを組み込む
ファイルの総数を先にカウントし、何件中何件処理したかを表示します。#!/bin/bash set -uo pipefail SRC_DIR="/var/data/logs" DST_DIR="/mnt/archive" mkdir -p "${DST_DIR}" # 総数を先にカウント total=$(find "${SRC_DIR}" -name "*.log" | wc -l) count=0 echo "[INFO] 処理対象: ${total} 件" >&2 while IFS= read -r -d '' file; do count=$(( count + 1 )) basename_file=$(basename "${file}") printf "[%d/%d] %s\n" "${count}" "${total}" "${basename_file}" >&2 gzip -c "${file}" > "${DST_DIR}/${basename_file}.gz" done < <(find "${SRC_DIR}" -name "*.log" -print0 | sort -z) echo "[INFO] 完了: ${count} 件処理しました" >&2
[INFO] 処理対象: 10245 件 [1/10245] access_2026-01-01.log [2/10245] access_2026-01-02.log [3/10245] access_2026-01-03.log ... [10245/10245] access_2026-12-31.log [INFO] 完了: 10245 件処理しました
>&2)に向けているのは、進捗ログとスクリプトの実際の出力(あれば)を分離するためです。cronで実行するときも、標準エラーだけをメールに飛ばす設定にすれば進捗ログを手軽に確認できます。途中再開を可能にするチェックポイント設計
進捗が見えても、中断したら最初からではまだ不十分です。チェックポイントファイルを使って「どこまで処理したか」を記録し、再実行時にスキップする仕組みを作ります。1. チェックポイントファイルの設計方針
処理済みのファイル名をテキストファイルに1行ずつ書き出していくシンプルな設計が実用的です。・処理成功後に即記録:処理前に書くと、失敗ファイルも「済み」になる
・ファイル名だけ記録:フルパスは移動した際に使えなくなるため basename を使う
・バッチID付きで管理:複数のバッチが並走するならIDをファイル名に含める
2. 処理済みをhashlookupで高速スキップする
#!/bin/bash set -uo pipefail SRC_DIR="/var/data/logs" DST_DIR="/mnt/archive" CHECKPOINT="/var/run/batch_compress.checkpoint" mkdir -p "${DST_DIR}" # 処理済みのファイル名を連想配列に読み込む declare -A processed=() if [[ -f "${CHECKPOINT}" ]]; then while IFS= read -r line; do [[ -z "${line}" ]] && continue processed["${line}"]=1 done < "${CHECKPOINT}" echo "[INFO] チェックポイント復元: ${#processed[@]} 件スキップします" >&2 fi total=$(find "${SRC_DIR}" -name "*.log" | wc -l) count=0 skip=0 while IFS= read -r -d '' file; do basename_file=$(basename "${file}") # 処理済みならスキップ if [[ -v processed["${basename_file}"] ]]; then skip=$(( skip + 1 )) continue fi count=$(( count + 1 )) printf "[%d件目] %s\n" "${count}" "${basename_file}" >&2 gzip -c "${file}" > "${DST_DIR}/${basename_file}.gz" # 成功後にチェックポイントを記録 echo "${basename_file}" >> "${CHECKPOINT}" done < <(find "${SRC_DIR}" -name "*.log" -print0 | sort -z) echo "[INFO] 今回処理: ${count} 件 / スキップ(処理済み): ${skip} 件" >&2
実行例(2回目の実行時):
# 1回目:途中でCtrl+Cした場合 $ bash batch_compress.sh [INFO] チェックポイント復元: 0 件スキップします [1件目] access_2026-01-01.log [2件目] access_2026-01-02.log ... [1543件目] access_2026-04-27.log ^C # 2回目:続きから自動再開 $ bash batch_compress.sh [INFO] チェックポイント復元: 1543 件スキップします [1件目] access_2026-04-28.log [2件目] access_2026-04-29.log ... [8702件目] access_2026-12-31.log [INFO] 今回処理: 8702 件 / スキップ(処理済み): 1543 件
シェルスクリプトでこうした設計パターンを体系的に学びたい方には、シェルスクリプト実践講座も参考にしてください。
エラーを集約して最後にまとめて報告する設計
バッチ処理でよくある失敗は、エラーが出ても処理を止めずに続けて、最後に「どれが失敗したか分からない」状態になることです。set -eで即停止させるか、エラーを収集して最後に報告するかは用途次第です。・即停止型(set -e):1件でも失敗したら止める。冪等なバッチで中断が許容できる場合に向く
・継続収集型:失敗しても次の件を処理し続ける。全件なるべく試みたい場合に向く
ここでは継続収集型のパターンを解説します。
1. エラーを一時ファイルに収集する
#!/bin/bash # set -e は使わない(エラーでも継続させるため) set -uo pipefail SRC_DIR="/var/data/logs" DST_DIR="/mnt/archive" CHECKPOINT="/var/run/batch_compress.checkpoint" ERROR_FILE=$(mktemp /tmp/batch_errors.XXXXXX) # スクリプト終了時に一時ファイルを削除 cleanup() { rm -f "${ERROR_FILE}" } trap cleanup EXIT mkdir -p "${DST_DIR}" declare -A processed=() if [[ -f "${CHECKPOINT}" ]]; then while IFS= read -r line; do [[ -z "${line}" ]] && continue processed["${line}"]=1 done < "${CHECKPOINT}" fi total=$(find "${SRC_DIR}" -name "*.log" | wc -l) count=0 while IFS= read -r -d '' file; do basename_file=$(basename "${file}") [[ -v processed["${basename_file}"] ]] && continue count=$(( count + 1 )) printf "[%d/%d] %s\n" "${count}" "${total}" "${basename_file}" >&2 # エラーが出ても継続し、エラーファイルに記録 if gzip -c "${file}" > "${DST_DIR}/${basename_file}.gz" 2>/dev/null; then echo "${basename_file}" >> "${CHECKPOINT}" else echo "${file}" >> "${ERROR_FILE}" fi done < <(find "${SRC_DIR}" -name "*.log" -print0 | sort -z) # 最後にエラーを集約報告 error_count=$(wc -l < "${ERROR_FILE}") if [[ "${error_count}" -gt 0 ]]; then echo "[WARN] ${error_count} 件でエラーが発生しました:" >&2 cat "${ERROR_FILE}" >&2 exit 1 fi echo "[INFO] 全件正常に処理しました" >&2
[1/10245] access_2026-01-01.log [2/10245] access_2026-01-02.log ... [10245/10245] access_2026-12-31.log [WARN] 3 件でエラーが発生しました: /var/data/logs/access_2026-03-15.log /var/data/logs/access_2026-07-22.log /var/data/logs/access_2026-11-08.log
実践例:ログアーカイブの一括gzip変換スクリプト
ここまでの設計をすべて組み合わせた実用スクリプトを示します。#!/bin/bash # batch_compress.sh -- ログファイルの一括gzip変換(RHEL 9.4 / Ubuntu 24.04 確認済み) set -uo pipefail # ---- 設定 ---- SRC_DIR="${1:-/var/data/logs}" DST_DIR="${2:-/mnt/archive}" CHECKPOINT="${DST_DIR}/.checkpoint" LOG_TAG="[batch_compress]" # ---- 初期化 ---- mkdir -p "${DST_DIR}" ERROR_FILE=$(mktemp /tmp/batch_errors.XXXXXX) trap 'rm -f "${ERROR_FILE}"' EXIT log() { echo "${LOG_TAG} $*" >&2; } # ---- チェックポイント読み込み ---- declare -A processed=() if [[ -f "${CHECKPOINT}" ]]; then while IFS= read -r line; do [[ -z "${line}" ]] && continue processed["${line}"]=1 done < "${CHECKPOINT}" log "チェックポイント復元: ${#processed[@]} 件スキップ" fi # ---- 進捗カウント ---- total=$(find "${SRC_DIR}" -maxdepth 1 -name "*.log" | wc -l) log "処理対象: ${total} 件" count=0 # ---- メインループ ---- while IFS= read -r -d '' file; do basename_file=$(basename "${file}") [[ -v processed["${basename_file}"] ]] && continue count=$(( count + 1 )) printf "${LOG_TAG} [%d/%d] %s\n" "${count}" "${total}" "${basename_file}" >&2 if gzip -c "${file}" > "${DST_DIR}/${basename_file}.gz" 2>/dev/null; then echo "${basename_file}" >> "${CHECKPOINT}" else echo "${file}" >> "${ERROR_FILE}" fi done < <(find "${SRC_DIR}" -maxdepth 1 -name "*.log" -print0 | sort -z) # ---- 結果報告 ---- error_count=$(wc -l < "${ERROR_FILE}") if [[ "${error_count}" -gt 0 ]]; then log "WARN: ${error_count} 件でエラーが発生しました:" cat "${ERROR_FILE}" >&2 exit 1 fi log "完了: 今回処理 ${count} 件"
# crontab での使用例 # 毎日AM3:00にWebログをアーカイブ 0 3 * * * /opt/scripts/batch_compress.sh /var/log/nginx /mnt/archive/nginx # 毎日AM4:00にアプリログをアーカイブ 0 4 * * * /opt/scripts/batch_compress.sh /var/log/app /mnt/archive/app
トラブルシュート:よくある問題と対処法
1. 「Argument list too long」でforループが動かない
ファイル数が多いとfor file in /path/*.log でエラーになることがあります。# エラー例 $ for file in /var/data/logs/*.log; do echo "$file"; done -bash: /bin/ls: Argument list too long # 対処:findを使う(引数展開しないため上限なし) find /var/data/logs -name "*.log" -print0 | sort -z | while IFS= read -r -d '' file; do echo "$file" done
2. チェックポイントが壊れて全件再処理になる
書き込み中に強制終了するとチェックポイントファイルが中途半端な状態になることがあります。# 対処:チェックポイントを一時ファイル経由で書き出す(アトミック追記は通常の >> で十分) # 問題が起きた場合はチェックポイントをリセットして全件再処理 rm -f /mnt/archive/.checkpoint bash batch_compress.sh
3. 進捗カウントが実際の処理件数とずれる
総数カウントに使うfind のオプションとメインループの find のオプションが異なると件数がずれます。-maxdepth・-name・-typeなどのオプションを両方に同じ形で書くことが重要です。# 悪い例(総数カウントとメインループでオプションが違う) total=$(find "${SRC_DIR}" -name "*.log" | wc -l) # サブディレクトリも含む while ... < <(find "${SRC_DIR}" -maxdepth 1 -name "*.log" -print0) # 直下のみ # 良い例(同じオプションを使う) FIND_OPTS=(-maxdepth 1 -name "*.log") total=$(find "${SRC_DIR}" "${FIND_OPTS[@]}" | wc -l) while ... < <(find "${SRC_DIR}" "${FIND_OPTS[@]}" -print0 | sort -z)
本記事のまとめ
シェルスクリプトで大量ファイルのバッチ処理を安定稼働させるための設計パターンを解説しました。| 設計の目的 | 実装方法 |
|---|---|
| スペース含みのファイル名を安全に扱う | find -print0 + while IFS= read -r -d '' |
| 処理の進捗を表示する | 総数を wc -l で先取りし printf "[%d/%d]" で表示 |
| チェックポイントを読み込む | declare -A processed に既処理ファイル名を格納 |
| 処理済みをスキップする | [[ -v processed["${basename_file}"] ]] で連想配列キー確認 |
| チェックポイントを記録する | 処理成功直後に echo "${basename_file}" >> "${CHECKPOINT}" |
| エラーを収集して継続する | if ! command; then echo "..." >> "${ERROR_FILE}"; fi |
| 終了時に一時ファイルを削除する | trap 'rm -f "${ERROR_FILE}"' EXIT |
| 引数上限を超えるファイル数に対応する | forループのグロブ展開をやめfindに切り替える |
シェルスクリプト講座を見る >>
3,100名以上が実践した「型」を無料で公開中
プロのエンジニアはコマンドを暗記していません。
「現場で使える型」を効率よく使いこなしているだけです。
その「型」を図解60Pにまとめた入門マニュアルを、完全無料でプレゼントしています。
姓・名・メールの3つだけ/30秒/解除は3秒 / 詳細はこちら
- 前のページへ:シェルスクリプトの算術計算設計|bash二重括弧とbcでカウンター・閾値・浮動小数点を実装する方法
- この記事の属するカテゴリ:シェルスクリプトへ戻る

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