Regelmäßige Backups von Mac-Ordnern mit rsync und launchd auf ein Synology NAS

Schwarz-Weiß-Strichzeichnung eines Desktop-Computers mit Mac-OS-Interface. Ein Terminal-Fenster zeigt einen rsync-Befehl. Im Hintergrund ist ein Synology NAS zu sehen, das über ein Netzwerk verbunden ist. Zusätzliche Elemente sind ein Uhr-Symbol, das periodische Backups darstellt, und ein Sicherheits-Symbol, das die Datenintegrität repräsentiert. Made by Dall-E

Nach den letz­ten Artikeln über das launchd-Framework und rsync, wer­den nun bei­de Ansätze kom­bi­niert, um ein Backup-Skript zu erstel­len. Dieses Skript syn­chro­ni­siert einen Ordner auf das Synology NAS und sichert zusätz­li­che gelösch­te Dateien vom Quellordner in Backup-Ordner auf dem NAS. Als Vorbereitung muss für die Verwendung des rsync-Befehls SSH auf dem Mac und auf der Synology ein­ge­rich­tet wer­den, was im vor­he­ri­gen Artikel aus­führ­lich beschrie­ben wur­de.

Anforderungen

Im Artikel Automatische Sicherung von Mac-Ordnern auf ein Synology NAS habe ich mei­ne Lösung beschrie­ben, wie ich mit einem Klick 1:1 Kopien mei­nes Obsidian Vaults mit auf mei­ner Synology NAS erstel­len kann.In die­sem Artikel gehe ich einen Schritt wei­ter und stel­le eine Lösung vor, wie dies mit rsync und launchd auto­ma­tisch und peri­odisch gesche­hen kann, wobei zusätz­lich eine Kopie von even­tu­ell im Quellordner gelösch­ten Dateien sepa­rat gespei­chert wird. Der ent­spre­chen­de Backup-Ordner auf dem NAS soll­te fol­gen­de Struktur haben:

ObsBackup/
├── 2024-06-08_16-06-49
├── 2024-06-08_17-06-49
├── 2024-06-08_18-06-49
└── current

Im Ordner “cur­rent” ent­hält die aktu­el­le 1:1 Kopie des Quellordners. In Ordnern, die nach Datum und Uhrzeit benannt sind (in der Form YYYY-MM-DD_HH-MM-SS), wer­den die Dateien gespei­chert, die wäh­rend seit dem letz­ten Backup-Laufs im Quellordner gelöscht wur­den. Die Anzahl die­ser Ordner soll begrenzt wer­den, so dass die ältes­ten Ordner auto­ma­tisch gelöscht wer­den, wenn eine bestimm­te Anzahl über­schrit­ten wird.

Ein Backup-Lauf soll nur dann gestar­tet wer­den, wenn das NAS erreicht­bar ist, d.h. wenn sich der Mac im Heimnetz befin­det und das Synology NAS läuft.

Lösung

Das Skript ist ähn­lich auf­ge­baut wie das in mei­nem rsync-Artikel, jedoch mit eini­gen Ergänzungen:

  • 1. Zunächst wird der Zeitstempel in die Logdatei geschrie­ben.
  • 2. Danach wird geprüft, ob der NAS-Server erreich­bar ist.
  • 3. Ist der Server erreich­bar, wird der Backup-Befehl aus­ge­führt und das Ergebnis in die Logdatei geschrie­ben.
  • 4. Anschließend wird über­prüft, wie vie­le Ordner mit gelösch­ten Dateien vor­han­den sind.
  • 5. Wenn eine bestimm­te Anzahl über­schrit­ten ist, wird der ältes­te Ordner gelöscht und dies im Log ver­merkt.
  • 6. Ist der NAS-Server nicht erreich­bar, wird ein ent­spre­chen­der Eintrag ins Log geschrie­ben.
  • 7. In bei­den Fällen wird das Skript danach been­det.

Das gesam­te Skript sieht in mei­nem Beispiel so aus:

#!/bin/zsh

# Füge einen Zeitstempel zur Protokolldatei hinzu
echo -e "\n\n$(date +"%Y-%m-%d %H:%M:%S")" >> /tmp/ObsBackup.log

# Variablen
SOURCE="/Users/leif/Documents/01 Meine Dokumente/ObsidianNotes/"
DESTINATION="synology:/volume1/homes/leif/ObsBackup/current"
BACKUP_DIR_BASE="/volume1/homes/leif/ObsBackup"
MAX_BACKUPS=30

TIMESTAMP=$(date +%Y-%m-%d_%H-%M-%S)
BACKUP_DIR="${BACKUP_DIR_BASE}/${TIMESTAMP}"

# Teste die Erreichbarkeit des Servers
if ping -c 1 192.168.1.80 &> /dev/null
then
  # Rsync mit --delete und --backup, Ausschluss der @eaDir und .DS_Store Ordner
  rsync -avz --delete --backup --backup-dir="${BACKUP_DIR}" --exclude='.DS_Store' --filter 'P @eaDir/' -e ssh "$SOURCE" "$DESTINATION" >> /tmp/ObsBackup.log 2>&1

  # Überprüfe die Anzahl der Backups und lösche die ältesten, wenn mehr als MAX_BACKUPS vorhanden sind
  BACKUP_COUNT=$(ssh synology "ls -1 ${BACKUP_DIR_BASE} | grep -E '^[0-9]{4}-[0-9]{2}-[0-9]{2}_[0-9]{2}-[0-9]{2}-[0-9]{2}$' | wc -l")

  if [ "$BACKUP_COUNT" -gt "$MAX_BACKUPS" ]; then
    echo "Anzahl der Backups ($BACKUP_COUNT) überschreitet das Maximum ($MAX_BACKUPS). Älteste Backups werden gelöscht." >> /tmp/ObsBackup.log
    ssh synology "ls -1 ${BACKUP_DIR_BASE} | grep -E '^[0-9]{4}-[0-9]{2}-[0-9]{2}_[0-9]{2}-[0-9]{2}-[0-9]{2}$' | sort | head -n -${MAX_BACKUPS} | xargs -I {} rm -rf ${BACKUP_DIR_BASE}/{}" >> /tmp/ObsBackup.log 2>&1
  fi
else
  echo "SERVER ist nicht erreichbar. Backup wird nicht ausgeführt."  >> /tmp/ObsBackup.log
fi

Mit dem Befehl echo wird der Zeitstempel in die Logdatei /tmp/ObsBackup.log geschrie­ben (>>), wobei der Eintrag zunächst mit zwei Leerzeilen getrennt wird (\n\n). Die Konstruktion $(date +"%Y-%m-%d %H:%M:%S") fügt das aktu­el­le Datum und die Uhrzeit im Format YYYY-MM-DD HH:MM:SS ein.

# Füge einen Zeitstempel zur Protokolldatei hinzu
echo -e "\n\n$(date +"%Y-%m-%d %H:%M:%S")" >> /tmp/ObsBackup.log

Danach wer­den die Variablen defi­niert, die das Quellverzeichnis auf dem Mac, das Zielverzeichnis auf der Synology sowie das Zielverzeichnis der Backup-Ordner für die gelösch­ten Dateien fest­le­gen. In der Variable MAX_BACKUPS wird die Anzahl der Backup-Ordner für die gelösch­ten Dateien defi­niert. Dieser Wert hängt davon ab, wie häu­fig Dateien geän­dert wer­den und wie oft das Skript spä­ter aus­ge­führt wird.

Der Aufrufparameter für mei­ne Synology in der Variable DESTINATION ist synology:, so wie er in der con­fig Datei in mei­nem .ssh Verzeichnis defi­niert wur­de.

# Variablen
SOURCE="/Users/leif/Documents/01 Meine Dokumente/ObsidianNotes/"
DESTINATION="synology:/volume1/homes/leif/ObsBackup/current"
BACKUP_DIR_BASE="/volume1/homes/leif/ObsBackup"
MAX_BACKUPS=30

Wie erwähnt, wird bei jedem Durchlauf, bei einer Änderung im Quellordner, ein Backup-Ordner erstellt. Ich habe hier neben dem Datum und der Uhrzeit auch noch die Sekunden als Namensbestandteil auf­ge­nom­men, da dies bei mei­nen Testläufen sehr prak­tisch war. Das BACK_DIR wird aus dem defi­nier­ten BACKUP_DIR_BASE, einem/ und dem TIMESTAMP zusam­men­ge­setzt.

TIMESTAMP=$(date +%Y-%m-%d_%H-%M-%S)
BACKUP_DIR="${BACKUP_DIR_BASE}/${TIMESTAMP}"

Um sicher­zu­stel­len, dass das rsync-Kommando nur auf­ge­ru­fen wird, wenn das NAS erreich­bar ist, wird mit ping ein Paket an den Server geschickt. Die Option -c 1 stellt sicher, dass nur ein Paket gesen­det wird. Wenn das NAS auf den ping ant­wor­tet, wird dies als true für das if-Statement gewer­tet und der then-Teil, also die rsync-Komanndozeile, aus­ge­führt.

# Teste die Erreichbarkeit des Servers
if ping -c 1 192.168.1.80 &> /dev/null
then
  # Rsync mit --delete und --backup, Ausschluss der @eaDir und .DS_Store Ordner
  rsync -avz --delete --backup --backup-dir="${BACKUP_DIR}" --exclude='.DS_Store' --filter 'P @eaDir/' -e ssh "$SOURCE" "$DESTINATION" >> /tmp/ObsBackup.log 2>&1

Die Kommandozeile setz­te sich dann aus den fol­gen­den Optionen zusam­men:

-avz: Eine Kombination aus der Archive-Option (-a), die defi­niert, dass alle Daten gesi­chert wer­den sol­len, der Verbose-Option (-v), die für eine aus­führ­li­che­re Ausgabe sorgt, und der Kompressionsoption (-z), die rsync anweist, die Daten für die Übertragung zu kom­pri­mie­ren. Letzteres ist in einem inter­nen Netz und bei weni­gen Änderungen wahr­schein­lich nicht not­wen­dig.

--delete: Sorgt dafür, dass alle Daten, die im Quellverzeichnis seit dem letz­ten Aufruf gelöscht wur­den, auch im Zielordner gelöscht wer­den. Im Ordner current befin­det sich so eine exak­te Kopie des Ordners auf dem Mac.

--backup: Definiert, dass gelösch­te Dateien gesi­chert wer­den.

--backup-dir="${BACKUP_DIR}": Gibt das Verzeichnis an, in dem die Backups gespei­chert wer­den sol­len.

--exclude='.DS_Store': Schließt die .DS_Store-Dateien auf dem Mac von der Synchronisation aus.

--filter 'P @eaDir/': Verhindert das wie­der­hol­te Löschen des @eaDir-Verzeichnis auf dem Synology NAS, das die Informationen für die „Universal Search” ent­hält

-e ssh: Definiert das Protokoll, das rsync für die Übertragung nutzt, hier SSH.

"$SOURCE" "$DESTINATION": Die oben defi­nier­ten Quell- und Zielpfade.

» /tmp/ObsBackup.log 2>&1: Sorgt dafür, dass die Ausgabe und even­tu­el­le Fehler in die Protokolldatei /tmp/ObsBackup.log umge­lei­tet wird.

Nun folgt etwas Unix-Pipe-Magie, die ich nicht im Detail durch­drin­ge. Hier wird die Variable BACKUP_COUNT mit dem Ergebnis eines Kommandos belegt, das per ssh auf der Synology aus­ge­führt wird. Der Aufruf lis­tet alle Ordner im Verzeichnis BACKUP_DIR_BASE auf der Synology auf, fil­tert die­je­ni­gen her­aus, die nicht dem Namensschema für die Backup-Dateien ent­spre­chen (also den Ordner cur­rent), und zählt die ver­blei­ben­den (wc -l).

 BACKUP_COUNT=$(ssh synology "ls -1 ${BACKUP_DIR_BASE} | grep -E '^[0-9]{4}-[0-9]{2}-[0-9]{2}_[0-9]{2}-[0-9]{2}-[0-9]{2}$' | wc -l")

Wenn der BACKUP_COUNT grö­ßer (-gt grea­ter then) als der oben defi­nier­te MAX_BACKUPS Wert wird, dann wird eine ent­spre­chen­de Meldung in die Logdatei geschrie­ben:

echo "Anzahl der Backups ($BACKUP_COUNT) überschreitet das Maximum ($MAX_BACKUPS). Älteste Backups werden gelöscht." >> /tmp/ObsBackup.log

Danach pas­siert noch wei­te­re Unix-Pipe-Magie: mit einem Befehl, der wie­der­um via ssh auf der Synology aus­ge­führt wird, wer­den die Backup-Ordner gelis­tet, nach Namen sor­tiert und dann der über­zäh­li­ge und ältes­te Ordner gelöscht. Eine ent­spre­chen­de Meldung wird eben­falls in die Logdatei geschrie­ben:

  if [ "$BACKUP_COUNT" -gt "$MAX_BACKUPS" ]; then
    echo "Anzahl der Backups ($BACKUP_COUNT) überschreitet das Maximum ($MAX_BACKUPS). Älteste Backups werden gelöscht." >> /tmp/ObsBackup.log
    ssh synology "ls -1 ${BACKUP_DIR_BASE} | grep -E '^[0-9]{4}-[0-9]{2}-[0-9]{2}_[0-9]{2}-[0-9]{2}-[0-9]{2}$' | sort | head -n -${MAX_BACKUPS} | xargs -I {} rm -rf ${BACKUP_DIR_BASE}/{}" >> /tmp/ObsBackup.log 2>&1
  fi

Am Ende wird in dem else-Teil der des if-Konstruktes, die Nicht.Erreichbarkeit des Servers in der Logdatei doku­men­tiert:

else
  echo "SERVER ist nicht erreichbar. Backup wird nicht ausgeführt."  >> /tmp/ObsBackup.log
fi

Skript speichern und testen

Nun soll­te das Shell-Skript getes­tet wer­den. Dazu wird es in gespei­chert und mit der Ausführungsberechtigung ver­se­hen. In die­sem Beispiel spei­cher ich das Skript auf mei­nem Scrheibtisch mit dem Namen Backup.sh und füge die Ausführungsberechtigung mit dem fol­gen­den Befehl hin­zu:

chmod +x ~/Desktop/Backup.sh

Nun kann das Skript auf­ru­fen und getes­tet wer­den:

~/Dektop/Backup.sh

Ergebnisse überprüfen

Wenn alles gut läuft, dann ist der als Quelle defi­nier­te Ordner nun auf die Synology kopiert. Ein Blick in die Logdatei soll­te in die­sem Fall kei­ne Fehler anzei­gen. Wenn der Quell-Ordner vie­le Dateien ent­hält, wird die Übertragung jeder ein­zel­nen Datei ange­zeigt, wodurch die Logdatei ziem­lich lang wer­den kann.

Ich emp­feh­le, den Inhalt der Logdatei nach dem ers­ten erfolg­rei­chen Lauf zu löschen, damit man spä­ter nicht so weit in der Datei blät­tern muss. Später wer­den nor­ma­ler­wei­se nur weni­ge Änderungen gemacht, sodass die Logdatei über­sicht­lich bleibt.

Die Automator-Hülle

Wie erwähnt, kann ein Shell-Skript, das Dateien mani­pu­liert, nicht ohne Weiteres in mit launchd gestar­tet wer­den. Das gilt übri­gens auch für das Ausführen von Shell-Skripten in Kurzbefehlen. Nur Programmen kann in den „Daten und Sicherheit” Einstellungen die Berechtigung zum Zugriff auf Ordner oder der Festplatte gewährt wer­den. Daher muss das Skript in eine Programm-Hülle gepackt wer­den. Wie ich in dem vor­he­ri­gen Artikel schon erwähnt habe, kann dies u.a. mit dem Automator erle­digt wer­den.

Dazu wird nach dem Starten des Automators ein neu­es Dokument ange­legt und dann als Art des Dokumentes, „Programm” aus­ge­wählt.

Nachdem das Dokumentfenster geöff­net wur­de, wird die Aktion “Shell-Skript aus­füh­ren” in den Programmierbereich gezo­gen. Das Ganze wird dann abge­spei­chert. Ich ver­wen­de für mei­ne loka­len Programme den Programme-Ordner direkt unter mei­nem Benutzerverzeichnis (~/Applications). In die­sem Beispiel spei­che­re ich das Programm unter dem Namen “ObsidianBackup” ab.

Screenshot des Automator-Fensters auf einem Mac, der ein geöffnetes Dokument zeigt. Im Dokument ist die Aktion “Shell-Skript ausführen” ausgewählt und in den Programmierbereich gezogen. Der Bereich enthält ein Shell-Skript mit mehreren Befehlen, einschließlich eines rsync-Befehls. Oben im Fenster sind die Menüs “Ablauf”, “Ergebnisse”, “Protokoll” und “Optionen” sichtbar.

Programm testen und Berechtigungen erteilen

Nun soll­te auch die­ses Programm getes­tet wer­den. Beim ers­ten Aufruf fragt das System nor­ma­ler­wei­se nach, ob das Programm auf den Ordner “Dokumente” zugrei­fen darf. Wenn die­ses Popup bestä­tigt wird, erhält das Programm die Berechtigung und es wird in den ent­spre­chen­den “Daten- und Sicherheit”-Einstellungen in der Liste ange­zeigt.

Falls es dabei Probleme gibt, kann das Programm-Icon auch manu­ell auf die Liste in den Einstellungen gezo­gen wer­den, um die Berechtigungen zu ertei­len.

Launchd-Einstellugen

Das launchd-Framework ermög­licht das auto­ma­ti­sche Ausführen von Skripten, Programmen und ande­ren Befehlen. So kön­nen Programme z.B. beim Systemstart auto­ma­tisch gestar­tet und Hintergrund aus­ge­führt wer­den, bis das System her­un­ter­ge­fah­ren wer­den, oder aber wie hier gewünscht zu bestimm­ten Zeiten gestar­tet wer­den. Unter Linux kennt man einen sol­chen Service unter dem Namen cron, der zwar auch noch im macOS zur Verfügung steht, aber mit­tel­fris­tig durch launchd ersetzt wer­den soll.

Die Konfiguration eines „launchd”-Job wird über eine XML-Datei im Verzeichnis ~/Library/LaunchAgents gesteu­ert. Für die­ses Beispiel habe ich die Datei ~/Library/LaunchAgent/com.leif.ObsidianBackup.plist erstellt und fol­gen­des XML hin­ein­ko­piert.

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>Label</key>
    <string>com.leif.ObsidianBackup</string>
    <key>ProgramArguments</key>
    <array>
        <string>/usr/bin/open</string>
        <string>-W</string>
        <string>/Users/leif/Applications/ObsidianBackup.app</string>
    </array>
    <key>RunAtLoad</key>
    <true/>
    <key>StartInterval</key>
    <integer>3600</integer>
</dict>
</plist>

Der Schlüssel Label ist der Name des Jobs, der ein­deu­tig sein muss und dem Dateinamen ent­spre­chen soll­te. Der Schlüssel ProgramArguments ist ein Array, das den Aufruf des oben erstell­ten Automator-Programms defi­niert, wobei die ein­zel­nen Befehle und Optionen, die sonst mit einem Leerzeichen getrennt sind, als eige­ne Strings defi­niert wer­den. Die Option -W sorgt dafür, dass gewar­tet wird, bis das Programm aus­ge­führt wur­de. RunAtLoad sorgt dafür, dass der Job sofort aus­ge­führt wird, wenn die plist gela­den wird, was immer bei einem Systemstart pas­siert. Das StartInterval, hier auf 3600 Sekunden (also eine Stunde) gesetzt, defi­niert die Zeit, die zwi­schen zwei Läufen des Skripts ver­ge­hen soll.

Wenn die Datei erstellt und abge­spei­chert ist, wird der Job manu­ell gestar­tet:

launchctl load /Users/leif/Library/LaunchAgents/com.leif.obsidian-backup.plist

Um zu kon­trol­lie­ren, ob der Job im Hintergrund läuft, kann der fol­gen­de Befehl ver­wen­det wer­den:

launchctl list | grep com.leif.obsidian-backup

Und falls irgend­et­was nicht funk­tio­nie­ren soll­te, kann der Job mit fol­gen­dem Befehl gestoppt wer­den:

launchctl unload /Users/leif/Library/LaunchAgents/com.leif.obsidian-backup.plist

Falls nun noch­mal eine Abfrage ange­zeigt wird, ob das vom Automator erstell­te Programm auf die Daten zugrei­fen darf, muss dies natür­lich bejaht wer­den. Das pas­siert auch wenn an dem Programm etwas geän­dert wird. So wie der Job defi­niert ist, wird er nach jedem Neustart wie­der akti­viert und es wird dann alle Stunde ein Backup-Lauf gestar­tet.

Anmerkungen

Ich habe etwas län­ger an dem Skript und an die­sem Artikel gear­bei­tet, da vor allem die Lösung von zwei Probleme Zeit gebraucht und vie­le Test benö­tigt haben.

Erstens nut­ze ich in mei­nem Home-Verzeichnis ein System, bei dem mei­ne Ordner mit einer Nummer und einem Leerzeichen ver­se­hen sind. So heißt mein eigent­li­cher Backup-Ordner “98 Backups”. Leerzeichen in Pfaden füh­ren oft zu Problemen, wenn man auf der Kommandozeile im Terminal arbei­tet. Die übli­chen Methoden zur Handhabung von Variablen und zum Zusammenbau der Backup-Ordner funk­tio­nier­ten ein­fach nicht. Deshalb habe ich am Ende einen neu­en Backup-Ordner in mei­nem Home-Verzeichnis erstellt, der kei­ne Leerzeichen ent­hält.

Zweitens führ­te die Idee als Lösung für das Leerzeichen-Problem, einen neu­en “Freigegebenen Ordner” zu nut­zen, der auch das Problem mit dem @eaDir-Verzeichnis gelöst hät­te, nicht zum Erfolg. Das Skript brach immer mit der Meldung ab, dass es Dateien nicht löschen konn­te, da es nicht genü­gend Rechte hät­te, obwohl der Benutzer, unter dem es lief, alle nöti­gen Rechte hat­te.

Nach lan­gem Probieren habe ich dann die ein­fa­che Lösung imple­men­tiert und in mei­nem Home-Verzeichnis einen Ordner ohne Leerzeichen für das Backup erstellt. Hier wur­den dann komi­scher­wei­se kei­ne Berechtigungsfehler mehr gemel­det. Falls jemand mir einen Hinweis geben kann, was mein Fehler war, neh­me ich die­sen ger­ne ent­ge­gen ;-).

Mit die­sem Skript wer­den die Änderungen im Ordner .obsidian auch mit­ge­si­chert. Ich habe jedoch fest­ge­stellt, dass in die­sem Ordner wäh­rend der Arbeit mit Obsidian vie­le Dateien aktua­li­siert und gelöscht wer­den, was die Anzahl der Backup-Ordner erhö­hen kann. Man könn­te daher den Ordner mit –exclude=’.obsidian’ vom Backup aus­schlie­ßen.

Natürlich kann die­ses neue Skript auch über einen Kurzbefehl, wie im vor­he­ri­gen Artikel beschrie­ben, jeder­zeit auf­ge­ru­fen wer­den. Das ermög­licht zwi­schen­durch ein­fach ein Backup zu star­ten, das sich genau­so ver­hält, wie ein von launchd gestar­te­tes Backup.

Anmerkungen, Korrekturen, und Kritik bit­te ger­ne in den Kommentaren hin­ter­las­sen.

2 Antworten zu „Regelmäßige Backups von Mac-Ordnern mit rsync und launchd auf ein Synology NAS“

  1. @blog Ahh, die all­be­kann­te Synoy Disk-Station.

    1. Ja, so ist da mit der KI.

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert