Zum Inhalt springen

Aus GitHub Actions deployen

Der übliche Weg, aus der CI auf Kubernetes zu deployen: eine Admin-kubeconfig in ein Repository-Secret kopieren und hoffen, dass sie nicht leakt. Dieses Credential ist cluster-admin, lebt jahrelang, wird in Forks und Artefakte kopiert und lässt sich nicht widerrufen, ohne den Cluster neu zu schlüsseln.

Ein besseres Muster funktioniert auf jedem Cluster: eine Deploy-Identität, die einen Namespace ändern darf und sonst nichts, mit einem Token, das abläuft. Ein ServiceAccount bekommt eine Role im Namespace deiner App, du stellst ihm ein Token mit Ablaufdatum aus, und der Workflow schreibt seine kubeconfig zu Beginn jedes Laufs aus drei Werten: der Adresse des API-Servers, seinem CA-Zertifikat und diesem Token. Zum Widerrufen löschst du den ServiceAccount, und jedes Token, das für ihn ausgestellt wurde, funktioniert sofort nicht mehr.

Bei PaaSbox Clusters sind die Kubeconfigs aus dem Portal für eine Person gemacht: Jede ist für einen Schlüssel versiegelt, den der Browser erzeugt, und läuft nach 10 Minuten bis 24 Stunden ab. Ein Programm kann sich eine über die REST-API holen, mit einem API-Schlüssel, der den Scope k3s:access hat (in Arbeit); das Rezept Ein Testcluster pro Pull Request macht das für Wegwerf-Cluster. Für einen Cluster, der bleibt, etwa die Produktion, funktioniert das Muster dieser Anleitung, wie es ist: Du legst den ServiceAccount einmal an, mit einer admin-Kubeconfig aus dem Portal.

  • Einen Cluster, seine Admin-kubeconfig auf deinem Rechner und kubectl.
  • Einen Namespace für deine App (unten myapp) und Manifeste zum Anwenden, zum Beispiel ein kustomize-Overlay.
  • Einen API-Server, den der Runner erreicht. Erlaubt deine Firewall die Kubernetes-API nur von deinen eigenen Adressen, lass den Job auf einem selbst gehosteten Runner in deinem Netz laufen statt auf denen von GitHub. Auf einem PaaSbox-Cluster ist Port 6443 nur für die Adressbereiche aus den Einstellungen des Clusters offen: Festlegen, wer die API erreicht.

Speichere als deployer.yaml. Die Role listet die Arten, die die Manifeste einer typischen Web-App enthalten; kürze sie auf deine, und nimm Secrets nur auf, wenn deine Pipeline sie anlegen muss:

apiVersion: v1
kind: ServiceAccount
metadata: { name: deployer, namespace: myapp }
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata: { name: deployer, namespace: myapp }
rules:
- apiGroups: [apps]
resources: [deployments]
verbs: [get, list, watch, create, update, patch]
- apiGroups: [apps]
resources: [replicasets]
verbs: [get, list, watch]
- apiGroups: [""]
resources: [services, configmaps]
verbs: [get, list, watch, create, update, patch]
- apiGroups: [""]
resources: [pods, pods/log]
verbs: [get, list, watch]
- apiGroups: [batch]
resources: [jobs]
verbs: [get, list, watch, create, delete]
- apiGroups: [networking.k8s.io]
resources: [ingresses]
verbs: [get, list, watch, create, update, patch]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata: { name: deployer, namespace: myapp }
subjects:
- { kind: ServiceAccount, name: deployer, namespace: myapp }
roleRef: { apiGroup: rbac.authorization.k8s.io, kind: Role, name: deployer }

Wende sie mit deiner Admin-kubeconfig an:

Terminal-Fenster
kubectl apply -f deployer.yaml
Terminal-Fenster
kubectl -n myapp create token deployer --duration=720h

Das fordert beim API-Server ein Token des ServiceAccounts an, das nach 30 Tagen abläuft. Ein Cluster kann begrenzen, wie lange ein solches Token lebt; tut deiner das, läuft das Token früher ab. Speichere es als Repository-Secret KUBE_TOKEN (GitHub → Settings → Secrets and variables → Actions → Secrets).

Adresse und CA-Zertifikat sind nicht geheim. Lies sie aus deiner Admin-kubeconfig und lege sie als Repository-Variablen KUBE_SERVER und KUBE_CA ab:

Terminal-Fenster
kubectl config view --raw --minify -o jsonpath='{.clusters[0].cluster.server}'
kubectl config view --raw --minify -o jsonpath='{.clusters[0].cluster.certificate-authority-data}'

Ein vollständiger Deploy-Workflow: kubeconfig schreiben, Manifeste anwenden, Rollout prüfen. Die Ubuntu-Runner von GitHub bringen kubectl mit.

name: deploy
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Write a kubeconfig for this run
env:
KUBE_SERVER: ${{ vars.KUBE_SERVER }}
KUBE_CA: ${{ vars.KUBE_CA }}
KUBE_TOKEN: ${{ secrets.KUBE_TOKEN }}
run: |
export KUBECONFIG="$RUNNER_TEMP/kubeconfig"
echo "$KUBE_CA" | base64 -d > "$RUNNER_TEMP/ca.crt"
kubectl config set-cluster target --server="$KUBE_SERVER" \
--certificate-authority="$RUNNER_TEMP/ca.crt" --embed-certs
kubectl config set-credentials deployer --token="$KUBE_TOKEN"
kubectl config set-context deploy --cluster=target --user=deployer --namespace=myapp
kubectl config use-context deploy
chmod 600 "$KUBECONFIG"
echo "KUBECONFIG=$KUBECONFIG" >> "$GITHUB_ENV"
- name: Deploy
run: kubectl apply -k deploy/overlays/production
- name: Wait for the rollout
run: kubectl rollout status deploy/web --timeout=180s

Enthält dein Release einen unveränderlichen Migrations-Job, wie den im Django-SaaS-Guide, kommt derselbe Löschen-dann-anwenden-Schritt zwischen Deploy und Rollout. Die Role oben erlaubt ihn:

- name: Run migrations
run: |
kubectl delete job myapp-migrate --ignore-not-found
kubectl apply -f deploy/overlays/production/migrate-job.yaml
kubectl wait --for=condition=complete job/myapp-migrate --timeout=300s

Was dir dieses Muster bringt, klar benannt:

  • Ein Leak bleibt begrenzt. Das Token kann einen Namespace ändern und in keinem anderen etwas lesen: nicht die Secrets einer anderen App, nicht die Nodes des Clusters.
  • Ein Leak läuft ab. Das Token funktioniert nach seinem Ablaufdatum nicht mehr. Wähle Wochen, nicht Jahre, und stelle rechtzeitig ein neues aus.
  • Widerrufen ist ein Befehl, und er berührt die Schlüssel des Clusters nicht. kubectl -n myapp delete serviceaccount deployer stoppt sofort jedes Token, das für ihn ausgestellt wurde; wende deployer.yaml wieder an und stelle ein neues Token aus.
  • Änderungen sind zuzuordnen. Wo das Audit-Log des API-Servers eingeschaltet ist, trägt jede Änderung der Pipeline den Namen system:serviceaccount:myapp:deployer. „Was hat um 02:00 Uhr deployt?“ hat damit eine Antwort.

GitHub kann jedem Job ein eigenes, kurzlebiges OIDC-Token geben. Ein Kubernetes-API-Server, der über die JWT-Authentifizierung in seiner Authentifizierungskonfiguration dem Aussteller von GitHub vertraut, nimmt dieses Token direkt an, und das Repository speichert gar keinen Cluster-Zugang mehr. Dafür brauchst du Zugriff auf die Flags des API-Servers, den dir eine gemanagte Control Plane womöglich nicht gibt.

  • Lade die kubeconfig nie als Artefakt hoch und gib sie nie per cat in einem Step aus. Sie enthält das Token.
  • Ein ServiceAccount pro Pipeline und Namespace (Staging und Produktion getrennt), damit du einen widerrufen kannst, ohne die anderen zu treffen.
  • Gib nur frei, was deine Manifeste enthalten. Lass Secrets weg, wenn die Pipeline sie nicht anlegen muss, und binde die Deploy-Identität nie an eine ClusterRole.
  • Pinne Image-Tags (sha-…, nicht latest), damit die CI genau das deployt, was sie gebaut hat.

Dasselbe Muster funktioniert in jedem CI-System, auch in GitLab CI, Forgejo Actions und Jenkins, denn es ist nur kubectl: eine beschränkte Identität, ein Token, das abläuft, und eine kubeconfig, die pro Lauf geschrieben wird.