← Übersicht  ·  Skripte & Dateien  ·  cloud-host · Runbook

Jenkins lokal aus dem Hetzner-Snapshot

Der geteilte CI-Jenkins für die Bibliothek-Enterprise-Pipeline lief auf einem Hetzner-Server. Er läuft jetzt lokal im enterprise-infrastructure-Compose – mit vollem Job- und Credential-Stand aus dem letzten Snapshot. Dieses Runbook hält fest, wie der Umzug lief, wo es klemmte und wie der Alltag jetzt aussieht.

Windows 10 · podman 6.0.2 enterprise-infrastructure Compose (podman-compose 1.6.0) Stand: 1. September 2026

01 Wo alles steht

Der Ist-Zustand nach dem Umzug – damit die restlichen Abschnitte konkret sind.

JenkinsLokal als Container shared-jenkins im enterprise-infrastructure-Compose. UI http://localhost:8080, Login admin / admin.
Hetzner-ServerAus. Snapshot 426664299 (~4–6 GB, ~0,10 €/Mon) bleibt. Zurück mit .\scripts\jenkins.ps1 up.
OKD-Zielokd-sno, Namespace library. Seit dem Tailscale-Umbau ist die OKD-API (6443) nur noch übers Tailnet erreichbar – der shared-jenkins-Container erbt den hosts-Eintrag des Laptops (100.67.147.8  api.sno.<okd-ip>.nip.io) und kommt darüber an den Cluster.
Die IP-Beispiele unten (62.238.124.7) sind der Stand zum Zeitpunkt dieses Laufs – deine steht in .\scripts\sno.ps1 status und wechselt bei jedem sno.ps1 install.
Letzte Deploys#20 grün (19,5 min, kalt) · #21 rot (Registry-Blip) · #22 grün (2,5 min, warm).
Wiederhergestellt4 Jobs, Build-Historie #1–17, Credentials github-library-deploykey / KUBECONFIG_PROD / okd-registry.

02 Warum lokal

Ein zweiter Server für ein Projekt mit gelegentlichen Deploys ist Overhead.

Auf Hetzner lief ein cpx42 (~0,10 €/h) plus die Firewall-Choreografie, die der Pipeline Zugriff auf die OKD-API verschafft (jenkins.ps1 trägt die Server-IP bei jedem up in okd-sno-fw:6443 ein und bei down wieder aus). Für den aktuellen Bedarf reicht ein Jenkins in der ohnehin laufenden podman-machine.

Der Hetzner-Weg bleibt erhalten: jenkins.ps1 up baut den Server aus dem Snapshot in ~95 s wieder auf. Der Snapshot ist die Rückfahrkarte, jenkins.ps1 destroy verbrennt sie.

Mitgekommen ist alles

Der komplette „25 Fallen“-Apparat aus der OKD-Deploy-Anleitung steckt als init.groovy.d/-Skripte im jenkins_home und läuft bei jedem Boot idempotent: 05-git-hostkey.groovy (GIT_SSH_COMMAND), 10-okd-creds.groovy (legt KUBECONFIG_PROD + okd-registry aus okd.kubeconfig + okd.token neu an), 21-job-scm.groovy (biegt den Job auf */main + Deploy-Key um, hängt DEPLOY_ONLY an).

03 Wie der Stand rüberkam

„Weg B“: den laufenden Stand aus dem Server ziehen statt neu aufzusetzen. Nachbaubar, falls es nochmal nötig wird.

  1. Container stoppen für eine konsistente Kopie.
ssh · hetzner
ssh root@<jenkins-ip> "podman stop -t 30 jenkins"
  1. /opt/jenkins-home als Tar ziehen – 750 MB roh, 333 MB gepackt.
ssh · hetzner
ssh root@<jenkins-ip> "tar -C /opt/jenkins-home --numeric-owner -czf - ." \
  > jenkins-home.tar.gz
  1. jenkins.ps1 down – Snapshot 426664299, Server gelöscht, die jenkins-auto-Regel in okd-sno-fw entfernt.
  2. Tar ins Volume shared-infra_jenkins-data entpacken über einen Helfer-Container – altes lokales jenkins_home vorher weg.
helfer-container
find /dest -mindepth 1 -delete
tar -C /dest -xzf /src/jenkins-home.tar.gz
chown -R 1000:1000 /dest
  1. Starten.
powershell
cd ../../shared/enterprise-infrastructure
podman compose up -d jenkins

Ergebnis: 4 Jobs, Build-Historie library-platform-pipeline #1–17, alle drei Credentials. credentials.xml + secret.key + secrets/ kommen im Tar mit, deshalb bleiben die verschlüsselten Credentials benutzbar.

04 Der podman-compose-Trick

Der einzige echte Umbau: den Podman-Socket der podman-machine in den Jenkins-Container bekommen, damit die podman build-Stufen laufen.

Wo die Konfiguration herkommt, wo der Build wirklich läuft – und warum Klicks in der Oberfläche nicht halten.

Zeichnung aus Diagramme: Hetzner & CRC (27).

Git-Repository enthält das Jenkinsfile Webhook HTTPS, eingehend oder Abfrage im Takt Jenkins-Container auf dem Server Controller – hier läuft auch jeder Build casc.yaml – Configuration as Code wird bei jedem Start eingelesen und angewandt Deshalb halten Klicks in der Oberfläche nicht: der nächste Start setzt alles auf den Stand der Datei zurück. Zugangsdaten-Speicher verschlüsselt abgelegt in den Logs durch Sterne ersetzt Arbeitsverzeichnis /var/jenkins_home/workspace/<job> je Job eines, bleibt zwischen Läufen Pipeline aus dem Jenkinsfile Build & Push Deploy post / failure Die Datei liegt im Repository – die Pipeline ändert sich also mit dem Code, nicht per Klick. Podman-Socket des Wirts hineingereicht als Volume Der Build läuft auf der Engine des Servers – nicht im Jenkins-Container. Wer hier Befehle ausführen kann, ist faktisch root auf dem Server. Unix-Socket, kein Netz Registry ghcr.io – Image mit Commit-Tag Anmeldung über --password-stdin HTTPS Zielcluster helm upgrade --install je Dienst kubeconfig aus dem Zugangsdaten-Speicher HTTPS 6443 Was diesen Aufbau ausmacht 1. Nichts Wichtiges lebt in Jenkins. Die Pipeline steht im Repository, die Konfiguration in casc.yaml. Beides ist versioniert. Deshalb überlebt der Aufbau das Löschen und Neuanlegen des Servers – genau das, was der Snapshot-Zyklus aus Zeichnung 05 täglich tut. 2. Jenkins baut nicht selbst. Er reicht die Arbeit an die Container-Engine des Wirts weiter. Das spart eine verschachtelte Engine (die auf overlay-on-overlay ohnehin scheitert) und kostet den Preis rechts: der Socket ist ein Vollzugriff auf den Server. 3. Was bleibt, ist der Zustand: Build-Verlauf und Arbeitsverzeichnisse unter /opt/jenkins-home. Das Verzeichnis muss UID 1000 gehören, sonst startet Jenkins und scheitert daran, seine eigene Konfiguration zu schreiben.
Jenkins ist hier bewusst austauschbar gehalten. Pipeline und Konfiguration liegen als Dateien im Repository, der Build läuft auf der Engine des Wirts, der Zustand beschränkt sich auf ein Verzeichnis. Was das kostet, steht rot rechts: der hineingereichte Socket ist gleichbedeutend mit Root auf dem Server – tragbar, solange dort nur eigene Pipelines laufen.

podman compose nutzt hier podman-compose 1.6.0 (Python, via scoop). Das schreibt unter Windows in assert_volume() jeden absoluten Bind-Mount-Pfad über os.path.abspath(os.path.join(basedir, src)) auf C:\… um – auch die Langform mit type: bind – und legt dort per os.makedirs leere Ordner an.

Bind-Mount – kurz oder lang

Quelle /run/user/1000/podman/podman.sock → podman-compose → im Container /mnt/c/run/user/1000/podman/podman.sock. Existiert nicht in der VM, Socket tot, leerer Ordner auf C:\.

Benanntes Volume mit bind-Backing

driver_opts: { o: bind, device: /run/user/1000/podman } geht unverändert an podman volume create und wird von podman in der VM aufgelöst. Echter Socket im Container.

Gebunden wird das Verzeichnis /run/user/1000/podman – die Socket-Datei selbst lässt sich nicht auf das _data-Dir des Volumes mounten. Gemountet nach /run/podman-host. Ein zweiter Mount desselben Volumes auf /var/run/docker.sock verdrängt den kaputten Basis-Mount aus docker-compose.yml.

shared/enterprise-infrastructure/docker-compose.override.yml
volumes:
  podman-sock:
    driver: local
    driver_opts:
      type: none
      o: bind
      device: /run/user/1000/podman

services:
  jenkins:
    ports:
      - "8080:8080"          # UI direkt - Traefik bindet rootless kein :80
      - "50000:50000"
    restart: unless-stopped
    environment:
      CONTAINER_HOST: unix:///run/podman-host/podman.sock
      DOCKER_HOST:    unix:///run/podman-host/podman.sock
    volumes:
      - podman-sock:/run/podman-host
      - podman-sock:/var/run/docker.sock   # verdraengt den Basis-Mount
Verifiziert

podman build aus dem Jenkins-Container gegen die Host-Engine läuft, und die Verdrahtung übersteht podman machine stop / start.

05 Dateien & Maschine

Was neu ist – und was in der podman-machine gesetzt werden musste.

Im Repository shared/enterprise-infrastructure/

.envNur Doku-Kommentar. CONTAINER_ENGINE_SOCKET bleibt bewusst ungesetzt – der Socket kommt über das Volume oben.
docker-compose.override.ymlWird von podman compose automatisch mitgeladen: Host-Port 8080, restart: unless-stopped, CONTAINER_HOST/DOCKER_HOST, das podman-sock-Volume.

Einmalig in der podman-machine

Per podman machine ssh. Verschwindet bei podman machine rm und muss dann neu gesetzt werden.

podman machine ssh
# shared-jenkins nach `podman machine start` selbst hochfahren
sudo systemctl enable podman-restart.service
sudo chown -R user:user ~/.config/systemd
systemctl --user enable podman-restart.service

# `podman build -v` loest die -v-Quelle in der VM auf
sudo mkdir -p /opt/m2cache && sudo chmod 777 /opt/m2cache

Und einmalig im Container, weil workspace/ mit kaputtem @script/.git im Tar lag:

bash
podman exec shared-jenkins rm -rf /var/jenkins_home/workspace/*

podman-machine ist größer als sie aussieht. podman machine list zeigt „2 GiB“ – das ist nominell, WSL2 vergibt RAM dynamisch (real ~16 GB / 8 CPU). podman machine set --cpus/--memory geht bei WSL-Maschinen nicht.

06 Täglicher Betrieb

Start, Stop, und der Weg zurück auf Hetzner.

start / stop
# nach `podman machine start` kommt Jenkins per podman-restart.service selbst;
# explizit:
cd ../../shared/enterprise-infrastructure && podman compose up -d jenkins

# stoppen - Daten bleiben im Volume jenkins-data
podman compose stop jenkins
  • UI: http://localhost:8080admin / admin. CASC setzt das Passwort bei jedem Boot neu aus JENKINS_ADMIN_PASSWORD, egal was im wiederhergestellten users/ steht.
  • Zurück auf Hetzner: .\scripts\jenkins.ps1 up – Server aus Snapshot, gleiche Firewall-Automatik.
  • Snapshot endgültig weg: .\scripts\jenkins.ps1 destroy.

07 Deploy auslösen

DEPLOY_ONLY=true überspringt Test-, Lint- und Frontend-Stufen und geht direkt Build → Push in die OKD-Registry → helm upgrade.

Der Crumb muss über einen Cookie-Jar mit – sonst HTTP 403 (Falle 11 der OKD-Deploy-Anleitung).

bash
J='http://admin:admin@localhost:8080'
CRUMB=$(curl -s -c cj "$J/crumbIssuer/api/json" \
  | python -c "import sys,json;print(json.load(sys.stdin)['crumb'])")

curl -s -b cj -H "Jenkins-Crumb:$CRUMB" -X POST \
  "$J/job/library-platform-pipeline/buildWithParameters" \
  --data-urlencode "DEPLOY_ONLY=true"

Oder in der UI: library-platform-pipeline → „Build mit Parametern“. OKD muss laufen (sno.ps1 up).

Voraussetzung: Tailnet

Die Deploy-Stufen reden mit der OKD-API auf 6443 – seit dem Tailscale-Umbau tailnet-only. Der Laptop braucht den hosts-Eintrag <tailscale-ip>  api.sno.<okd-ip>.nip.io und eine aktive Tailscale-Verbindung. Der shared-jenkins-Container erbt beides über die podman-machine (geprüft: kubectl get ns library aus dem Container löst den Namen auf die Tailnet-IP auf und antwortet). Die Image-Pushes gehen dagegen an die Registry-Route auf 80/443 – die bleibt öffentlich.

08 Stolpersteine (#18–#22)

Was in den ersten lokalen Läufen hochkam – und der Fix.

BuildSymptomUrsacheFix
#18 Checkout: fatal: not in a git directory workspace/ aus dem Tar mit kaputtem @script/.git rm -rf /var/jenkins_home/workspace/*
#19 podman build: validating volumes: faccessat /opt/m2cache: no such file or directory -v-Quelle wird gegen die VM aufgelöst, dort fehlte sie mkdir -p /opt/m2cache && chmod 777 in der podman-machine
#21 podman logindial tcp 62.238.124.7:80: connection refused openshift-apiserver auf dem Single-Node kurz neu → Route-API weg → Ingress-Router NotReady → *.apps.sno… komplett offline selbstheilend in 1–2 min, dann neu triggern
#22 einzelner helm upgradecluster unreachable: i/o timeout kurzer 6443-Blip, Pipeline fiel für den Service auf helm template-Dry-Run zurück nicht-fatal – Service lag ohnehin schon auf dem Tag
Traefik / jenkins.localhost reagiert nicht rootless podman bindet Port 80 nicht Override gibt Jenkins direkt :8080, Traefik bleibt außen vor
Muster

#21 und der Blip in #22 sind SNO-Fragilität unter Last, nicht das lokale Setup: ein winziger Node trägt OKD-Plattform + Monitoring + 10 Services + Kafka/Keycloak/Postgres, und helm --wait plus Image-Pushes treiben etcd/apiserver an die Grenze. Sauberer Fix wäre ein retry-Block auf der Push-/Deploy-Stage im Jenkinsfile des Ziel-Repos.

09 Verifikation

Zwei komplette Läufe vom lokalen Jenkins nach OKD.

Build #20SUCCESS, ~19,5 min (kalt). 10 Images gebaut → OKD-interne Registry → 9× helm upgrade --install … --wait --timeout 3m, alle STATUS: deployed.
Build #22SUCCESS, 145 s (m2cache und Image-Layer warm). Ein 6443-Blip bei fine-service → Dry-Run-Fallback, Rest deployte.
Endstandlibrary.apps.sno.62.238.124.7.nip.io/actuator/health{"status":"UP"} · Frontend HTTP 200 · 9/9 Library-Pods ready · alle Deployments auf Tag 597452ba.

+ Was noch aussteht

  • Jenkinsfile: retry-Block auf Push- und Deploy-Stage, um die SNO-Blips abzufangen – Änderung im Ziel-Repo library-enterprise-platform.
  • okd.kubeconfig / okd.token im jenkins_home sind auf die OKD-IP zum Snapshot-Zeitpunkt gebacken. Nach einem sno.ps1 install (neue IP) erneuern – 10-okd-creds.groovy legt die Credentials daraus bei jedem Boot neu an.
  • Traefik lokal ungelöst (rootless kein Port 80). Der direkte :8080 reicht für den Jenkins-Betrieb.

+ Bezug zu den Skripten

scripts/jenkins.ps1up / down / destroy für den Hetzner-Server. down zieht den Snapshot, den dieses Runbook auspackt.
shared/enterprise-infrastructure/docker-compose.ymlDer geteilte Stack (Traefik, Jenkins, optional Postgres/Keycloak/Kafka/Observability). Die .override.yml daneben ist lokal, unversioniert.
shared/enterprise-infrastructure/jenkins/Dockerfile + casc.yaml + plugins.txt – das Jenkins-Image (Maven 3.9.9, Node 20, Helm, kubectl, Chromium). Baut lokal wie auf dem Server.
⌂ Cockpit