Server-Konfiguration ausrollen
Im vorigen Artikel habe ich meine Konfigurationsdateien unter Versionskontrolle gebracht. Nun will ich die auf dem Server bereitgestellten Updates automatisiert einspielen.
Worum geht es hier?
- Schreiben eines Skripts zur automatischen Erkennung des Update-Triggers
- Server-Anwendungen sollen heruntergefahren und eine Sicherungskopie erstellt werden
- Das Skript soll die Konfiguration auf dem System verteilen
- Anschließend sollen alle Anwendungen neu gestartet werden
Wege und Möglichkeiten
Auch hier habe ich wieder mehrere Stunden mit Recherche verbracht. Bei größeren Projekten verwenden Infrastrukturexperten anscheinend spezialisierte Automatisierungswerkzeuge. Dazu zählen Ansible, einfachere Tools wie cdist oder gar Kubernetes für stark skalierende Dienste.
Automatisierungsdienste Ansible, cdist, Kubernetes
Ansible und cdist scheinen dabei ein ähnliches Konzept zu verfolgen: Auf der Quellmaschine (in diesem Beispiel meinem Laptop) erstelle ich die Konfiguration für meinen Server und lege diese sowie die Vorschrift zur Konfiguration meiner Dienste in einem “Playbook” (Ansible) bzw. in “types” (cdist) ab.
Vereinfacht gesagt - so wie ich das verstanden habe - kümmert sich das Werkzeug anschließend auf Knopfdruck darum, die Konfiguration zu bauen, sich per ssh
auf dem Zielhost einzuwählen, sie dort hinüberzuschieben und anschließend zu starten. Für Ansible existieren sogar für mein Szenario mit docker-compose
Tutorials wie dieses, was den Einstieg nochmal erleichtert.
Einen anderen Weg geht Kubernetes, was sich als eher als Containermanager, Loadbalancer und Skalierungsagent versteht, aber für meine Zwecke Ähnliches leisten kann.
Mein Weg
Ich hingegen brauche von den Fähigkeiten dieser Programme nur einen Bruchteil. Zudem schrecken mich der “zusätzliche” ssh
-Kanal, der Konfigurationsaufwand, die teils auf dem Zielsystem erforderlichen, zusätzlichen Programme sowie das erforderliche Einlesen und Auswählen des für mich besten Werkzeuges ab. Denn dank meiner sehr einfachen Pipeline aus dem letzten Artikel befindet sich die Konfiguration bereits auf meinem Server. Sie muss “nur noch” an die richtigen Stellen kopiert und betroffene Services einem Neustart unterzogen werden.
Daher versuche ich, dieses Problem mit Bordwerkzeug, meinem Gehirn in Betriebsbereitschaft und ein paar Suchanfragen bei Einschlägigen Foren zu den Themen “Ausführen eines Skriptes bei Dateiänderung, “Kopieren von Ordnerstrukturen unter Linux” zu lösen.
Risiko Zirkelbezug
Ein Risiko gehe ich dabei jedoch ein: Da das Deployment über Gitea läuft, die Konfiguration aber Gitea selbst betrifft, kann ich bei einem Fehler in diesem Modul nichts mehr um- oder zurückstellen: Der Gitea-Service ist dann ja kaputt. In diesem Szenario müsste ich also händisch mit auf dem Server die Konfiguration wieder ans Laufen bekommen.
Ich probiere es jetzt trotzdem aus und schaue, ob ich tatsächlich mit so einem Problem konfrontiert werde. Falls ja, steige ich halt auf cdist
um und dokumentiere dies in einem eigenen Artikel! 🤗
Vorbereitung: Ordnersystem und Skripte erstellen
Ich überlege kurz und erstelle ein paar Ordner im Server-Dateisystem:
# /opt/server-config
mkdir automation-hooks-trigger
mkdir automation-hooks-handler
Diese Ordner sollen die zwei Seiten der Automation abbilden. trigger
enthält Textdateien, die von der (Web)-Serviceseite aus manipuliert werden können. So soll act_runner beispielsweise die Datei server-config-update
schreiben, sobald ein Update vorliegt.
service –> writes to trigger file server_handler() –> trigger_file.changed ? run_action() : loop()
Im Handler-Ordner wiederum befinden sich Skriptdateien, die die erforderlichen Änderungen auf dem Server-Dateisystem vornehmen.
Durchführung: Shell-Script zum Abhandeln des Updates
Im vorigen Artikel habe ich im Container act_runner zum Signalisieren des Vorhandenseins einer neuen Serverkonfiguration das Kommando touch server-config-update.txt
ausgeführt. Auf dem Server prüfe ich nun mit folgendem Code periodisch ab, ob sich diese Datei geändert hat.
### set directories
actionfile=/opt/server-config/automation-hooks-handler/server-config-action.sh
triggerfile=/opt/server-config/automation-hooks-trigger/server-config-update.txt
### Set initial time of file
LTIME=`stat -c %Z ${triggerfile}`
while true
do
ATIME=`stat -c %Z ${triggerfile}`
if [[ "$ATIME" != "$LTIME" ]]
then
${actionfile} 2>&1
LTIME=$ATIME
fi
sleep 10
done
Per #!/bin/bash wird dem Betriebssystem der Interpreter genannt, mit dem das Skript auszuführen ist. Der Befehl stat -c %Z fragt ab, wann die Datei zuletzt geändert wurde und gibt den Zeitpunkt im Epoch-Format zurück. Schließlich wird die zu Anfang des Skriptes mit der in der Schleife genommenen Zeit verglichen und bei Veränderung kann dann die noch zu schreibende Deploy-Routine ablaufen. Anschließend wird der Referenzzeitpunkt aktualisiert.
Zum Schluss pausiert das Skript (sleep 10
) für zehn Sekunden, bevor die Abfrage erneut losläuft. Ich verwende absolute Pfade, da ich das Skript sowohl für Tests von der Konsole aus als auch automatisch per cron
oder systemd
starten können will.
Erster Test und Einhängen in den “Autostart”
Nun müssen wir die Datei für einen ersten Test ausführbar machen:
# change file mode bits: add "executable" flag to server-config-handler script
chmod +x /opt/server-config/automation-hooks-handler/server-config-handler.sh
Dann erstelle ich die Datei server-config-action.sh
und gebe ihr als einziges Kommando ein echo
. Schauen wir mal, ob bis hierhin alles funktioniert. Dafür erstelle ich lokal meine Update-Triggerdatei und starte das Skript. Anschließend modifiziere ich sie in einer anderen Shell-Instanz per touch server-config-update.txt
und prompt erscheint in der Konsole --- CONFIG UPDATE TRIGGER detected ---
. Super!
Auf dem Server später muss ich das Skript automatisch nach einem Neustart ausführen lassen. Dafür nehme ich das Werkzeug cron
zur Hilfe und erstelle einen neuen Eintrag per crontab -e
:
# cron can automatically execute recurring tasks
# in this case, we're running server-config-handler script on reboot
@reboot sh /opt/server-config/automation-hooks-handler/server-config-handler.sh
Mit dem Befehl ps aux
kann ich nun prüfen, ob das Skript auch wirklich ausgeführt wird:
schallbert@server: ps aux
[...]
8:15 0:00 /bin/bash ./server-config-handler.sh
8:15 0:00 sleep 10
[...]
Sicherungskopie
Eine Sicherungskopie meiner Anwendungen und Dateien anzufertigen bevor ich das Update ausrolle macht total Sinn. Also teile ich Borg mit, dass ich jetzt ein Backup anlegen möchte. Vorher muss ich natürlich alle Services stoppen. Somit sind alle Daten zugriffsfrei, kohärent und statisch.
Zustand und Daten einfrieren
Hierfür lege ich in automation-hooks-handler
ein Skript an, welches alle Container bis auf borg selbst beendet.
#! /bin/bash
# /opt/automation-hooks-handler/backup-pre-action.sh
# this shell script shuts down all docker containers prior to backup
echo "Shutting down containers for backup:"
echo "watchtower..."
cd /opt/watchtower
docker compose down 2>&1
# [...]
Der Ausdruck 2>&1
bedeutet, dass eventuelle Ausgabe von Fehlern in die Konsole umgeleitet wird. Die Zahl 1
stellt nämlich den Filedescriptor für stdout
dar, während 2
stderr
meint. Der Operator >&
funktioniert als redirect merger
, also als Umleitung und Zusammenführung. Später können wir an diesem Punkt hingehen und die Ausgabe in ein Logfile schreiben - das lasse ich aber der Einfachheit halber erst einmal aus.
Borgmatic: Trigger-Handler Mechanismus #2 und #3
Das Skript kann ich nun auf zwei Arten ausführen lassen:
- Als Aufruf durch das weiter oben beschriebene Skript
server-config-handler
- Durch die vor borg geschaltete Automatisierungslösung borgmatic
Ich entscheide mich für die zweite Option und schreibe daher in die borgmatic.d/config.yml
die folgenden Befehle:
# borgmatic.d/config.yml
# List of one or more shell commands or scripts to execute before
# creating a backup, run once per repository.
before_backup:
- echo "Triggering container shutdown for backup."
- touch /etc/automation-hooks-trigger/backup-pre.txt
- sleep 20
- echo "Assuming container shutdown complete. Creating the backup now."
# [...]
after_backup:
- echo "Triggering container restart after backup."
- touch /etc/automation-hooks-trigger/backup-post.txt
- sleep 10
- echo "Assuming container restart complete. Exiting."
# [...]
Damit dies richtig funktioniert, muss ich in der zugehörigen docker-compose.yml
natürlich ein Volume anlegen und auf den Pfad im Dateisystem des Servers zeigen lassen: ${VOLUME_UPDATE_TRIGGER}:/etc/automation-hooks-trigger
Nun muss ich die Gegenseite dieser Trigger einrichten: Handler überwachen die Triggerdateien auf Änderungen und rufen entsprechend Action-Skripte auf. Sie sind bis auf die Pfade von triggerfile
und actionfile
identisch mit dem oben erläuterten Handler.
Das Backup anlegen
Würde ich borg direkt ansprechen, so ließe sich das Backup über create
erstellen. Dazu müsste ich angeben, in welchem Repository die Sicherungskopie abgelegt werden soll und unter welchem Namen. Im Beispiel unten wird das mit dem Scope-Operator vorgegeben: ::config-update
. Anschließend werden die zu sichernden Ordner angegeben.
borg create /path/to/repo::config-update ~/opt
Ich verwende ja borgmatic, was mir über die Konfigurationsdatei einiges abnimmt. Dafür muss ich den Befehl allerdings im Container ausführen. Um besser prüfen zu können, ob auch alles funktioniert, lasse ich mir Statistiken “verbose” auf die Konsole ausgeben (--stats -v 1
) sowie die kopierten Dateien anzeigen --files
.
docker exec borgmatic sh -c "cd && borgmatic --stats -v 1 --files 2>&1"
Diese Zeile füge ich dem Automationsskript hinzu.
Zweiter Test zum Anlegen des Backups
Wenn jetzt alles funktioniert, sieht der komplette Ablauf wie folgt aus:
- Nach Eingang der geänderten Dateien durch Giteas Automation legt act_runner (im Docker-Container) die Dateien auf dem Server ab und setzt anschließend den
server-config-update
Trigger. - Binnen
10sec
wird der Trigger vonserver-config-handler.sh
erkannt, welches anschließendserver-config-action.sh
aufruft. - borgmatic (im Docker-Container) wird darin beauftragt, ein Backup anzufertigen. Dieses wiederum schreibt den
pre-backup
Trigger. - Erneut binnen
10sec
wird durch diesenpre-backup-handler.sh
dasbackup-pre-action.sh
Skript aufgerufen und alle Container bis auf borgmatic gestoppt. - Durch die eingebaute Verzögerung wartet borgmatic dies ab und erstellt nun das Backup.
- Nach dem Backup schreibt borgmatic den
backup-post-action.sh
Trigger. - Binnen weiterer
10sec
erkenntpost-backup-handler.sh
die geänderte Datei und fährt alle Container überpost-backup-actions.sh
wieder hoch. - borgmatic teilt
server-config-update.sh
mit, ob irgendwo im Prozess bis jetzt Fehler auftraten. Falls nicht, wird fortgefahren. - Alle Docker-Container werden gestoppt.
- Die Server-Konfiguration wird auf die entsprechenden Stellen ausgerollt.
- Es findet ein Neustart aller Docker-Container mit neuer Konfiguration statt.
Ein erster Erfolg: Bis Punkt 6 laufen diese Skripte sogar auf meinem Laptop bereits durch:
schallbert@laptop: touch server-config-update.txt
server-config-handler@laptop: --- RUN backup ---
borgmatic@docker: /etc/borgmatic.d/config.yml: Running 4 commands for pre-backup hook
Triggering container shutdown for backup.
backup-pre-handler.sh@laptop: --- RUN backup-pre-action.sh ---
Shutting down containers for backup:
watchtower...
[...]
complete.
borgmatic@docker: Assuming container shutdown complete. Creating the backup now.
local: Creating archive
Failed to create/acquire the lock /mnt/repository/lock.exclusive
Fehlerbehebung für “failed to acquire the lock”
Dieses Problem taucht bei mir dann auf, wenn borg beim Erstellen eines Backups einen Fehler meldet, der das Programm zum Abbruch bringt. In diesem Falle wird das Repository anscheinend nicht korrekt freigegeben, sodass es nach Neustart des Containers für den alten, nun nicht mehr existierenden Container reserviert bleibt. Das folgende Kommando löst dieses Problem:
docker exec borgmatic sh -c "cd && borg break-lock /mnt/repository
Übertragen der Konfiguration
Nun müssen die Konfigurationsdateien noch an die jeweils richtige Stelle auf dem Server kopiert werden. Glücklicherweise hatte ich die Ziel-Ordnerstruktur bereits beim Erstellen des Repository geklont, sodass ich das mit einem einzigen Kopierbefehl ohne “hardcoding” hinbekommen sollte. Nach ein paar Netzrecherchen und einem Blick in die Bedienungsanleitung für den Kopierbefehl man cp
habe ich mein Kommando:
# copy recursively contents of folder "server-config" to "/opt"
sudo cp -r -v /opt/server-config/. /opt 2>&1
Somit sage ich dem Betriebssystem, es solle den Inhalt (/.
) des Ordners server-config
rekursiv (-r
), also inklusive aller Unterordner, in den Ordner opt
befördern, welcher sich im Stammverzeichnis /
befindet. cp
arbeitet hierbei überschreibend-ergänzend, wird also noch nicht vorhandene Dateien erstellen und vorhandene überschreiben, sie also nicht unter gleichem Namen “daneben kopieren”. Mit der Option -v
lasse ich zusätzliche Details ausgeben, und mit 2>&1
leite ich die Fehlerausgabe in die Konsole um.
Alle Ordnerindikatoren müssen sich genau dort befinden, wo sie im Befehl stehen: Ein Slash hinter /opt/
würde Ordner nicht-überschreibend redundant kopieren, Dateien aber überschreiben. Ohne .
würde der Ordner server-config
am Zielpfad erstellt.
Umstieg auf rsync
Der cp
-Befehl kopiert leider auch ein paar Dateien, die ich gar nicht kopiert haben möchte: Repository-spezifische Ordner wie .gitea
, oder auch die Ordner für Trigger und Handler. Diese benötige ich nur unter server-config
, nicht aber auch direkt in opt
. Um dies zu beheben nutze ich stattdessen das Kommando rsync
. Dort kann ich mit -u
einstellen, dass nur neuere Dateien überschrieben werden sollen und über eine --exclude
-Option nicht zu kopierende Dateien und Ordner ausschließen. Dies sieht dann so aus:
rsync -r -u -v --exclude '.*' --exclude 'README.md' --exclude '<otherFolders>' /opt/server-config/. /opt 2>&1
Doch plötzlich startet gitea nicht mehr. Fehlermeldung:
docker@server: [...] failed to load config file "app.ini": open: permission denied
Nach längerem Grübeln und diversen Neustarts des Docker-Clients sehe ich, dass rsync die Berechtigungen der Quelldatei auf die Zieldatei geschrieben hat, was bei cp vorher nicht der Fall war: -rw-------
. Nun ändere ich dies, indem ich chmod +r app.ini
ausführe. Schon fährt alles wieder wie gewohnt hoch! 🎉
Alle Anwendungen neu starten
Da ich alles auf meinem Server in Docker betreibe, genügen hierfür zwei einfache Befehle:
docker stop $(docker ps -a -q) 2>&1
# [...roll out config changes...]
docker restart $(docker ps -a -q) 2>&1
Die fertige Automation
Mein Skript ist nun fertig und ruft lediglich das Action-Skript auf.
#! /bin/bash
#/opt/automation-hooks-handler/server-config-handler.sh
#[...]
if [[ "$ATIME" != "$LTIME" ]]
then
echo "--- CONFIG UPDATE TRIGGER detected ---"
./server-config-action.sh 2>&1
LTIME=$ATIME
fi
sleep 10
done
Das Skript server-config-action
führt dann die oben beschriebenen Aktionen aus:
#! /bin/bash
#/opt/automation-hooks-handler/server-config-action.sh
echo "--- RUN backup ---"
docker exec borgmatic sh -c "cd && borgmatic --stats -v 1 --files 2>&1"
echo "--- STOP all containers ---"
docker stop $(docker ps -a -q) 2>&1
echo "--- DEPLOY config ---"
cp -r -v /opt/server-config/. /opt 2>&1
echo "--- RESTART all containers ---"
docker restart $(docker ps -a -q) 2>&1
Damit das Ganze nun zuverlässig funktioniert, müssen die drei Handler-Skripte im Hintergrund laufen:
server-config-handler.sh
backup-pre-handler.sh
backup-post-handler.sh
Sie reagieren auf die jeweiligen Trigger, ausgelöst durch act_runner von Gitea bzw. durch borgmatic. Ich erweitere das crontab jetzt entsprechend und bin dann mit der Aufgabe vorerst fertig. Die Konsolenausgabe des gesamten Vorgangs sieht so aus:
--- RUN backup ---
/etc/borgmatic.d/config.yml: Running 4 commands for pre-backup hook
Triggering container shutdown for backup.
--- RUN backup-pre-action.sh ---
Shutting down containers for backup:
gitea...
fail2ban...
complete.
Assuming container shutdown complete. Creating the backup now.
local: Creating archive
<borg archive stats>
/etc/borgmatic.d/config.yml: Running 4 commands for post-backup hook
Triggering container restart after backup.
--- RUN backup-post-action.sh ---
Restarting containers after backup:
fail2ban...
gitea...
watchtower...
complete.
Assuming container restart complete. Exiting.
local: Pruning archives
local: Compacting segments
compaction freed about 1.82 MB repository space.
local: Running consistency checks
summary:
/etc/borgmatic.d/config.yml: Successfully ran configuration file
--- STOP all containers ---
<container ids>
--- DEPLOY config ---
sending incremental file list
<files that are copied>
sent 206,558 bytes received 3,463 bytes 420,042.00 bytes/sec
total size is 193,167 speedup is 0.92
--- RESTART all containers ---
<container ids>
Prima! Nun fehlt mir nur noch, dass diese Ausgabe mir zugestellt wird, falls etwas bei diesem Vorgang schief geht.