ChaosBlog

Einstieg in Canary Deployments

1. Aug 2019
ca. 8 Minuten

Aufbau einer DevOps-Pipeline für eine Multiservice-Anwendung in Kubernetes

Abstract

Dieser Beitrag hat das Ziel, alle notwendigen Schritte aufzuzeigen, mit denen man eine DevOps-Pipeline in einem Kubernetes-Cluster aufbaut. Grundlegend basiert dies auf der sehr ausführlichen Anleitung in der GCP-Doku. Dabei wurde das Beispiel erweitert, um ein realistischeres Anwendungsszenario abzudecken: eine moderne Anwendung mit zugrundeliegender Microservices-Architektur die mit Canary-Deployments zu schnellen Production-Deployments führt und dabei die Gesamtqualität der Anwendung nur in einem kontrollierten Rahmen gefährdet. Dabei zeige ich die Herausforderungen auf, vor die uns ein klassisches Kubernetes-Cluster stellt.

Problemstellung

Das zuvor genannte, umfassende Tutorial deckt meiner Meinung nach nicht den Use-Case moderner Softwarearchitekturen ab. Daher habe ich eine Beispielanwendung gebaut, die aus insgesamt drei Komponenten besteht. Es gibt ein frontend, welches neben der Website auch noch einen Endpunkt mit dynamischen Grafiken anbietet (/local/*). Die Startseite bindet dabei Bilder von diesem lokalen Service ein. Zusätzlich referenziert die Seite aber auch einen weiteren Endpunkt im frontend (/remote/*), der Bilder von zwei entfernten Microservices (rectangle und circle) abfragt. Neben dem automatischen Deployment drei verschiedener Komponenten ist eine Herausforderung, wie in solch einer Architektur canary-Deployments realisiert werden.

Microservice-Architektur der Beispielanwendung

Einrichtung einer DevOps-Pipeline

Alle folgenden Schritte basieren auf einer Implementierung in der Google Cloud Platform (GCP), da die angebotenen Services wirklich gut verzahnt sind und schnelle Erfolge bei geringsten Kosten versprechen. Für das Nachvollziehen der Schritte reicht es, ein neues Projekt anzulegen und sich in der Web-Console die Cloud Shell zu öffnen. Das zugehörige Git Repo befindet sich auf GitHub

APIs aktivieren

In dem Projekt müssen einige APIs aktiviert werden:

  gcloud services enable container.googleapis.com \
    cloudbuild.googleapis.com \
    sourcerepo.googleapis.com \
    containeranalysis.googleapis.com

Einen k8s-Cluster bereitstellen

Mit einem Befehl kann ein neuer Cluster konfiguriert werden. Ich habe mich hier auf die Zone europe-west4-b festgelegt und die Pipelinekonfiguration ist darauf abgestimmt. Zur Verringerung der Kosten nutze ich immer preemptible Nodes, da diese geringere Kosten verursachen.

  gcloud container clusters create canary-example \
     --num-nodes 2 --preemptible --zone europe-west4-b

Git konfigurieren

Falls nicht schon früher geschehen müssen wir Git für unseren Nutzer noch konfigurieren.

git config --global user.email "[YOUR_EMAIL_ADDRESS]"
git config --global user.name "[YOUR_NAME]"

Zwei neue Git Repos anlegen

Das Beispiel ist für zwei Git Repos konfiguriert, die im Kontext des Projekts angelegt werden. Diese legen wir nun an.

gcloud source repos create canary-example
gcloud source repos create canary-env

Das Beispiel-Repo klonen

Mein Beispiel-Repo wird nun in das GCP-Repo geklont

cd ~
git clone https://github.com/adulescentulus/k8s-canary-example.git canary-example
cd ~/canary-example
PROJECT_ID=$(gcloud config get-value project)
git remote add google \
    "https://source.developers.google.com/p/${PROJECT_ID}/r/canary-example"
git push google master
git push google canary:canary

Berechtigung erteilen für den Zugriff auf die Kubernetes Engine

PROJECT_NUMBER="$(gcloud projects describe ${PROJECT_ID} --format='get(projectNumber)')"
gcloud projects add-iam-policy-binding ${PROJECT_NUMBER} \
    --member=serviceAccount:${PROJECT_NUMBER}@cloudbuild.gserviceaccount.com \
    --role=roles/container.developer

Deployment-Repo einrichten

Die aktive k8s-Konfiguration liegt in dem Repo canary-env. Dieses wird nun mit Dateien aus dem canary-example Repo initialisiert.

cd ~
gcloud source repos clone canary-env
cd ~/canary-env
git checkout -b production
cd ~/canary-env
cp -R ~/canary-example/env-template/* ~/canary-env/
git add .
git commit -m "Create cloudbuild.yaml and folders for deployment"
git checkout -b candidate
git push origin production
git push origin candidate

Schreibzugriff für Cloud Build auf Repo

Damit die Pipeline funktioniert, muss Cloud Build Git Commits im Repo canary-env durchführen. Diese Berechtigung müssen wir auch noch erteilen.

PROJECT_NUMBER="$(gcloud projects describe ${PROJECT_ID} \
    --format='get(projectNumber)')"
cat >/tmp/canary-env-policy.yaml <<EOF
bindings:
- members:
  - serviceAccount:${PROJECT_NUMBER}@cloudbuild.gserviceaccount.com
  role: roles/source.writer
EOF
gcloud source repos set-iam-policy \
    canary-env /tmp/canary-env-policy.yaml

Automatisches Deployment konfigurieren

Ein Build unserer Applikationen in canary-example schreibt die gewünschte k8s-Konfiguration in das canary-env Repo. Dies löst einen automatischen Bau aus. Dafür konfigurieren wir in Cloud Build einen neuen Trigger für das Projekt canary-env:

  • Name: Push to candidate
  • Branch (regex): candidate
  • Build configuration: cloudbuild.yaml

Automatische Builds für die Services

In dem Quell-Repo canary-example sind ingesamt drei eigenständige Services untergebracht. Um nun das Bauen dieser anzustoßen, müssen nun drei Build Trigger für den master-Branch angelegt werden, hier beispielhaft für das frontend:

  • Name: “frontend: Push to master”
  • Branch (regex): master
  • Included files filter: frontend/*
  • Build configuration: frontend/cloudbuild.yaml

Diese drei Trigger kann man nun nacheinander manuell ausführen. Wichtig ist aber, dass die Builds nicht gleichzeitig starten (der Zeitabstand von einer Minute je Build hat sich bewährt), denn alle drei Builds schreiben in das canary-env Repo und das kann nicht gleichzeitig funktionieren.

Zur Vorbereitung legen wir noch zwei Trigger für den canary-Branch an von den Services frontend und rectangle:

  • Name: “frontend: Push to canary”
  • Branch (regex): canary
  • Included files filter: frontend/*
  • Build configuration: frontend/cloudbuild.yaml

Diese führen wir aber noch nicht aus!

Anwendung prüfen

Nach dem Anstoßen der drei Builds sollten diese erfolgreich gelaufen sein und insgesamt drei Builds von canary-env ausgelöst haben. Ein Besuch der GKE Services-Seite sollte nun eine öffentliche IP mit Load-Balancer für unseren Service frontend zeigen. Rufen wir diese auf, dann sollte es so aussehen:

Das erfolreiche master-Deployment stellt alle Bilder grün dar

Die Bilder in der ersten Zeile werden alle durch die frontend-Service ausgeliefert. Erst die beiden unteren Zeilen liefern Bilder, die das frontend von den jeweiligen Backends rectangle und circle abruft. Alle Bilder werden in der Version auf master in allen Services in Grün dargestellt.

Canary-Deployment durchführen

Um nun ein Canary-Deployment durchzuführen bedarf es keiner weiteren, großen Anstrungen. Mit den Build-Triggern für den canary-Branch haben wir schon alles vorbereitet. Ein Commit auf diesen Branch würde die Änderungen bauen und im k8s-Cluster ausrollen. Die Änderungen sind aber schon auf dem Branch vorhanden und müssen nun nur noch durch manuelles Auslösen der Build-Trigger ausgerollt werden. Die Builds für frontend und rectangle löst man wieder mit einer Minute Verzögerung aus.

Microservice-Architektur mit Canary-Deployments

Das Ergebnis lässt sich nach wenigen Minuten in der Cloud Console prüfen:

gcloud container clusters get-credentials canary-example --zone europe-west4-b
kubectl get deployment.apps -L branch

Das Ergebnis sollte folgende Ausgabe sein:

NAME               DESIRED   CURRENT   UP-TO-DATE   AVAILABLE   AGE     BRANCH
circle-master      1         1         1            1           52m     master
frontend-canary    1         1         1            1           2m47s   canary
frontend-master    1         1         1            1           56m     master
rectangle-canary   1         1         1            1           2m46s   canary
rectangle-master   1         1         1            1           50m     master

Das Deployment enthält nun auch Instanzen des canary-Branch und kann nun durch Aufruf der Seite erneut getestet werden. Die Canary-Änderung im frontend setzt die Rahmen aller Bilder auf Blau und auch die lokalen Rechtecke werden in der Farbe Blau dargestellt. Die Canary-Änderung für rectangle stellt alle Rechtecke ebenfalls in Blau dar und sollte sich in der zweiten Zeile auswirken.

Das tatsächliche Ergebnis mehrer Aufrufe der Seite hintereinander sollte ungefähr so aussehen:

Das Ergebnis des Canary-Deployments nach mehreren Aktualisierungen des Browsers

Das Ergebnis ist also durchwachsen. Jede Aktualisierung gleicht einem wilden Blinken unserer Bilder und wir können ein Problem von Canary-Deployments mit k8s-Bordmitteln feststellen.

Fehleranalyse

Bei der Fehlerbetrachtung müssen wir zwei Phänomene unterscheiden: Die unterschiedlichen Farben in der ersten Zeile von “Serving local images” und die in der zweiten Zeile, die Rechtecke in “Serving remote images”.

Das erste Fehlerbild betrifft die Darstellung von Bildern, die alle, wie auch die index.html, vom frontend-Service bereitgestellt werden. Allerdings sind für die index.html und die vier Bilder insgesamt fünf HTTP-Requests notwendig. Jeder Request hat die Chance entweder vom master- oder canary-Release bedient zu werden.

Das zweite Fehlerbild zeigt uns die “Komplexität” unserer Applikation und die Grenzen eines “dummen” Load-Balancers auf. Unsere Services hängen voneinander ab, das heißt, dass ein Zugriff auf das canary-frontend auch einen nachgelagerten Zugriff auf das canary-rectangle-Service auslösen müsste. Der Load-Balancer selektiert die Deployments in unserer k8s-Konfiguration nur nach Labels app=frontend oder app=rectangle und prüft nicht, ob der Request eventuell von einem canary-Service initiiert wurde.

Die Lösung dieser beiden Problem ist nach meinem Kenntnisstand nicht trivial und kann eventuell durch einen Ingress-Controller (in Teilen) oder mit einem Service-Mesh wie istio gelöst werden. Meine Lösungen werde ich im Rahmen dieser Artikelserie hier im Blog veröffentlichen.

Details der Deployment-Pipeline

Zu Beginn habe ich nur die Schritte der Anleitung mit wenig Details weggeschrieben. Ich möchte hier noch auf ein paar Details der Implementierung eingehen. Einige Dinge werden in dem bereits verlinkten Tutorial der GCP Doku erläutert.

k8s-Konfiguration

Jeder Service ist selbst für eine funktionierende k8s-Konfigurationsdatei verantwortlich. In jedem Projektverzeichnis befindet sich eine k8s.yaml.tpl die am Ende des Builds in das canary-env-Projekt übertragen wird. Dabei wird in dem Template der aktuelle Branch als Label gesetzt. Ebenso wird der Commit-SHA für das Docker-Image und die GCP-Project-ID ersetzt. Die Datei wird dann im Ziel-Repo mit dem Branch als Suffix abgelegt, so dass jeder Branch eine separate Konfiguration besitzt.

Jede Änderung im canary-env-Repo führt zu einem kubectl-Durchlauf. Es werden immer zuerst alle Canary-Deployments gelöscht und dann alle Konfigurationsdateien in den drei Service-Verzeichnissen angewandt. Das sorgt zwar immer für ein Aus- und Anschalten der Canary-Services, aber so bleibt die Cluster-Konsistenz gewahrt.

Löschen der Canary-Deployments

Wie zuvor erwähnt führt jeder canary-env-Build eine Cluster-Konfiguration durch und löscht Canary-Deployments. Wird nun ein Service auf dem master-Branch deployed werden hier alle anderen Branch-Konfigurationen für diesen Service gelöscht, so dass diese nicht mehr neu deployed werden. Das macht auch Sinn. Im Continuous Deployment Lifecycle sollte ein Canary-Feature immer auf master gemerged werden, was das Canary-Deployment überflüssig macht.

Probleme bei vielen Commits

Die aktuelle Implementierung hat definitiv noch einen Haken: parallele Commits in den einzelnen Services. Dadurch dass jeder Build auch in das canary-env-Repo schreibt, kann die parallele Ausführung zu einem unerwarteten Abbruch wegen eines Git-Fehlers führen. Dies läßt sich in Google Cloud Build leider nicht einschränken. Parallele Builds sind definitiv vorgesehen. Man könnte dies nur durch clevere Scripts in der Build-Pipeline beheben.

Quarkus und GraalVM

Wie auch schon bei meinem Test von Google Cloud Run setze ich konsequent auf Quarkus, dem “Kubernetes Native Java stack”. Die Startzeiten der Container sind phänomenal. Alle Service-Builds in meinem Beispielprojekt laufen wegen der kürzeren Compile-Zeit in einer JVM. In der cloudbuild.yaml kann einfach zwischen Dockerfile.jvm und Dockerfile.native gewechselt werden, um noch weiter von den Optimierungen der GraalVM zu profitieren.

Fazit

Canary-Deployments in komplexeren Microservice-Architekturen sind leider keine einfache Angelegenheit. Es ist definitiv eine lösbare Aufgabe, wenn man erst einmal alle Probleme vor Augen hat. Diese Artikelserie wird in der Folge hoffentlich mindestens eine Lösung hervorbringen.


Read more...
Cookie-Richtlinie

Diese Website benutzt Cookies, u.a. zur automatisierten Nutzerauswertung mit Google Analytics.

Bitte beachten Sie die Datenschutzerklärung!