Remote-Teams betreiben CI Runner auf Cloud-Macs häufig als dauerhaft verfügbare Infrastruktur. Doch auch die Runner selbst müssen aktualisiert werden. Besonders riskant ist es, das Programmverzeichnis mitten in einem laufenden Job zu überschreiben und den Dienst neu zu starten: Build-Prozesse verwenden möglicherweise weiterhin alte Dateien, während der Scheduler bereits neue Komponenten geladen hat. Dadurch entsteht ein schwer reproduzierbarer Mischzustand. Sicherer ist es, zunächst alle laufenden Jobs kontrolliert abzuschließen und anschließend mithilfe unveränderlicher Versionsverzeichnisse und eines atomar ausgetauschten symbolischen Links umzuschalten.
Sicherheitsgrenzen für das Update festlegen
Ein kontrolliertes Update muss mindestens vier Bedingungen erfüllen: Der Runner nimmt keine neuen Jobs an, der aktuelle Job kann regulär enden, alte und neue Version können parallel vorhanden sein und bei einem fehlgeschlagenen Wechsel lässt sich der vorherige Zustand sofort wiederherstellen. „Draining“ bedeutet hier weder, den Arbeitsbereich zu leeren, noch einen Build abzubrechen. Stattdessen wird der Runner an einer Job-Grenze pausiert.
Während des Updates ist nicht entscheidend, ob der Dienst gestartet ist. Entscheidend ist, ob der alte Job beendet wurde und noch kein neuer Job begonnen hat.
Vor dem Update sollten die aktuelle Version, die Prozess-ID des Runners, die Kennung des laufenden Jobs und dessen Arbeitsverzeichnis protokolliert werden. Falls der Job einen Xcode-Build umfasst, muss außerdem geprüft werden, ob noch xcodebuild, Testprozesse oder untergeordnete Prozesse für den Archivexport laufen. Es genügt nicht, nur den übergeordneten Runner-Prozess zu kontrollieren. Selbst nach dessen Beendigung können fehlerhafte Skripte Kindprozesse im Hintergrund zurücklassen.
Die Kriterien für ein Update sollten als klare Regeln dokumentiert sein: Wie lange darf ein aktueller Job maximal laufen? Ab wann ist eine manuelle Prüfung erforderlich? Welche Smoke-Tests müssen erfolgreich sein, bevor wieder neue Jobs angenommen werden? Diese Regeln sollte das Team anhand der üblichen Projektlaufzeiten festlegen, statt sie erst während eines Störfalls zu improvisieren.
Programm, Konfiguration und Arbeitsbereich trennen
Das Runner-Programm sollte für jede Version in einem eigenen Verzeichnis liegen. Dauerhafte Konfigurationen und Build-Arbeitsbereiche gehören dagegen außerhalb der Versionsverzeichnisse. Eine einfache Struktur sieht beispielsweise so aus:
/opt/ci-runner/
├── current -> releases/runner-current
├── previous-target
├── drain
├── active.pid
├── config/
├── work/
└── releases/
├── runner-old/
└── runner-current/
config enthält Registrierungsdaten und vertrauliche Einstellungen. Die Zugriffsrechte sollten auf den Benutzer des Runners beschränkt sein. In work liegen ausgecheckte Repositories und temporäre Build-Dateien, während releases ausschließlich die Programmdateien enthält. Tokens dürfen weder in jedes Versionsverzeichnis kopiert werden noch darf ein Update-Paket den Arbeitsbereich überschreiben.
Nach dieser Trennung verändert ein Update nur noch das Ziel von current. Die alte Version bleibt vollständig erhalten, sodass ein Rollback weder einen erneuten Download noch eine Neuinstallation erfordert. Alte Versionen sollten erst nach Ablauf des Beobachtungszeitraums entfernt werden. Mindestens die zuletzt erfolgreich geprüfte Version muss erhalten bleiben.
Jobs mit einem Wrapper kontrolliert auslaufen lassen
Am einfachsten lässt sich der Ablauf steuern, wenn der Runner pro Aufruf genau einen Job verarbeitet und sich anschließend beendet. Ein Wrapper entscheidet danach, ob der nächste Job angenommen wird. Da die Option für den Einzeljobbetrieb je nach Runner unterschiedlich sein kann, wird der tatsächliche Startbefehl zunächst als $RUNNER_CMD gekapselt und anschließend in einer einheitlichen Schleife ausgeführt:
#!/bin/zsh
set -u
root=/opt/ci-runner
lock="$root/wrapper.lock"
mkdir "$lock" 2>/dev/null || exit 1
cleanup() {
rm -f "$root/active.pid"
rmdir "$lock" 2>/dev/null
}
trap cleanup EXIT INT TERM
while [[ ! -e "$root/drain" ]]; do
"$root/current/bin/$RUNNER_CMD" run --once &
child=$!
print -r -- "$child" > "$root/active.pid"
if wait "$child"; then
status=0
else
status=$?
fi
rm -f "$root/active.pid"
if (( status != 0 )); then
sleep 10
fi
done
Zur Vorbereitung des Updates wird touch /opt/ci-runner/drain ausgeführt. Der Wrapper lässt den aktuellen Kindprozess zu Ende laufen, startet aber keinen weiteren Durchgang. Danach kann die Prozess-ID aus active.pid gelesen und mit kill -0 PID geprüft werden, ob der Prozess noch existiert. Eine leere PID-Datei bedeutet jedoch nicht zwangsläufig, dass der Rechner inaktiv ist. Zusätzlich müssen die zum Job gehörenden Build- und Testprozesse kontrolliert werden.
Unterstützt der Runner keinen Modus, in dem er sich nach einem einzelnen Job beendet, sollte seine native Funktion zum Pausieren der Job-Annahme verwendet werden. Dabei muss geprüft werden, dass nach dem Pausieren keine Jobs vorab abgerufen werden. Ist dieses Verhalten nicht eindeutig dokumentiert, sollte zunächst in einer isolierten Umgebung ein lang laufender Job simuliert und das Draining beobachtet werden.
Update-Paket prüfen und atomar umschalten
Die neue Version wird zunächst in ein temporäres Verzeichnis entpackt. Erst nach erfolgreicher Prüfung wird dieses in releases umbenannt. Der erwartete Prüfwert des heruntergeladenen Pakets muss aus einer vertrauenswürdigen Release-Dokumentation des Teams stammen. Er darf nicht nachträglich neben demselben ungeprüften Archiv erzeugt werden.
root=/opt/ci-runner
release="$root/releases/runner-next"
archive=/tmp/runner-next.tar.gz
expected="$EXPECTED_SHA256"
actual="$(shasum -a 256 "$archive" | awk '{print $1}')"
[[ "$actual" = "$expected" ]] || exit 2
mkdir -p "$release"
tar -xzf "$archive" -C "$release"
"$release/bin/$RUNNER_CMD" --version || exit 3
old="$(readlink "$root/current")"
print -r -- "$old" > "$root/previous-target"
ln -sfn "$release" "$root/current.next"
mv -fh "$root/current.next" "$root/current"
Vor dem Umschalten des symbolischen Links muss sichergestellt sein, dass das Programm im neuen Verzeichnis ausführbar ist, zur Systemarchitektur passt und die externe Konfiguration lesen kann. Der eigentliche Wechsel sollte so kurz wie möglich bleiben. Entpacken, Installieren von Abhängigkeiten oder Netzwerk-Downloads gehören nicht in diesen kritischen Abschnitt. Erfordert das Update zusätzlich eine Konfigurationsmigration, sollte die neue Konfiguration zunächst in eine temporäre Datei geschrieben, auf ihr Format geprüft und erst danach atomar ersetzt werden.
Abweichende Berechtigungen nach dem Entpacken vermeiden
Archive können andere Eigentümer- oder Berechtigungseinstellungen enthalten. Vor dem Wechsel ist zu prüfen, dass Benutzer außerhalb des Runner-Kontos die Programmdateien nicht verändern können. Vertrauliche Konfigurationsdateien sollten üblicherweise den Modus 600, das Konfigurationsverzeichnis den Modus 700 verwenden. Außerdem muss das Arbeitsverzeichnis weiterhin dem bisherigen Runner-Benutzer gehören, damit Schreibfehler nicht erst beim ersten Start der neuen Version auffallen.
Smoke-Tests und schneller Rollback
Nach dem Umschalten darf drain zunächst nicht gelöscht werden. Stattdessen wird manuell ein kontrollierter Smoke-Test gestartet. Dieser sollte mindestens bestätigen, dass der Runner die Konfiguration lesen, einen temporären Arbeitsbereich anlegen, die Entwicklungswerkzeuge aufrufen, einen minimalen Build abschließen, Artefakte speichern und anschließend sauber aufräumen und beenden kann.
Bei der Prüfung der Protokolle ist besonders auf die verwendeten Pfade zu achten. Der neue Prozess muss aus dem neuen Verzeichnis starten, auf das current zeigt. Cache, Konfiguration und Arbeitsbereich müssen dagegen weiterhin die unveränderlichen Pfade verwenden. Falls alte und neue Versionspfade gemeinsam in den Protokollen erscheinen, läuft noch mindestens ein alter Prozess. In diesem Zustand dürfen keine neuen Jobs angenommen werden.
Schlägt die Prüfung fehl, wird das vorherige Link-Ziel aus previous-target gelesen und mit demselben Verfahren über einen temporären symbolischen Link wieder aktiviert. Anschließend wird der minimale Test erneut ausgeführt. Die neue Version darf nicht direkt im Release-Verzeichnis repariert und wiederholt getestet werden, da dies das Prinzip unveränderlicher Releases verletzen würde. Erst nachdem die alte Version nachweislich wieder funktioniert, kann die fehlerhafte Version gelöscht und das Release-Paket neu erstellt werden.
Nach erfolgreicher Abnahme wird rm -f /opt/ci-runner/drain ausgeführt und der Wrapper durch den Prozessmanager neu gestartet. Abschließend sollte mindestens ein echter Job von der Annahme über den Build und die Artefaktarchivierung bis zum Beenden beobachtet werden. Versionsnummer, Ergebnis des Wechsels und Rollback-Ziel gehören in das Update-Protokoll. Erst dann ist das Update abgeschlossen – nicht bereits in dem Moment, in dem der Runner wieder als online angezeigt wird.
Häufig gestellte Fragen
Warum sollte ein CI Runner nicht direkt im bestehenden Verzeichnis aktualisiert werden?
Ein laufender Job könnte alte und neue Dateien mischen. Außerdem fehlt danach ein unveränderter Rücksprungpunkt. Getrennte Release-Verzeichnisse vermeiden beide Risiken.
Muss ein laufender Build beim Drain beendet werden?
Nein. Der Drain verhindert nur die Annahme des nächsten Jobs. Der aktive Kindprozess darf regulär enden; vor einem erzwungenen Abbruch werden Protokolle und Prozesszustand gesichert.
Welche Prüfungen sind nach dem Update unverzichtbar?
Zu prüfen sind Version, Registrierung, Schreibzugriff im Arbeitsverzeichnis, ein minimaler Build, die Artefaktablage und die Rechte sensibler Konfigurationsdateien.
Benötigen Sie eine dedizierte Apple-Silicon-Build-Maschine?
Mieten Sie dedizierte physische Knoten tage-, wochen-, monats- oder quartalsweise für Xcode-Builds, iOS-CI, Remote-Entwicklung und Automatisierungsaufgaben.