WSL対応 rsyncでリモートバックアップや世代管理するPowerShellスクリプト

TL;DR

  • WindowsのWSLでもLinuxでもrsyncが動作
  • インクリメンタル世代管理バックアップで容量削減
  • 世代管理しない差分コピーで大容量ファイルをバックアップ
  • リモートバックアップ
  • ディレクトリを選んでバックアップ
  • ディレクトリ毎に前処理、後処理をプログラムする(例: バックアップ直前にアプリケーションを停止)
  • DiscordやSlackに通知

これ全部できます。

動作確認環境

更新履歴

日時 内容
2019/08/27 ・動作確認環境を追記
・導入手順にwsl.confの編集が含まれていなかったのを修正
・ログの文字化けに関する対策
・Remove-Itemが削除できないファイルパスの条件を明瞭に

導入

1.まずはPowerShellシンタックスハイライトが機能するテキストエディタをインストールしておきましょう。
オススメはVSCodeです。
code.visualstudio.com

2.PowerShell Coreをインストールします。Windows PowerShellは古いので動作しません。
docs.microsoft.com
docs.microsoft.com

3.GitHubからスクリプトをダウンロードします。
github.com

4.スクリプト内のユーザ設定を編集します。
スクリプト内の設定例と照らし合わせつつ行うと良いでしょう。

設定1 グローバル設定
項目 解説
OS pwshを実行するオペレーティングシステムを指定します。
LinuxではrsyncWindowsではwsl rsyncが使用されます。
Windowsでは関数ConvertTo-WslPathによってWindowsからLinuxのフォーマットにパスが変換されます。
DateTime ディレクトリの日付ではなく、ディレクトリ名によって世代管理を行うため、わからない場合は変更しないでください。
Log Path ログを出力するディレクトリを指定します。
OSに合わせたパスのフォーマットにしてください。
CntMax ログローテートの閾値を設定します。
Post hookUrl DiscordSlack のWebhook URLを指定します。
設定2 ディレクトリ毎の設定
項目 解説
BeginScript
EndScript
処理順は 設定1 -> 設定2 -> 関数の定義 -> BeginScript -> MirList -> GenList -> EndScript
設定1の内容は設定2の中で使用可能($Settings.DateTime等)

前処理、後処理ではPowerShellのコマンドレット以外に以下の独自の関数が使用可能です。
WebhookにPostするSend-Webhook
# 既定値
Send-Webhook -UserName "AutoBackupWSL.ps1" -Content "投稿内容が未指定です" -hookUrl $Settings.Post.hookUrl
# 使用例
Send-Webhook -Content "Backup $($Settings.DateTime.Replace('\','')) Started."
トースト通知を行うSend-Toast
# 既定値
Send-Toast -Icon "$PSHome\assets\Powershell_black.ico" -Title "AutoBackupWSL.ps1" -Text "通知内容が未指定です"
# 使用例
Send-Toast -Icon "$PSHome\assets\Powershell_av_colors.ico" -Text "バックアップ終了"
wslpath -uの上位互換ConvertTo-WslPath
ConvertTo-WslPath -Path "Windowsのパス"
stdout、stderrを正しく受け取れるプロセス起動関数Invoke-Process
# Windows
Invoke-Process -File "実行ファイルパス" -Arg "引数"
# Linux
Invoke-Process -File "実行ファイルパス" -ArgList "引数","引数"
MirList *は必須。
ローカル/リモートの差分バックアップ用の設定
SrcPath* コピー元のパスを指定します。
SrcClude コピー元の中から除外または含める条件を指定します。
DstPath* コピー先のパスを指定します。
Execute リモートバックアップ時はSSHに関する引数を指定します。
Begin このディレクトリのバックアップの直前に実行されるカスタム処理を追加できます(ScriptBlock)。
End このディレクトリのバックアップの直後に実行されるカスタム処理を追加できます(ScriptBlock)。
GenList *は必須。
ローカルのインクリメンタル世代管理バックアップ用の設定
SrcPath* コピー元のパスを指定します。
rsync -av --delete --delete-excluded $Clude --link-dest=`"$Link`" `"$Src`" `"$Dst`"
SrcClude コピー元の中から除外または含める条件を指定します。
DstParentPath* 世代管理先の親ディレクトリのパスを指定します。
DstGenThold 同じDstParentPathの最初*世代数の閾値を指定します。
DstGenExclude 同じDstParentPathの最初に世代管理先の親ディレクトリと同等の場所に除外すべきディレクトリがある場合はここで指定します。
DstChildPath 世代管理先に作られるディレクトリを指定します。
Begin このディレクトリのバックアップの直前に実行されるカスタム処理を追加できます(ScriptBlock)。
End このディレクトリのバックアップの直後に実行されるカスタム処理を追加できます(ScriptBlock)。

5.#WSLのWindows上のファイルアクセス権の通り、wsl.confを編集します。

使い方

1.pwshで直に実行

./AutoBackupWSL.ps1
C:\bin\AutoBackupWSL.ps1

2.cmdやshからpwshを実行

pwsh AutoBackupWSL.ps1

3.タスクスケジューラやcronで自動実行

プログラムの開始: "C:\Program Files\PowerShell\6\pwsh.exe"
引数の追加: -WindowStyle Hidden -File "C:\bin\AutoBackupWSL.ps1"(例)

# cronの設定ファイルを開く
vi /etc/crontab

# 毎日9時に実行
0 9 * * * minecraft /usr/bin/pwsh /home/minecraft/Servers/AutoBackupWSL.ps1

解説

なぜrobocopy・scpではなくrsyncなのか

  1. robocopyはインクリメンタルバックアップに対応していない。
    差分コピーは出来るが、コピー元・コピー先以外のディレクトリを参照してインクリメントバックアップを行うことは出来ない。
    4TBのHDDに、1世代数百GBを数ヶ月分世代管理できるのはrsyncあってのもの。
    実はrobocopyで擬似的にそれを再現した未公開のスクリプトがあるのだが...rsyncの方が上位互換であり存在意義が無くなってしまった。

  2. scpは差分コピーに対応していない。
    毎回フルバックアップ、というのはちょっと。
    これを自前実装するのは困難と考え、WSL rsyncWindowsのバックアップに使えるようにしたのがAutoBackupWSL.ps1。

なぜWindows PowerShellではなくPowerShell Coreなのか

  1. 日本語環境ではWindows PowerShellはASCII、PowerShell CoreはUTF-8
    Windowsにおける文字列恐怖症の貴方なら分かってくれるはず。うん。

  2. Invoke-CommandPowerShell Remorting over SSHができない。
    ほぼ決定打。

  3. Remove-Itemでパスが260char(文字)以上のアイテムが含まれるディレクトリを削除できない。
    かなりしんどい。
    因みにWSL上のpwshの場合260byte~不可。

WSLのWindows上のファイルアクセス権

初期設定では以下の通り。

/bin/mount -l

C:\ on /mnt/c type drvfs (rw,noatime,uid=1000,gid=1000,case=off)

wsl.confで以下の設定を行うことで、アクセス権が得られる。

# /etc/wsl.confを作成、以下のように設定
vi /etc/wsl.conf

[automount]
enabled = true
root = /mnt/
options = "rw,noatime,uid=1000,gid=1000,umask=22,fmask=11"
mountFsTab = true

# 確認
/bin/mount -l

C:\ on /mnt/c type drvfs (rw,noatime,uid=1000,gid=1000,umask=22,fmask=11,case=off)

devblogs.microsoft.com

インクリメンタルとリモートバックアップは同時にできない

リモートバックアップと--link-destを一行に書いたら、フルバックアップになってしまいました。
リモートバックアップの世代管理を行う場合、rsyncで差分リモートバックアップしてから、ローカルにあるリモートのファイルをインクリメンタルバックアップする、という流れになります。
GenListにリモートバックアップ機能が無く、MirListの後にGenListを実行しているのはそれが理由です。

wslpathでは変換できないパスがある

WSLにはwslpathというWindowsLinuxのパスを相互に変換するコマンドが用意されています。
ただしこれでは全てを網羅しません。

# wslpathではドライブレターのみを変換できない

$ wslpath -u 'D:'
wslpath: D:

# \があれば正常に変換できる
$ wslpath -u 'D:\'
/mnt/d/

関数ConvertTo-WslPathとして、自前正規表現を作って対処することにしました。

function ConvertTo-WslPath
{
    param
    (
        [String]$Path
    )
    #"D:"に対応
    return [Regex]::Replace($Path, "^([A-Z]):(\\.*)?", { "/mnt/" + $args.Groups[1].Value.ToLower() + $args.Groups[2].Value.Replace('\','/')})
}
> ConvertTo-WslPath -Path 'D:'
/mnt/d

> ConvertTo-WslPath -Path 'D:\'
/mnt/d/

標準出力・エラー出力が記録されない

PowerShellにおいて、ただwsl rsyncと書くのでは、引数が長くなるにつれて正しく扱えません。
また、Start-Processではstdout・stderr両方を直接1つのログファイルに出力出来ません。
.NETクラスSystem.Diagnostics.Processを使用して、安全に購読します。

以下を丸パk参考にさせて頂きました。
私のスクリプトの方は最低限必要なところと、次項で説明するLinuxへの対応が含まれています。

github.com

WindowsLinuxの引数の違い

# Linux上のpwshでStart-Processからrsyncを実行
Start-Process "/bin/bash" -ArgumentList "-c '/usr/bin/rsync -av --delete  --link-dest=`'/home/hoge/Backup/190627_104610`' `'/home/hoge/.ssh`' `'/home/hoge/Backup/190627_121149`''"

-av: -c: line 0: unexpected EOF while looking for matching `''
-av: -c: line 1: syntax error: unexpected end of file

シングルクォートをエスケープしたり、それを更にエスケープ…というのは可読性も低く、ユーザ設定が困難になる為却下。

関数Invoke-Process内では、WindowsではStartInfo.ArgumentsLinuxではStartInfo.ArgumentListプロパティを使用するようにして対応。

if ($Arg)
{
    #Windows
    $ps.StartInfo.Arguments = $Arg
} elseif ($ArgList)
{
    #Linux
    $ArgList | ForEach-Object {
        $ps.StartInfo.ArgumentList.Add("$_")
    }
}

github.com

rsyncのexcludeが効かないと思ったら見当違いだった話

関数Invoke-DiffBackup内には以下のコードがあります。

Invoke-Process -File "wsl" -Arg "/usr/bin/rsync $Execute -av --delete --delete-excluded $Clude `"$Src`" `"$Dst`""

--deleteはコピー元にあってコピー先にないものを削除する引数ですが、--excludeされたものが既にあろうと削除しません。
--delete-excludedを指定することによって、--excludeされたものも含めることが出来ます。
ちゃんと--excludeされているにも関わらず、--excludeされてねぇ!!!と奔走しましたが、原因は斜め上にありました。

serverfault.com