PHPとrsyncでNASを世代バックアップ

こちらでXubuntuとSambaを利用したNASを構築しましたが、そのデータをrsyncを使って自動で一定時間おきに世代バックアップするために作成したスクリプトです。
(こちらに改訂版あり)
通常ならこういったバッチ処理は.sh等のシェルスクリプトで記述することが多いかと思いますが、個人的に使い慣れたPHPで書いています。

使用方法

backup.phpと、バックアップ対象パスなどrsyncへ渡すオプションを設定するbackup_config.phpを同一のディレクトリへ置いて使用します。
ローテーション回数の設定はbackup.php側にあります。
ローテーション回数を2以上に設定すると、--link-destオプションを使用した差分バックアップを行います。初回のみフルバックアップとなるのでバックアップ元のサイズによっては時間がかかる場合もありますが、2回目以降は変化のなかったファイルに対してはハードリンクのみを作成し、変化のあったファイルのみが実体ファイルとしてコピーされますので、ローテーション回数が比較的多めでもディスク消費量やバックアップにかかる時間を必要最小限に抑えることができます。
動作の概要についてはこちらを。
ローテーションを1回に指定した場合は--link-destオプションは使わず、既存のバックアップの中のいちばん新しいものに対して、--deleteオプションを付けて実行するミラーリング動作になります。
ローテーション有り(2回以上)か無し(1回)かのどちらか一方で動作させるだけでも良いですが、backup.phpを例えばbackup_sub.phpという名前でコピーし、backup.php側をローテーション指定で1日1回実行、backup_sub.php側をローテーション無し(1回)指定で例えば1時間おきに実行させることで、最新バックアップは1時間おきにミラーリングさせつつ、1日1回世代バックアップを残すといった使い方もできます。

PHPファイルですが、Webアプリケーションではありませんのでシェルから実行します。

$ php /スクリプト設置パス/backup.php

YYYYMMDD_hhmmss.bak という名前のディレクトリにバックアップ、
YYYYMMDD_hhmmss.log にrsyncのログを保存します。

cronでスケジューリングする場合は以下のようになります。
設定例 毎日午前4時に実行

# rotation backup
0 4 * * * php /スクリプト設置パス/backup.php &> /dev/null

設定例 毎日午前4時に履歴差分バックアップをしつつ、毎正時にミラーリング
(backup.php側でローテーション回数2以上で設定、backup.phpをコピーしたbackup_sub.php側でローテーション回数1を指定している前提です。)

# main rotation backup
0 4 * * * php /スクリプト設置パス/backup.php &> /dev/null
# sub mirroring
0 * * * * php /スクリプト設置パス/backup_sub.php &> /del/null

設定によっては複数が同時に実行開始される場合がありますが、実行の際にはロックファイルを用いて排他処理しているので、同時に複数のバックアップ処理が回ってしまうことはありません。
また、ローテーション無しでの実行の場合はバックアップ開始まで5秒遅らせるようにしてありますので、同時に走り出してもローテーション有りのほうが優先して実行されるようになっています。

バックアップの削除サポートスクリプトとしてold_delete.phpとcapacity_check.phpも用意しました。
どちらもbackup.phpでローテーション回数を多く設定した場合のサポート的な位置付けです。
使用する場合は、どちらもbackup.phpと同じディレクトリへ配置してください。
old_delete.phpは古いバックアップを間引くスクリプトで、1日以上経過したらその日のバックアップの中で最終のものを残して削除、1か月以上経過したらその月のバックアップの中で最終のものを残して削除します。
capacity_check.phpはバックアップ先ディスクの使用容量をチェックし、指定した割合に達したら下回るまで古いバックアップから順に削除するスクリプトです。
どちらか一方でも、両方設置しても問題ありません。

cronでスケジュール実行する場合は、1日1回程度backup.phpとは時間をずらして実行すればよいかと思います。

設定例 old_delete.phpを毎日5時に、capacity_check.phpを毎日5時5分に実行するよう設定しています。

# main rotation backup
0 4 * * * php /スクリプト設置パス/backup.php &> /dev/null
# sub mirroring
0 * * * * php /スクリプト設置パス/backup_sub.php &> /del/null
# olddata delete
0 5 * * * php /スクリプト設置パス/old_delete.php &> /dev/null
# diskcapacity check delete
5 5 * * * php /スクリプト設置パス/capacity_check.php &> /dev/null

各スクリプト

backup.php

<?php
/**
 *  rsyncでバックアップ元ディレクトリをバックアップ先ディレクトリへ
 *  命名規則 YYYYMMDD_hhmmss.bak で指定した世代分ローテーションバックアップ
 */

$gen = 7; // バックアップのローテーション数
$test = 0; // 0:実行 1:実際には実行せずコマンドのみ表示

/**
 *
 */
set_time_limit(0);
if(!file_exists(__DIR__. '/backup_config.php')) {
   print "Configuration file 'backup_conf.php' not found.\n";
   exit;
}
include(__DIR__. '/backup_config.php');

// デリミタ補正
$sourceDir = preg_replace('|/+$|', '/', $sourceDir.'/');
$backupDir = preg_replace('|/+$|', '/', $backupDir.'/');

// ロックファイル名
$lockFilename = (preg_match('/:/', $backupDir) ? __DIR__.'/' : $backupDir). 'backup.lock';

// バックアップ元・バックアップ先が無かったら終了
if(!preg_match('/:/', $sourceDir) && !file_exists($sourceDir) || !preg_match('/:/', $backupDir) && !file_exists($backupDir)) {
   print "The source '{$sourceDir}' or backup '{$backupDir}' destination directory does not exist.\n";
   exit;
}

// ローテーション無しの場合は5秒遅延(ローテーションありと同時に実行開始された場合にそちらを優先させる)
if($gen <= 1) sleep(5);

// ロックファイルが存在していたら同名のプロセス実行中なので終了
if(file_exists($lockFilename)) {
   print "A process with the same name is running.\n";
   exit;
} else if(!$test) {
   // 無ければロックファイル作成
   file_put_contents($lockFilename, '');
}

// 除外指定からexcludeオプション構築
if(isset($excludes) && is_array($excludes)){
   foreach($excludes as $k => $v) {
       if(trim($v) == '') {
           unset($excludes[$k]);
       } else {
           $excludes[$k] = "--exclude='{$v}'";
       }
   }
   $excludes = implode(' ', $excludes);
} else $excludes = '';

// 既にあるバックアップ済みディレクトリ名を取得
$backupList = [];
$logList = [];
if($dir = opendir($backupDir)) {
   while($fn = readdir($dir)) {
       if(preg_match('/^\w+\.bak$/i', $fn)) {
           $backupList[] = $fn;
       }
       elseif(preg_match('/^\w+\.log$/i', $fn)) {
           $logList[] = $fn;
       }
   }
   closedir($dir);
}

if($test) print "-- Check that the following command is correct. --\n";

$nowDate = date('Ymd_His');

// 新規バックアップディレクトリ名
$backupName = "{$nowDate}.bak";

// ローテーション外の古いバックアップは削除
$linkDest = '';
if(count($backupList)) {
   rsort($backupList);
   if($gen > 1) {
       $linkDest = "--link-dest={$backupDir}{$backupList[0]}";
   } else {
       // ローテーション無しの場合最新バックアップディレクトリ自体をバックアップ先に指定
       $backupName_ = $backupList[0];
   }
   // ローテーション外バックアップの削除(ローテーション無し時は除く)
   if($gen > 1 && count($backupList) >= $gen) {
       $delNames = array_slice($backupList, $gen > 1 ? $gen -1 : 1);
       foreach($delNames as $del) {
           $command = "rm -rf {$backupDir}{$del}";
           print "$command\n";
           if(!$test) exec($command);
       }
   }
}

$logName = "{$nowDate}.log";
// ローテーション外のログファイルの削除
rsort($logList);
if(!$test && count($logList) >= $gen) {
   $delNames = array_slice($logList, $gen -1);
   if($gen > 1) {
       foreach($delNames as $del) {
           if(file_exists("{$backupDir}{$del}")) unlink("{$backupDir}{$del}");
       }
   } else {
       if(file_exists("{$backupDir}{$logList[0]}")) unlink("{$backupDir}{$logList[0]}");
   }
}

// rsyncコマンドでバックアップ
$command = implode(" ", [
       "rsync -av",
       $gen <= 1 ? '--delete' : '',
       $excludes,
       $otherOptions,
       $linkDest,
       $sourceDir,
       sprintf("%s%s", 
           $backupDir, isset($backupName_) ? $backupName_ : $backupName),
       "--log-file={$backupDir}{$logName}"
   ]);
print "$command\n";
if(isset($backupName_)) $rename = "mv {$backupDir}{$backupName_} {$backupDir}{$backupName}";
if(!$test) {
   exec($command);
   if(isset($rename)) {
       exec($rename);
   }
   chmod("{$backupDir}{$logName}", 0777);

   // ロックファイル削除
   unlink($lockFilename);
}
if(isset($rename)) print "$rename\n";

exit;


backup_config.php

<?php
/**
 *  rsyncオプション設定
 */

// バックアップ元ディレクトリ
$sourceDir = '/var/www/html/source/';

// バックアップ先ディレクトリ
$backupDir = '/home/username/backup/';

// 除外指定 例: ['/exclude/', '*.bak'];
$excludes = [];

// その他のオプション 例: '--update --dirs';
$otherOptions = '';

/**
 *
 */
date_default_timezone_set('Asia/Tokyo');


old_delete.php

<?php
/**
 *  1か月以上経過したものはその月の最終以外のものを、1日以上経過したものはその日の最終以外のものを削除
 */
set_time_limit(0);
if(!file_exists(__DIR__. '/backup_config.php')) {
   print "Configuration file 'backup_conf.php' not found.\n";
   exit;
}
include(__DIR__. '/backup_config.php');

// 既にあるバックアップ済みディレクトリ名を取得
$backupList = [];
$logList = [];
if($dir = opendir($backupDir)) {
   while($fn = readdir($dir)) {
       if(preg_match('/^\w+\.bak$/i', $fn)) {
           $backupList[] = $fn;
       }
   }
   closedir($dir);
}

$processed = [];
foreach($backupList as $backupName) {
   if(!preg_match('/^(\d{4})(\d\d)(\d\d)\D(\d\d)(\d\d)/', $backupName, $m) || isset($processed[$backupName])) continue;
   list($year, $month, $day, $hour, $minute) = array_splice($m,1);
   $fDate = "$year-$month-$day $hour:$minute";

   // 1か月以上経過しているものはその月の最終のもの以外を削除
   if(time() >= strtotime("$fDate +1 month")) {
       $pickup = [];
       foreach($backupList as $tmp) {
           if(substr($tmp, 0, 6) == "$year$month" && substr($tmp, 0, 8) <= "$year$month$day") $pickup[] = $tmp;
       }
       rsort($pickup);
       foreach(array_slice($pickup, 1) as $tmp) {
           deleteBackup($tmp);
       }
   }
   // 1日以上経過しているものはその日の最終のもの以外を削除
   elseif(time() >= strtotime("$fDate +1 day")) {
       $pickup = [];
       foreach($backupList as $tmp) {
           if(substr($tmp, 0, 8) == "$year$month$day" && $tmp <= $backupName) $pickup[] = $tmp;
       }
       rsort($pickup);
       foreach(array_slice($pickup, 1) as $tmp) {
           deleteBackup($tmp);
       }
   }
}

function deleteBackup($str) {
   global $backupDir;
   global $processed;
   if(isset($processed[$str])) return;
   // 該当ディレクトリ削除
   if(file_exists("{$backupDir}{$str}")) {
       $command = "rm -rf {$backupDir}{$str}";
       print"$command\n";
       exec($command);
       $processed[$str] = 1;
   }
   // 同じ日時のログファイルがあれば削除
   $logFile = $backupDir. preg_replace("/\..*$/", '', $str). '.log';
   if(file_exists($logFile)) {
       unlink($logFile);
   }
}


capacity_check.php

<?php
/**
 *  バックアップ先ドライブのディスク使用割合を確認し、指定した割合に達していたら下回るまで古いバックアップから順に削除
 *  但し最低1つはバックアップを残す
 */

// ディスク使用許容割合(%)
// 使用量がこの値に達したら下回るまで古いバックアップから順に削除
$threshold = 95;

set_time_limit(0);
if(!file_exists(__DIR__. '/backup_config.php')) {
   print "Configuration file 'backup_conf.php' not found.\n";
   exit;
}
include(__DIR__. '/backup_config.php');

// 既にあるバックアップ済みディレクトリ名を取得
$backupList = [];
$logList = [];
if($dir = opendir($backupDir)) {
   while($fn = readdir($dir)) {
       if(preg_match('/^\w+\.bak$/i', $fn)) {
           $backupList[] = $fn;
       }
   }
   closedir($dir);
}

sort($backupList);
// ディスク使用量が指定割合を下回るまで古いバックアップから削除
while(checkPercentage($threshold) && count($backupList) > 1) {
   print "delete $backupList[0]\n";
   deleteBackup($backupList[0]);
   array_shift($backupList);
}

function checkPercentage($n) {
   global $backupDir;
   exec("df {$backupDir}", $ret);
   if(!isset($ret[1])) return false;

   if(preg_match('/(\d+)\%/', $ret[1], $ret)) {
       if($ret[1] >= $n) return true;
   }
   return false;
}

function deleteBackup($str) {
   global $backupDir;
   // 該当ディレクトリ削除
   if(file_exists("{$backupDir}{$str}")) {
       $command = "rm -rf {$backupDir}{$str}";
       exec($command);
   }
   // 同じ日時のログファイルがあれば削除
   $logFile = $backupDir. preg_replace("/\..*$/", '', $str). '.log';
   if(file_exists($logFile)) {
       unlink($logFile);
   }
}

この記事が気に入ったらサポートをしてみませんか?