GitOps mit Argo CD
Auftrag
«Unser Webshop soll im Frühling laufen», schreibt das Veloatelier Matte. «Wir möchten Änderungen selbst vorschlagen können, ohne dass jemand von euch nachts auf einem Server Befehle eintippt.»
Lernziele
Nach diesem Kapitel kannst du:
- Die vier Prinzipien von GitOps erklären
- Begründen, warum der Git-Server ausserhalb des Clusters läuft, den er steuert
- Gitea auf dem Front-end betreiben und ein Repo mit Zugriff nur zum Lesen freigeben
- Argo CD installieren und mit Applications den Webshop aus Git ausrollen
- Sync und Self-Heal an einem Beispiel zeigen und erklären, was Prune löscht
Mit OpenTofu beschreibst du die VMs in Code, ausführen musst du ihn aber selbst. Bei GitOps übernimmt das eine Software im System. Die Arbeitsgruppe OpenGitOps der CNCF fasst GitOps in vier Prinzipien zusammen.
| Prinzip | Bedeutung |
|---|---|
| Deklarativ | Das System ist als Soll-Zustand beschrieben. |
| Versioniert und unveränderlich | Der Soll-Zustand liegt in einem Speicher, der jede Version behält, meist Git. |
| Automatisch geholt | Software im System holt sich den Soll-Zustand selbst ab. |
| Laufend abgeglichen | Dieselbe Software vergleicht Soll und Ist ständig und gleicht Abweichungen an. |
Der Unterschied zum klassischen Ausrollen liegt in der Richtung. Bei einem Push-Ansatz meldet sich eine Pipeline oder ein Mensch mit Rechten am Cluster an und spielt Änderungen ein. Bei GitOps braucht ausser dem Agent im Cluster niemand diese Rechte. Wer etwas ändern will, ändert Git. Die Geschichte von Git ist zugleich das Protokoll aller Änderungen.
Gitea auf dem Front-end
Abschnitt betitelt „Gitea auf dem Front-end“Argo CD, der Agent dieses Kapitels, braucht ein Git-Repo, das er aus dem Cluster erreicht. Das Plattform-Team betreibt dafür ein eigenes Gitea. Gitea ist ein schlanker Git-Server mit Weboberfläche, Benutzern, Organisationen und Pull Requests, unter der Lizenz MIT.
Gitea läuft auf dem Front-end, ausserhalb des Clusters. Den Cluster baust du spätestens in Kapitel 6.2 für etcd neu auf, im Zusatzauftrag A von Kapitel 4.6 schon früher. Läge der Soll-Zustand in einem Gitea im selben Cluster, wäre er nach tofu destroy weg. Der neue Cluster wüsste dann nicht, was er ausrollen soll. Der Soll-Zustand, aus dem ein Cluster neu entsteht, gehört deshalb nicht in diesen Cluster. Das Front-end hat genug Speicher und läuft ohnehin, weil OpenNebula es braucht.
#!/bin/bash# Installs Gitea on the front-end as a systemd service with SQLite, the Git server of chapter 4.4.# Gitea runs outside the k3s cluster, so that rebuilding the cluster keeps the desired state.## sudo ~/cc/platform/gitea/install-gitea.sh## The admin "platform" gets a random password, it is stored in ~/cc-lab/gitea/platform.pass of the calling user.set -euo pipefail
VERSION=${GITEA_VERSION:-28.3.0}BIN=/usr/local/bin/gitea# shellcheck source=/dev/nullsource /etc/cc-lab.envOWNER=${SUDO_USER:?run with sudo as your own user}OWNER_HOME=$(getent passwd "$OWNER" | cut -d: -f6)
# Binary with its published checksumurl="https://dl.gitea.com/gitea/$VERSION/gitea-$VERSION-linux-amd64"tmp=$(mktemp -d)curl -fsSL -o "$tmp/gitea" "$url"echo "$(curl -fsSL "$url.sha256" | cut -d' ' -f1) $tmp/gitea" | sha256sum -c -install -m 0755 "$tmp/gitea" "$BIN"rm -rf "$tmp"
# A system user without login owns the dataid git >/dev/null 2>&1 || adduser --system --shell /bin/bash --group --disabled-password --home /home/git gitinstall -d -o git -g git -m 0750 /var/lib/gitea /var/lib/gitea/custom /var/lib/gitea/data /var/lib/gitea/loginstall -d -o root -g git -m 0770 /etc/gitea
# Configuration without the web installer, HTTP on port 3000 of the front-end, no self-registrationif [ ! -f /etc/gitea/app.ini ]; then cat > /etc/gitea/app.ini <<INIAPP_NAME = Wolkenwerk GitRUN_USER = gitWORK_PATH = /var/lib/gitea
[server]HTTP_PORT = 3000ROOT_URL = http://$CC_FE_IP:3000/DISABLE_SSH = true
[database]DB_TYPE = sqlite3PATH = /var/lib/gitea/data/gitea.db
[security]INSTALL_LOCK = trueSECRET_KEY = $($BIN generate secret SECRET_KEY)INTERNAL_TOKEN = $($BIN generate secret INTERNAL_TOKEN)
[oauth2]JWT_SECRET = $($BIN generate secret JWT_SECRET)
[service]DISABLE_REGISTRATION = trueREQUIRE_SIGNIN_VIEW = true
[repository]DEFAULT_BRANCH = main
[log]MODE = fileLEVEL = InfoINI chown root:git /etc/gitea/app.ini chmod 0640 /etc/gitea/app.inifi
cat > /etc/systemd/system/gitea.service <<UNIT[Unit]Description=GiteaAfter=network.target
[Service]User=gitGroup=gitWorkingDirectory=/var/lib/giteaExecStart=$BIN web --config /etc/gitea/app.iniRestart=alwaysEnvironment=USER=git HOME=/home/git GITEA_WORK_DIR=/var/lib/gitea
[Install]WantedBy=multi-user.targetUNITsystemctl daemon-reloadsystemctl enable --now giteauntil curl -fs -o /dev/null http://localhost:3000/api/healthz; do sleep 2; done
# Admin of the platform team, created oncepass_file="$OWNER_HOME/cc-lab/gitea/platform.pass"if [ ! -f "$pass_file" ]; then install -d -o "$OWNER" -g "$OWNER" -m 0700 "$OWNER_HOME/cc-lab" "$OWNER_HOME/cc-lab/gitea" pass=$(openssl rand -hex 16) sudo -u git "$BIN" --config /etc/gitea/app.ini admin user create --admin --username platform \ --email platform@wolkenwerk.example --password "$pass" --must-change-password=false install -o "$OWNER" -g "$OWNER" -m 0600 /dev/null "$pass_file" echo "$pass" > "$pass_file"fiecho "Gitea $VERSION runs on http://$CC_FE_IP:3000/, user platform, password in $pass_file"sudo ~/cc/platform/gitea/install-gitea.shDas Skript prüft die Prüfsumme des Programms, legt den Systembenutzer git an und schreibt die Konfiguration selbst, damit kein Installationsassistent im Browser nötig ist. Gitea speichert seine Daten in SQLite und läuft als Dienst von systemd. Die Dokumentation von Gitea 28 erklärt diese Schritte unter Installation from binary. Niemand kann sich selbst ein Konto anlegen. Ohne Anmeldung sieht man nichts. Im Browser des Clients (auf smartlearn die Kali-VM, mit eigenem Hypervisor dein PC) erreichst du Gitea unter Port 3000 des Front-ends. Du meldest dich als platform mit dem Passwort aus ~/cc-lab/gitea/platform.pass an.
Das Repo platform
Abschnitt betitelt „Das Repo platform“Ein zweites Skript legt die Organisation wolkenwerk, das private Repo platform und zwei Tokens an.
#!/bin/bash# Creates the organisation wolkenwerk and its private repo platform in Gitea, and a token that may only read it.## ~/cc/platform/gitea/make-repo.sh## The token for Argo CD is stored in ~/cc-lab/gitea/argocd.token, the token of the platform team in platform.token.set -euo pipefail
# shellcheck source=/dev/nullsource /etc/cc-lab.envAPI="http://$CC_FE_IP:3000/api/v1"DIR=~/cc-lab/giteaAUTH="platform:$(cat "$DIR/platform.pass")"
api() { curl -fsS -u "$AUTH" -H "Content-Type: application/json" "$@"}
# Organisation and repo, both only onceapi -o /dev/null "$API/orgs/wolkenwerk" 2>/dev/null || api -o /dev/null -X POST "$API/orgs" -d '{"username": "wolkenwerk", "visibility": "private"}'api -o /dev/null "$API/repos/wolkenwerk/platform" 2>/dev/null || api -o /dev/null -X POST "$API/orgs/wolkenwerk/repos" -d '{"name": "platform", "private": true, "default_branch": "main"}'
# Tokens are shown only once, so they are written right awaytoken() { local name=$1 scopes=$2 file="$DIR/$1.token" [ -f "$file" ] && return install -m 0600 /dev/null "$file" api -X POST "$API/users/platform/tokens" -d "{\"name\": \"$name\", \"scopes\": $scopes}" | python3 -c 'import json, sys; print(json.load(sys.stdin)["sha1"])' > "$file"}token platform '["write:repository", "write:organization"]'token argocd '["read:repository"]'
echo "Repo http://$CC_FE_IP:3000/wolkenwerk/platform.git, tokens in $DIR"~/cc/platform/gitea/make-repo.sh| Token | Rechte | Wer es nutzt |
|---|---|---|
platform.token | Repos und Organisation schreiben | du, beim Push |
argocd.token | Repos lesen | Argo CD |
Argo CD bekommt nur Leserechte. Gelangt sein Token an die falsche Stelle, kann damit niemand den Soll-Zustand ändern. Wie Tokens mit ihren Rechten über die API entstehen, beschreibt die Anleitung zur API von Gitea 28.
In ~/platform liegt seit Kapitel 4.2 die Vorlage. Die Dateien für Argo CD enthalten den Platzhalter FRONTEND, den du durch die Adresse deines Front-ends ersetzt. Danach machst du daraus ein Git-Repo und schickst es an Gitea.
source /etc/cc-lab.envcd ~/platformsed -i "s#http://FRONTEND:3000#http://$CC_FE_IP:3000#" root.yaml apps/*.yamlgit init -b maingit config user.name "Plattform-Team"git config user.email "platform@wolkenwerk.example"git config credential.helper storeprintf 'http://platform:%s@%s:3000\n' "$(cat ~/cc-lab/gitea/platform.token)" "$CC_FE_IP" > ~/.git-credentialschmod 600 ~/.git-credentialsgit add .git statusgit commit -m "Plattform von Wolkenwerk Bern"git remote add origin "http://$CC_FE_IP:3000/wolkenwerk/platform.git"git push -u origin mainVor dem Commit zeigt git status keinen State von OpenTofu, weil .gitignore ihn draussen hält. Nach dem Push zeigt Gitea die Ordner tofu, apps, webshop, templates sowie members.
Der Webshop
Abschnitt betitelt „Der Webshop“Der Webshop des Veloateliers ist ein kleines Programm in Python, das nur die Standardbibliothek nutzt. Es liegt im Kursrepo unter case/webshop/.
| Pfad | Antwort |
|---|---|
/ | die Seite des Shops mit den Velos |
/api/products | die Produkte als JSON |
/healthz | ok, für die Proben von Kubernetes |
/metrics | Anfragen und Antwortzeiten im Format von Prometheus, Thema von Teil 5 |
Die CI des Kursrepos baut daraus ein Image und legt es in der Registry von GitHub ab, ghcr.io/ndum/cc/webshop:1.0.0. Das Image beruht auf dem offiziellen Image python:3.14-slim und läuft als Benutzer ohne Root-Rechte.
FROM docker.io/library/python:3.14-slim
LABEL org.opencontainers.image.title="webshop" \ org.opencontainers.image.description="Web shop of Veloatelier Matte for the course CC" \ org.opencontainers.image.source="https://github.com/ndum/cc" \ org.opencontainers.image.licenses="MIT"
# tini runs as the first process and passes signals on. Python is then an ordinary process and stops# on SIGTERM even while it is still starting, before the shop has set its own handlerRUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends tini \ && rm -rf /var/lib/apt/lists/*
WORKDIR /appCOPY app.py load.py ./
# The shop runs as an unprivileged user, Kubernetes checks this with runAsNonRootUSER 10001ENV PYTHONUNBUFFERED=1 PORT=8080EXPOSE 8080ENTRYPOINT ["/usr/bin/tini", "--"]CMD ["python3", "/app/app.py"]Im Plattform-Repo beschreiben drei Dateien den Shop im Cluster.
apiVersion: apps/v1kind: Deploymentmetadata: name: webshop labels: app: webshopspec: replicas: 2 selector: matchLabels: app: webshop template: metadata: labels: app: webshop spec: securityContext: runAsNonRoot: true containers: - name: webshop image: ghcr.io/ndum/cc/webshop:1.0.0 imagePullPolicy: IfNotPresent ports: - name: http containerPort: 8080 env: - name: SHOP_NAME value: Veloatelier Matte resources: requests: cpu: 50m memory: 64Mi limits: cpu: 500m memory: 128Mi readinessProbe: httpGet: path: /healthz port: http timeoutSeconds: 3 livenessProbe: httpGet: path: /healthz port: http # A busy pod may need longer than the default second, a failed liveness check restarts it timeoutSeconds: 3Das Deployment hält zwei Pods am Laufen und gibt ihnen Requests und Limits für CPU und Speicher. Die Proben fragen /healthz ab. Ein Pod bekommt erst Anfragen, wenn die Readiness-Probe antwortet. Bleibt die Liveness-Probe aus, startet Kubernetes ihn neu. Der Service webshop gibt den Pods einen festen Namen. Über den Ingress leitet Traefik Anfragen auf Port 80 der beiden VMs an den Service weiter.
Argo CD
Abschnitt betitelt „Argo CD“Argo CD ist ein Projekt der CNCF und läuft als Satz von Controllern im Cluster. Er liest Repos, rendert die Manifeste, auch mit Kustomize oder Helm, vergleicht sie mit dem Cluster und gleicht ab. Eine Application sagt ihm, welcher Ordner welches Repos in welchen Namespace gehört.
Ein Skript installiert Argo CD mit Helm, hinterlegt das Token für Gitea und wendet die erste Application an. Das Chart argo-cd in der Version 10.11.6 bringt Argo CD 3.5 mit. Seine Werte erklärt das README des Charts.
#!/bin/bash# Installs Argo CD in a new k3s cluster and connects it to the platform repo in Gitea, chapter 4.4.# After this step Argo CD builds everything else from Git.## ~/cc/platform/bootstrap-argocd.shset -euo pipefail
CHART_VERSION=${ARGOCD_CHART_VERSION:-10.11.6}# shellcheck source=/dev/nullsource /etc/cc-lab.envREPO="http://$CC_FE_IP:3000/wolkenwerk/platform.git"HERE=$(dirname "$(readlink -f "$0")")
helm repo add argo https://argoproj.github.io/argo-helm >/dev/nullhelm repo update argo >/dev/nullhelm upgrade --install argocd argo/argo-cd --version "$CHART_VERSION" \ --namespace argocd --create-namespace -f "$HERE/argocd/values.yaml" --wait --timeout 10m
# Read access to the repo, the token comes without a line break, otherwise Gitea refuses itkubectl -n argocd create secret generic repo-platform \ --from-literal=type=git \ --from-literal=url="$REPO" \ --from-literal=username=platform \ --from-literal=password="$(tr -d '\n' < ~/cc-lab/gitea/argocd.token)" \ --dry-run=client -o yaml | kubectl label --local -f - argocd.argoproj.io/secret-type=repository -o yaml | kubectl apply -f -
kubectl apply -f ~/platform/root.yaml# The password goes to a file instead of the terminal, so it does not end up in a screenshot(umask 077; kubectl -n argocd get secret argocd-initial-admin-secret -o jsonpath='{.data.password}' | base64 -d > ~/cc-lab/argocd-admin.pass)echo "Argo CD runs, UI on http://$CC_K3S_SERVER_IP:30080/, user admin, password in ~/cc-lab/argocd-admin.pass"# Values of the Helm chart argo-cd for the lab, chapter 4.4
# Login with the admin of Argo CD only, no single sign-on and no notificationsdex: enabled: falsenotifications: enabled: false
configs: params: # The lab has no certificate, the UI answers over HTTP server.insecure: true cm: # Check Git every minute instead of every three minutes, so that changes show up quickly in class timeout.reconciliation: 60s
server: service: type: NodePort nodePortHttp: 30080 nodePortHttps: 30443~/cc/platform/bootstrap-argocd.shkubectl -n argocd get podskubectl -n argocd get applicationsDas Token geht als Secret mit dem Label argocd.argoproj.io/secret-type=repository an Argo CD. Im Lab scheiterte ein erster Versuch mit kubectl create secret --from-file. Das Secret enthielt den Zeilenumbruch am Ende der Datei, worauf Gitea mit «authentication required» antwortete. Das Skript entfernt den Zeilenumbruch darum vorher.
App of Apps
Abschnitt betitelt „App of Apps“Die erste Application heisst root. Sie zeigt auf den Ordner apps im Repo, in dem weitere Applications liegen.
# The root application, it deploys every application in apps/ of the platform repo, apply it once with kubectlapiVersion: argoproj.io/v1alpha1kind: Applicationmetadata: name: root namespace: argocdspec: project: default source: repoURL: http://FRONTEND:3000/wolkenwerk/platform.git targetRevision: main path: apps destination: server: https://kubernetes.default.svc namespace: argocd syncPolicy: automated: prune: true selfHeal: true# The web shop of Veloatelier Matte in the namespace veloapiVersion: argoproj.io/v1alpha1kind: Applicationmetadata: name: webshop namespace: argocdspec: project: default source: repoURL: http://FRONTEND:3000/wolkenwerk/platform.git targetRevision: main path: webshop destination: server: https://kubernetes.default.svc namespace: velo syncPolicy: automated: prune: true selfHeal: true syncOptions: - CreateNamespace=trueArgo CD rollt also zuerst root aus und findet darin die Application webshop, die den Ordner webshop in den Namespace velo bringt. Für eine neue Anwendung legst du nur eine Datei in apps an, kubectl brauchst du dafür nicht mehr. Dieses Muster heisst App of Apps. Die Dokumentation von Argo CD 3.5 beschreibt es unter Cluster Bootstrapping.
| Einstellung | Wirkung |
|---|---|
automated | Argo CD gleicht ohne Klick ab, sobald Git sich ändert |
prune: true | was aus Git verschwindet, löscht Argo CD auch im Cluster |
selfHeal: true | was jemand im Cluster von Hand ändert, setzt Argo CD auf den Stand von Git zurück |
CreateNamespace=true | Argo CD legt den Namespace an, wenn er fehlt |
Automatischen Abgleich, Prune und Self-Heal beschreibt die Dokumentation von Argo CD 3.5 unter Automated Sync Policy. Im Lab waren alle Applications 5 bis 15 Sekunden nach dem Bootstrap Synced und Healthy. Argo CD fragt Gitea hier jede Minute ab, das legt timeout.reconciliation in den Werten fest. Ohne diese Einstellung wären es drei Minuten.
In der Weboberfläche zeigt Argo CD jede Application als Baum ihrer Objekte.

Zugriff vom Client
Abschnitt betitelt „Zugriff vom Client“Der Cluster liegt im internen Netz cc-net, das nur vom Front-end aus erreichbar ist. Damit der Browser des Clients Argo CD und den Webshop öffnen kann, leitest du im Terminal des Clients zwei Ports durch SSH weiter. Die Adressen stehen in deinen Parametern. Das Handbuch von ssh beschreibt die Option -L.
ssh -L 8080:<adresse-des-servers>:30080 -L 8081:<adresse-des-servers>:80 mmuster-feSolange die Verbindung offen ist, zeigt http://localhost:8080/ die Oberfläche von Argo CD und http://localhost:8081/ den Webshop. In Argo CD meldest du dich als admin mit dem Passwort aus ~/cc-lab/argocd-admin.pass an, das Skript hat es dort abgelegt. Gitea erreichst du direkt unter Port 3000 des Front-ends, bis die Firewall aus Kapitel 6.1 den Port von aussen sperrt.
Abgleich zeigen
Abschnitt betitelt „Abgleich zeigen“Self-Heal siehst du am schnellsten, wenn du den Cluster an Git vorbei änderst.
kubectl -n velo scale deployment webshop --replicas=4kubectl -n velo get deployment webshopkubectl -n velo delete deployment webshopkubectl -n velo get deployment webshopIm Lab stand das Deployment beim ersten Versuch nach etwa einer Sekunde wieder auf zwei Pods. Die zwei zusätzlichen Pods waren nach zwei Sekunden beendet. Das gelöschte Deployment war nach einer bis drei Sekunden zurück und seine Pods liefen wenige Sekunden später. Nach dem Signal zum Beenden beantwortet ein Pod des Webshops noch zwei Sekunden lang Anfragen. So kann Traefik ihn aus seiner Liste nehmen, bevor er sich beendet. Wiederholst du den Versuch, wartet Argo CD jedes Mal etwas länger, damit sich Self-Heal und ein Mensch nicht endlos abwechseln. Im Lab kam das Deployment beim dritten Versuch erst nach 15 Sekunden zurück. Eine Änderung über Git dauert länger, weil Argo CD erst bei der nächsten Abfrage davon erfährt.
cd ~/platformsed -i 's/replicas: 2/replicas: 3/' webshop/deployment.yamlgit commit -am "Webshop mit drei Pods für die Saison"git pushIm Lab lief der dritte Pod 40 Sekunden bis gut zwei Minuten nach dem Push, denn Argo CD fragt jede Minute ab und verschiebt die Abfrage um einen zufälligen Anteil. Ein Webhook von Gitea an Argo CD würde den Abgleich sofort auslösen. Für das Lab genügt die Abfrage jede Minute.
Aufgabe Lab
Installiere Gitea, lege das Repo an, rolle Argo CD aus und öffne Webshop und Argo CD im Browser des Clients. Ändere die Zahl der Pods über Git.
Lösung
kubectl -n argocd get applications zeigt root und webshop sowie member-velo als Synced und Healthy. member-velo kommt schon mit dem ersten Push, Kapitel 4.5 erklärt es. Der Webshop zeigt die Velos und unten den Namen des Pods, der geantwortet hat. Nach einem Push mit replicas: 3 zeigt kubectl -n velo get pods nach spätestens gut zwei Minuten drei Pods. In Argo CD erscheint der neue Commit bei der Application.
Aufgabe Zurück zum alten Stand
Die neue Version mit drei Pods war ein Fehler. Wie stellst du den alten Stand wieder her, ohne kubectl zu benutzen?
Lösung
git revert HEAD legt einen Commit an, der die letzte Änderung rückgängig macht, git push schickt ihn an Gitea. Argo CD gleicht bei der nächsten Abfrage ab und entfernt den dritten Pod. Der Fehler und seine Korrektur bleiben in der Geschichte von Git sichtbar.
Aufgabe Rechte
Wer braucht nach diesem Kapitel noch Schreibrechte auf den Cluster und was bedeutet das für die kubeconfig auf dem Front-end?
Lösung
Für den Betrieb nur noch Argo CD, alles andere geht über Git. Die kubeconfig mit vollen Rechten brauchst du zum Bootstrap, für die Fehlersuche und im Notfall. In einem Team würde man sie wenigen Personen geben und für den Alltag Konten mit Leserechten anlegen.
Texte und Abbildungen stehen unter CC BY-SA 4.0 und der Code unter der MIT-Lizenz. Die Quellen liegen auf GitHub.