Server-Konfiguration mit Git

9 Minuten Lesezeit

Worum geht es?

  • Ich wünsche eine Versionskontrolle für die Konfiguration von Serverapplikationen
  • Diskussion technischer Lösungen zur Umsetzung
  • Tutorial: Docker/Gitea/andere “Secrets” von der Versionskontrolle ausnehmen
  • Tutorial: Deploy-Pipeline erstellen
  • Automationstrigger für nachgelagertes Ausrollen der Dateien auf dem Server programmieren
  • Nächster Artikel: Automation auf dem Server integrieren und Upgrades einspielen

Hintergrund

Ich habe auf meinem Server ja Watchtower laufen, um die Installierten Distributionen automatisch auf Stand und frisch gepatcht zu halten. Vor Kurzem hatte ich nun den Fall, dass sich mein Gitea Action Runner nicht mehr automatisch starten lassen wollte und auch bei manuellem Start eine Fehlermeldung ausgab.

Anscheinend hatte sich Docker ohne meine Aufsicht auf Stand gebracht und warf nun beim Hochfahren des Runner-Containers einen Volumefehler, die ich so vorher noch nicht gesehen hatte. Die Fehlermeldung war eindeutig und ließ sich mittels kleiner Änderungen in einer Konfigurationsdatei für Gitea leicht beheben. Dennoch hatte ich nun das Gefühl, dass eine Versionskontrolle für meine Konfiguration sinnvoll wäre, um Änderungen, Updates und ihre Beweggründe später besser nachvollziehen zu können.

Vorüberlegungen

Klingt nun für mich nach einem Zirkelbezug: Ich nehme die Konfigurationsdateien für meinen Server in Gitea auf, welches selbst auf meinem Server läuft. Das könnte beim Auto-Deploy noch spannend werden. Aber mehr dazu später.

Spontan fallen mir zwei Möglichkeiten ein, eine Versionskontrolle mit automatischer Synchronisation zu bekommen:

Diese Lösung würde die auf meinem Server in vielen Ordnern verstreuten Konfigurationdateien per Hardlink in einen zum Repository erklärten Ordner ablegen. Warum per Hardlink? Weil jeder Service auf meinem Server in sich gekapselt läuft und dafür jeweils eigene Konfigurationsdateien und Umgebungsvariablen gemeinsam mit dem Service abgelegt bekommt. Ohne Hardlinks müsste ich den gesamten Service-Ordner versionieren und meine .gitignore entsprechend komplex aufblasen.

Mit Git würde ich die per Hardlink abgebildeten Dateien versionieren und ihre Inhalte auf diese Weise auf Gitea verfügbar machen.

schallbert@server:/server-config-files –> hardlinks –> repository –> Gitea

Ich bekäme also eine nachgelagerte “Versionsbeobachtung” mit Sicherungskopie in dem Sinne, dass die Dateien nun mehrfach herumliegen.

2. Repo und auto-deploy auf den Server

Image: Diagram how version control with auto-deploy might work on my server

In diesem Szenario halte ich die Konfigurationsdateien lokal auf meinem Laptop und kann sie wie üblich mit Git versionieren. Auf Gitea würde ich das Repository abbilden, und am Ende der Kette müsste mein Runner bei jeder Änderung der Konfiguration ein Auto-Deploy auf dem Server ausführen und die betroffenen Container anschließend neu starten.

schallbert@laptop:/server-config-files –> repository –> Gitea –> act-runner –> server:/<service1...ServiceN>/config-files

Ich bekäme in diesem Falle den Verwaltungs-“Main” auf meinen Laptop, und der Server würde der Abbildung auf Gitea folgen.

Probleme

In beiden Fällen habe ich noch keine Möglichkeit, geänderte Konfigurationen vorab zu testen. Alles, was ich tue geht direkt nach “Prod” und wäre live. Schlimmstenfalls kann ich mir so recht leicht mein Setup zerschießen.

Ändert nun den Status Quo ante nicht, bei dem ich die Dateien direkt auf dem Server verändere. Also ein neues Problem nur in der Hinsicht, dass ich beim Aufsetzen damals noch keinen Prod-Betrieb hatte und so kein Ausfallrisiko bestand.

Lösung Zwei scheint mir komplexer umzusetzen zu sein, denn ich benötige hierfür eine Deployment-Pipeline, welche die Dateien im Repository auf meinem Server ausrollt. Zumal sich der Runner in einem Docker-Container aufhält, während die Konfigurationsdateien direkt im Dateisystem des Servers befinden.

Ein direkter Durchgriff auf das Serverdateisystem ist zwar technisch möglich, würde aber meinem beschränkten Verständnis nach eine große Angriffsfläche für alle meine Dienste bedeuten, deren Konfigurationsdateien nun über das Repository in Gitea direkt manipulierbar würden.

Auswahl meiner Lösung

Lösung Zwei lagert mir das Sofort-Live-Problem zumindest auf meinen Laptop vor, sodass ich in einem ersten Schritt nicht direkt am Prod-System herumfuhrwerken muss. Hier ließe sich, denke ich, leichter eine Integrationsumgebung aufsetzen, mit der ich meine Konfigurationsänderungen vorab prüfen kann. Da ich alle meine Dienste im Docker laufen habe, könnte sich das vielleicht ganz bequem lösen lassen.

Frisch ans Werk

Konfigurations-Repo anlegen

Gut, dann ziehe ich mir in einem ersten Schritt die Konfigurationsdateien vom Server. Dafür nutze ich das Dateitransferkommando scp: scp server:/path/to/source path/to/target etwa ein Dutzend mal, bis ich alle Konfigurationsdateien auf meinem Server erwischt habe.

Ich setze die Ordnerstruktur in diesem Repo direkt so auf, wie die Dateien auf dem Server liegen. So, hoffe ich, mache ich mir das Leben später etwas leichter.

Secrets in docker-compose.yml

Nur - was tue ich mit “secrets” in den Konfigurationsdateien? Also privaten Schlüsseln, registration tokens, hashes? Die möchte ich lieber nicht mehr oder minder offen im Repository herumliegen haben. Bei Verwendung von docker-compose ist das das recht einfach: Ich kann geheime Werte in versteckte Dateien für Umgebungsvariablen auslagern und vom Git-Tracking ausnehmen. Im einfachsten Falle heißen solche Dateien schlicht .env und beinhalten eine Aufzählung an Umgebungsvariablen im Stil von

BORG_PASSPHRASE="<redacted>" 

In der entsprechenden docker-compose.yml ziehe ich die Variable aus der Datei .env wie folgt an:

- BORG_PASSPHRASE=${BORG_PASSPHRASE}

Diese Änderungen nehme ich direkt mal lokal auf meinem Laptop vor. Über die .gitignore teile ich mittels .* mit, dass versteckte Dateien und damit .env nicht ins Repository aufgenommen werden sollen. Doch woher weiß ich nun, ob die Container noch korrekt hochfahren?

Secrets in Giteas app.ini

Bei Gitea habe ich mich schon deutlich schwerer damit getan, Secrets in Dateien auszulagern. Die app.ini wird auch von Gitea dynamisch geschrieben, sodass die Datei bei jedem Neustart des Services ein wenig anders aussieht. Nach langer Recherche fand ich dieses Issue, in welchem eine Lösung für das separate Speichern von Secrets gesucht und gefunden wurde.

  • Sinnvoll erscheint mir nur das separate Speichern der Werte INTERNAL_TOKEN sowie SECRET_KEY.
  • Die anderen beiden Properties LFS_JWT_SECRET und JWT_SECRET werden ohnehin automatisch generiert und regelmäßig überschrieben.

In meiner Konfiguration verwende ich lediglich INTERNAL_TOKEN. Also werde jenes im Klartext und ohne Anführungszeichen in eine versteckte Datei kopieren (.INTERNAL_TOKEN) und sie per Docker-Volume dem Container zur Verfügung stellen:

# Gitea's docker-compose.yml
# [...]
    volumes:
      - ./.INTERNAL_TOKEN:/run/secrets/INTERNAL_TOKEN:ro
      # [...]

In der app.ini ist es nun wichtig, den in der Composedatei angegebenen Pfad einzusetzen:

# Gitea's app.ini
# [...]
  server:
    INTERNAL_TOKEN = /run/secrets/INTERNAL_TOKEN
    # [...]

Nicht etwa wie im oben verlinkten Issue angegeben INTERNAL_TOKEN_URI=/run/secrets/INTERNAL_TOKEN, denn dies erzeugt in der von mir momentan verwendeten V1.22.1 einen Fehler: Unsupported URI-Scheme.

Mal ganz grob testen

Um zu prüfen, ob die geänderten Konfigurationsdateien noch funktionieren, installiere ich mir docker und docker-compose auf meinem Laptop. Anschließend probiere ich, die Container mal hochzufahren.

# docker console log
Error: Network 'caddy-proxy' declared as external, but could not be found.

Stimmt ja, Docker ist hier noch gar nicht konfiguriert. Also das Netzwerk erstellen: sudo docker network create caddy-proxy und erneut versuchen. Schon beginnt der Download von Gitea samt Abhängigkeiten und der Container startet - wenn auch nicht so wie ich mir das vorgestellt habe: Innerhalb von Docker stimmen die Ordnerberechtigungen nicht, sodass weder Gitea, noch Act-runner auf alle benötigten Dateien zugreifen kann.

Dennoch, einen ersten Fehler finde ich:

# gitea container log
WARNING: The GITEA_RUNNER_REGISTRATION_TOKEN variable is not set. Defaulting to a blank string.

Ich hatte nämlich vergessen, den Token-String in Anführungszeichen zu setzen.

Für eine voll funktionsfähige Integrationsumgebung musste ich also noch mindestens zwei weitere Probleme lösen: Ordnerberechtigungen für den Main auf meinem Laptop müssen so angelegt sein, dass auch der User Docker Schreibrechte bekommt. Als einfache Lösung genügt vorerst, den betreffenden Volumes ein :Z anzuhängen und sie so als private unshared kennzuzeichnen. Eine Volumendefinition sieht in meiner docker-compose.yml nun so aus:

# gitea/docker-compose.yml
# [...]
volumes:
  - ./gitea:/data:Z
  # [...]

Ich benötige eine zweite Datei für Umgebungsvariablen, um meine Dienste auf localhost umzubiegen. Immerhin startet der Service auch so und ich sehe die Logausgabe. Die meisten Konfigurationsfehler kann ich bereits jetzt entdecken.

Ich bin mir nicht sicher, aber möglicherweise bekomme ich zusätzliche Probleme mit dem caddyserver wie Zertifikatsverwaltung, Proxy-Einstellungen und so weiter.

Eine Deploy-Pipeline erstellen

Jetzt wäre es noch super, wenn die in das Gitea-Repo hochgeladenen (und vorab lokal auf Funktion getesteten) Dateien automatisch ihren Weg auf meinen Server finden würden. Hierfür könnte ich ein weiteres Docker-Volume anlegen, wo act-runner dann die per on:push Trigger abgelegten Daten hinpackt. Sie würden dann auf meinem Server verfügbar werden.

Setup

Erinnern wir uns an meine letzten Versuche mittels act_runner Artefakte auf dem Server bereit zu stellen, so können wir einen Großteil dessen auch für diese Aufgabe übernehmen:

# deploy-to-server.yml
# Workflow for saving the server's config repo to the local disk system

name: Upload-server-config
run-name: $ uploads server configuration files
on:
  push:
    branches:
    - main

jobs:
  # Deploy job
  build:
    runs-on: ubuntu-latest # this is the "label" the runner will use and map to docker target OS
    container: 
      volumes: 
        # left: where the output will end up on disk, right: volume name inside container
        - /opt/server-config:/workspace/schallbert/server-config/tmp

    steps:
      - name: --- CHECKOUT ---
        uses: actions/checkout@v3
        with: 
          path: ./tmp
      - name: --- RUN FILE CHANGE TRIGGER ---
        run: |
          cd ./tmp/automation-hooks-trigger
          touch server-config-update.txt

Merkwürdige Volumefehler

Doch der Weg bis hierhin war steinig. Lange hatte ich unter volumes: auf der Containerseite lediglich /server-config angegeben und nicht das Arbeitsverzeichnis des Runners. Dann läuft die Action zwar durch und alle Befehle der #TEST-Sektion funktionieren auch. Wenn ich aber auf meinem Server nachschaue, bleibt der von Docker angelegte server-config Ordner leer.

Mit folgenem Debug-Code unter RUN FILE CHANGE TRIGGER helfe ich mir, die Pfadfehler zu finden:

# deploy-to-server.yml
# [...]
run: |
  echo "hello world"
  touch updated.txt
  pwd
  ls -al

Mit echo prüfe ich, ob mein Code im Runner überhaupt ausgeführt wird. Das touch-Kommando setzt mir den aktuellen Zeitstempel in die updated.txt-Datei, sodass ich dies später als “Hook” für weitere Automation verwenden kann. pwd zeigt mir den aktiven Pfad innerhalb des Runners an, sodass ich das Docker-Volume korrekt auf die Server-Festplatte abbilden kann. ls -al zeigt mir, ob die im Schritt CHECKOUT zusammengestellten Konfigurationsdateien korrekt geschrieben wurden.

Ich erkenne dadurch, dass der Volume-Pfad auf der “rechten Seite” falsch war. Ich biege ihn entsprechend auf das aktive Verzeichnis des Runners um:

# deploy-to-server.yml
# [...]
   # left: where the output will end up on disk, right: volume name inside container
        - /opt/server-config:/workspace/schallbert/server-config/
# [...]

Anschließend bekam ich Folgendes zu lesen:

# gitea / act-runner console log
failed to create container: 'Error response from daemon: Duplicate mount point: /workspace/schallbert/server-config'

Anscheinend legt sich der Runner die “rechte Seite” des Mountpoints automatisch selbst an und kann daher nicht erneut belegt werden. Erst durch das Hinzufügen eines weiteren Pfadteiles, in meinem Falle /tmp - siehe oben - behebe ich den Fehler und auf meinem Server erscheint endlich 😌 der lang ersehnte Ordner server-config mit Inhalt:

schallbert@schallbert-ubuntu-:/opt/server-config# ls -al
total 52
drwxr-xr-x 9 root root 4096 Jul 12 15:38 .
drwxr-xr-x 9 root root 4096 Jul 11 19:56 ..
-rwxr-xr-x 1 root root  395 Jul 11 20:05 boot-after-backup.sh
drwxr-xr-x 3 root root 4096 Jul 11 20:05 borgmatic
drwxr-xr-x 2 root root 4096 Jul 11 20:05 caddy2
drwxr-xr-x 3 root root 4096 Jul 11 20:05 fail2ban
drwxr-xr-x 8 root root 4096 Jul 12 15:38 .git
drwxr-xr-x 3 root root 4096 Jul 11 20:05 .gitea
drwxr-xr-x 4 root root 4096 Jul 11 20:05 gitea
-rw-r--r-- 1 root root  312 Jul 11 20:05 .gitignore
-rw-r--r-- 1 root root  557 Jul 11 20:05 README.md
-rwxr-xr-x 1 root root  371 Jul 11 20:05 shutdown-for-backup.sh
-rw-r--r-- 1 root root    0 Jul 12 15:38 updated.txt
drwxr-xr-x 2 root root 4096 Jul 11 20:05 watchtower

Verteilen der Konfiguration auf dem Server

Gut, damit sind die ersten Schritte getan. Ich habe nun ein vernünftig konfiguriertes Git-Repository, was meine Server-Konfiguration abbildet und vom Laptop aus gepflegt und zumindest rudimentär getestet werden kann. Außerdem kann ich über eine automatische Action Updates der Konfiguration auf dem Server ablegen und eine Update-Datei mit Zeitstempel beschreiben.

Nun muss das Update auf dem Server noch entgegengenommen, verteilt und die betroffenen Programme und Dienste sollen neu gestartet werden. Doch dies schauen wir uns im Artikel Serverkonfiguration ausrollen an.