Server-Konfiguration ausrollen

11 Minuten Lesezeit

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:

  1. Als Aufruf durch das weiter oben beschriebene Skript server-config-handler
  2. 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:

  1. 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.
  2. Binnen 10sec wird der Trigger von server-config-handler.sh erkannt, welches anschließend server-config-action.sh aufruft.
  3. borgmatic (im Docker-Container) wird darin beauftragt, ein Backup anzufertigen. Dieses wiederum schreibt den pre-backup Trigger.
  4. Erneut binnen 10sec wird durch diesen pre-backup-handler.sh das backup-pre-action.sh Skript aufgerufen und alle Container bis auf borgmatic gestoppt.
  5. Durch die eingebaute Verzögerung wartet borgmatic dies ab und erstellt nun das Backup.
  6. Nach dem Backup schreibt borgmatic den backup-post-action.sh Trigger.
  7. Binnen weiterer 10sec erkennt post-backup-handler.sh die geänderte Datei und fährt alle Container über post-backup-actions.sh wieder hoch.
  8. borgmatic teilt server-config-update.sh mit, ob irgendwo im Prozess bis jetzt Fehler auftraten. Falls nicht, wird fortgefahren.
  9. Alle Docker-Container werden gestoppt.
  10. Die Server-Konfiguration wird auf die entsprechenden Stellen ausgerollt.
  11. 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.