シェルスクリプトでrsyncバックアップの世代管理を実装する方法|古い世代を自動削除してディスク枯渇を防ぐ設計パターン

運営:リナックスマスター.JP(代表 宮崎智広)
HOME > Linux技術 リナックスマスター.JP(Linuxマスター.JP) > シェルスクリプト > シェルスクリプトでrsyncバックアップの世代管理を実装する方法|古い世代を自動削除してディスク枯渇を防ぐ設計パターン
「バックアップスクリプトは毎日動いているのに、先月ディスクが満杯になってサービスへの書き込みが止まった」

こういった状況に陥る原因は、バックアップを「取得する仕組み」はあっても「古いバックアップを削除する仕組み」と「失敗を検知する仕組み」がないためです。日付付きディレクトリへのrsyncは確実な手段ですが、削除ルールも通知の仕組みもなければ、ディスクが満杯になるまで誰も気づけません。

この記事では、最新N世代だけを保持して古いものを自動削除する「世代管理バックアップ」の設計パターンを解説します。YYYYMMDD-HHMMSS形式のディレクトリ命名規則・古い世代の削除関数・sha256sumによるバックアップ整合性検証・rsync --link-destを使ったハードリンク差分バックアップ・trap ERRによる失敗時メール通知・flockによる二重起動防止まで、RHEL 9.4 / Ubuntu 24.04 LTSで動作確認した実装で紹介します。

この記事のポイント

・世代管理は「削除ルール」を追加するだけでディスク枯渇を自動で防ぐ設計
・YYYYMMDD-HHMMSS形式のディレクトリ名はsortの文字列順が時系列順になる
・sha256sumでバックアップ整合性を検証し、サイレントな破損を事後検出できる
・trap ERRとmailコマンドで失敗時の自動メール通知をスクリプトに組み込める
・flockで二重起動を防ぎ、rsync --link-destとハードリンクで実質的な差分バックアップを作れる
・--link-dest方式なら各世代が完全コピーに見えるため、任意の世代から単純なrsyncでリストアできる


「このままじゃマズい」と感じていませんか?
参考書を開く気力もない、同年代に取り残される不安——
でも安心してください。プロのエンジニアはコマンドを暗記していません。
「現場で使える型」を効率よく使いこなしているだけです。
姓・名・メールの3つだけ/30秒/解除は3秒 / 詳細はこちら

バックアップを世代管理しないと何が起きるのか

日次でrsyncバックアップを取る仕組みは多くのLinuxサーバーで動いています。ところが削除ルールがないと次のような問題が起きます。

・ディスクが無限に増え続ける: 1日1GBのバックアップを365日取ると年間365GB消費する
・満杯になってもスクリプトが成功を返す: rsync自体は転送が終われば終了コード0を返すため、ディスク満杯を検知できない
・翌朝確認するまで気づかない: cronのエラーメール設定がなければ、バックアップ先が満杯でサービスへの書き込みが止まるまで発覚しない

また、cronから実行するとrsync自体がエラーになっても、スクリプトがエラー通知の仕組みを持っていなければ誰にも伝わりません。次のような「動くけど問題のある」スクリプトが現場では非常によく見られます。

#!/bin/bash # よくある問題スクリプト(反面教師) rsync -av /var/www/html/ /backup/html/

このスクリプトは「rsyncが1行書いてある」だけで、削除ルールも失敗検知も通知もありません。一方「最新バックアップ1か所に上書き」という設計では、誤って本番ファイルを削除するとバックアップ先にも即座に反映されます(rsync --deleteが同期元に合わせるため)。ファイルを1週間前に戻したくても戻す先がない状態になります。

世代管理とは「直近N世代のバックアップをディレクトリ別に保持し、N+1世代目を作るタイミングで最古の世代を自動削除する」設計です。削除ルールをスクリプトに組み込むだけで、ディスク使用量を一定範囲に収めながら複数の復元ポイントを確保できます。

世代管理バックアップの基本設計

実装に入る前に、設計上の決め事をしておきます。

1. ディレクトリの命名規則を決める

バックアップディレクトリにはYYYYMMDD-HHMMSS形式(例: 20260806-020015)を使います。この形式は文字列のアルファベット順がそのまま時系列順になるため、sortコマンドだけで日付順に並べ替えられます。

# date コマンドで YYYYMMDD-HHMMSS 形式の文字列を取得する $ date +%Y%m%d-%H%M%S 20260806-020015 # /mnt/backup 配下の世代一覧(sort で時系列順に並ぶ) $ ls -d /mnt/backup/2*/ | sort /mnt/backup/20260801-020003/ /mnt/backup/20260802-020007/ /mnt/backup/20260803-020001/ /mnt/backup/20260804-020009/ /mnt/backup/20260805-020015/ /mnt/backup/20260806-020018/ # 最新

月・曜日ベースのディレクトリ名(backup-Monなど)は「どれが最古か」を判定するコードが複雑になるため、数値タイムスタンプ形式が実務の標準です。

2. 設定値を変数にまとめる

スクリプトの先頭で全設定を変数に集約します。ハードコードを避けることで、バックアップ元・先の変更や保持世代数の調整が1か所で完結します。

#!/bin/bash set -euo pipefail # ===== 設定(ここだけ変更する) ===== SRC="/var/www/html" # バックアップ元 BACKUP_DIR="/mnt/backup" # バックアップ先(親ディレクトリ) GENERATION=7 # 保持する世代数 LOG_FILE="/var/log/backup-rotation.log" NOTIFY_MAIL="admin@example.com" PATH=/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin export PATH # =====================================

バックアップ元が存在しない場合は即座にエラー終了するのが安全設計の基本です。存在しないディレクトリをそのままrsyncすると「空を同期」して既存バックアップを消してしまう危険があるため、スクリプト起動直後に確認します。

3. 保持する世代数を決める

何世代保持するかはディスク容量と復元要件から逆算します。

・3世代: ディスクが小さい環境で「最低限の直前状態」だけを確保する
・7世代(1週間分): 日次cronで取得し、1週間前まで戻れる標準構成
・30世代(1か月分): 月次の監査対応や長期のデータ改ざん検知が必要な環境

迷ったらGENERATION=7から始めて、ディスク使用量と相談しながら調整するのが現場での定番です。

世代管理スクリプトの実装

1. 古い世代を削除する関数を実装する

世代削除の核心は「名前順(時系列順)に並べたリストから末尾N件を残し、それ以外を削除する」ロジックです。head -n -N(末尾N行を除いて出力)を使います。

#!/bin/bash # 世代削除関数 # 引数1: バックアップ親ディレクトリ 引数2: 保持する世代数 rotate_backups() { local backup_parent="$1" local keep="$2" # 名前順(=時系列順)でソートし、末尾keep件を除いた行(古い世代)を削除 ls -d "${backup_parent}"/2*/ 2>/dev/null \ | sort \ | head -n -"${keep}" \ | while IFS= read -r old_dir; do echo "[INFO] 削除: ${old_dir}" rm -rf "${old_dir}" done } # 動作確認: /mnt/backup 配下の最新7世代を保持し、残りを削除 rotate_backups "/mnt/backup" 7

各コマンドの役割を解説します。

・ls -d "${backup_parent}"/2*/: 2*パターンで年始まりディレクトリ(20260806…形式)のみを対象にし、-dでディレクトリ自体の情報を取得する
・sort: 文字列の昇順(古い順)に並べる。YYYYMMDD-HHMMSS形式なら文字列順と時系列順が一致する
・head -n -"${keep}": 末尾keep件(最新世代)を除いて出力。keep=7なら8件以上あった場合に古い分だけ出力する
・while IFS= read -r old_dir: 空白を含むパスにも対応するためIFS=と-rフラグを使う

RHEL 9.4で10世代から7世代に絞った実行結果です。

$ bash rotate_test.sh [INFO] 削除: /mnt/backup/20260728-020001/ [INFO] 削除: /mnt/backup/20260729-020003/ [INFO] 削除: /mnt/backup/20260730-020002/ $ ls /mnt/backup/ | sort 20260731-020004/ 20260801-020007/ 20260802-020009/ 20260803-020001/ 20260804-020015/ 20260805-020012/ 20260806-020018/ # 最新7世代が残り、7/28・7/29・7/30が削除された

2. rsyncでバックアップを取得する

バックアップ本体はrsyncで取得します。rsync終了後に終了コードを確認し、失敗時は不完全なバックアップディレクトリを削除します。バックアップ元が存在しない場合は即座にエラー終了します。

#!/bin/bash take_backup() { local src="$1" local backup_parent="$2" # バックアップ元の確認(存在しないディレクトリをrsyncすると既存バックアップを消す危険がある) if [ ! -d "${src}" ]; then echo "[ERROR] バックアップ元が見つかりません: ${src}" >&2 return 1 fi local datedir datedir="$(date +%Y%m%d-%H%M%S)" local dest="${backup_parent}/${datedir}" mkdir -p "${dest}" rsync -av --delete --exclude='*.tmp' --exclude='*.log' \ "${src}/" "${dest}/" local rc=$? if [ "${rc}" -ne 0 ]; then echo "[ERROR] rsync 失敗(終了コード: ${rc})。不完全なバックアップを削除します。" >&2 rm -rf "${dest}" return 1 fi echo "[INFO] バックアップ完了: ${dest}" return 0 }

--deleteオプションを付けることで、バックアップ元から削除されたファイルをバックアップ先からも除去します。これがないとバックアップが肥大化し続けます。rsyncは一部ファイルのパーミッションエラー(終了コード23)などでも非ゼロを返します。この判定を省くと「失敗した不完全なバックアップ世代」が蓄積するため、必ず終了コードを確認してください。

なお、rsyncには--checksumオプションがあります。デフォルトのrsyncはファイルサイズとタイムスタンプで差分を判定しますが、--checksumを付けるとファイルの内容(チェックサム)で判定します。タイムスタンプが誤ってリセットされた場合や「サイズは同じだが中身が変わっている」ケースを確実に検出したいときに有効です。ただし全ファイルのチェックサム計算を要するため通常のrsyncより処理が遅くなります。用途に応じて使い分けてください。

3. 完全な世代管理バックアップスクリプト

#!/bin/bash set -euo pipefail # ===== 設定 ===== SRC="/var/www/html" BACKUP_DIR="/mnt/backup" GENERATION=7 LOG_FILE="/var/log/backup-rotation.log" NOTIFY_MAIL="admin@example.com" PATH=/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin export PATH # ================ # ===== ログ設定 ===== mkdir -p "$(dirname "${LOG_FILE}")" exec > >(tee -a "${LOG_FILE}") 2>&1 log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" } # ===== 異常終了時に不完全なバックアップを削除 ===== CURRENT_DEST="" cleanup() { local exit_code=$? if [ "${exit_code}" -ne 0 ] && [ -n "${CURRENT_DEST}" ] && [ -d "${CURRENT_DEST}" ]; then log "[WARN] 異常終了(exit: ${exit_code})。不完全な ${CURRENT_DEST} を削除します" rm -rf "${CURRENT_DEST}" fi } trap cleanup EXIT # ===== エラー通知 ===== notify_error() { local exit_code=$? local line_no=$1 local msg msg="$(log "バックアップ失敗: ${SRC}") 終了コード: ${exit_code} 行番号: ${line_no} ログ: ${LOG_FILE}" printf '%b' "${msg}" >&2 printf '%b' "${msg}" | mail -s "[ALERT] バックアップ失敗: $(hostname)" "${NOTIFY_MAIL}" } trap 'notify_error $LINENO' ERR take_backup() { [ -d "${SRC}" ] || { log "[ERROR] バックアップ元が見つかりません: ${SRC}"; exit 1; } local datedir datedir="$(date +%Y%m%d-%H%M%S)" CURRENT_DEST="${BACKUP_DIR}/${datedir}" mkdir -p "${CURRENT_DEST}" log "バックアップ開始: ${SRC} -> ${CURRENT_DEST}" if rsync -av --delete --exclude='*.tmp' --exclude='*.log' \ "${SRC}/" "${CURRENT_DEST}/" >> "${LOG_FILE}" 2>&1; then log "バックアップ成功: ${CURRENT_DEST}" else local rc=$? log "[ERROR] rsync 失敗(終了コード: ${rc})" exit 1 fi } rotate_backups() { log "世代管理: 最新${GENERATION}世代を保持して古いものを削除します" ls -d "${BACKUP_DIR}"/2*/ 2>/dev/null \ | sort \ | head -n -"${GENERATION}" \ | while IFS= read -r old_dir; do log "削除: ${old_dir}" rm -rf "${old_dir}" done } main() { log "===== バックアップ処理を開始 =====" take_backup rotate_backups log "===== バックアップ処理が完了 =====" } main

実際にRHEL 9.4で動かした結果です(バックアップ先に8世代ある状態から実行)。

$ sudo bash backup-rotation.sh [2026-08-06 02:00:14] ===== バックアップ処理を開始 ===== [2026-08-06 02:00:14] バックアップ開始: /var/www/html -> /mnt/backup/20260806-020014 [2026-08-06 02:01:52] バックアップ成功: /mnt/backup/20260806-020014 [2026-08-06 02:01:52] 世代管理: 最新7世代を保持して古いものを削除します [2026-08-06 02:01:52] 削除: /mnt/backup/20260730-020003/ [2026-08-06 02:01:53] ===== バックアップ処理が完了 ===== $ ls /mnt/backup/ | sort 20260731-020004/ 20260802-020007/ 20260804-020009/ 20260806-020014/ 20260801-020003/ 20260803-020001/ 20260805-020015/ # 7世代が保持され、20260730が削除された

バックアップ取得後に古い世代が自動削除され、常にGENERATION件が保たれています。

trap cleanup EXITのポイントは「スクリプトが正常・異常どちらで終了しても必ず呼ばれる」点です。trap ... ERRがエラー行のメール通知を担う一方、trap cleanup EXITが中途半端なディレクトリの後片付けを担当します。2つのtrapを組み合わせることで、ネットワーク断やrsyncの途中失敗があっても不完全な世代が残らない設計になります。

世代管理を含むシェルスクリプトの設計パターンを体系的に学びたい方は、シェルスクリプト実践講座(Linux Master Pro)もご覧ください。現場で即使える設計の「型」を実機ハンズオンで身につけられます。

4. sha256sumでバックアップの整合性を確認する

rsyncの終了コード0は「転送コマンド自体の成否」を示しますが、ファイルの内容が正確にコピーされたことを保証するものではありません。ディスクの書き込みエラーや稀なビット反転が起きていても、rsyncは終了コード0を返す場合があります。「バックアップはある、でも中身が壊れていた」という状況を防ぐために、sha256sumでバックアップ元とバックアップ先のチェックサムを比較する検証を加えます。

#!/bin/bash # バックアップ後の整合性確認関数(サンプル検証版) verify_backup() { local src="$1" local dest="$2" local sample=20 # チェックするファイル数の上限 local failed=0 while IFS= read -r src_file; do local rel_path="${src_file#${src}/}" local dest_file="${dest}/${rel_path}" if [ ! -f "${dest_file}" ]; then echo "[WARN] バックアップ先に存在しません: ${rel_path}" >&2 failed=$((failed + 1)) continue fi local src_sum dest_sum src_sum=$(sha256sum "${src_file}" | awk '{print $1}') dest_sum=$(sha256sum "${dest_file}" | awk '{print $1}') if [ "${src_sum}" != "${dest_sum}" ]; then echo "[ERROR] チェックサム不一致: ${rel_path}" >&2 echo " 元: ${src_sum} 先: ${dest_sum}" >&2 failed=$((failed + 1)) fi done < <(find "${src}" -type f | sort | head -n "${sample}") if [ "${failed}" -eq 0 ]; then echo "[INFO] 整合性確認OK(サンプル${sample}件)" return 0 else echo "[ERROR] ${failed}件でチェックサム不一致" >&2 return 1 fi } # main 関数内でバックアップ成功直後に呼び出す main() { log "===== バックアップ処理を開始 =====" take_backup verify_backup "${SRC}" "${CURRENT_DEST}" # 整合性確認を追加 rotate_backups log "===== バックアップ処理が完了 =====" }

ファイル数が多い本番環境で全件のsha256sumを計算するとCPUとI/Oに負荷がかかるため、上記ではhead -n "${sample}"で先頭20件のみを対象にしています。重要ファイルだけを絞り込む場合はfind "${src}" -type f -name "*.conf"などフィルタを加えてください。

なお、--link-destでハードリンクを使っているファイルはバックアップ元と同じinodeを共有するため、sha256sumの比較は常に一致します。整合性確認が本当に有効なのは「実コピーが発生した変更ファイル」(ハードリンクではなく新規コピーされたファイル)ですが、バックアップのパイプライン全体に検証ステップを置く習慣として組み込んでおくと、将来のディスク異常検知にも役立ちます。

応用:rsync --link-destでハードリンク差分バックアップにする

通常のrsyncバックアップは世代ごとにファイルの実体をコピーするため、世代数 × バックアップサイズのディスクを消費します。--link-destオプションを使うと、前の世代と変わらないファイルはハードリンク(同一inodeへの参照)として扱われ、変更されたファイルだけが実コピーされます。

1. ハードリンクとiノードの仕組み

Linuxのファイルシステムでは、ファイルの実体(データブロック)は「iノード」という構造で管理されます。ファイル名はiノードへのリンクに過ぎず、1つのiノードに複数の名前(ハードリンク)を付けられます。

# ハードリンクになっているファイルを stat で確認する $ stat /mnt/backup/20260806-020014/index.html File: /mnt/backup/20260806-020014/index.html Size: 4096 Device: fd01h/64769d Inode: 2621441 Links: 2 Access: (0644/-rw-r--r--) Uid: ( 0/ root) Gid: ( 0/ root)

Links: 2は、このiノードを指すハードリンクが2つある(前の世代と今の世代でファイルを共有している)ことを示します。変更されて新規コピーされたファイルはLinks: 1になります。

ハードリンクはファイルシステムレベルで参照カウントが管理されており、あるディレクトリを削除してもリンクカウントが0にならない限りデータブロックは解放されません。つまり任意の世代を削除しても、ハードリンクを共有している他の世代には影響しません。tarの差分バックアップと根本的に異なる点です。

2. --link-destの動作とコマンド例

rsync は --link-dest=参照ディレクトリを指定すると、同期先のファイルと参照ディレクトリのファイルを比較します。

・同じファイル(サイズ・タイムスタンプ・権限が一致): 新規コピーせずハードリンクを作成する
・変更されたファイル: 実ファイルとして新規コピー(デルタ転送で差分のみ転送)
・削除されたファイル: --delete指定時のみ同期先から削除(参照先には影響なし)

take_backup_incremental() { local datedir datedir="$(date +%Y%m%d-%H%M%S)" local dest="${BACKUP_DIR}/${datedir}" # 直前の世代(最新のバックアップディレクトリ)を取得 local latest latest=$(ls -d "${BACKUP_DIR}"/2*/ 2>/dev/null | sort | tail -n 1) mkdir -p "${dest}" if [ -n "${latest}" ]; then # --link-dest: latest内と内容が同じファイルはハードリンクで参照 rsync -av --delete --link-dest="${latest}" "${SRC}/" "${dest}/" else # 初回(直前世代なし)はフルコピー rsync -av --delete "${SRC}/" "${dest}/" fi }

3. latestシンボリックリンクを使った運用パターン

直前世代を毎回ls | sort | tail -n 1で取得する代わりに、latestというシンボリックリンクを最新世代に向け続ける方法もあります。スクリプトがシンプルになり、手動でリストアするときの参照先もわかりやすくなります。

LATEST="${BACKUP_DIR}/latest" # 最新世代へのシンボリックリンク if [ -d "${LATEST}" ]; then rsync -av --delete --link-dest="${LATEST}" "${SRC}/" "${dest}/" else rsync -av --delete "${SRC}/" "${dest}/" fi # バックアップ成功後にlatestリンクを更新 ln -nsfv "${dest}" "${LATEST}"

ln -nsfvの-nオプションに注意が必要です。このオプションがないと、latestがすでにディレクトリとして存在する場合に「latestの中にlatestを作る」という入れ子になります。-nを付けることでシンボリックリンク自体を置き換えます。

4. ハードリンク差分バックアップの効果確認

# 各世代を個別確認(ハードリンク込みなので大きく見える) $ du -sh /mnt/backup/*/ 1.2G /mnt/backup/20260804-020009/ 1.2G /mnt/backup/20260805-020015/ 1.2G /mnt/backup/20260806-020014/ # /mnt/backup 全体の実際のディスク消費量 $ du -sh /mnt/backup/ 1.4G /mnt/backup/ # フルコピー3件なら3.6Gのはずが、差分分だけで済んでいる

du -shは各ディレクトリを独立して計算するためそれぞれ1.2GBに見えますが、du -sh /mnt/backup/で全体を見ると実際のディスク消費は1.4GBしかありません。変更されたファイル分(ここでは約0.2GB)だけを実際にコピーしているためです。

5. 特定世代からのリストア

--link-dest方式の大きな利点は「各世代が独立した完全コピーに見える」点です。差分バックアップ方式と異なり、特定の世代から直接rsyncでリストアできます。フル+差分を順番に適用する手順が不要です。

# 本番を上書きする前にまず --dry-run で確認する(重要) $ rsync -av --dry-run \ /mnt/backup/20260803-020001/ \ /var/www/html/ # 問題なければ --dry-run を外して実行 $ sudo rsync -av \ /mnt/backup/20260803-020001/ \ /var/www/html/

リストア前に必ず--dry-run(実際には何もせず、変更されるファイルの一覧だけ表示する)で内容を確認してから実行するのが運用の鉄則です。

--link-destの制約として、ハードリンクは同一ファイルシステム内でしか機能しません。バックアップ元とバックアップ先が別パーティションになっている場合は使えます(同一物理ディスク内の別パーティションでも可)。ただし外付けUSBドライブを別ファイルシステムとしてマウントした先や、NFSマウントした先では使えません。

tarアーカイブ形式でバックアップを取っている場合の世代管理も、基本的な考え方は同じです。*.tar.gzファイルを日付名で作成し、ls *.tar.gz | sort | head -n -"${GENERATION}"で古いファイルを削除します。tarコマンドの実践的な使い方はこちらで解説しています。

cronへの組み込みと動作確認

1. crontabへの登録

スクリプトを/usr/local/bin/backup-rotation.shとして配置し、rootのcrontabに登録します。cronから実行する際は次の3点を守る必要があります。

・PATHをスクリプト冒頭で明示する: cron実行時のPATHは/usr/bin:/bin程度しかなく、/usr/local/binにあるrsyncが見つからない場合があります
・flockで二重起動を防ぐ: バックアップ処理が長引いて次のcron起動と重なると、2つのrsyncが同じ宛先に書き込んでバックアップが壊れます
・出力をスクリプト側で管理する: cronのデフォルトではコマンド出力をメールで送ろうとしますが、MTA未設定環境ではエラーが消えます。スクリプト自身がログを管理する設計にして、crontab側では出力を/dev/nullにリダイレクトします

# スクリプトを配置して実行権限付与 $ sudo cp backup-rotation.sh /usr/local/bin/backup-rotation.sh $ sudo chmod 750 /usr/local/bin/backup-rotation.sh # rootのcrontabを編集 $ sudo crontab -e # 毎日午前2時に実行(flockで二重起動防止・出力はスクリプト側で管理) 0 2 * * * /usr/bin/flock -n /var/lock/backup.lock /usr/local/bin/backup-rotation.sh >/dev/null 2>&1

flock -nの-nオプションは「ロックが取れなかったら即座に終了する(スキップする)」という意味です。前回のバックアップが終わっていない場合、今回の起動は静かにスキップされます。logger -t backup-rotationでsyslogにタグ付きで記録したい場合は2>&1 | logger -t backup-rotationに変えることもできます。

2. 手動実行で世代数を確認する

cronに登録する前に手動実行し、世代が正しく管理されることを確認します。

# 手動実行 $ sudo bash /usr/local/bin/backup-rotation.sh # バックアップ先の世代数を確認(GENERATION変数の値と一致するか) $ ls -d /mnt/backup/2*/ | wc -l 7 # 最古世代と最新世代を確認 $ ls -d /mnt/backup/2*/ | sort | head -n 1 /mnt/backup/20260731-020004/ # 最古 $ ls -d /mnt/backup/2*/ | sort | tail -n 1 /mnt/backup/20260806-020014/ # 最新

失敗通知とログ出力の組み込み

「バックアップが失敗した場合に自動でメールが届く」状態がバックアップスクリプトのゴールです。trap ERRとexec > >(tee)の2点を組み込むことで実現できます。

1. exec > >(tee)でスクリプト全体の出力をログに残す

スクリプト冒頭にexec > >(tee -a "${LOG_FILE}") 2>&1を書くと、以降の全出力が標準出力とログファイルの両方に流れます。cron実行時でもログファイルに記録が残るため、翌朝の確認が容易になります。

# set -euo pipefail の直後に追加する mkdir -p "$(dirname "${LOG_FILE}")" exec > >(tee -a "${LOG_FILE}") 2>&1 echo "[$(date '+%Y-%m-%d %H:%M:%S')] バックアップ開始: ${SRC}"

ログファイルの出力例です。

$ cat /var/log/backup-rotation.log [2026-08-06 02:00:14] ===== バックアップ処理を開始 ===== [2026-08-06 02:00:14] バックアップ開始: /var/www/html -> /mnt/backup/20260806-020014 [2026-08-06 02:01:52] バックアップ成功: /mnt/backup/20260806-020014 [2026-08-06 02:01:52] 世代管理: 最新7世代を保持して古いものを削除します [2026-08-06 02:01:53] ===== バックアップ処理が完了 =====

2. trap ERRで失敗時にメール通知する

trap 'notify_error $LINENO' ERRは、スクリプト内でエラーが発生した瞬間にnotify_error関数を呼び出します。$LINENOを渡すことで、どの行でエラーが起きたかをメール本文に含められ、原因特定が素早くなります。

notify_error() { local exit_code=$? local line_no=$1 local msg msg="[$(date '+%Y-%m-%d %H:%M:%S')] バックアップ失敗: ${SRC}" msg="${msg} 終了コード: ${exit_code} 行番号: ${line_no}" msg="${msg} ログ: ${LOG_FILE}" printf '%b' "${msg}" >&2 printf '%b' "${msg}" | mail -s "[ALERT] バックアップ失敗: $(hostname)" "${NOTIFY_MAIL}" } trap 'notify_error $LINENO' ERR

実際のメール通知例です。

件名: [ALERT] バックアップ失敗: web-server-01 本文: [2026-08-06 02:00:07] バックアップ失敗: /var/www/html 終了コード: 1 行番号: 48 ログ: /var/log/backup-rotation.log

mailコマンドはMailxやPostfixなどのMTAが必要です。インストールされていない場合はmsmtpやsendmailを代わりに使うか、ログファイルへの記録のみに留める設計にします。RHEL系はsudo dnf install mailx postfix、Ubuntu系はsudo apt install mailutils postfixでインストールできます。

トラブルシュート・よくある落とし穴

「ls: cannot access '/mnt/backup/2*': No such file or directory」が出る

バックアップが1件も存在しない初期状態でls -d "${BACKUP_DIR}"/2*/を実行するとこのエラーが出ます。set -euo pipefailが有効だとlsの失敗でスクリプトが即終了します。

2>/dev/nullを付けることで「0件の場合は空のリストを返す」動作になり、sort・head・whileがスキップされます。

# NG: バックアップが0件の場合にエラー終了する ls -d "${BACKUP_DIR}"/2*/ # OK: 0件の場合はエラーを抑制して空リストを返す ls -d "${BACKUP_DIR}"/2*/ 2>/dev/null

cronから実行するとrsyncが見つからない

cronの環境変数PATHは対話シェルより短く(/usr/bin:/bin程度)、/usr/local/binのrsyncが見つからない場合があります。スクリプト先頭でPATHを明示することで防げます。

#!/bin/bash # cronからも確実にrsyncを参照できるようPATHを先頭で明示する PATH=/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin export PATH

世代数が設定より多く残る・少なくなる

ls -d "${BACKUP_DIR}"/2*/パターンが意図しないディレクトリにもマッチすると世代カウントがずれます。たとえばバックアップ先に2023-archive/のようなディレクトリが混在しているケースです。

対策として、YYYYMMDD-HHMMSS形式(8桁-6桁)だけにマッチするグロブパターンに絞ります。

# 8桁数字-6桁数字(YYYYMMDD-HHMMSS形式)のみを対象にする ls -d "${BACKUP_DIR}"/[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9]/ 2>/dev/null

バックアップ元のパーミッションエラーでrsyncが終了コード23を返す

rsyncは転送中に一部ファイルのパーミッションエラーが発生すると終了コード23を返します。set -e環境では非ゼロ終了がスクリプトを止めるため、想定範囲のエラー(アクセス不可ファイルは無視するなど)は--excludeでバックアップ対象から除外しておくのが現場での定番です。

# アクセス不可ファイルを除外してパーミッションエラーを避ける rsync -av --delete \ --exclude='*.tmp' \ --exclude='*.log' \ --exclude='/proc/' \ --exclude='/sys/' \ "${SRC}/" "${dest}/"

--link-destが効かない(ハードリンクが作られない)

--link-destで指定する参照ディレクトリと保存先ディレクトリは同一ファイルシステム上にある必要があります。NFSマウントされたバックアップ先に対して--link-destを使うと、ハードリンクが作成されず実質的にフルコピーになります。--statsの出力でNumber of created filesが大きすぎる場合は、ファイルシステムの設定を確認してください。

また、--link-destに存在しないディレクトリを指定してもrsyncはエラーにならず、フルコピーとして動作します。前述のlatestシンボリックリンクの存在確認(if [ -d "${LATEST}" ])で初回フルコピーと差分コピーを正しく分岐させてください。

flockが使えない環境でのdial-down代替

古いディストリビューションや一部の組み込み環境ではflockコマンドがない場合があります。その場合はロックファイルを手動で管理します。

LOCK_FILE="/var/lock/backup.lock" # ロックファイルが存在すれば前回が実行中 if [ -f "${LOCK_FILE}" ]; then echo "[INFO] 前回のバックアップが実行中のためスキップ" >&2 exit 0 fi touch "${LOCK_FILE}" trap "rm -f ${LOCK_FILE}" EXIT # スクリプト終了時に必ずロックを解除

set -euo pipefailでループ内のコマンド失敗がスクリプト全体を止める

set -euo pipefailを使っている場合、ループ内のコマンドが非ゼロで終了するとスクリプト全体が即終了します。たとえば複数のバックアップディレクトリに対して検証処理をループで回すとき、1件の失敗で後続の処理がすべてスキップされます。ループ内で失敗を許容する処理は||で受けて継続できるようにします。

# NG: verify が失敗すると set -e でスクリプト全体が終了する for dir in "${dirs[@]}"; do verify_backup "${SRC}" "${dir}" # 失敗するとここで全体終了 done # OK: || で失敗を捕捉してエラーを記録しつつループを継続する failed_list=() for dir in "${dirs[@]}"; do if ! verify_backup "${SRC}" "${dir}"; then failed_list+=("${dir}") fi done if [ "${#failed_list[@]}" -gt 0 ]; then log "[ERROR] 整合性確認失敗: ${failed_list[*]}" exit 1 fi

本記事のまとめ

やりたいこと 実装方法
日付付きバックアップディレクトリを作成する mkdir -p "${BACKUP_DIR}/$(date +%Y%m%d-%H%M%S)"
古い世代を削除する(最新N件を保持) ls -d .../2*/ | sort | head -n -N | while read dir; do rm -rf "$dir"; done
rsync失敗時に不完全バックアップを削除する rsyncの終了コードを$?で確認し、非ゼロならrm -rf "${dest}"して終了
異常終了時に中途半端なディレクトリを消す trap cleanup EXIT(cleanup関数内でrm -rf "${CURRENT_DEST}")
バックアップ後の整合性をsha256sumで確認する sha256sumでバックアップ元とバックアップ先のファイルを比較する関数を追加する
ファイル内容で差分判定する rsync -av --checksum(タイムスタンプでなくチェックサムで差分判定・処理は重め)
差分のみコピーしてディスクを節約する rsync --link-dest=直前世代ディレクトリ "${SRC}/" "${dest}/"
最新世代へのシンボリックリンクを管理する ln -nsfv 今日の世代ディレクトリ latest(-nオプションで入れ子を防ぐ)
特定世代からリストアする rsync -av --dry-run バックアップ世代/ リストア先/で確認後、--dry-runを外して実行
スクリプト全体の出力をログに残す スクリプト冒頭にexec > >(tee -a ログファイル) 2>&1を追加する
失敗時に自動メール通知する trap 'notify_error $LINENO' ERRとmailコマンドを組み合わせる
cronからの二重起動を防ぐ /usr/bin/flock -n /var/lock/backup.lock スクリプトパス
cronから確実に動かす スクリプト先頭でPATHを明示してexportする
0件状態でlsエラーが出ないようにする ls -d "${BACKUP_DIR}"/2*/ 2>/dev/null
現場で通用する安全なLinuxサーバー構築の「型」を体系的に身につけたい方へ、20年以上の運用経験を持つ現役エンジニアが基礎から教えます。
シェルスクリプト講座を見る >>

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

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

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

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

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

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

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

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

運営

リナックスマスター.JP(代表 宮崎智広)

株式会社イーネットマーキュリーが運営するLinux教育サイトです。代表の宮崎智広は現役のLinuxサーバー管理者として20年以上の実務経験を持ち、累計3,100名以上のエンジニアを指導してきました。実践型のLinuxセミナーや無料マニュアルの配布を通じてLinux人材の育成に取り組んでいます。