Die Absicherung von Backups gegen heimtückische Modifikationen von Informationswerten hilft natürlich nicht gegen versehentliche Löschungen und andere Ungeschicklichkeiten, wenn jeweils das bestehende Backup durch ein neues vollständig überschrieben wird. Eine inkrementelle Backup-Methode ermöglicht dagegen die regelmäßige Sicherung eines veränderten Datenbestands, ohne die vorangehenden Sicherungen zu beeinträchtigen.
Rollierende Backups lassen sich mit einem Shell-Skript realisieren –
# directory to backup BDIR=/home/ # exclude the backup user's home directory (contains the private ssh key!) EXCLUDES='backup' # name of the backup user BUSER=backup_user # the name of the backup machine BSERVER=rsync.backupservice.com ######################################################################## BACKUPDIR=`date +%A` OPTS="--force --ignore-errors --delete-excluded --exclude $EXCLUDES --delete --backup --backup-dir=/$BACKUPDIR -a" export PATH=$PATH:/bin:/usr/bin:/usr/local/bin # the following line clears the last weeks incremental directory [ -d $HOME/emptydir ] || mkdir $HOME/emptydir rsync --delete -a $HOME/emptydir/ $BUSER@$BSERVER:/users/backup/$BACKUPDIR/ rmdir $HOME/emptydir # now the actual transfer rsync $OPTS $BDIR $BUSER@$BSERVER:/users/backup/current
– aber für ein echtes inkrementelles Backup bietet rsync mit dem Parameter --link-dest
eine Möglichkeit, die zu sichernden Daten mit einem bestehenden Ziel-Verzeichnis abzugleichen und für alle unveränderten Dateien lediglich einen zusätzlichen hard link zu setzen. Um diesen Parameter zu nutzen, muss mein Wrapper rbackup wie folgt ergänzt werden:
class BackupProfile: # ... @property def target_dir(self): if self.incremental: return f'{self.target_path}/{datetime.date.today().strftime("%Y-%m-%d")}' else: return self.target_path @property def link_dest(self): if self.incremental: return [f'--link-dest=../latest'] else: return [] # ... def backup(self): print(f'Executing backup profile "{self.label}" ...') self.ransom() command_list = ["/opt/homebrew/bin/rsync", "-e", "ssh"] command_list.extend(self.options) command_list.extend(self.link_dest) command_list.extend(self.excludepath) command_list.extend([self.source, self.target_dir]) return_code = subprocess.call(command_list) self.rotate()
../latest
ist ein Symlink auf das jeweils aktuellste Zielverzeichnis, er muss also initial von Hand erstellt (ln -s /volume1/backup/YYYY-MM-DD latest
) und nach jeder Sicherung aktualisiert werden (mit der Funktion self.rotate()
). Für lokale Speichermedien ist das sehr simpel (os.unlink()
und os.symlink()
), aber für serverbasierte Backups muss eine SFTP-Verbindung her:
def ssh_key(self, id_file): if hasattr(self, 'keytype'): if self.keytype == 'ecdsa': return paramiko.ECDSAKey.from_private_key(open(id_file)) else: return paramiko.RSAKey.from_private_key(open(id_file)) else: return False def rotate(self): if self.incremental: if hasattr(self, 'target_host'): my_config = paramiko.SSHConfig() my_config.parse(open('/Users/jan/.ssh/config')) conf = my_config.lookup(self.target_host) transport = paramiko.Transport((conf['hostname'],int(conf['port']))) transport.connect(username=conf['user'], pkey=self.ssh_key(conf['identityfile'][0])) sftp = paramiko.SFTPClient.from_transport(transport) sftp.unlink(self.sftp_latest_dir) sftp.symlink(self.target_dir, self.sftp_latest_dir) sftp.close() transport.close() else: os.unlink(self.latest_dir) os.symlink(self.target_dir, self.latest_dir)
Wenn man ein Synology NAS zur Sicherung nutzt, gibt es eine weitere Besonderheit: SSH-Verbindungen erfordern eine vollständige Pfadangabe samt Volume (z.B. /volume1/backup
), für SFTP-Verbindungen dagegen wird der jeweilige shared folder als Root-Verzeichnis betrachtet (/backup
). Der Symlink in unserer Konstellation muss daher als /backup/latest
aktualisiert werden, aber auf /volume1/backup/YYYY-MM-DD
verweisen (andernfalls findet rsync den Vergleichsordner beim nächsten Durchlauf nicht). Deshalb bedarf es eines ausgesprochen schmutzigen Hacks, um self.sftp_latest_dir
abhängig vom Zielsystem zu generieren (während self.target_dir
für jedes Zielsystem unverändert genutzt werden kann):
@property def sftp_latest_dir(self): if hasattr(self, 'target_host'): if self.target_host.startswith('synology'): return self.latest_dir[8:] else: return self.latest_dir
Eventuell werde ich demnächst vom interstellaren Servicedesk hinter Synology abgeholt.
Um den Platzbedarf der einzelnen Backup-Verzeichnisse zu eruieren, lässt sich das Verhalten eines altgedienten Unix-Tools bei Verwendung mehrerer Argumente ausnutzen (Files having multiple hard links are counted (and displayed) a single time per du execution.
) –
snafu@localhost % du -sh . 2022-04-05 2022-04-07 2022-04-08 27G . 27G 2022-04-05 60M 2022-04-07 177M 2022-04-08
– und neue/geänderte Dateien im aktuellsten Backup-Verzeichnis lassen sich als diejenigen identifizieren, für die nur ein Link existiert (find . -type f -links 1 -print
).