本文へ移動
購読
GUIDE#infra更新 2026.10.03

rsyncの使い方:オプションの読み方と--deleteを安全に使う手順

rsync 3.4.1を2台のマシン間のSSHで検証。末尾スラッシュ、--deleteのdry-run、--excludeの基準、-aで落ちる属性、--link-destの世代管理、rrsyncの鍵制限を出力で確認。

level
中級
time
約60分
stack
Ubuntu 26.04.1 LTS / rsync 3.4.1 (3.4.1+ds1-7ubuntu0.3) / OpenSSH 10.2p1 / Python 3.14.4(rrsync用)
author
@ryoma_t
// 前提条件
Linuxマシン2台(またはDockerコンテナ2つ)とsudo権限 / 2台の間でSSH(22番)が通ること / シェルの基本操作
rsyncの--delete付きdry-runで、転送元を間違えると18行の*deletingが並び、正しい転送元では1行だけになることを確認したターミナル
画像:nagoya.dev(実際の画面・実行結果をもとに作成)

rsyncとは、転送元と転送先のファイルを比べ、サイズか更新時刻が変わったものだけを送って2つのディレクトリを同じ状態にそろえるコマンドです。片方向のコピーであり、双方向の同期ツールでも、世代を持つバックアップソフトでもありません。Ubuntu 26.04.1 の rsync 3.4.1 を2台のマシンの間でSSH越しに動かし、末尾スラッシュの違い、--deleteで消えるファイル、--excludeの効き方、エラーメッセージを実際の出力で確かめました。

対象は、サーバー間のファイルコピーやバックアップをrsyncで組んでいる(これから組む)インフラ担当・情シスの方と、自宅のNASやサーバーへデータを逃がしたい方です。読み終えると、--delete付きのコマンドを流す前に何を確認するか、cronに載せる鍵をどこまで絞れるか、rsyncで済ませる場面とscp・resticに任せる場面の線引きが手元に残ります。

この記事でわかること

  • 転送元の末尾スラッシュの有無で、転送先のディレクトリ構成がどう変わるか(実際の一覧で比較)
  • --deleteの前に--dry-run --itemize-changesで「消えるファイル」を読む手順と、--max-deleteが実際に止める範囲
  • --excludeのパターンが「転送ルート」を起点に照合される規則と、-aが運ばないもの(ハードリンク・ACL・拡張属性)
  • --link-destで作った世代バックアップのディスク使用量の実測と、ファイル単位の共有であることの限界
  • rrsyncでバックアップ用の鍵からシェルを取り上げる設定と、rsync・scp・resticの判断基準

検証環境:2台のマシンにrsyncとSSH鍵を用意する

検証はDockerのubuntu:26.04コンテナ2つで行いました。転送元のホスト名は nagoyadevlab01、転送先は nagoyadevlab02、作業ユーザーはどちらも ryoma です。いずれも検証用に付けた名前なので、コマンドや出力に出てくるryoma、/home/ryoma、nagoyadevlab01、nagoyadevlab02は自分のユーザー名とホスト名に読み替えてください。途中に出てくるssh-ed25519 AAAA…の公開鍵も検証環境のものです。コンテナは--privilegedなし・ポート公開なしで同じユーザー定義ネットワークに置き、転送先には nagoyadevlab02 という名前で届くようネットワークエイリアスを付けました。出力はどれも端末を割り当てないセッション(docker exec)で取ったものです。端末から手で打つと、scpはファイルごとの進捗行を表示し、rrsync -hの折り返し位置は端末の幅で変わります。実機2台で試すなら、名前の部分をIPアドレスやDNS名に替えるだけで同じ手順になります。

接続にはSSHを使います。転送元のrsyncがsshで転送先に入り、そこで相手側のrsyncを起動して、2つのrsyncがSSHの中で会話する仕組みです。通信に必要なのはSSHの22番ポートだけで、常駐型のrsyncデーモン(既定でTCP 873番)は起動しておらず、今回は扱いません。書き分けはrsync --helpに出ているとおりで、ホスト:パスとコロン1つならSSH経由、ホスト::モジュールやrsync://はデーモン宛てです。

転送先(例では nagoyadevlab02)で root として実行したのは次のコマンドです。SSHサーバーのほか、後半の節で使うpython3(rrsyncの実行に必要)と、ACL・拡張属性を確認するacl・attrを入れ、作業ユーザーを作ってsshdを起動します。rsync本体は、入れ忘れたときのエラーを次の節で見るために、ここではわざと入れていません。コンテナにはsystemdがないので/usr/sbin/sshdを直接起動していますが、すでにSSHでログインできている実機のサーバーではこの行は不要で、パッケージはsudo apt installで入れます。

terminal(転送先・root)
apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq openssh-server python3 acl attr >/dev/null 2>&1; echo "exit=$?"
exit=0
useradd -m -s /bin/bash ryoma && id ryoma
uid=1001(ryoma) gid=1001(ryoma) groups=1001(ryoma)
/usr/sbin/sshd; echo "exit=$?"
exit=0

転送元(例では nagoyadevlab01)にはrsyncとSSHクライアントを入れます。sudoは「つまずきポイント」の節でrootとしてrsyncを動かすために入れ、検証を止めないようパスワードなしで通る設定にしました。この/etc/sudoers.dの行は検証コンテナ専用で、実機に持ち込むものではありません。

terminal(転送元・root)
apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq rsync openssh-client sudo acl attr >/dev/null 2>&1; echo "exit=$?"
exit=0
useradd -m -s /bin/bash ryoma && echo "ryoma ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/ryoma && chmod 440 /etc/sudoers.d/ryoma && id ryoma
uid=1001(ryoma) gid=1001(ryoma) groups=1001(ryoma)
grep PRETTY_NAME /etc/os-release; rsync --version | head -n 1; ssh -V
PRETTY_NAME="Ubuntu 26.04.1 LTS"
rsync  version 3.4.1  protocol version 32
OpenSSH_10.2p1 Ubuntu-2ubuntu3.6, OpenSSL 3.5.5 27 Jan 2026

ここからは作業ユーザーで進めます。鍵はed25519で作りました。検証を非対話で回すために-N ""でパスフレーズを空にしていますが、人が日常的に使う鍵には必ずパスフレーズを付けます(鍵の種類とパスフレーズの扱いはssh-keygenでSSH鍵を作る記事にまとめています)。パスフレーズなしが許されるのは、後半で設定する、できることを絞った無人実行用の鍵だけです。

terminal(転送元)
ssh-keygen -q -t ed25519 -N "" -C "ryoma@nagoyadevlab01" -f ~/.ssh/id_ed25519 && cat ~/.ssh/id_ed25519.pub
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICHfz4ehY/R4A4qqztWIkS25cDeEY155/lqyvvOaHpgz ryoma@nagoyadevlab01

表示された公開鍵の1行を、転送先の同じユーザーの~/.ssh/authorized_keysに追記します。下のブロックのssh-ed25519 AAAA…は検証環境の鍵なので、必ず自分の画面に出た1行に置き換えてください。転送先にパスワードでSSHログインできるならssh-copy-idでも同じ結果になります(検証用ユーザーにはパスワードを設定していないため、ここでは実行していません)。続けて、転送先のホスト鍵の指紋を表示しておきます。

terminal(転送先)
install -d -m 700 ~/.ssh && echo "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICHfz4ehY/R4A4qqztWIkS25cDeEY155/lqyvvOaHpgz ryoma@nagoyadevlab01" >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys
ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub
256 SHA256:y3JwqoxvdfWUyBQnk2tjSuD6w3zqo5hqomXugO7RGXs root@nagoyadevlab02 (ED25519)

転送元に戻り、転送先のホスト鍵をknown_hostsに登録します。ssh-keyscanが取ってきた鍵の指紋が、いま転送先で表示したSHA256:以降と同じであることを目で確かめてから先へ進みます。これを飛ばしたまま、cronのように対話できない環境から初めて接続すると、sshは確認の質問を出せずにHost key verification failed.で止まります。

terminal(転送元)
ssh-keyscan -t ed25519 nagoyadevlab02 >> ~/.ssh/known_hosts; ssh-keygen -lf ~/.ssh/known_hosts
256 SHA256:y3JwqoxvdfWUyBQnk2tjSuD6w3zqo5hqomXugO7RGXs nagoyadevlab02 (ED25519)
ssh ryoma@nagoyadevlab02 hostname
nagoyadevlab02

rsyncコマンドの基本形と最初の転送

テスト用のデータは転送元のホームに~/dataとして作りました。設定ファイル、文書、2MiBの画像相当ファイルが4つ、20万行のログ、一時ファイルで、合計18Mです。画像と一時ファイルの中身は乱数なので、同じ手順でも内容は毎回違いますが、ファイル名とサイズはそろいます。

terminal(転送元)
mkdir -p ~/data/conf ~/data/docs/tmp ~/data/images ~/data/logs ~/data/tmp
printf 'listen = 127.0.0.1:8080\nworkers = 4\n' > ~/data/conf/app.conf
printf 'rsync manual v1\n' > ~/data/docs/manual.txt; printf 'draft\n' > ~/data/docs/tmp/draft.txt
for m in 08 09; do printf 'minutes 2026-%s\n' "$m" > ~/data/docs/minutes-2026-$m.txt; done
for i in 1 2 3 4; do head -c 2M /dev/urandom > ~/data/images/photo-0$i.bin; done
seq 1 200000 | sed 's/^/2026-10-01T09:00:00 INFO request_id=/' > ~/data/logs/app.log
head -c 1M /dev/urandom > ~/data/tmp/scratch.bin
find ~/data -type f | sort; du -sh ~/data
/home/ryoma/data/conf/app.conf
/home/ryoma/data/docs/manual.txt
/home/ryoma/data/docs/minutes-2026-08.txt
/home/ryoma/data/docs/minutes-2026-09.txt
/home/ryoma/data/docs/tmp/draft.txt
/home/ryoma/data/images/photo-01.bin
/home/ryoma/data/images/photo-02.bin
/home/ryoma/data/images/photo-03.bin
/home/ryoma/data/images/photo-04.bin
/home/ryoma/data/logs/app.log
/home/ryoma/data/tmp/scratch.bin
18M	/home/ryoma/data

rsyncコマンドの基本形はrsync オプション 転送元 転送先で、リモート側はユーザー名@ホスト名:パスと書きます。パスを/で始めなければ、相手のホームディレクトリからの相対パスです。リモートにできるのは転送元か転送先のどちらか片方だけで、リモート同士を直接つなぐことはできません。まず-a(属性を保って再帰的にコピー)と-v(ファイル名を表示)だけで送ってみます。

terminal(転送元・エラー)
rsync -av ~/data/ ryoma@nagoyadevlab02:backup/; echo "exit=$?"
bash: line 1: rsync: command not found
rsync: connection unexpectedly closed (0 bytes received so far) [sender]
rsync error: error in rsync protocol data stream (code 12) at io.c(232) [sender=3.4.1]
exit=12

1行目のbash: line 1: rsync: command not foundは、転送先のシェルが出したメッセージです。転送元だけでなく転送先にもrsyncが入っていなければ動きません。立てたばかりのサーバーや、NAS・アプライアンスを相手にしたときに最初に当たるのがこれです。同じ状態でもscpは通ります。scpはSFTPで転送するので、相手に必要なのはsshdだけだからです。

terminal(転送元)
scp -rp ~/data/docs ryoma@nagoyadevlab02:scp-test; echo "exit=$?"; ssh ryoma@nagoyadevlab02 'ls scp-test'
exit=0
manual.txt
minutes-2026-08.txt
minutes-2026-09.txt
tmp

転送先にrsyncを入れて、同じコマンドをもう一度実行します。

terminal(転送先・root)
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq rsync >/dev/null 2>&1; rsync --version | head -n 1
rsync  version 3.4.1  protocol version 32
terminal(転送元)
rsync -av ~/data/ ryoma@nagoyadevlab02:backup/
sending incremental file list
created directory backup
./
conf/
conf/app.conf
docs/
docs/manual.txt
docs/minutes-2026-08.txt
docs/minutes-2026-09.txt
docs/tmp/
docs/tmp/draft.txt
images/
images/photo-01.bin
images/photo-02.bin
images/photo-03.bin
images/photo-04.bin
logs/
logs/app.log
tmp/
tmp/scratch.bin

sent 17,931,526 bytes  received 297 bytes  35,863,646.00 bytes/sec
total size is 17,926,169  speedup is 1.00

created directory backupのとおり、転送先のディレクトリは最後の1階層だけならrsyncが作ります(2階層以上足りないときのエラーは「つまずきポイント」で扱います)。最終行のspeedupは、total size(対象ファイルの合計サイズ)を送受信したバイト数の合計で割った値です。全部送った初回は1.00になります。直後に同じコマンドをもう一度流すと、こうなります。

terminal(転送元)
rsync -av ~/data/ ryoma@nagoyadevlab02:backup/
sending incremental file list

sent 503 bytes  received 18 bytes  1,042.00 bytes/sec
total size is 17,926,169  speedup is 34,407.23

ファイル名は1つも表示されず、送信503バイト・受信18バイトで終わりました。両側のファイル一覧を比べ、サイズと更新時刻が同じファイルは中身を読まずに飛ばしたからです。17,926,169バイトのデータに対してやり取りしたのは一覧の情報だけで、これがcpやscpとのいちばんの違いです。なお、送信側のバイト数は同じ手順でも実行のたびに数十バイトの幅で変わり(別の回では494バイトでした)、speedupとbytes/secも毎回変わります。

末尾スラッシュで転送先の構成が変わる

rsyncでいちばん事故につながりやすいのが、転送元の末尾の/です。~/data/docsと書くと「docsというディレクトリそのもの」を、~/data/docs/と書くと「docsの中身」を送ります。別々の転送先に送り、できあがった構成を並べました。

terminal(転送元)
rsync -a ~/data/docs ryoma@nagoyadevlab02:slash-a/
rsync -a ~/data/docs/ ryoma@nagoyadevlab02:slash-b/
ssh ryoma@nagoyadevlab02 'find slash-a slash-b | sort'
slash-a
slash-a/docs
slash-a/docs/manual.txt
slash-a/docs/minutes-2026-08.txt
slash-a/docs/minutes-2026-09.txt
slash-a/docs/tmp
slash-a/docs/tmp/draft.txt
slash-b
slash-b/manual.txt
slash-b/minutes-2026-08.txt
slash-b/minutes-2026-09.txt
slash-b/tmp
slash-b/tmp/draft.txt

スラッシュなしのslash-aにはdocsという階層が1つ増え、スラッシュありのslash-bには中身が直接並びました。転送先側の末尾スラッシュは、転送元がディレクトリなら結果を変えません(slash-cと/なしで送ってもslash-bと同じ構成になりました)。

筆者は「転送元も転送先も、ディレクトリ名のあとに/を付ける」に統一しています。rsync -a ~/data/ 相手:backup/なら「dataの中身をbackupの中身にする」と読め、次々節の--deleteで消える範囲もbackup/の中だけだと一目でわかるからです。逆に、/srv/appを相手の/srv/の下へ置くように、ディレクトリごと別の場所へ移すときはスラッシュなしの方が自然です。

rsyncのオプションの読み方:-aの中身と-iの11文字

-aは7つのオプションをまとめた省略形です。何が含まれ、何が含まれないかはrsync --helpの1行に書いてあります。

terminal(転送元)
rsync --help | grep -E -- '--(archive|hard-links|acls|xattrs),'
--archive, -a            archive mode is -rlptgoD (no -A,-X,-U,-N,-H)
--hard-links, -H         preserve hard links
--acls, -A               preserve ACLs (implies --perms)
--xattrs, -X             preserve extended attributes

-rlptgoDは順に、再帰(-r)、シンボリックリンクをリンクのまま(-l)、パーミッション(-p)、更新時刻(-t)、グループ(-g)、所有者(-o)、デバイスファイルと特殊ファイル(-D)です。このうち-tが、2回目以降の「サイズと更新時刻が同じなら飛ばす」判定を支えています。更新時刻を運ばないと転送先のファイルは転送した時刻になり、次回はすべてのファイルが更新対象に見えてしまいます。括弧の中の-A・-X・-U・-N・-Hは含まれない側で、ACL・拡張属性・ハードリンクが実際に落ちる様子は後の節で確かめます。

よく使うオプションを、筆者がいつ付けていつ外すかと合わせて表にします。

オプション何をするか付ける場面・外す条件
-a(--archive)-rlptgoDの省略形ほぼ常に付ける。所有者の保持は受け側がrootのときだけ効く
-v(--verbose)転送したファイル名と最後の集計を表示手で打つとき。cronでは外し、終了コードとエラー出力で判断する
-n(--dry-run)何も変更せず、実行した場合の結果だけ表示--deleteや--excludeを変えた直後は必ず
-i(--itemize-changes)変更の種類を11文字の記号で表示-nと組み合わせ、何がなぜ送られるかを読む
--delete転送元にないファイルを転送先から消すミラーにするとき。転送元の指定を誤ると全消去になる(後述)
-z(--compress)転送中のデータを圧縮回線が細いWAN越しのテキスト向き。LAN内や、画像・動画・zipのような圧縮済みデータでは外す
-H・-A・-Xハードリンク・ACL・拡張属性を保つ-aには含まれない。属性に意味があるデータを移すときに足す
-e(--rsh)接続に使うコマンドを指定鍵やポートを変えるとき。-e "ssh -p 2222"のようにsshの引数ごと1つの文字列で渡す
--stats転送量の内訳を表示差分転送がどれだけ効いたかを数字で確かめる
-P--partial --progressと同じ大きなファイルを切れやすい回線で送るとき。途中まで届いたファイルを残して再開に使う
--bwlimit=値転送速度の上限。単位を付けなければ1024バイト/秒単位業務時間中に回線を占有したくないとき

-nと-iの組み合わせは、rsyncが何を、なぜ送ろうとしているかを実行前に教えてくれます。転送元で5種類の変更を加えてから試しました。設定ファイルの書き換え(サイズは同じ)、touchだけ、パーミッションの変更、新規ファイル、ログへの100行の追記です。

terminal(転送元)
sed -i 's/workers = 4/workers = 8/' ~/data/conf/app.conf
touch ~/data/docs/manual.txt
chmod 600 ~/data/docs/minutes-2026-09.txt
printf 'minutes 2026-10\n' > ~/data/docs/minutes-2026-10.txt
seq 200001 200100 | sed 's/^/2026-10-01T09:00:00 INFO request_id=/' >> ~/data/logs/app.log
rsync -a --dry-run --itemize-changes ~/data/ ryoma@nagoyadevlab02:backup/
.d..t...... conf/
<f..t...... conf/app.conf
.d..t...... docs/
<f..t...... docs/manual.txt
.f...p..... docs/minutes-2026-09.txt
<f+++++++++ docs/minutes-2026-10.txt
<f.st...... logs/app.log

行頭の11文字はYXcstpoguaxという並びで、1文字目が動作、2文字目が種類(fはファイル、dはディレクトリ)、3文字目以降が「どの属性が違うか」です。この記事に出てくる出力は、次の読み方ですべて説明できます。

出力読み方
<f+++++++++新しく送るファイル。<はこちらから相手へ送る向き(相手から受け取るときは>)
<f.st......サイズ(s)と更新時刻(t)が違うので送り直す
<f..t......更新時刻だけが違う。サイズが同じでも転送対象になる
.f...p.....中身は送らず、パーミッション(p)だけ直す
.d..t......ディレクトリの更新時刻だけ直す
cd+++++++++ディレクトリを新しく作る
cd..t......ディレクトリを作る。--link-destで前の世代と比べたときの表示で、更新時刻が前の世代と違うことを示す
*deleting転送先から消す
hf と =>既にあるファイルへのハードリンクとして作る(-Hのとき)
.f.......a.・.f........xACL(a)・拡張属性(x)だけ直す(-A・-Xのとき)

conf/app.confはサイズが同じでも更新時刻が違うので転送対象になり、minutes-2026-09.txtは中身を送らずにパーミッションだけが直されます。では、20万行のログに100行足しただけのlogs/app.logは丸ごと送り直されるのでしょうか。--statsを付けて本番実行すると内訳が出ます。

terminal(転送元・抜粋)
rsync -a --stats ~/data/ ryoma@nagoyadevlab02:backup/
Number of regular files transferred: 4
Total transferred file size: 8,493,263 bytes
Literal data: 4,767 bytes
Matched data: 8,488,496 bytes
Total bytes sent: 17,147
Total bytes received: 17,615

転送対象になった4ファイルは合計8,493,263バイトありますが、新しく送ったデータ(Literal data)は4,767バイトだけで、残りの8,488,496バイトは転送先に既にある内容を使い回しています(Matched data)。通信量は送信17,147バイト、受信17,615バイトでした。これがrsyncの差分転送です。転送先が手元の古いファイルをブロックに区切ってチェックサムを返し、転送元は一致しない部分だけを送ります。送るものがなかった2回目の実行では受信が18バイトだったので、受信の大半はこのチェックサムです。

この仕組みが働くのはネットワーク越しのときです。転送元も転送先もローカルのパスなら、rsyncは既定でファイル全体をコピーします(-Wを付けたのと同じ動作)。同じディスク内のコピーでrsyncを使う利点は、差分転送ではなく「変わっていないファイルを飛ばす」ことの方にあります。

--deleteを安全に使う:dry-runで消えるファイルを先に読む

既定では、rsyncは転送先のファイルを消しません。転送元でimages/photo-04.binを削除してから普通に同期しても、転送先には残ったままです。

terminal(転送元)
rm ~/data/images/photo-04.bin
rsync -a ~/data/ ryoma@nagoyadevlab02:backup/; ssh ryoma@nagoyadevlab02 'ls backup/images'
photo-01.bin
photo-02.bin
photo-03.bin
photo-04.bin

転送元にないファイルを転送先からも消して、完全なミラーにするのが--deleteです。ここで、ありがちな打ち間違いを再現します。「変えたのはimagesだけだから」と転送元を~/data/images/に絞り、転送先をbackup/のままにしたコマンドです。実行する前に--dry-run --itemize-changesを付けます。

terminal(転送元)
rsync -a --delete --dry-run --itemize-changes ~/data/images/ ryoma@nagoyadevlab02:backup/
*deleting   tmp/scratch.bin
*deleting   tmp/
*deleting   logs/app.log
*deleting   logs/
*deleting   images/photo-04.bin
*deleting   images/photo-03.bin
*deleting   images/photo-02.bin
*deleting   images/photo-01.bin
*deleting   images/
*deleting   docs/tmp/draft.txt
*deleting   docs/tmp/
*deleting   docs/minutes-2026-10.txt
*deleting   docs/minutes-2026-09.txt
*deleting   docs/minutes-2026-08.txt
*deleting   docs/manual.txt
*deleting   docs/
*deleting   conf/app.conf
*deleting   conf/
.d..t...... ./
<f+++++++++ photo-01.bin
<f+++++++++ photo-02.bin
<f+++++++++ photo-03.bin

*deletingが18行並びました。rsyncは指示どおり「backup/をimages/の中身と同じにする」つもりなので、画像以外のディレクトリとファイルはすべて「転送元にない余分なもの」として削除対象になり、画像はbackup/の直下に置き直されます。dry-runなので、実際には何も消えていません。転送元を正しく~/data/に戻すと、削除されるのは意図した1ファイルだけになります。確認できたら--dry-runを外して本番実行します。

terminal(転送元)
rsync -a --delete --dry-run --itemize-changes ~/data/ ryoma@nagoyadevlab02:backup/
*deleting   images/photo-04.bin
rsync -a --delete ~/data/ ryoma@nagoyadevlab02:backup/; ssh ryoma@nagoyadevlab02 'ls backup/images'
photo-01.bin
photo-02.bin
photo-03.bin

筆者は--deleteを含むコマンドを、必ずこの順で実行します。まず--dry-run --itemize-changesを付けて流し、*deletingの行が想定した範囲に収まっていることを読んでから、同じコマンドを呼び戻して--dry-runだけを消します。-n -iと短く書いても同じです。削除のタイミングは、現在のrsync同士なら転送と並行(--delete-during)が既定で、rsync --helpにある--delete-delayや--delete-afterを付けると転送が終わってからに変わります。

--delete付きのコマンドで転送元と転送先を逆に書くと、古い側の内容で新しい側が上書きされ、新しく作ったファイルは削除されます。rsyncに取り消しはありません。dry-runの出力では、矢印が<(こちらから送る)か>(こちらが受け取る)かと、*deletingの行の2点を必ず読んでください。

転送元が空だったときの歯止め:--max-deleteと目印ファイル

dry-runは人が見ているときの対策で、cronで毎晩動くジョブには効きません。無人実行で怖いのは、転送元が空になっているケースです。外付けディスクやNFSのマウントが外れていると、マウントポイントは空のディレクトリに見え、--deleteは「転送元に何もないので全部消す」と解釈します。空のディレクトリ~/mnt-usbを転送元にして再現しました。dry-runで数えると、転送先の17個すべてが削除対象です。そこへ--max-delete=3を付けて、本当に実行します。

terminal(転送元・エラー)
mkdir ~/mnt-usb
rsync -a --delete --dry-run --itemize-changes ~/mnt-usb/ ryoma@nagoyadevlab02:backup/ | grep -c '^\*deleting'
17
rsync -a --delete --max-delete=3 ~/mnt-usb/ ryoma@nagoyadevlab02:backup/; echo "exit=$?"
cannot delete non-empty directory: images
cannot delete non-empty directory: docs/tmp
cannot delete non-empty directory: docs
cannot delete non-empty directory: conf
Deletions stopped due to --max-delete limit (11 skipped)
rsync error: the --max-delete limit stopped deletions (code 25) at main.c(1356) [sender=3.4.1]
exit=25

3つ消した時点で残りの削除をやめ、終了コード25で終わりました。転送先を見ると、ファイルはlogs/app.logとtmp/scratch.binの2つが消え(もう1つはディレクトリtmp/)、ほかの9ファイルは残っています。正しい転送元で同期し直せば、消えた分は転送元から戻ります。

terminal(転送元)
ssh ryoma@nagoyadevlab02 'find backup -type f | sort'
backup/conf/app.conf
backup/docs/manual.txt
backup/docs/minutes-2026-08.txt
backup/docs/minutes-2026-09.txt
backup/docs/minutes-2026-10.txt
backup/docs/tmp/draft.txt
backup/images/photo-01.bin
backup/images/photo-02.bin
backup/images/photo-03.bin
rsync -a --delete --itemize-changes ~/data/ ryoma@nagoyadevlab02:backup/
.d..t...... ./
.d..t...... logs/
<f+++++++++ logs/app.log
cd+++++++++ tmp/
<f+++++++++ tmp/scratch.bin

--max-deleteは事故を防ぐのではなく、被害に上限を付けるオプションです。上限までは実際に消えることと、上限に達すると終了コードが25になることを前提に、cronのジョブには「ふだんの実行で消える数より少し大きい値」を指定し、25が返ったら通知する、という使い方をします。戻せるのは転送元に元データが残っている場合だけなので、これだけに頼るのは危険です。

もう1段手前で止めるなら、転送元に目印のファイルを置き、それが見えないときはrsyncを起動しない書き方が確実です。マウントが外れていれば目印も見えません。

terminal(転送元)
test -e ~/mnt-usb/.rsync-source && rsync -a --delete ~/mnt-usb/ ryoma@nagoyadevlab02:backup/; echo "exit=$?"
exit=1

test -eが失敗して&&の右側(rsync)は実行されず、exit=1になりました。目印は、転送元が正しく見えている状態で一度だけtouch 転送元/.rsync-sourceで作っておきます。転送元がマウントポイントそのものなら、mountpoint -q /mnt/usb &&でも同じ判定ができます(空の~/mnt-usbに対してmountpointはis not a mountpointを返しました)。

--excludeと--exclude-from:パターンは転送ルートから数える

除外パターンでつまずく原因は、ほぼ「どこを起点に照合されるか」です。~/dataにはtmpという名前のディレクトリが2か所あります。直下のtmp/(捨ててよい一時ファイル)と、docs/tmp/(書きかけの下書きで、こちらは送りたい)です。まだ存在しない転送先ex-test/へのdry-runで、tmpを含む行だけを抜き出して比べます。

terminal(転送元)
rsync -a --dry-run --itemize-changes ~/data/ ryoma@nagoyadevlab02:ex-test/ | grep tmp
cd+++++++++ docs/tmp/
<f+++++++++ docs/tmp/draft.txt
cd+++++++++ tmp/
<f+++++++++ tmp/scratch.bin
rsync -a --dry-run --itemize-changes --exclude='tmp/' ~/data/ ryoma@nagoyadevlab02:ex-test/ | grep tmp | wc -l
0
rsync -a --dry-run --itemize-changes --exclude='/tmp/' ~/data/ ryoma@nagoyadevlab02:ex-test/ | grep tmp
cd+++++++++ docs/tmp/
<f+++++++++ docs/tmp/draft.txt
rsync -a --dry-run --itemize-changes --exclude='/home/ryoma/data/tmp/' ~/data/ ryoma@nagoyadevlab02:ex-test/ | grep tmp
cd+++++++++ docs/tmp/
<f+++++++++ docs/tmp/draft.txt
cd+++++++++ tmp/
<f+++++++++ tmp/scratch.bin
rsync -a --dry-run --itemize-changes --exclude='/tmp/' ~/data ryoma@nagoyadevlab02:ex-test/ | grep tmp
cd+++++++++ data/docs/tmp/
<f+++++++++ data/docs/tmp/draft.txt
cd+++++++++ data/tmp/
<f+++++++++ data/tmp/scratch.bin
rsync -a --dry-run --itemize-changes --exclude='/data/tmp/' ~/data ryoma@nagoyadevlab02:ex-test/ | grep tmp
cd+++++++++ data/docs/tmp/
<f+++++++++ data/docs/tmp/draft.txt

結果から読み取れる規則は次のとおりです。

  • tmp/のように途中に/を含まないパターンは、どの階層の名前にも一致する(2つ目のコマンドでは両方のtmpが転送対象から外れた)。末尾の/は「ディレクトリだけ」の意味
  • 先頭に/を付けると「転送ルートの直下」に固定される(3つ目)。転送ルートとは転送元に指定したディレクトリのことで、ファイルシステムの/ではない。だから絶対パスをそのまま書いた4つ目は何も除外しない
  • 転送元の末尾スラッシュを外すと、転送ルートが1階層上にずれる。同じ/tmp/が効かなくなり(5つ目)、/data/tmp/と書く必要が出る(6つ目)

迷ったら、dry-runの出力に出てくるパスの先頭に/を付けたものが、そのまま固定パターンになります。パターンが増えたらファイルにまとめ、--exclude-fromで読み込みます。#で始まる行はコメントです。

terminal(転送元)
cat > ~/rsync-exclude.txt <<'EOF'
# 転送ルート直下の tmp/ だけを外す(docs/tmp/ は送る)
/tmp/
# どの階層でも .bak で終わるファイルを外す
*.bak
EOF
cp ~/data/conf/app.conf ~/data/conf/app.conf.bak

--exclude-from=~/rsync-exclude.txtのように=でつなぐと、シェルは~を展開しません。そのままの文字列を受け取ったrsyncは~という名前のディレクトリを探しに行き、下のエラーで止まります。"$HOME/rsync-exclude.txt"と書くか、=を空白に変えて--exclude-from ~/rsync-exclude.txtとします。

terminal(転送元・エラー)
rsync -a --dry-run --exclude-from=~/rsync-exclude.txt ~/data/ ryoma@nagoyadevlab02:backup/; echo "exit=$?"
rsync: [client] failed to open exclude file ~/rsync-exclude.txt: No such file or directory (2)
rsync error: error in file IO (code 11) at exclude.c(1482) [client=3.4.1]
exit=11

--deleteと除外の組み合わせには、もう1つ知っておくべき既定動作があります。除外したファイルは「送らない」だけでなく「転送先で消さない」対象にもなります。前の節までの同期で転送先に入ったtmp/は、除外リストに加えたあとも残り続けます。消すには--delete-excludedを足します。

terminal(転送元)
rsync -a --delete --dry-run --itemize-changes --exclude-from="$HOME/rsync-exclude.txt" ~/data/ ryoma@nagoyadevlab02:backup/
.d..t...... conf/
rsync -a --delete --delete-excluded --dry-run --itemize-changes --exclude-from="$HOME/rsync-exclude.txt" ~/data/ ryoma@nagoyadevlab02:backup/
*deleting   tmp/scratch.bin
*deleting   tmp/
.d..t...... conf/
rsync -a --delete --delete-excluded --exclude-from="$HOME/rsync-exclude.txt" ~/data/ ryoma@nagoyadevlab02:backup/; ssh ryoma@nagoyadevlab02 'ls backup backup/conf'
backup:
conf
docs
images
logs

backup/conf:
app.conf

1つ目のdry-runには*deletingが出ず、--delete-excludedを足した2つ目でtmp/が削除対象になりました。両方に出ている.d..t...... conf/は、app.conf.bakを作ったことで変わったconf/の更新時刻を直すという意味で、.bak自体は除外されて送られていません。最後の本番実行のあと、転送先からtmpが消え、confに.bakがないことを確認しています。

-aがコピーしないもの:ハードリンク・ACL・拡張属性

-aは「全部保つ」ように見えて、ハードリンク・ACL・拡張属性は運びません。3つとも転送元に作って確かめます。photo-01.binのハードリンクcover.bin、app.confへのACL(www-dataユーザーに読み取りを追加)、manual.txtへの拡張属性user.deptです。

terminal(転送元)
ln ~/data/images/photo-01.bin ~/data/images/cover.bin
setfacl -m u:www-data:r ~/data/conf/app.conf
setfattr -n user.dept -v soumu ~/data/docs/manual.txt
rsync -a --itemize-changes --exclude-from="$HOME/rsync-exclude.txt" ~/data/ ryoma@nagoyadevlab02:backup/
.d..t...... images/
<f+++++++++ images/cover.bin

-aだけで同期すると、cover.binは新しいファイルとして送られ、ACLと拡張属性については何も表示されません。両側を比べます。1つ目が転送元、2つ目が転送先です。

terminal(転送元)
cd ~; stat -c '%h %n' data/images/photo-01.bin data/images/cover.bin; du -sh data/images; getfacl -c data/conf/app.conf; getfattr -d data/docs/manual.txt
2 data/images/photo-01.bin
2 data/images/cover.bin
6.1M	data/images
user::rw-
user:www-data:r--
group::r--
mask::r--
other::r--

# file: data/docs/manual.txt
user.dept="soumu"
ssh ryoma@nagoyadevlab02 "stat -c '%h %n' backup/images/photo-01.bin backup/images/cover.bin; du -sh backup/images; getfacl -c backup/conf/app.conf; getfattr -d backup/docs/manual.txt"
1 backup/images/photo-01.bin
1 backup/images/cover.bin
8.1M	backup/images
user::rw-
group::r--
other::r--

転送元ではリンク数2で同じ実体を指していた2つのファイルが、転送先ではリンク数1の別々のファイルになり、imagesの使用量は6.1Mから8.1Mに増えました。ACLのuser:www-data:r--とmaskの行、拡張属性user.deptも転送先にはありません。-H・-A・-Xを足してやり直します。

terminal(転送元)
rsync -aHAX --itemize-changes --exclude-from="$HOME/rsync-exclude.txt" ~/data/ ryoma@nagoyadevlab02:backup/
.f.......a. conf/app.conf
.f........x docs/manual.txt
hf          images/photo-01.bin => images/cover.bin
ssh ryoma@nagoyadevlab02 "stat -c '%h %n' backup/images/photo-01.bin backup/images/cover.bin; du -sh backup/images; getfacl -c backup/conf/app.conf; getfattr -d backup/docs/manual.txt"
2 backup/images/photo-01.bin
2 backup/images/cover.bin
6.1M	backup/images
user::rw-
user:www-data:r--
group::r--
mask::r--
other::r--

# file: backup/docs/manual.txt
user.dept="soumu"

hfの行がハードリンクとしての作り直し、.f.......a.と.f........xがACLと拡張属性だけの修正です。リンク数は2に戻り、使用量も6.1Mになりました。

筆者の使い分けはこうです。OSの設定やアプリケーションのデータ領域、次の節のスナップショット置き場のように、属性やリンクに意味があるものを移すときは-aHAX、Webコンテンツの配置や文書フォルダのコピーなら-aで足ります。見落としやすいのがファイルサーバーです。Sambaは既定でDOS属性(隠し・読み取り専用など)をファイルの拡張属性に保存するので、Sambaで作った共有の中身を-aだけで引っ越すと、その属性が落ちます。一方で-Hはリンクの対応を覚えておくぶんメモリと時間を使い、-A・-Xは転送先のファイルシステムやマウント方法が対応していないと失敗するので、常に付ければよいわけでもありません。

--link-destで世代バックアップを作り、ディスク使用量を測る

rsync単体が作るのはミラーですが、--link-destを使うと「前回の世代と同じファイルはハードリンクで共有し、変わったファイルだけ実体を持つ」世代ディレクトリを作れます。どの世代も普通のディレクトリとして丸ごと見えるのに、ディスクは増えた分しか使いません。3世代を作って使用量を測りました。1世代目はそのまま送り、2世代目の前にログへ100行追記、3世代目の前に2MiBの画像を1つ追加しています。

terminal(転送元)
rsync -aHAX --mkpath --exclude-from="$HOME/rsync-exclude.txt" ~/data/ ryoma@nagoyadevlab02:snapshots/2026-09-28/
seq 200101 200200 | sed 's/^/2026-10-01T09:00:00 INFO request_id=/' >> ~/data/logs/app.log
rsync -aHAX --itemize-changes --link-dest=../2026-09-28 --exclude-from="$HOME/rsync-exclude.txt" ~/data/ ryoma@nagoyadevlab02:snapshots/2026-09-29/
created directory snapshots/2026-09-29
<f.st...... logs/app.log
head -c 2M /dev/urandom > ~/data/images/photo-05.bin
rsync -aHAX --itemize-changes --link-dest=../2026-09-29 --exclude-from="$HOME/rsync-exclude.txt" ~/data/ ryoma@nagoyadevlab02:snapshots/2026-09-30/
created directory snapshots/2026-09-30
cd..t...... images/
<f+++++++++ images/photo-05.bin

--link-dest=../2026-09-28の相対パスは、転送先ディレクトリ(snapshots/2026-09-29/)から見た位置です。1つ目のコマンドの--mkpathは、まだないsnapshots/ごと作らせるために付けました。itemizeの出力は「前の世代と違うもの」だけで、2世代目はログ、3世代目は画像です。転送先のディスク使用量とリンク数を見ます。

terminal(転送元)
ssh ryoma@nagoyadevlab02 'du -sh snapshots/*; du -sh snapshots'
15M	snapshots/2026-09-28
8.2M	snapshots/2026-09-29
2.1M	snapshots/2026-09-30
25M	snapshots
ssh ryoma@nagoyadevlab02 "stat -c '%h %n' snapshots/*/images/photo-02.bin snapshots/*/logs/app.log"
3 snapshots/2026-09-28/images/photo-02.bin
3 snapshots/2026-09-29/images/photo-02.bin
3 snapshots/2026-09-30/images/photo-02.bin
1 snapshots/2026-09-28/logs/app.log
2 snapshots/2026-09-29/logs/app.log
2 snapshots/2026-09-30/logs/app.log

1世代目が15M、2世代目は8.2M、3世代目は2.1Mで、合計25Mです。duは同じ実体を最初に見つけた世代にだけ計上するので、2世代目以降の数字が「その世代で新しく増えた分」になります。リンク数を見ると、一度も変わっていないphoto-02.binは3世代で1つの実体を共有し(リンク数3)、ログは1世代目だけ別の実体(リンク数1)で、2世代目と3世代目が共有しています(リンク数2)。

ここに--link-dest方式の限界が出ています。2世代目で増えた8.2Mは、ログに100行追記しただけの結果です。共有の単位はファイルなので、少しでも変わったファイルは丸ごともう1つ持つことになります。追記され続けるログ、毎晩少しずつ変わるデータベースのダンプ、仮想マシンのディスクイメージが多いデータでは、世代の数だけ膨らみます。ファイルを可変長のかたまりに分けて重複を省くresticとの違いはここです。

古い世代は、ディレクトリごと消すだけです。ほかの世代と共有している実体は、最後のリンクが消えるまで残ります。

terminal(転送元)
ssh ryoma@nagoyadevlab02 'rm -rf snapshots/2026-09-28; du -sh snapshots/*; du -sh snapshots'
15M	snapshots/2026-09-29
2.1M	snapshots/2026-09-30
17M	snapshots

1世代目を消しても合計は25Mから17Mに減っただけで、残った2世代はどちらも完全な形で読めます。減った分は、1世代目だけが持っていた古いログです。

rrsyncでバックアップ用の鍵を制限する

ここまで使ってきた鍵は、転送先でシェルを開けて何でもできる鍵でした。同じ鍵をパスフレーズなしでcronに載せると、転送元が侵害された時点で転送先も自由に操作されます。rsyncパッケージに入っているrrsyncは、SSHの「この鍵で入ってきたら必ずこのコマンドを実行する」機能(authorized_keysのcommand=)を使い、鍵でできることを「指定したディレクトリの中でのrsync」だけに絞るスクリプトです。転送先で、置き場所とオプションを確認します。

terminal(転送先)
dpkg -L rsync | grep bin/; head -n 1 /usr/bin/rrsync; python3 --version
/usr/bin/rrsync
/usr/bin/rsync
/usr/bin/rsync-ssl
#!/usr/bin/python3
Python 3.14.4
rrsync -h
usage: rrsync [-ro | -wo] [-munge] [-no-del] [-no-lock] [-no-overwrite]
              [-help]
              DIR

Use "man rrsync" to learn how to restrict ssh users to using a restricted
rsync command.

positional arguments:
  DIR            The restricted directory to use.

options:
  -ro            Allow only reading from the DIR. Implies -no-del and -no-
                 lock.
  -wo            Allow only writing to the DIR.
  -munge         Enable rsync's --munge-links on the server side.
  -no-del        Disable rsync's --delete* and --remove* options.
  -no-lock       Avoid the single-run (per-user) lock check.
  -no-overwrite  Prevent overwriting existing files by enforcing --ignore-
                 existing
  -help, -h      Output this help message and exit.

rrsyncは/usr/bin/rrsyncにあるPythonのスクリプトです。rsyncパッケージはpython3に依存していないので(Suggests止まり)、最小構成のコンテナでは前提の節でpython3を別に入れました。オプションは、-roが読み出し専用(バックアップサーバーの側から取りに来させる構成向け)、-woが書き込み専用、-no-delが--delete系の禁止です。

バックアップ専用の鍵を転送元で作り、転送先のauthorized_keysに制限つきで登録します。世代置き場への書き込みだけを許すので-wo -no-delにしました。restrictは、ポート転送・エージェント転送・端末の割り当てをまとめて禁止する指定です。

terminal(転送元)
ssh-keygen -q -t ed25519 -N "" -C "backup@nagoyadevlab01" -f ~/.ssh/id_ed25519_backup && cat ~/.ssh/id_ed25519_backup.pub
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHR8hhpbDM4r0v9/L8Pm+6yw9zY3n6pch2MheH/d7yLt backup@nagoyadevlab01
terminal(転送先)
echo 'command="/usr/bin/rrsync -wo -no-del /home/ryoma/snapshots",restrict ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHR8hhpbDM4r0v9/L8Pm+6yw9zY3n6pch2MheH/d7yLt backup@nagoyadevlab01' >> ~/.ssh/authorized_keys

登録する行は「command="…",restrict、空白、公開鍵」の順です。公開鍵の部分は自分の鍵に、ディレクトリは自分の世代置き場の絶対パス(例では/home/ryoma/snapshots)に置き換えます。この鍵で何ができて何ができないかを、転送元から試します。-eでsshに鍵を指定し、IdentitiesOnly=yesで「この鍵だけを使う」ことを強制しています。ssh-agentに普段の鍵が載っていると、sshはその鍵でも認証を試すので、制限のかからない接続になることがあるためです。

terminal(転送元・エラー)
ssh -i ~/.ssh/id_ed25519_backup -o IdentitiesOnly=yes ryoma@nagoyadevlab02 hostname; echo "exit=$?"
/usr/bin/rrsync error: SSH_ORIGINAL_COMMAND does not run rsync
exit=1
rsync -a -e "ssh -i ~/.ssh/id_ed25519_backup -o IdentitiesOnly=yes" ~/.ssh/id_ed25519_backup.pub ryoma@nagoyadevlab02:../.ssh/authorized_keys; echo "exit=$?"
/usr/bin/rrsync error: do not use .. in arg (anchor the path at the root of your restricted dir)
rsync: connection unexpectedly closed (0 bytes received so far) [sender]
rsync error: error in rsync protocol data stream (code 12) at io.c(232) [sender=3.4.1]
exit=12
rsync -a --delete -e "ssh -i ~/.ssh/id_ed25519_backup -o IdentitiesOnly=yes" ~/mnt-usb/ ryoma@nagoyadevlab02:2026-09-30/; echo "exit=$?"
/usr/bin/rrsync error: option --delete has been disabled on this server.
rsync: connection unexpectedly closed (0 bytes received so far) [sender]
rsync error: error in rsync protocol data stream (code 12) at io.c(232) [sender=3.4.1]
exit=12

rsync以外のコマンドは実行を断られ、制限ディレクトリの外(..)への書き込みも、--deleteも通りません。2つ目はauthorized_keysを書き換えて制限を外そうとする操作を想定したものです。接続を切るのはrrsyncなので、rsync側の最終行はcode 12になります。許可された使い方では、リモートのパスは制限ディレクトリからの相対になります。snapshots/2026-10-01/ではなく2026-10-01/と書く点と、--link-destに..が使えない点が、制限なしの鍵との違いです。

terminal(転送元・1つ目はエラー)
printf 'minutes 2026-11\n' > ~/data/docs/minutes-2026-11.txt
rsync -aHAX --itemize-changes -e "ssh -i ~/.ssh/id_ed25519_backup -o IdentitiesOnly=yes" --link-dest=../2026-09-30 --exclude-from="$HOME/rsync-exclude.txt" ~/data/ ryoma@nagoyadevlab02:2026-10-01/; echo "exit=$?"
/usr/bin/rrsync error: do not use .. in --link-dest (anchor the path at the root of your restricted dir)
rsync: connection unexpectedly closed (0 bytes received so far) [sender]
rsync error: error in rsync protocol data stream (code 12) at io.c(232) [sender=3.4.1]
exit=12
rsync -aHAX --itemize-changes -e "ssh -i ~/.ssh/id_ed25519_backup -o IdentitiesOnly=yes" --link-dest=/2026-09-30 --exclude-from="$HOME/rsync-exclude.txt" ~/data/ ryoma@nagoyadevlab02:2026-10-01/; echo "exit=$?"
created directory 2026-10-01
cd..t...... docs/
<f+++++++++ docs/minutes-2026-11.txt
exit=0

エラーメッセージの指示どおり、--link-dest=/2026-09-30と、制限ディレクトリを/に見立てた絶対パスにすると通りました。毎日の実行はスクリプトにまとめます。制限つきの鍵では転送先の一覧を見られないので、「前回成功した世代の名前」は転送元のファイルに覚えさせます。

terminal(転送元)
cat > ~/rsync-snapshot.sh <<'EOF'
#!/bin/sh
set -eu
SRC="$HOME/data/"
DEST="ryoma@nagoyadevlab02"
KEY="$HOME/.ssh/id_ed25519_backup"
STATE="$HOME/.rsync-snapshot-last"
TODAY="$(date +%F)"
LINK=""
if [ -s "$STATE" ] && [ "$(cat "$STATE")" != "$TODAY" ]; then
  LINK="--link-dest=/$(cat "$STATE")"
fi
rsync -aHAX $LINK --exclude-from="$HOME/rsync-exclude.txt" -e "ssh -i $KEY -o IdentitiesOnly=yes" "$SRC" "$DEST:$TODAY/"
echo "$TODAY" > "$STATE"
echo "snapshot $TODAY done ${LINK:-(no --link-dest)}"
EOF
chmod 755 ~/rsync-snapshot.sh; echo 2026-10-01 > ~/.rsync-snapshot-last; ~/rsync-snapshot.sh
snapshot 2026-10-02 done --link-dest=/2026-10-01
  • set -eu:失敗したコマンドや未定義の変数があればそこで止める。rsyncが失敗した日はSTATEを書き換えないので、次回は最後に成功した世代を基準にやり直せる
  • SRC:末尾スラッシュ付き。dataの中身を世代ディレクトリの直下に置く
  • STATEとLINK:前回成功した世代名が記録されていて、それが今日でなければ--link-dest=/世代名を付ける。rrsync経由なので../ではなく/始まり。同じ日に再実行した場合は--link-destなしで同じ世代を更新する
  • -e:制限つきの鍵だけを使う。--deleteは付けない(世代ごとに新しいディレクトリへ送るので不要で、-no-delにより付けても断られる)
  • 最後のecho:cronのログに「どの世代を基準にしたか」を残す

出力の日付は実行した日になります(筆者の実行日は2026-10-02)。状態ファイルに2026-10-01を書いてから走らせたので、手で送った世代を基準に差分だけが作られました。定期実行の組み方と、cronで動かないときの調べ方はcrontabとsystemd timerの記事にまとめています。古い世代の削除は制限つきの鍵にはやらせず、転送先の側で行います。新しい順に3つ残す例です。

terminal(転送先)
du -sh snapshots/*; du -sh snapshots
15M	snapshots/2026-09-29
2.1M	snapshots/2026-09-30
28K	snapshots/2026-10-01
24K	snapshots/2026-10-02
17M	snapshots
cd ~/snapshots && ls -1d 20??-??-?? | head -n -3 | xargs -r rm -rf -- && ls -1
2026-09-30
2026-10-01
2026-10-02

無人実行に使う鍵をこの制限つきの鍵だけにしておけば、鍵が盗まれてもシェルは開けず、世代置き場の外にも手が届かず、--deleteで世代をまとめて消すこともできません。ただし、-iに書いた鍵ファイルが見つからないと、sshはIdentity file … not accessibleと警告したうえで既定の鍵(検証では~/.ssh/id_ed25519)で接続しました。IdentitiesOnly=yesを付けていても同じです。スクリプトを動かすユーザーには、転送先が受け付けるほかの鍵を置かないでください。検証では作業用と同じユーザーに登録しましたが、実運用では転送先にバックアップ受け取り専用のユーザーを作り、そのauthorized_keysに登録すると、ほかのデータと権限ごと分けられます。

ただし、rrsyncは完全な檻ではありません。-no-delを付けていても、既存の世代ディレクトリを宛先にすればその世代のファイルは上書きできます(試すと、上書きされたのはその世代だけで、ハードリンクで共有していたほかの世代の内容は元のままでした)。-no-overwriteを足すとこの上書きは無視されるようになりますが、同じ日に再実行しても既存のファイルは更新されなくなります。また、上流のrsync 3.5.0(2026年8月公開)では、rrsyncの制限ディレクトリを抜けられる問題(CVE-2026-53783)が修正されています。執筆時点で検証環境に入ったパッケージのchangelogには、下のとおりこの番号は載っていません。Ubuntu側の対応状況はUbuntuのセキュリティ情報で確認し、更新が出たら適用してください。改ざんに強い置き場が必要なら、resticのappend-only構成の方が向いています。

terminal(転送先)
dpkg-query -W rsync; zcat /usr/share/doc/rsync/changelog.Debian.gz | grep -o 'CVE-2026-[0-9]*' | sort -u
rsync	3.4.1+ds1-7ubuntu0.3
CVE-2026-29518
CVE-2026-41035
CVE-2026-43617
CVE-2026-43618
CVE-2026-43619
CVE-2026-43620
CVE-2026-45232

動作確認

最後に、ミラー(backup/)が転送元と一致していることをrsync自身に確かめさせます。ここまでの変更を-aHAX --deleteで反映したあと、--checksumを足したdry-runを流します。--checksumは、サイズと更新時刻ではなく中身のチェックサムで比べる指定です。ここで何も出なければ、除外したもの以外は内容も属性も一致しています。

terminal(転送元)
rsync -aHAX --delete --exclude-from="$HOME/rsync-exclude.txt" ~/data/ ryoma@nagoyadevlab02:backup/; echo "exit=$?"
exit=0
rsync -aHAX --delete --checksum --dry-run --itemize-changes --exclude-from="$HOME/rsync-exclude.txt" ~/data/ ryoma@nagoyadevlab02:backup/ | wc -l
0
sha256sum ~/data/logs/app.log | cut -d' ' -f1; ssh ryoma@nagoyadevlab02 'sha256sum backup/logs/app.log' | cut -d' ' -f1
8bce71b89d5aea9452f341017c68f9badba446b245b154fc741f80585e0891b7
8bce71b89d5aea9452f341017c68f9badba446b245b154fc741f80585e0891b7

期待する結果は、1つ目がexit=0、2つ目の行数が0、3つ目は同じハッシュ値が2行です。ログはseqから作っているので、手順どおりに進めていればハッシュ値もこの記事と同じ8bce71b8…になります(画像は乱数なので一致しません)。行数が0にならないときは| wc -lを外して出力を読みます。行頭の記号は「オプションの読み方」の表のとおりです。

つまずきポイント

エラーが出たら、最後のrsync error:の行より先に、その上の行を読みます。原因はたいてい1行目に書いてあり、最後の行のcodeは同じ原因でも変わることがあるからです。

「rsync: command not found」で接続が切れる

「最初の転送」で再現したとおり、転送先にrsyncが入っていないと、bash: line 1: rsync: command not foundに続いてconnection unexpectedly closedとcode 12が出ます。転送先でsudo apt install rsyncを実行します。標準と違う場所にrsyncが入っている機器では、--rsync-path=/usr/bin/rsyncのように相手側のrsyncの場所をフルパスで教えます。rsyncを置けない相手には、scpかsftpを使います。

「Permission denied (13)」と「code 23」:読めないファイルがある

一般ユーザーで/etcを丸ごと送ろうとすると、rootしか読めないファイルのところで次のようになります。

terminal(転送元・エラー)
rsync -a /etc/ ryoma@nagoyadevlab02:etc-copy/; echo "exit=$?"
rsync: [sender] opendir "/etc/ssl/private" failed: Permission denied (13)
rsync: [sender] send_files failed to open "/etc/.pwd.lock": Permission denied (13)
rsync: [sender] send_files failed to open "/etc/gshadow": Permission denied (13)
rsync: [sender] send_files failed to open "/etc/gshadow-": Permission denied (13)
rsync: [sender] send_files failed to open "/etc/shadow": Permission denied (13)
rsync: [sender] send_files failed to open "/etc/shadow-": Permission denied (13)
rsync: [sender] send_files failed to open "/etc/sudoers": Permission denied (13)
rsync: [sender] send_files failed to open "/etc/security/opasswd": Permission denied (13)
rsync: [sender] send_files failed to open "/etc/sudoers.d/README": Permission denied (13)
rsync: [sender] send_files failed to open "/etc/sudoers.d/ryoma": Permission denied (13)
rsync error: some files/attrs were not transferred (see previous errors) (code 23) at main.c(1356) [sender=3.4.1]
exit=23

code 23は「一部だけ転送できた」という意味で、読めたファイルはすべて届いています。cronで動かしていると、成功しているように見えて毎回同じファイルが欠けている、という状態になりやすいエラーです。必要のないファイルなら--excludeで外し、必要なら読める権限(root)でrsyncを動かします。なお、rsyncは転送元の読み取りエラーに気づくと、それ以降の転送先の削除をやめます(やめたときはIO error encountered -- skipping file deletionと表示されます)。一時的な読み取り失敗を「ファイルが消えた」と取り違えないための動作ですが、守られるのは気づいたあとの分だけです。既定の--deleteはディレクトリごとに転送と並行して消していくので、読めないディレクトリが1つある転送元で試すと、それより先に処理されたディレクトリの余分なファイルは消え、読めないファイルが1つあるだけの転送元では余分なファイルがすべて消えました。--delete-afterに替えると、同じ2つの条件でどちらも1つも消えませんでした。「消えるはずのファイルが一部だけ残る」ときは、出力にPermission deniedが混じっていないかを見てください。

sudoを付けたら「Host key verification failed.」や「Permission denied (publickey,password).」になる

読めないファイルがあるからと、同じコマンドの頭にsudoを付けると、今度はSSHの段階で失敗します。下の出力は、cronと同じく端末のない状態で実行したものです。端末から手で打つと、1つ目はAre you sure you want to continue connecting (yes/no/[fingerprint])?、2つ目はryoma@nagoyadevlab02's password:で入力待ちになります。

terminal(転送元・エラー)
sudo rsync -a /etc/ ryoma@nagoyadevlab02:etc-copy/; echo "exit=$?"
Host key verification failed.
rsync: connection unexpectedly closed (0 bytes received so far) [sender]
rsync error: unexplained error (code 255) at io.c(232) [sender=3.4.1]
exit=255
sudo rsync -a -e "ssh -o UserKnownHostsFile=/home/ryoma/.ssh/known_hosts" /etc/ ryoma@nagoyadevlab02:etc-copy/; echo "exit=$?"
Permission denied, please try again.
Permission denied, please try again.
ryoma@nagoyadevlab02: Permission denied (publickey,password).
rsync: connection unexpectedly closed (0 bytes received so far) [sender]
rsync error: unexplained error (code 255) at io.c(232) [sender=3.4.1]
exit=255

sudoを付けると、rsyncが起動するsshもrootとして動きます。rootの~/.sshには、ふつうknown_hostsも秘密鍵もありません。そのため1つ目は相手のホスト鍵を確認できずに止まり、known_hostsの場所だけ教えた2つ目は鍵が見つからず認証で断られました。両方を-eで渡すと通ります。

terminal(転送元)
sudo rsync -a -e "ssh -i /home/ryoma/.ssh/id_ed25519 -o UserKnownHostsFile=/home/ryoma/.ssh/known_hosts" /etc/ ryoma@nagoyadevlab02:etc-copy/; echo "exit=$?"
exit=0
sudo stat -c '%U:%G %a %n' /etc/shadow; ssh ryoma@nagoyadevlab02 "stat -c '%U:%G %a %n' etc-copy/shadow"
root:shadow 640 /etc/shadow
ryoma:ryoma 640 etc-copy/shadow

/etc/shadowも届きましたが、転送先での所有者はrootではなく受け側のユーザーになり、そのユーザーなら読める状態です。所有者(-o)が保たれるのは受け側のrsyncがrootで動いているときだけで、そうでなければ受け側のユーザーの所有になります。パスワードハッシュや秘密鍵を含む領域を、別のマシンの一般ユーザーの配下に平文で置くこと自体も検討が要ります。システム領域のバックアップは、暗号化して保管できるresticに任せる方が筋がよい、というのが筆者の判断です。

SSHの段階で失敗したときの最終行は、同じ原因でもunexplained error (code 255)になる回とerror in rsync protocol data stream (code 12)になる回がありました(上の1つ目のコマンドは、別の回ではcode 12でした)。読むべきは1行目のsshのメッセージです。また、認証の失敗を短い間に何度も繰り返すと、OpenSSH 10.2のsshdは接続元をしばらく拒否し、メッセージがConnection closed by … port 22に変わります(sshdのPerSourcePenaltiesという既定で有効な機能です)。鍵の指定を直したのにつながらないときは、少し待ってから試します。

「mkdir … failed: No such file or directory (2)」:転送先の親ディレクトリがない

転送先に、まだ存在しない階層を2段まとめて指定した例です。

terminal(転送元・1つ目はエラー)
rsync -a ~/data/docs/ ryoma@nagoyadevlab02:archive/2026/docs/; echo "exit=$?"
rsync: [Receiver] mkdir "/home/ryoma/archive/2026/docs" failed: No such file or directory (2)
rsync error: error in file IO (code 11) at main.c(800) [Receiver=3.4.1]
exit=11
rsync -a --mkpath ~/data/docs/ ryoma@nagoyadevlab02:archive/2026/docs/; echo "exit=$?"
exit=0

rsyncが作るのは、転送先パスの最後の1階層だけです。archive/もarchive/2026/もない状態でarchive/2026/docs/を指定するとcode 11で止まります。--mkpathを付けると途中の階層も作ります。相手のrsyncが古くてこのオプションを知らない場合は、先にssh 相手 'mkdir -p archive/2026'を実行します。同じmkdir … failedでも末尾がPermission denied (13)なら、原因は親ディレクトリの書き込み権限です(一般ユーザーで/srvの直下を指定したときなど)。

rsync・scp・resticの使い分け

3つは競合ではなく、得意な場面が違います。今回の検証で確かめた点を軸に並べます。

判断軸rsyncscprestic
2回目以降の転送量変わったファイルだけ。大きなファイルも差分で送る(8,493,263バイト中、新規に送ったのは4,767バイト)毎回すべて送る変わったかたまり(チャンク)だけ
相手側に必要なもの同じくrsync(ないとcommand not found)sshdだけリポジトリの置き場(SFTPやRESTサーバーなど)
転送元で消したファイル--deleteで転送先からも消える反映されない過去のスナップショットに残る
世代管理--link-destで自作。共有はファイル単位(100行の追記で8.2M増えた)なし標準機能。重複排除はチャンク単位
保管時の暗号化なし(通信経路はSSHが暗号化)なし(同左)リポジトリ全体を暗号化
取り出し方そのままファイルとして読めるそのままファイルとして読めるresticのコマンドで復元する

判断の順番はこうです。一度きりのコピーや、rsyncを入れられない相手にはscp。同じディレクトリを繰り返し同じ状態にそろえる用途(デプロイ、ミラー、サーバー移行、世代のいらない複製)はrsync。消したファイルや書き換える前の内容を過去から取り戻したい、保管先を信用しきれない、という要件が1つでもあればresticです。--link-destの世代バックアップは、「保管先でファイルをそのまま開きたい」「相手がrsyncしか動かないNAS」のようにresticを使えない事情があるときの選択肢で、追記型の大きなファイルが多いデータには向きません。

よくある質問

rsyncとcpの違いは何ですか?

cpは指定したファイルを毎回すべてコピーします。rsyncは転送先と比べてサイズか更新時刻が違うファイルだけを送り、--deleteで消えたファイルも反映でき、SSH越しの別マシンも相手にできます。ローカル同士では差分転送は既定で使われないので、利点は「変わっていないファイルを飛ばす」点にあります。

rsyncが重いのはなぜですか?

多い原因は、ファイル数が多くて一覧の作成と比較に時間がかかっている、-c(--checksum)を付けていて両側が全ファイルの中身を読んでいる、LAN内なのに-zで圧縮してCPUを使っている、のどれかです。まず-cと-zを外し、送らなくてよいディレクトリを--excludeで減らします。

syncとrsyncの違いは何ですか?

名前は似ていますが別物です。syncは、メモリ上にたまっている書き込みをディスクへ書き出すコマンドで、ファイルのコピーはしません。一方のrsyncは、2つのディレクトリの内容をそろえるコピーツールです。外付けディスクへrsyncしたあと、取り外す前にsyncを実行する、という形で続けて使うことはあります。

rsyncとscpはどちらを使うべきですか?

一度きりで小さなファイルを送るだけならscpで足ります。相手側にrsyncを入れなくてよいのが利点です。同じディレクトリを繰り返し送る、消したファイルも反映したい、大きなファイルの変更分だけを送りたい、のどれかに当てはまるならrsyncです。2回目以降は変わった分しか送りません。

まとめ

今回再現した取り違えは、転送元の末尾スラッシュ、--deleteの前のdry-run、転送ルート基準の除外パターンの3か所を確認していれば、実行前に気づけるものでした。無人で動かすなら、--max-deleteと目印ファイルで被害に上限を付け、鍵はrrsyncで世代置き場だけに絞ります。次の作業として、いまcronや手順書にあるrsyncの行を1つ選び、--dry-run --itemize-changesを足して手で流してください。*deletingの行と末尾スラッシュが意図どおりかを読み、--deleteがあるのに--max-deleteも目印の確認もない行が見つかったら、そこから直します。

次に読む

$ subscribe --weekly
毎週月曜、企業ITとホームラボの要点を5分で。