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

宮崎智広 この記事の監修:宮崎智広(Linux実務・教育歴20年以上・受講者3,100名超)
HOMELinux技術 リナックスマスター.JP(Linuxマスター.JP)シェルスクリプト > シェルスクリプトでrsyncバックアップの世代管理を実装する方法|古い世代を自動削除してディスク枯渇を防ぐ設計パターン
「バックアップスクリプトは毎日動いているのに、先月ディスクが満杯になってサービスへの書き込みが止まった」

こういった状況に陥る原因は、バックアップを「取得する仕組み」はあっても「古いバックアップを削除する仕組み」がないためです。日付付きディレクトリへのrsyncは確実な手段ですが、削除ルールがなければ無限にディスクを消費します。

この記事では、最新N世代だけを保持して古いものを自動削除する「世代管理バックアップ」の設計パターンを解説します。YYYYMMDD-HHMMSS形式のディレクトリ命名規則・古い世代の削除関数・完全スクリプト・rsync --link-destを使ったハードリンク差分バックアップの応用まで、RHEL 9.4 / Ubuntu 24.04 LTSで動作確認した実装で紹介します。

この記事のポイント

・世代管理は「削除ルール」を追加するだけでディスク枯渇を自動で防ぐ設計
・YYYYMMDD-HHMMSS形式のディレクトリ名はsortの文字列順が時系列順になる
・古い世代の削除はls・sort・head -n -Nで「最新N件を除いた行」を削除して実装する
・rsync --link-destで未変更ファイルをハードリンク化して実質的な差分バックアップを作れる


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

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

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

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

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

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

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

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

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. 保持する世代数を決める

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

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" local datedir datedir="$(date +%Y%m%d-%H%M%S)" local dest="${backup_parent}/${datedir}" mkdir -p "${dest}" rsync -av --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 }

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

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

#!/bin/bash set -euo pipefail # ===== 設定 ===== SRC="/var/www/html" BACKUP_DIR="/mnt/backup" GENERATION=7 LOG_FILE="/var/log/backup-rotation.log" PATH=/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin export PATH # ================ log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "${LOG_FILE}" } take_backup() { local datedir datedir="$(date +%Y%m%d-%H%M%S)" local dest="${BACKUP_DIR}/${datedir}" mkdir -p "${dest}" log "バックアップ開始: ${SRC} -> ${dest}" if rsync -av --exclude='*.tmp' --exclude='*.log' \ "${SRC}/" "${dest}/" >> "${LOG_FILE}" 2>&1; then log "バックアップ成功: ${dest}" else local rc=$? log "[ERROR] rsync 失敗(終了コード: ${rc})。不完全なバックアップを削除します。" rm -rf "${dest}" 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件が保たれています。

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

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

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

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 --link-dest="${latest}" "${SRC}/" "${dest}/" else # 初回(直前世代なし)はフルコピー rsync -av "${SRC}/" "${dest}/" fi }

ハードリンク差分バックアップの効果を確認します。

# 各世代を個別確認(ハードリンク込みなので大きく見える) $ 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のはずが、差分分だけで済んでいる

--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に登録します。

# スクリプトを配置して実行権限付与 $ 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時に実行(標準エラーもsyslogへ転送) 0 2 * * * /usr/local/bin/backup-rotation.sh 2>&1 | logger -t backup-rotation

logger -t backup-rotationでsyslogにタグ付きで記録されるため、grep backup-rotation /var/log/messagesでcronからの実行ログをまとめて確認できます。

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/ # 最新

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

「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件の場合は空のリストを返す」動作になり、sortheadwhileがスキップされます。

# 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

本記事のまとめ

やりたいこと 実装方法
日付付きバックアップディレクトリを作成する 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}"して終了
差分のみコピーしてディスクを節約する rsync --link-dest=直前世代ディレクトリ "${SRC}/" "${dest}/"
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秒登録
宮崎 智広

この記事を書いた人

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

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

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