NASの世代バックアップ及びミラーリング

rsyncで世代バックアップやミラーリングを行うためのPHPスクリプトです。
こちらで作成したものと基本的に同内容ですが、各処理ごとに分けていたスクリプトを個人的に扱いやすいよう1つにまとめたものです。

スクリプト

backup.php

<?php
/**
 *  rsync backup
 */

set_time_limit(0);
date_default_timezone_set('Asia/Tokyo');

// configファイル存在チェック
if(!file_exists(__DIR__. '/backup_config.php')) {
    print "Configuration file 'backup_config.php' not found.\n";
    exit;
}
include(__DIR__. '/backup_config.php');

// 一時ファイル保存用ディレクトリを/var/tmp下に作成
define('TEMP_DIR', '/var/tmp/.'. md5(__FILE__));
if(!file_exists(TEMP_DIR)) {
    mkdir(TEMP_DIR);
    chmod(TEMP_DIR, 0700);
}

$tempFile = TEMP_DIR. '/backup.tmp';
$temps = getTmpFile($tempFile);

// ローテーション数1以下の場合はミラーリング
$mirroring = (BACKUP_GENERATION <= 1) ? 1 : 0;

// 前回の世代バックアップ日時から規定時間が経過していない場合はミラーリング
if(isset($temps['generation_backup_last']) &&
    strtotime($temps['generation_backup_last']) + GENERATION_BACKUP_INTERVAL * 60 >= time() &&
    (int)(strtotime(date('H:i')) / 60 - 900) % (GENERATION_BACKUP_INTERVAL ? GENERATION_BACKUP_INTERVAL : 1440)) $mirroring = 1;
$generationBackupTime = time();

// 各ディレクトリ名のデリミタ補正
$sourceDir = preg_replace('|/+$|', '/', SOURCE_DIR. '/');
$backupDir = preg_replace('|/+$|', '/', BACKUP_DIR. '/');

// ミラーリングの場合はバックアップ元ディスク使用量をチェック、前回から変化が無ければ何もせず終了
if($mirroring) {
    exec("df {$sourceDir}", $ret);
    $usedSize = (preg_split('/\s+/', $ret[1]))[2];
    $prevUsedSize = isset($temps['prev_used_size']) ? $temps['prev_used_size'] : 0;
    if($usedSize == $prevUsedSize) exit;
}

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

// ロックファイル名
$lockFilename = TEMP_DIR. '/backup.lock';

// ロックファイルが存在していたら同名のプロセス実行中とみなし終了
if(file_exists($lockFilename)) {
    print "A process with the same name is running.\n";
    exit;
} else {
    // ロックファイル作成
    if(!@file_put_contents($lockFilename, 'Process is running.')) {
        print "Could not create `$lockFilename`.\nSet the permissions of the directory `". TEMP_DIR. "` to 0700.\n";
        exit;
    }
    chmod($lockFilename, 0600);
}

// tmpファイルに保存する情報更新
if($mirroring) {
    // ミラーリングの場合はバックアップ元の使用ブロック数
    exec("df {$sourceDir}", $ret);
    $temps['prev_used_size'] = (preg_split('/\s+/', $ret[1]))[2];
} else {
    // 世代バックアップの場合は最終バックアップ日時
    $temps['generation_backup_last'] = date('Y-m-d H:i', $generationBackupTime);
}
setTmpFile($tempFile, $temps);

// バックアップ済みディレクトリ名を取得
$backupList = getBackupList($backupDir);

// 古いバックアップを間引き
$processed = [];
foreach($backupList as $backupName) {
    if(!preg_match('/^(\d{4})-(\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か月以上経過しているものはその月の最終のもの以外を削除
    // (+5 minuteは他のバックアップ処理とのタイミングをずらすためのマージン)
    if(time() >= strtotime("$fDate +1 month +5 minute")) {
        $pickup = [];
        foreach($backupList as $tmp) {
            if(substr($tmp, 0, 7) == "{$year}-{$month}" && substr($tmp, 0, 10) <= "{$year}-{$month}-{$day}") $pickup[] = $tmp;
        }
        rsort($pickup);
        foreach(array_slice($pickup, 1) as $tmp) {
            deleteBackup($backupDir, $tmp, $processed);
        }
    }
    // 1日以上経過しているものはその日の最終のもの以外を削除
    elseif(time() >= strtotime("$fDate +1 day +5 minute")) {
        $pickup = [];
        foreach($backupList as $tmp) {
            if(substr($tmp, 0, 10) == "{$year}-{$month}-{$day}" && $tmp <= $backupName) $pickup[] = $tmp;
        }
        rsort($pickup);
        foreach(array_slice($pickup, 1) as $tmp) {
            deleteBackup($backupDir, $tmp, $processed);
        }
    }
}
// バックアップ済みディレクトリ名を再取得
$backupList = getBackupList($backupDir);

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

// 既存バックアップがある場合
$linkDest = '';
if(count($backupList)) {
    rsort($backupList);
    if($mirroring) {
        // ミラーリングの場合最新バックアップディレクトリ自体をバックアップ先に指定
        $backupName_ = $backupList[0];
    } else {
        // 世代バックアップの場合既存の最新バックアップディレクトリを--link-destの参照元に指定
        $linkDest = "--link-dest=". (preg_match('/^\.+/', $backupDir) ? "../{$backupList[0]}" : "{$backupDir}{$backupList[0]}");
    }
    // 保存世代数を超えるバックアップを古いものから削除
    if(!$mirroring && count($backupList) >= BACKUP_GENERATION) {
        $delNames = array_slice($backupList, $mirroring ? 1 : BACKUP_GENERATION -1);
        foreach($delNames as $del) {
            $command = "rm -rf {$backupDir}{$del}";
            print "$command\n";
            exec($command);
        }
    }
}

// 新規バックアップディレクトリ名
$nowDate = date('Y-m-d_Hi');
$backupName = "{$nowDate}";
$logName = "{$nowDate}.log";

$backupLogDir = '__backup_log';
if($mirroring && file_exists("{$backupDir}{$backupName_}/{$backupLogDir}")) {
    exec("mv {$backupDir}{$backupName_}/{$backupLogDir} {$backupDir}");
}
if(!file_exists("{$backupDir}{$backupLogDir}")) {
    exec("mkdir {$backupDir}{$backupLogDir}");
}

// rsyncコマンドでバックアップ
$command = implode(" ", [
        "rsync -av",
        $mirroring ? '--delete' : '',
        OTHER_OPTIONS,
        $linkDest,
        $sourceDir,
        sprintf("%s%s", 
            $backupDir, isset($backupName_) ? $backupName_ : $backupName),
        "--log-file={$backupDir}{$backupLogDir}/{$logName}"
    ]);
print "$command\n";
exec($command);

// ミラーリングの場合バックアップ先ディレクトリ名を更新
if(MIRRORING_DIR_NAME_UPDATE && isset($backupName_)) {
    if(!file_exists("{$backupDir}{$backupName}")) {
        $rename = "mv {$backupDir}{$backupName_} {$backupDir}{$backupName}";
        print "$rename\n";
        exec($rename);
    }
} else {
    if(isset($backupName_)) $backupName = $backupName_;
}

// ログファイル移動
exec("mv {$backupDir}{$backupLogDir} {$backupDir}{$backupName}");

// ロックファイル削除
unlink($lockFilename);

exit;

/**
 *
 */

// 既存バックアップディレクトリ名取得
function getBackupList($backupDir) {
    $backupList = [];
    if($dir = opendir($backupDir)) {
        while($fn = readdir($dir)) {
            if(preg_match('/^\w{4}-\w{2}-\w{2}_\w{4,6}$/', $fn) && is_dir("{$backupDir}{$fn}")) {
                $backupList[] = $fn;
            }
        }
        closedir($dir);
    }
    return $backupList;
}

// バックアップ削除
function deleteBackup($backupDir, $str, &$processed) {
    if(isset($processed[$str])) return;
    if(file_exists("{$backupDir}{$str}")) {
        $command = "rm -rf {$backupDir}{$str}";
        print"$command\n";
        exec($command);
        $processed[$str] = 1;
    }
}

// ディスク使用量チェック
function checkPercentage($backupDir) {
    exec("df {$backupDir}", $ret);
    if(!isset($ret[1])) return false;
    if(preg_match('/(\d+)\%/', $ret[1], $ret)) {
        if($ret[1] >= THRESHOLD) return true;
    }
    return false;
}

// tmpファイル取得
function getTmpFile($fn) {
    if(file_exists($fn)) {
        $tmp = file_get_contents($fn);
        return(json_decode($tmp, true));
    }
    return [];
}

// tmpファイル保存
function setTmpFile($fn, $temps) {
    if(getTmpFile($fn) != json_encode($temps)) {
        if(!@file_put_contents($fn, json_encode($temps))) {
            print "Could not create `$fn`.\nSet the permissions of the directory `". TEMP_DIR. "` to 0700.\n";
            exit;
        }
        chmod($fn, 0600);
    }
}

backup_config.php

<?php
/**
 *  rsync backup configuration
 */

// バックアップ元ディレクトリ
define('SOURCE_DIR', '/mnt/nas/');

// バックアップ先ディレクトリ
define('BACKUP_DIR', '/mnt/nas_backup/');

// その他のrsyncオプション 例: '--exclude=/temp/ --exclude=/*.bak/';
define('OTHER_OPTIONS', '');

// バックアップ世代数
define('BACKUP_GENERATION', 200);

// 世代バックアップ間隔(分)
// 0の場合は1日間隔
define('GENERATION_BACKUP_INTERVAL', 360);

// ミラーリング時にバックアップ先となったディレクトリ名の更新(0:更新しない 1:更新する)
define('MIRRORING_DIR_NAME_UPDATE', 0);

// 古いバックアップを削除するディスク容量閾値(%)
// 0の場合はディスク容量のチェックは行いません
define('THRESHOLD', 95);

/**
 *
 */

backup_config.phpの設定

// バックアップ元ディレクトリ
define('SOURCE_DIR', '/mnt/nas/');

バックアップ元のディレクトリをフルパスで指定します。
リモートには非対応です。

// バックアップ先ディレクトリ
define('BACKUP_DIR', '/mnt/nas_backup/');

バックアップ元のディレクトリをフルパスで指定します。
リモートには非対応です。
このディレクトリ以下に、バックアップ日時を名前としたサブディレクトリとともにバックアップが行なわれます。

// バックアップ世代数
define('BACKUP_GENERATION', 200);

世代バックアップを行いたい場合、残したい世代数を2以上の値を指定します。
1以下にすると世代バックアップはせずに最新のバックアップに対するミラーリングとなります。
後述するバックアップ先容量監視での削除や間引き処理もありますので、この世代数に達していなくても削除が行なわれる場合があります。

間引き処理は以下のものがあります。
1日以上経過したバックアップはその日の最終のもののみ残し、それ以外を削除
1か月以上経過したバックアップはその月の最終のもののみ残し、それ以外を削除

// 世代バックアップ間隔(分)
// 0の場合は1日間隔
define('GENERATION_BACKUP_INTERVAL', 360);

世代バックアップを行う間隔を分単位で指定します。
360で6時間間隔、720で12時間間隔になります。

// ミラーリング時にバックアップ先となったディレクトリ名の更新(0:更新しない 1:更新する)
define('MIRRORING_DIR_NAME_UPDATE', 0);

ミラーリング時は既に存在する最新のバックアップに対してミラーリングを行いますが、ミラーリング先としたディレクトリ名を処理完了日時に合わせて変更するかを指定します。

// 古いバックアップを削除するディスク容量閾値(%)
// 0の場合はディスク容量のチェックは行いません
define('THRESHOLD', 95);

バックアップ先のディスクに対して古いバックアップの削除を開始する容量の閾値を%で指定します。
指定した値に達したら、それを下回るまで古いバックアップから順に削除されます。

設置と実行スケジューリング

backup.phpとbackup_config.phpを同一ディレクトリへ保存し、crontabにて以下の設定を行います。

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

上記の例では毎分ごとにスクリプトが実行されます。
バックアップ元のディスク使用量に変化が無ければ処理を行わず終了し、排他処理も行い同一プロセスが複数同時に走らないようにもしていますので必要以上の負担はかからないかと思いますが、気になるようでしたら実行間隔を広げてください。
逆に、もっと間隔を短くしたい場合は以下のような指定方法もあります。

* * * * * php /スクリプト設置パス/backup.php &> /dev/null
* * * * * sleep 30; php /スクリプト設置パス/backup.php &> /dev/null

上記の例では毎分0秒と30秒に処理が実行されます。

実行した時の挙動は初回はフルバックアップ、GENERATION_BACKUP_INTERVAL で指定した間隔ごとに世代バックアップ、それ以外は最新バックアップに対するミラーリングとなります。

作成されるバックアップの例

画像1


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