Besser entwickeln mit Devops-Praktiken: Continuous Delivery mit Kubernetes
Der Begriff Continuous Delivery beschreibt den Wechsel von konstanten Release-Zyklen („neue Versionen werden immer zum Quartalsende veröffentlicht“) hin zu einem kontinuierlichen Ausrollen neuer Versionen einer Software. Damit Continuous Delivery in der Praxis funktioniert, müssen mehrere Voraussetzungen erfüllt sein: Zum einen benötigt das Entwicklungsteam einen gewissen Spielraum, in dem es selbstständig und eigenverantwortlich agieren kann; Abhängigkeiten von anderen Teams oder Abteilungen sollten möglichst vermieden werden. Viele Teams greifen zu einer Devops-Organisationsstruktur, in der die (ansonsten oft klaren) Grenzen zwischen Entwicklung (Development) und Betrieb (Operations) verschwimmen – etwa in Form von Entwicklern, die gleichzeitig auch für den operativen Betrieb ihrer Applikationen verantwortlich sind.
Zum anderen benötigt es einen schnellen, wiederholbaren und gut automatisierbaren Prozess zur Auslieferung und zum Betrieb der Software. Diese Anforderungen werden von aktuellen Technologien wie Docker und Kubernetes gut bedient, die Devops-Teams passende (und gut automatisierbare) Werkzeuge zum Bauen, Ausliefern und Betreiben von Applikationen an die Hand geben. Wie das mit Kubernetes gelingen kann, zeigen wir euch im Folgenden.
Deployment nach Kubernetes
Am Anfang jeden Deployments steht zunächst das Erstellen eines Container-Images, das beispielsweise mithilfe eines Dockerfiles und dem Befehl docker build
erstellt werden kann (im Folgenden nehmen wir das Image your-app:v1.2.3
als Beispiel an).
Zum Starten einer Applikation auf Kubernetes kann am besten ein Deployment-Objekt benutzt werden. Dieses wird in Form einer YAML-Datei definiert, die wie folgt aussehen könnte:
apiVersion: apps/v1
kind: Deployment
metadata:
name: your-application
spec:
replicas: 4
template:
containers:
- name: app
image: "your-app:v1.2.3"
ports:
- containerPort: 8080
name: http
Das Deployment kann mit dem Befehl kubectl apply
erstellt werden. In diesem Fall startet Kubernetes vier „Replicas“ des angegebenen (und zuvor gebauten) Container-Images:
> kubectl apply -f your-deployment.yaml
Die gestarteten Instanzen (Pods) können im Anschluss mit dem Befehl kubectl get pods
abgefragt werden:
> kubectl get pods
NAME READY STATUS RESTARTS AGE
my-app-351368469-brjcd 1/1 Running 0 1m
my-app-351368469-nrzxh 1/1 Running 0 1m
my-app-351368469-owkfz 1/1 Running 0 1m
my-app-351368469-bhaz6 1/1 Running 0 1m
Zukünftige Versionen der Software können nun als jeweils neues Image veröffentlicht werden. Um eine neue Version zu releasen, kann das Deployment über den folgenden Befehl bearbeitet werden:
> kubectl set image deployment/your-application app=your-app:v1.2.4
deployment.apps/your-application image updated
Kubernetes führt in diesem Fall ein Rolling Update der Applikation aus. Das bedeutet, dass die Container mit der vorherigen Version des Images einer nach dem anderen heruntergefahren werden. Für jeden gestoppten Container der Vorversion wird ein neuer Container mit der neuen Version gestartet. Auf diese Weise werden alle laufenden Instanzen der Applikation nach und nach ausgetauscht. Die Applikation als Ganzes bleibt dabei erreichbar. Allerdings muss berücksichtigt werden, dass für einen kurzen Zeitraum zwei Versionen parallel laufen. Sollte die neue Version der Software nicht so laufen wie gewünscht, kann das Ganze über den kubectl rollout undo
-Befehl wieder rückgängig gemacht werden:
> kubectl rollout undo deployment/your-application
Der Helm-Paketmanager
Die Methode, Deployments über den kubectl set image
-Befehl zu aktualisieren, funktioniert in vielen einfachen Fällen, stößt jedoch schnell an ihre Grenzen. Häufig besteht eine auf Kubernetes betriebene Applikation nicht nur aus einem Deployment-Objekt, sondern aus zahlreichen weiteren Objekten (beispielsweise aus Service- und Ingress-Objekten, die definieren, wie eingehender Traffic die Applikation erreicht oder Configmap- und Secret-Ressourcen, die Konfigurationsdaten für die Applikation beinhalten). Änderungen an solchen Ressourcen können in einer Deployment-Pipeline über den obigen kubectl set image
-Befehl nicht abgebildet werden.
Hier kommt jetzt Helm ins Spiel: Konzipiert als Paketmanager für Kubernetes, verwaltet Helm Definitionen für komplexe Kubernetes-Deployments, die aus zahlreichen verschiedenen Objekten bestehen können. Bei Helm heißen diese Definitionen Charts. Ein Helm-Chart kann zahlreiche verschiedene Kubernetes-Objekte definieren (beispielsweise ein Deployment und dazugehörige Services, Ingress-Definitionen und auch andere).
Helm besteht aus einem Client, der auf eurem lokalen Rechner (oder eurer CI-Umgebung) laufen muss, sowie einem Server (genannt Tiller), der innerhalb des Kubernetes-Clusters laufen muss. Charts können aus einem (oder mehreren) zentralen Repositories oder aus dem lokalen Dateisystem geladen werden. Der Helm-Server kann in einem bestehenden Kubernetes-Cluster über den Befehl helm init
installiert werden.
Helm-Charts erstellen
Für das kontinuierliche Deployment eigener Applikationen ist es ratsam, das Helm-Chart einer Applikation zusammen mit der Applikation selbst in demselben Git-Repository zu verwalten. Mit folgendem Befehl kann ein neues Helm-Chart erstellt werden:
> helm create charts/my-app
Creating charts/my-app
Jedes Helm-Chart besteht aus einem Satz an Templates, dabei handelt es sich um Vorlagen für Kubernetes-Objekte, die später bei der Installation eines Charts von Helm erstellt werden. Jedes Template enthält Platzhalter für bestimmte Werte. Das Template für ein Deployment könnte beispielsweise folgendermaßen aussehen:
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ template "my-app.fullname" . }}
labels:
app: {{ template "my-app.name" . }}
chart: {{ template "my-app.chart" . }}
release: {{ .Release.Name }}
heritage: {{ .Release.Service }}
spec:
replicas: {{ .Values.replicaCount }}
selector:
matchLabels:
app: {{ template "my-app.name" . }}
release: {{ .Release.Name }}
template:
metadata:
labels:
app: {{ template "my-app.name" . }}
release: {{ .Release.Name }}
spec:
containers:
- name: {{ .Chart.Name }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
ports:
- name: http
containerPort: 8080
Bei der verwendeten Template-Sprache handelt es sich um die Standard-Template-Sprache von Go, die in der offiziellen Dokumentation „go-template“ näher beschrieben ist.
Zusätzlich zu den Templates enthält jedes Helm-Chart eine yalues.yml
-Datei. Diese enthält Werte, die dann anschließend in den Templates genutzt werden können (beispielsweise oben in Form der .Values.replicaCount-Variable
). Ein bestehendes Helm-Chart kann anschließend mit dem Befehl helm install
in einem Kubernetes-Cluster installiert werden:
> helm install --set image.tag=v1.2.3 --name my-app ./charts/my-app
Über das --set
-Argument können bei der Installation einzelne Werte aus der values.yml
überschrieben werden. Indem der image.tag
-Wert überschrieben wird, kann beispielsweise eine ganz bestimmte Version einer Applikation installiert werden.
Ist ein Chart bereits installiert, kann die bestehende Installation über den helm upgrade
-Befehl aktualisiert werden. Dieser Befehl kann auch beispielsweise genutzt werden, um bestimmte Werte zu aktualisieren:
> helm upgrade --install --set image.tag=v1.2.4 my-app ./charts/my-app
Hier wurde beispielsweise die Version des Container-Images geändert. Der helm upgrade
-Befehl würde nun dafür sorgen, dass sämtliche im Chart definierten Ressourcen auf den gewünschten Stand aktualisiert werden. Das Deployment würde zudem auf die Image-Version v1.2.4 aktualisiert und Kubernetes würde (ähnlich wie beim kubectl set image
-Befehl) ein Rolling Update des Deployments starten.
Das --install
-Argument in dem obigen Befehl sorgt übrigens dafür, dass das Chart installiert wird, falls dies noch nicht der Fall ist. Dadurch kann man sich auch den initialen helm install
-Befehl bequemerweise sparen. Und natürlich gibt es auch einen helm rollback
-Befehl, mit dem im Fehlerfall schnell zum vorherigen Release zurückgewechselt werden kann.
Integration mit einem CI-Tool nach Wahl
Mithilfe von Helm reicht mit helm upgrade
nun ein einziger Befehl, um eine auf Kubernetes betriebene Applikation auf den im Quelltext-Repository definierten Stand zu bringen. Um diesen Prozess völlig zu automatisieren, bietet sich nun eine Integration in die üblichen Continuous-Integration-Lösungen an, sodass beispielsweise beim Erstellen eines neuen Git-Tags auch gleich das dazugehörige Deployment aktualisiert wird.
CI-Lösungen gibt es viele, stellvertretend soll an dieser Stelle die Gitlab CI als Beispiel dienen. Ein Job, der eine Applikation per Helm-Chart installiert, könnte in Gitlab CI wie folgt aussehen:
dockerbuild:
stage: build
image: docker
script:
- "docker build -t my-app:${CI_COMMIT_TAG} ."
- "docker push my-app:${CI_COMMIT_TAG}"
only: [tags]
deploy:
stage: deploy
image: quay.io/martinhelmich/k8s-deployer:v1.1.0
environment: development
script:
- >
helm upgrade --install
--set image.tag=${CI_COMMIT_TAG}
${CI_PROJECT_NAME} ./charts/my-app
only: [tags]
Wichtig ist hier insbesondere die Variable ${CI_COMMIT_TAG}
: Wird ein Git-Tag in das Repository gepushed (beispielsweise v1.2.5
), enthält diese Variable den Namen des Tags. Im dockerbuild
-Job wird dabei zunächst ein Docker-Image dieses Namens gebaut. Anschließend wird das gebaute Image im deploy
-Build über den schon vorgestellten helm upgrade
-Befehl installiert.
Der Vorteil dieses Ansatzes ist, dass jedes Helm-Chart mehrfach in einem Cluster installiert werden kann. Das ist insbesondere in Entwicklungsumgebungen nützlich. Hier kann beispielsweise jeder Git-Branch (beispielsweise für Pull Requests) einzeln deployed werden:
deploy:
stage: deploy
image: quay.io/martinhelmich/k8s-deployer:v1.0.0
environment:
name: review/$CI_COMMIT_REF_NAME
url: https://$CI_ENVIRONMENT_SLUG.your-app.example
script:
- >
helm upgrade --install
--set image.tag=${CI_COMMIT_TAG}
${CI_PROJECT_NAME}-${CI_ENVIRONMENT_SLUG} .
/charts/my-app
Das Beispiel nutzt die Review-Apps-Funktionalität von Gitlab, um für jeden Pull Request einer Applikation ein eigenes Release des hinterlegten Helm-Charts zu erstellen.
Fazit
Die gezeigte Kombination aus Tools ermöglicht die Konstruktion recht komplexer und durchaus praxistauglicher Deployment-Pipelines. Natürlich ist hier noch längst nicht Schluss. Beispielsweise lassen sich mit Kubernetes und Helm auch komplexere Deployments wie beispielsweise ein Blue/Green-Deployment umsetzen. Weitere Möglichkeiten eröffnen sich durch den Einsatz von Service Meshes wie beispielsweise Istio. Diese unterstützen unter anderem Canary Releases, bei denen zunächst nur ein geringer Teil des Traffics an ein neues Release der Anwendung geleitet wird. Generell ermöglichen solche Meshes auch eine hohe Observability, sodass Fehler, die sich durch ein neues Release eingeschlichen haben, schnell gefunden werden können.