Continuous Deployment mit Kubernetes

Build once Deploy Everywhere

Bernhard @ Comsysto Reply
comsystoreply
10 min readJul 1, 2020

--

Deploy-Chain Konzept für eine Client-Server Anwendung basierend auf PostgreSQL, Express und Angular. Zero Downtime Deployments durch RollingUpdate Strategie.

Einleitung

Für Kubernetes brennt aktuell mein Herz, da es mir das Leben enorm erleichtert. Meine aktuelle Spielerei ist eine Anwendung für ein “Haushaltsbuch” basierend auf Express, TypeORM, PostgreSQL und Angular, denn wem kann man schon trauen, außer der eigenen Infrastruktur ^^.

Doch direkt zu Anfang der Entwicklung stellte sich die Frage wie bekomme ich das deployed? Und wie bleiben die Daten in der Datenbank persistent? Wie behandle ich verschiedene Environments? Wie erreiche ich ‘Build once Deploy Everywhere’?

In diesem BlogPost wird es um die Konzepte gehen, wie man so eine Anwendung in simpler Form mit Kubernetes und einer Jenkins Pipeline aufsetzt. Wir können nicht überall ins Detail gehen, geben aber einen guten Einblick in die Thematik

Haushaltsbuch ‘Moneyclou’ und Infrastruktur

Meine Anwendung Moneyclou besteht aus folgenden Bestandteilen. Das Frontend ist ein ExpressJS Server, der über SSL eine Angular Anwendung ausliefert. Das Backend ist ein ExpressJS Server welcher mittels TypeORM auf eine PostgreSQL Datenbank zugreift und eine REST API via SSL bereitstellt.

Ich betreibe eine Kubernetes Bare-Metal Installation (kubeadm) auf einem HP Microserver Gen8 auf Ubuntu. Darin läuft ein Jenkins mit Kubernetes Plugin. Zusätzlich auch ein Nexus OSS welches Repositories für Docker, Maven und NPM bereitstellt.

‍Infrastruktur und Moneyclou

Grundlagen von Kubernetes

Dieser Blogpost benötigt ein gewisses Grundverständnis von Kubernetes. Ich gehe daher nicht auf jedes Detail ein und werde auch nicht auf das initiale Aufsetzen von Kubernetes eingehen. Wir setzen eine funktionierende Kubernetes Installation mit mindestens Version 1.11 voraus. Die Beispiele werden für eine Bare-Metal Installation angegeben.

Jetzt da das geklärt ist, hier eine Mikrozusammenfassung von den für uns wichtigen Kubernetes Begriffen und Konzepten.

Pod

  • Ein Pod ist üblicherweise ein laufender Docker Container und etwas config welche Kubernetes mitteilt wie der Container zu starten ist und was er bspw. für Speicher benötigt. Einen Pod kann man manuell starten. Löscht man ihn dann aber ist er für immer weg und man müsste ihn erneut manuell anlegen und starten. Daher nutzt man i.d.R. StatefulSets.

StatefulSet

  • Ein StatefulSet managed die Instanziierung von Pods. Bspw. kann man ein StatefulSet definieren: “Fahre 4 Instanzen von Pod XYZ hoch”. Stoppt oder löscht man dann bspw. Pod Instanz 3 manuell, dann wird das StatefulSet versuchen seinen definierten Zustand (State) wieder herzustellen und wird einen Pod starten, damit es wieder wie definiert 4 Instanzen sind. Dieses Verhalten werden wir uns später für unser Rolling Deployment (ohne Downtime) zu nutze machen.

Service

  • Ein Service im Sinne von Kubernetes ist eine Abstraktion auf mehrere Pods und wie auf sie zugegriffen wird. So wird in unserem Fall bei einer BareMetal Installation über den NodePort auf unsere Pods zugegriffen. Ein Service könnte aber auch eine Abstraktion auf einen LoadBalancer darstellen (wenn man Kubernetes bspw. in der Cloud bei AWS betreibt).

Persistent Volume (PV)

  • Ist wie der Name sagt zur Verfügung gestellter Speicherplatz, der persistent (nicht flüchtig) ist. Ein PV kann dabei verschiedene Eigenschaften definieren wie “Ich biete 2GB Speicher auf einer SSD an”. Der PV stellt sich mit seinen Eigenschaften zur Nutzung innerhalb seines Namespaces bereit. Wir speichern bspw. das Datenverzeichnis unserer PostgreSQL Datenbank in einem PV.

Persistent Volume Claim (PVC)

  • Ist das Gegenstück zum Persistent Volume. Mit einem PVC “verlangt” man anhand gewisser Kriterien nach einem Persistent Volume welches den Kriterien entspricht. Wenn wir also für unseren Pod “Ich brauche 500MB Speicher auf SSD” verlangen, dann wird das PV mit den 2GB auf der SSD automatisch ausgewählt. Es stehen dann weiterhin 1,5GB Speicher zur Verfügung, die ein anderer Pod nutzen kann. Man entkoppelt also das Bereitstellen und das Verwenden von persistentem Speicher.

Namespace

  • Ein Namespace trennt bspw. Projekte oder Teams voneinander, wenn sie den selben Kubernetes Cluster nutzen. Ich lege bspw. für alle verschiedenen Projekte eigene Namespaces an. So vermeide ich bspw. das verschiedene Pods aus verschiedenen Projekten sich PersistentVolumes teilen, die ich bspw. explizit getrennt backupen will. Auch kann dann bspw. das Team A keine Pods von Team B stoppen. Man kommt sich also nicht aus versehen in die Quere.

ServiceAccount

  • Es hat bei mir lange gedauert bis ich verstanden habe was ein Service Account ist. Wenn wir mit Kubernetes arbeiten, tun wir das i.d.R. über den “kubectl” Kommandozeilen Client. Dieser arbeitet mit einem Token aus der Config Datei “~/.kube/config”. Damit hat man meist volle Adminrechte auf allen Namespaces. Ich will aber bspw. dass mein Jenkins nur Zugriff auf gewisse Funktionalitäten hat also bspw. Pods starten, StatefulSets patchen. Ich kann ServiceAccounts anlegen und ihnen gewisse Rechte geben (RBAC), damit man nur die Aktionen ausführen kann für die man berechtigt ist. Wir gehen später noch näher darauf ein.

Vorbereitung: Namespace und ServiceAccount anlegen

Damit wir loslegen können, müssen wir in unserem k8s cluster nun erstmal einen Namespace anlegen. Da unser Projekt moneyclou heißt, legen wir auch alle Dinge entsprechend gekennzeichnet an.

kubectl create namespace moneyclou

Und damit unsere folgenden Kommandos auch in diesem neu angelegten Namespace ausgeführt werden, wechseln wir in diesen Namespace.

kubectl config set-context $(kubectl config current-context) — namespace=moneyclou

Da unsere Pods mittels Docker gebaut werden und wir diese in ein Nexus OSS Docker Repository pushen, benötigen wir Zugriffsdaten auf das Docker Repository und legen dafür ein Secret an.

kubectl — namespace=moneyclou create secret docker-registry regsecret — docker-server=nexus.k8s.home.mydomain:32554 — docker-username=admin — docker-password=XXXXXXXX — docker-email=admin@mydomain

Da unser Nexus mit einem selbst signierten Zertifikat läuft müssen wir Docker noch mitteilen, was unser CA Zertifikat ist. Daher muss das CA Zertifikat an folgende Stellen auf dem Kubernetes Host gelegt werden:

  • /etc/docker/certs.d/nexus.k8s.home.mydomain:32555/ca.crt
  • /etc/docker/certs.d/nexus.k8s.home.mydomain:32554/ca.crt (=Docker Port)

Nun legen wir den ServiceAccount an. Dazu benötigen wir ein wenig config zu den Berechtigungen.

Haben wir die Datei angelegt, können wir den Service Account anlegen.

kubectl — namespace=moneyclou create -f k8s-moneyclou-service-account.yml

Gut, jetzt ist der Service Account angelegt, aber wie kann ich ihn nutzen? Wir benötigen noch ein Secret für den ServiceAccount welches ein Token bereitstellt. Mit dem Token kann dann später bspw. Jenkins den ServiceAccount nutzen, um zu deployen.

kubectl — namespace=moneyclou create -f k8s-moneyclou-service-account-secret.yml

Damit wir an das Token herankommen führen wir describe auf dem Secret aus, und bekommen das token angezeigt.

kubectl — namespace=moneyclou describe secrets/moneyclou

Wir speichern uns den Token für spätere Verwendung ab. Die grundlegenden Vorbereitungen sind abgeschlossen.

Wie bleiben die Daten in der Datenbank persistent?

Wir haben die Begrifflichkeiten in der Zusammenfassung bereits kennengelernt. Kurz gesagt benötigen wir ein PersistentVolume und einen PeristentVolumeClaim.

Wir legen für unsere PostgreSQL Datenbank folgendes PersistentVolume mit 1GB Speicher auf der SSD an.

kubectl — namespace=moneyclou create -f ./k8s-postgresql-persistent-volume.yml

Nun legen wir einen Pod für unsere PostgreSQL Datenbank an. Dabei wird ein Service den Port 5432 exposen und die Datenbank innerhalb des Namespace nutzbar machen. Der PersistentVolumeClaim ist passend für unser zuvor angelegtes PersistentVolume und wählt dieses anhand von “storageClassName”, “accessModes” und “storage” aus.

kubectl — namespace=moneyclou create -f ./k8s-postgresql-create.yml

Der Datenbank-Pod ist somit angelegt und läuft. Da wir nicht nur ein Pod sondern ein Deployment angelegt haben, wird unser Pod auch bspw. nach Absturz oder manueller Entfernung wieder gestartet. (Hinweis: Deployment ist ähnlich zu StatefulSet)

Doch wir haben noch kein Datenbank-Schema angelegt. Dies tun wir indem wir über “kubectl” zur Datenbank verbinden. Dazu starten wir einen Pod, der uns den “psql” client bereitstellt und verbinden uns zum Datenbank-Pod.

kubectl — namespace=moneyclou run -it — env=”PGPASSWORD=password” — rm — image=postgres:10-alpine — restart=Never psql — psql -h moneyclou-backend-postgresql -U postgres

Nun können wir in die “psql” shell folgende Anweisungen zum Anlegen der Datenbank-User und Datenbank-Schemata “pasten”.

Da unsere Datenbank in ihrem eigenen Pod läuft, welches von einem Deployment gesteuert wird und wir ein PersistentVolume nutzen, bleiben die Daten persistent, auch wenn der Datenbank-Pod “restartet”.

Wie behandle ich verschiedene Environments?

Wenn ich von Environment spreche, dann meine ich damit ganze Umgebungen wie DEV, STAGE und LIVE. Generell würde ich empfehlen pro Environment einen eigenen Namespace anzulegen.

Wir bauen unsere Anwendung einmal und pushen sie in ein Docker Repository. Von dort holen wir sie und können sie frei in jedes Environment deployen. Somit erreichen wir ‘Build Once Deploy Everywhere’.

Damit aber jeder Teil unserer Anwendung weiß, in welchem Environment es läuft, setzen wir Environment Variablen für unsere Pods, die sie zur Laufzeit auslesen und dadurch entscheiden können bspw. zu welchem Datenbankhost sie verbinden sollen. Wir gehen später noch näher darauf ein.

Wie bekomme ich das deployed?

Generell hat jeder Microservice (Frontend + Backend) in Jenkins einen Pipeline Job welcher das Artefakt baut. Wir bauen Artefakte immer auf dem Develop Branch und dem Master Branch. Jeder build führt zu einem Artefakt, welches aus der Anwendung gekappselt in einem Docker Image besteht. Dieses Docker Image pushen wir mit spezifischer Version in ein Docker Repository.

Später werden wir sehen, wir wir über Jenkins Pipeline Jobs die gebauten Artefakte in beliebige Environments deployen können.

Die Versions Konvention ist dabei

  • Develop Branch: 1.0.0-rc-aef23242af, also Semantic-Version + Release-Candidate + GitCommit
  • Master Branch: 1.0.0-release-aef23242af, also Semantic-Version + Release + GitCommit

Wir trennen generell Build von Deployment. Das bedeutet wir bauen immer Artefakte auf beiden Branches und diese können in Environments deployed werden. Hierzu haben wir pro Environment eine Deploy-Descriptor-Datei in einem separaten Deployment Repository abgelegt. Wir können dann bspw. am Ende des Build Jobs diesen Deploy-Descriptor automatisiert patchen und ein Deployment für unser Artefakt auf einer Umgebung anstoßen.

BEISPIEL: Code Änderung führt zu Deployment in getrennten Build und Deploy Schritten

TRIGGER

  • Git Push von Code auf “moneyclou-backend” Repository in Branch “develop”

BUILD

  • build JenkinsJob gestartet
  • bauen der Anwendung, Tests ausführen
  • Docker Image bauen für moneyclou-backend mit gebauter Anwendung
  • pushen des Docker Image als “moneyclou-backend:1.1.0-rc-020c8bb63785074bc3702c003333852fc8d9a343”
  • patchen des DEV Environment deploy descriptors für “moneyclou-backend:1.1.0-rc-020c8bb63785074bc3702c003333852fc8d9a343” im deployment repository
  • JenkinsJob ende

DEPLOYMENT

  • git push auf deployment repository im branch “DEV” festgestellt, deployment JenkinsJob gestartet
  • patche k8s StatefulSet von “moneyclou-backend” mit neuer version “moneyclou-backend:1.1.0-rc-020c8bb63785074bc3702c003333852fc8d9a343” in Namespace moneyclou-dev
  • JenkinsJob ende
  • k8s StatefulSet führt rolling deployment der neuen Version aus

Frontend und Backend initial aufsetzen

Wir setzen zuerst das Frontend initial auf. Das Frontend besteht aus einem kleinen ExpressJS Server, welcher über Port 5000 via HTTPS (SSL) die gebaute Angular Anwendung (static content) ausliefert. Hostname und SSL Zertifikate werden dabei über ENV Variablen beim Hochfahren ausgelesen. Unser Frontend benötigt daher folgendes:

StatefulSet

  • für Rolling Deployments
  • imagePullSecret ermöglicht den Zugriff auf privates Docker Repository (haben wir zu Anfangs angelegt)
  • containers.image definiert moneyclou-frontend Anwendung als “Docker-Repository + Anwendungsname + Version”
  • verwende Port 5000 als HTTPS (SSL) Port für die eigentliche Anwendung
  • die Env-Variable ‘SERVER_NAME’ definiert den Hostname der Anwendung
  • die Env-Variable ‘ENV=pod’ definiert das Environment in unserem Fall production
  • die Env-Variablen ‘SSL_KEY’ und ‘SSL_CRT’ definieren das SSL Zertifikat der Awendung welches als Secret angelegt wurde und dem Pod bereitgestellt wird.
  • Auf Port 5000 wird ein LivenessProbe und ReadinessProbe ausgeführt. Anhand dieser Checks werden Pods restartet.
  • Über securityContext.runAsUser und fsGroup kann man definieren, mit welcher UID und GID der Pod laufen soll. Das verhindert “permission denied” Probleme beim Zugriff auf PersistentVolumes.

Service

  • Ein Service exposed abschließend noch HTTPS Port 32478 als NodePort (BareMetal).
kubectl — namespace=moneyclou create -f ./k8s-moneyclou-frontend.yml

Unsere Anwendung ist nun über folgende URL erreichbar:

  • https://moneyclou.k8s.home.mydomain:32478/

Natürlich erst wenn auch wirklich bereits das Image ‘nexus.k8s.home.mydomain:32554/mydomain/moneyclou-frontend:v1’ im Docker Repository zur Verfügung steht. Ich habe mir angewöhnt ein minimales Image (v1), welches Port 5000 exposed initial zu deployen, um vor dem Aufsetzen der eigentlichen Deployment Pipeline bereits testen zu können ob alles funktioniert.

Analog gehen wir für das Backend vor. Das Backend besteht aus einem ExpressJS Server, welcher auf die Datenbank zugreift. Ich gehe jetzt nur noch auf die Dinge ein, die nicht analog zum Frontend sind.

StatefulSet

  • die Env-Variable ‘TYPEORM_HOST’ definiert den Hostnamen der Datenbank.
kubectl — namespace=moneyclou create -f ./k8s-moneyclou-backend.yml

Das Backend ist über folgende URL erreichbar:

  • https://moneyclou-backend.k8s.home.mydomain:32477/

Jetzt ist alles final aufgesetzt und wir können unsere Pipeline aufsetzen. Der Namespace sollte im Kubernetes Dashboard nun in etwa so aussehen:

Rolling Deployment für Frontend und Backend aufsetzen

Da der Blogpost jetzt schon länger ist als ich wollte, zeige ich hier nur die wichtigen Teile der Deployment Pipeline. Die Details zum NodeJS ExpressJS Server und das Dockerfile würden den Rahmen sprengen, daher verzichte ich auf weitere Details hierzu. Auch zeige ich nur das Deployment des Backends, da das Frontend Deployment analog funktioniert.

Die Build Pipeline des Backends baut die Anwendung, kapselt diese anschließend in einem Docker Image und pushed dieses in ein Docker Repository. Zuletzt wird der deploy descriptor für das Environment gepatched, was die Deploy Pipeline triggered.

Die Deploy Pipeline ist für Frontend und Backend gleich und sieht wie folgt aus. Sie nutzt den Token des ServiceAccounts, um mittels ‘kubectl’ die StatefulSets zu patchen und dort die neue Version der Anwendung zu hinterlegen. Auch wird die UpdateStrategy auf ‘RollingUpdate’ gesetzt (nur zur Sicherheit). Das StatefulSet wird nun (falls sich die Versionen geändert haben) die Pods in der neuen Version starten und die alten Pods terminieren und das ohne Downtime.

Es wird eine Schleife mit ‘deployDescriptor.apps.each’ durchlaufen und somit jede App neu deployed. Ein Deploy Descriptor sieht dabei wie folgt aus. Und wir haben pro Environment einen Branch mit einem solchen Deploy Descriptor.

Fazit

Es wurde gezeigt wie man mittels RollingUpdate Strategy eines StatefulSets Zero-Downtime-Deployments durchführen kann. Die Trennung von Build und Deployment ist sehr ratsam. Ein Artefakt wird nur einmal gebaut und anschließend durch die QS getestet. Nun kann es ohne Sorge auf einem weiteren Environment deployed werden. Die Anwendungen in Docker Images zu kapseln hat den großen Vorteil, dass man es den Entwicklern der Anwendung überlassen kann, wie das Dockerfile gebaut wird. Wichtig ist nur die richtigen Ports zu Exposen für HealthCheck und Anwendung und schon hat man eine saubere Schnittstelle zu DevOps geschaffen.

This blogpost is published by Comsysto Reply GmbH

--

--