phpとrsyncでNASのバックアップ

XubuntuとSambaで構築したNASにネットワーク共有用とバックアップ用それぞれ独立したHDDを載せた以下のような環境に対してrsyncでミラーリング及び世代管理バックアップを行う前提で記述したphpスクリプトです。
多少構成が異なっても応用はできるかと思います。

**NAS用ドライブ**
ルート直下に
/data/　… ネットワークドライブ用
というディレクトリを作成。
これをマウントポイント
/home/nas/
にマウントしてあり、
/home/nas/data/
をsambaで共有ディレクトリに設定することでNASのネットワークドライブとして使用。

**バックアップ用ドライブ**
ルート直下に
/data/　… ミラーリング用
/generation/　… 世代管理バックアップ用
というディレクトリを作成。
これをマウントポイント
/home/nas_backup/
にマウントすることで、
/home/nas_backup/data/
を/home/nas/data/のミラーリング先、
/home/nas_backup/generation/
を/home/nas_backup/data/の世代バックアップ先としています。

文字ではイメージが掴みにくいかもしれませんが、図にするとこんな感じになります。
![blockimage.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/618708/eab9b3a6-5480-1530-6623-24863ba7d865.jpeg)

注意点としては、世代管理バックアップではハードリンクを活用するrsyncの--link-destオプションを使用しているので、対象ドライブはハードリンクを問題なく扱えるファイルシステムでないと世代管理バックアップのたびに毎回フルバックアップ相当のディスク消費と処理時間がかかると思われます。
私の場合はext4を使用しています。

----

###スクリプト

**mirroring.php**
NAS共有ディレクトリをバックアップ元として、バックアップ先ドライブに対してrsyncの--deleteオプションを使用したミラーリングを行うスクリプトです。

```php:mirroring.php
<?php
/**
 *  rsync ミラーリング
 */

// ミラーリング元ディレクトリ
define('SOURCE_DIR', '/home/nas/data/');

// ミラーリング先ディレクトリ
define('BACKUP_DIR', '/home/nas_backup/data/');

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

/**
 *
 */

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

// 一時ファイル保存用ディレクトリ
define('TEMP_DIR', (file_exists('/dev/shm/') ? '/dev/shm/.' : '/var/tmp/.'). md5(__DIR__));
if(!file_exists(TEMP_DIR)) {
    mkdir(TEMP_DIR);
    chmod(TEMP_DIR, 0700);
}

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

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

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

// バックアップ元ディスク使用量をチェック、前回から変化が無ければ何もせず終了
// 但しリネームや小サイズの更新ではブロックサイズが変化しない場合もあるので
// 前回ミラーリングから1時間以上経過している場合はブロックサイズの変化に関わらずミラーリングを行う
exec("df {$sourceDir}", $ret);
$usedSize = (preg_split('/\s+/', $ret[1]))[2];
$prevUsedSize = isset($temps['prev_used_size']) ? (time() - filemtime($tempFile) < 3600 ? $temps['prev_used_size'] : 0) : 0;
if($usedSize == $prevUsedSize) 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ファイルに保存する情報更新
// ミラーリングの場合はバックアップ元の使用ブロック数
$temps['prev_used_size'] = $usedSize;
setTmpFile($tempFile, $temps);

$updateDirList = getUpdataDirList($sourceDir);
if(!$updateDirList) {
    $updateDirList[] = $sourceDir;
}

foreach($updateDirList as $dir) {
    $path = str_replace($sourceDir, '', $dir);
    // rsyncコマンド
    $command = implode(" ", [
            'rsync -avH',
            '--delete',
            OTHER_OPTIONS,
            '"'. preg_replace('|/+$|', '/', ($sourceDir. $path. '/')). '"',
            '"'. preg_replace('|/+$|', '/', ($backupDir. $path. '/')). '"',
        ]);
    print "$command\n";
    exec($command);
}

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

exit;

/**
 *
 */

// 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);
    }
}

// 更新ディレクトリ取得
function getUpdataDirList($sourceDir) {
    $duFile = TEMP_DIR. '/prev_du.txt';
    $prevDirList = duToArray($duFile);

    exec("du {$sourceDir} > {$duFile}");
    chmod($duFile, 0600);
    $dirList = duToArray($duFile);

    $tmpArr = [];
    foreach($dirList as $k => $v) {
        if(isset($prevDirList[$k]) && $prevDirList[$k] != $v) $tmpArr[$k] = $v;
    }
    unset($prevDirList, $dirList);

    $retArr = $tmpArr;
    foreach($tmpArr as $k => $v) {
        foreach($tmpArr as $k_ => $v_) {
            if($k == $k_) continue;
            if(isset($retArr[$k]) && strpos($k_, $k) === 0) unset($retArr[$k]);
        }
    }
    return array_keys($retArr);
}

// duコマンドの結果を配列に変換
function duToArray($duFile) {
    $retArr = [];
    if(file_exists($duFile)) {
        if($fp = @fopen($duFile, 'r')) {
            while(($l = fgets($fp)) !== false) {
                $l = trim($l);
                if(!$l) continue;
                $l = explode("\t", $l);
                $retArr[$l[1]] = $l[0];
            }
            fclose($fp);
        }
    }
    return $retArr;
}
```

バックアップ元ディスク容量が前回実行時から変化していなければrsyncは行わず終了するようにしてありますので頻繁に実行しても極端に負荷が高くなることは無いとは思いますが、その辺りは環境に合わせて加減してください。
容量チェックはdfコマンドを使用したものでファイル名の変更や小サイズの変更などブロックサイズの変化しない更新は察知できませんので、前回実行から1時間以上経過していたらバックアップ元ディスク容量が変化していなくてもrsyncを実行するようにしています。

**主な設定項目**

```
// ミラーリング元ディレクトリ
define('SOURCE_DIR', '/home/nas/data/');
```
ミラーリング元となるディレクトリを指定。

```
// ミラーリング先ディレクトリ
define('BACKUP_DIR', '/home/nas_backup/data/');
```
ミラーリング先となるディレクトリを指定。
こちらは先頭に「ユーザー名@ホスト名:」等を含めたリモートの指定も可能です。

```
define('BACKUP_DIR', 'username@hostname:/home/username/data/');
```

リモートを指定した場合、cronでの自動実行時にはリモートへのログイン時にパスワード入力待ちが発生しないようパスワード無しでの[公開鍵認証ログイン](https://akebi.jp/temp/ssh-keygen.html)ができるよう適宜設定しておく必要があります。

----

**generation.php**
ミラーリングされたディレクトリを元に、世代管理用ディレクトリに対してrsyncの--link-destオプションを使用したバックアップを行うスクリプトです。
バックアップ用ドライブがNAS本体とは別のリモートにある場合はこのスクリプトもリモート側へ設置します。

```php:generation.php
<?php
/**
 *  rsync 世代バックアップ
 */

// バックアップ元ディレクトリ
define('SOURCE_DIR', '/home/nas_backup/data/');

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

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

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

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

/**
 *
 */

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

// 一時ファイル保存用ディレクトリ
define('TEMP_DIR', (file_exists('/dev/shm/') ? '/dev/shm/.' : '/var/tmp/.'). md5(__DIR__));
if(!file_exists(TEMP_DIR)) {
    mkdir(TEMP_DIR);
    chmod(TEMP_DIR, 0700);
}

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

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

$nowDate = date('Y-m-d_Hi');

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

// ロックファイルが存在していたら同名のプロセス実行中とみなし2分まで待機、その間に開放されなければ終了
$time = time();
while(file_exists($lockFilename)) {
    sleep(1);
    if($time + 120 < time()) {
        print "A process with the same name is running.\n";
        exit;
    }
}
// ロックファイル作成
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);

// バックアップ済みディレクトリ名を取得
$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_slice($m, 1);
    $fDate = "$year-$month-$day $hour:$minute";

    // 1か月以上経過しているものはその月の最終のもの以外を削除
    if(time() >= strtotime("$fDate +1 month")) {
        $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")) {
        $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);
}

// 既存世代バックアップがある場合
if(count($backupList)) {
    rsort($backupList);
    // 保存世代数を超えるバックアップを古いものから削除
    if(count($backupList) >= BACKUP_GENERATION) {
        $delNames = array_slice($backupList, BACKUP_GENERATION -1);
        foreach($delNames as $del) {
            $command = "rm -rf {$backupDir}{$del}";
            print "$command\n";
            exec($command);
        }
    }
}

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

// rsyncコマンド
$command = implode(" ", [
        "rsync -avH",
        OTHER_OPTIONS,
        "--link-dest={$sourceDir}",
        $sourceDir,
        sprintf("%s%s", $backupDir, $backupName),
    ]);
print "$command\n";
exec($command);

// バックアップ済みディレクトリ名を再取得
$backupList = getBackupList($backupDir);
// 1世代前のバックアップとの差分でログのみ取得
if(count($backupList) > 1) {
    rsort($backupList);
    $command = "rsync -avHn --delete --exclude=/_rsync.log {$backupDir}{$backupList[0]}/ {$backupDir}{$backupList[1]}/ > {$backupDir}_rsync.log";
    exec($command);
    exec("mv {$backupDir}_rsync.log {$backupDir}{$backupList[0]}");
}

// ロックファイル削除
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;
}
```

実行日時を名前としたディレクトリを作成し、その中にその時点のバックアップを残していきます。
rsyncの--link-destオプションを使うことで新規追加や変化のあったファイルのみが実体として保存され、それ以外のファイルはハードリンクが追加されるだけですので、ディスク容量消費や処理時間は増分バックアップと同程度でありながら作成されるバックアップはそれぞれがフルバックアップ相当になるという特徴があります。
1日以上経過したバックアップはその日の最終版のみ残して削除、1か月以上経過したバックアップはその月の最終版を残して削除、THRESHOLDで指定したディスク使用量に達した場合は下回るまで古いバックアップから削除といった処理もこちらのスクリプトで行なっています。

ハードリンクを有効に活用するため--link-destには1世代前のバックアップを指定するのが一般的ですが、今回の場合$sourceDir自身が既にミラーリングされたバックアップの一部なのでこちらを--link-destに指定しています。
こうすることで、容量の節約と同時に処理速度の短縮も図れます。

**主な設定項目**

```
// バックアップ元ディレクトリ
define('SOURCE_DIR', '/home/nas_backup/data/');
```
mirroring.phpでミラーリング先となったディレクトリを指定します。

```
// バックアップ先ディレクトリ
define('BACKUP_DIR', '/home/nas_backup/generation/');
```
世代管理バックアップ保存先を指定します。
このディレクトリの下に
YYYY-MM-DD_HHMM
形式でディレクトリが作成され、その中に各世代のバックアップが保存されていきます。
rsyncの--link-destオプションを使用し変更のないファイルは実体ではなくハードリンクが作成されますので、必要以上にディスク容量を消費しません。

```
// バックアップ世代数
define('BACKUP_GENERATION', 200);
```
保存したい世代数を指定します。
世代バックアップ数がこの値を超えたら古いバックアップから削除されますが、間引き処理やディスク容量による削除処理の兼ね合いで、ここで指定した数に達する前に削除が行なわれる場合もあります。

```
// 古いバックアップを削除するディスク容量閾値(%)
// 0の場合はディスク容量のチェックは行いません
define('THRESHOLD', 95);
```
dfコマンドでバックアップ先のディスク使用量(%)をチェックし、この値に達していたら値を下回るまで古いバックアップから順に削除を行います。
0では削除処理を行わなくなりますが、バックアップ先の空き容量が足りなくてもrsyncの実行を抑制する等の処理は行いません。

----

###crontab設定例

```
# rsync mirroring
* * * * * php /スクリプト設置パス/mirroring.php &> /dev/null
* * * * * sleep 30; php /スクリプト設置パス/mirroring.php &> /dev/null

# rsync generation backup
0 */6 * * * php /スクリプト設置パス/generation.php &> /dev/null
```

上記の例では前半ブロックで30秒ごとのミラーリングを、後半ブロックで6時間ごとに世代管理バックアップを行なっています。