03.06.2021

Review Umgebungen

In den ersten beiden Post zu meinem Raspberry PI Cluster (Teil 1 / Teil 2) ging es um die Hardware, Kubernetes und das Bereitstellen von Anwendungen. In diesem Post möchte ich auf eine der Möglichkeiten eingehen, welches einem ein Kubernetes Cluster in der Web-Software Entwicklung bietet.

Bei der Entwicklung von Web-Software nutzt man neben der lokalen Umgebung, für die Entwicklung, und einer Produktiv-Umgebung mindestens eine weitere Umgebung um neue Features/Funktionen oder Bugfixes vor dem produktiv Schalten zu testen. In größeren Projekten kommen oft auch mehr als eine Umgebung zum Einsatz, z.B. eine für das Entwickler/Test-Team, eine für die fachliche Abnahme und eine produktive nahe für Last- und Performance-Tests. Die Anzahl der zur Verfügung stehen Umgebungen ist in vielen Projekten fix und oft deployen mehrere Entwickler ihre Arbeit auf dieselbe Umgebung, damit ihre Arbeit anderen im Team/Projekt zur Verfügung steht. Steht dem Entwickler Team nur eine geringe Anzahl an Umgebungen zur Verfügung, bedarf es einer guten Kommunikation und Deployment-Strategie, um zu verhindern, dass es zu Konflikten auf den Umgebungen kommt, wenn unterschiedliche Entwickler ihre Arbeit auf diese Umgebung deployen. Oft ist es dann auch nicht immer ganz klar, ob und auf welcher Umgebung z.B. ein neues Feature zur Verfügung steht.

Kubernetes macht es uns leicht neue Services in Form von Container zu deployen. Wie im vorherigen Post gezeigt, muss man hierfür nur dem Server Node mitteilen welche Kubernetes-Objekte man deployen möchte und Kubernetes kümmert sich um die Bereitstellung. Dies kombiniert mit einer CI/CD Pipeline erlaubt es uns neue Software-Versionen/Feature automatisiert bereitzustellen, ohne an eine fixe Anzahl an Umgebungen gebunden zu sein. Es ist uns so möglich für jedes Release, Feature oder jeden Bugfix eine eigene Umgebung on Demand zu erzeugen, auf welchen Tests, Reviews und fachliche Abnahmen stattfinden können. Dadurch, dass jeder Release-, Feature-, Bugfix-Branch seine eigene Umgebung besitzt, ist immer eindeutig unter welcher Adresse ein neues Release, Feature zu finden ist. Zudem sind solche Feature Umgebungen sehr stabil, da nicht mehrere Entwickler etwas auf dieselbe Umgebung deployen.

Voraussetzungen

Zum Erstellen solcher Umgebungen, welche auch Review Umgebungen genannt werden, benötigt man neben Kubernetes ein Git-Repository, eine CI/CD Pipeline und einen Wildcard DNS-Eintrag auf das Kubernetes Cluster.

Durch den Wildcard DNS-Eintrag müssen keine DNS-Einträge für neue Umgebungen angelegt werden, was das dynamische Bereitstellen von neuen Umgebungen vereinfacht. Jede Umgebung ist dabei unter ihrer eigenen Subdomain erreichbar, z.B. feature-x.review.example.com, release-X-Y.review.example.com.

Als Git-Repository nutze ich Gitlab, da dies eine CI/CD Pipeline und Kubernetes-Support bereits integriert hat und man nicht auf weitere Dienste zurückgreifen muss. Der Kubernetes-Support ermöglicht es, dass man innerhalb der CI/CD Pipeline mit dem Cluster kommunizieren kann, das verfügbare Review-Umgebungen innerhalb von Kubernetes angezeigt und verwaltet werden können. So bekommt man z.B. in einem Merge-Request die Domain der Review-Umgebungen angezeigt und kann nach dem Merge automatisch die Review-Umgebung wieder löschen. Auch ein Rollback auf eine ältere Version kann innerhalb von GitLab durchgeführt werden.

Deployment Strategie

Bevor man alles einrichtet, sollte man sich eine Deployment Strategie überlegen, die zum eigenen Bedarf passt. In meinem Beispiel werde ich die Strategie recht einfach halten und lediglich mit einem Main-Branch und mehreren Bugfix/Feature-Branches in meinem Repository arbeiten. Der Main-Branch wird für die Stagging/Abnahme Umgebung und die Prod Umgebung genutzt. Dabei wird jede Änderung auf dem Main-Branch automatisch auf die Stagging Umgebung ausgerollt, wo ich eine Qualitätskontrolle durchführen kann. Nach der Qualitätskontrolle kann ich über die CI/CD Pipeline dieselbe Version auf Produktion ausrollen. Erstelle ich für einen Bugfix/Feature-Branch einen Merge-Request, wird automatisch für diesen Branch eine Review-Umgebung erstellt, auf welcher ich die Änderungen begutachten kann, bevor ich den Branch in den Main-Branch merge.

Depoyment

Diese einfache Strategie erlaubt es mir neue Feature oder einen Bugfix isoliert zu prüfen und mehrere neue Feature zusammen auf der Stagging-Umgebung zu testen, bevor ich sie auf Produktion ausrolle.

GitLab einrichten

Um das Kubernetes Cluster mit GitLab zu verbinden, muss zunächst auf dem Router ein Port-Forwarding für den Kubernets-API Port 6443 eingerichtet werden, bei dem die Ziel-IP, die des Server Nodes ist. Ohne diese Port-Freigabe ist es GitLab nicht möglich Befehle an das Cluster zu senden. Sollte einem diese Freigabe sorgen machen, sollte man über den Einsatz von Tools wie Argo CD nachdenken. Argo CD arbeitet nach dem Pull-Prinzip, für welches keine Firewall-Freigabe nötigt ist.

Ist das Port-Forwarding eingerichtet, folgt man dieser Anleitung um Verwaltungsrechte für GitLab einzurichten.

Im nächsten Schritt erstellen wir mit einer CI/CD Pipeline unser Container Image und legen das Image in der Container-Registry von GitLab ab. Damit Kubernetes in der Lage ist das Image aus einem privaten Container-Registry zu laden, müssen wir Zugangsdaten für das Container-Registry als Secret im Cluster hinterlegen. Hierfür legt man in den Repository Einstellungen im Bereich "Repository" einen Deploy-Token an, welcher min. die Berechtigung read_registry benötigt:

Deploy-Token

Definiert man keinen Benutzernamen für den Deploy-Token, muss man seinen eigenen GitLab-Usernamen nutzen. Die Zugangsdaten legt man in einem Secret vom Typ dockerconfigjson ab:

gitlab-secret.yml
apiVersion: v1
kind: Secret
metadata:
  name: gitlab-registry
  namespace: exampleapp-123456
type: kubernetes.io/dockerconfigjson
data:
  .dockerconfigjson: abdasdasd...

Kubernets erstellt für das Deployment einen eigenen Namespace in welchem auf der Secret abgelegt werden muss. Der Namepspace besteht aus dem Repository-Namen und der Repository-ID, welche man in den Einstellungen findet. Die Zugangsdaten legt man als Base64 codierten String unter dem Namen .dockerconfigjson ab. Wie man diesen String erstellt, erfährt man hier. Ist der Secret dem Cluster hinzugefügt worden, ist das Setup des Clusters abgeschlossen.

Container bauen

Für unser Beispiel nutzen wir eine statische Webseite, welche mit dem Nuxt.js Framework erstellt wird. Nuxt.js basiert auf dem Single Page Framework VueJS und erlaubt es die Seiten vorab zu generieren. Diese vorab generierten Seiten können durch einen Browser schneller dargestellt werden, was sich positiv auf die User Experience und das Ranking bei den Suchmaschinen auswirkt. Um diese statischen Seiten zu generieren, reicht ein Befehl:

Generate

Am Ende des Prozesses wird der generierte Inhalt im Verzeichnis dist/ abgelegt.

Im nächsten Schritt wird das Container-Image mit dem Webserver erstellt. Hierfür nutzt am besten ein Base-Image von einem der populären Webservern wie nginx, apache oder caddy. Ich habe mich für den Webserver Caddy entschieden und erstelle im ersten Schritt die Konfiguration für den Webserver:

Caddyfile
:80 {
  header Cache-Control max-age=21600
  header Strict-Transport-Security max-age=31536000;
  header X-Content-Type-Options nosniff
  header X-Frame-Options DENY
  header Referrer-Policy no-referrer-when-downgrade
  root * /usr/share/caddy
  encode zstd gzip
  file_server

  handle_errors {
    @404 {
        expression {http.error.status_code} == 404
    }
    rewrite @404 /404.html
    file_server
  }
}

Der letzte Schritt ist das Erstellen der dockerfile Datei in dem wir die Build-Steps für das Image definieren. Neben dem Definieren des Base-Images und des Ports, brauchen wir nur die Caddy Konfigurationsdatei und das Dist-Verzeichnis in das Image kopieren. Zu beachten ist, dass wir beim Base-Image eine Variable für die Zielplattform nutzen. Dies nutzen wir, um in der Pipeline ein Container-Image für die Arm64 Prozessor-Architektur zu bauen (hierzu später mehr):

FROM --platform=$TARGETPLATFORM caddy

COPY Caddyfile /etc/caddy/Caddyfile
COPY dist /usr/share/caddy

EXPOSE 80

Deployment definieren

Für das Deployment schreiben wir ein eigenes Helm Chart, wodurch wir in der Lage sind innerhalb der CI/CD Pipeline das Image-Tag und die Domain zu überschreiben. Für das Helm Chart sind mehrere Dateien notwendig, welche wir in das helm-chart Verzeichnis ablegen. Jedes Chart benötigt eine Chart.yaml Datei, welche einige Informationen zum Chart beinhaltet:

Chart.yaml
apiVersion: v2
name: homepage
description: A Helm chart for Kubernetes

type: application

version: 1.0.0

appVersion: "1.0.0"

Mehr zur Chart.yaml erfährst du hier.

Als Nächstes legen wir im helm-chart Verzeichnis das Unterverzeichnis templates an, in welchem wir folgende Template-Dateien ablegen:

Certificate

Für den sicheren Betrieb der Umgebung erstellen wir TLS-Zertifikat via Let's Encrypt

cert.yml
{{- if eq .Values.tls true }}
apiVersion: cert-manager.io/v1alpha2
kind: Certificate
metadata:
  name: secure-{{ include "app.fullname" . }}-cert
  namespace: {{ .Release.Namespace }}
spec:
  commonName: {{ .Values.domain}}
  secretName: secure-{{ include "app.fullname" . }}-cert
  dnsNames:
    - {{ .Values.domain}}
  issuerRef:
    name: letsencrypt-prod
    kind: ClusterIssuer
{{ end }}

Deployment

Im Template für das Deployment legen wir einige statische und variable Eintellungen fest, z.B. ob das Deployment eine fixe Anzahl an Pods verwenden soll oder je nach Auslastung automatisch neue Pods hochfährt, um die Last zu verteilen.

deployment.yaml
kind: Deployment
apiVersion: apps/v1
metadata:
  name: {{ include "app.fullname" . }}
  namespace: {{ .Release.Namespace }}
  labels:
    {{- include "app.labels" . | nindent 4 }}
  annotations:
    {{ if .Values.gitlab.app }}app.gitlab.com/app: {{ .Values.gitlab.app | quote }}{{ end }}
    {{ if .Values.gitlab.env }}app.gitlab.com/env: {{ .Values.gitlab.env | quote }}{{ end }}

spec:
  {{- if not .Values.autoscaling.enabled }}
  replicas: {{ .Values.replicaCount }}
  {{- end }}
  strategy: 
    type: Recreate
    rollingUpdate: null
  selector:
    matchLabels:
      {{- include "app.selectorLabels" . | nindent 6 }}
  template:
    metadata:
      labels:
        {{- include "app.selectorLabels" . | nindent 8 }}
      annotations:
        {{ if .Values.gitlab.app }}app.gitlab.com/app: {{ .Values.gitlab.app | quote }}{{ end }}
        {{ if .Values.gitlab.env }}app.gitlab.com/env: {{ .Values.gitlab.env | quote }}{{ end }}
    spec:
      {{- with .Values.imagePullSecrets }}
      imagePullSecrets:
        {{- toYaml . | nindent 8 }}
      {{- end }}
      containers:
        - name: {{ .Chart.Name }}
          image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
          imagePullPolicy: {{ .Values.image.pullPolicy }}
          resources:
{{ toYaml .Values.resources | indent 12 }}
          ports:
            - name: http
              containerPort: 80
              protocol: TCP
          readinessProbe:
            httpGet:
              path: /
              port: http
          livenessProbe:
            httpGet:
              path: /
              port: http
            initialDelaySeconds: 5
            periodSeconds: 30
            failureThreshold: 3

HorizontalPodAutoscaler

Für das gerade erwähnte Autoscaling muss ein HorizontalPodAutoscaler angelegt werden. Dieser wird jedoch nur angelegt, wenn die Autoscaling-Option für das Deployment auf aktiv gesetzt wurde.

hpa.yaml
{{- if .Values.autoscaling.enabled }}
apiVersion: autoscaling/v2beta1
kind: HorizontalPodAutoscaler
metadata:
  name: {{ include "app.fullname" . }}
  namespace: {{ .Release.Namespace }}
  labels:
    {{- include "app.labels" . | nindent 4 }}
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: {{ include "app.fullname" . }}
  minReplicas: {{ .Values.autoscaling.minReplicas }}
  maxReplicas: {{ .Values.autoscaling.maxReplicas }}
  metrics:
    {{- if .Values.autoscaling.targetCPUUtilizationPercentage }}
    - type: Resource
      resource:
        name: cpu
        targetAverageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }}
    {{- end }}
    {{- if .Values.autoscaling.targetMemoryUtilizationPercentage }}
    - type: Resource
      resource:
        name: memory
        targetAverageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }}
    {{- end }}
{{- end }}

Service und IngressRoute

Damit unser Deployment außerhalb des Clusters erreichbar ist, müssen wir einen Service und eine IngressRoute definieren. Neben einer IngressRoute für den HTTPS-Endpoint, erstellen wir auch einen für den HTTP-Endpoint, welcher jedoch über eine Middleware auf den HTTPS-Endpoint umgeleitet wird.

service.yaml
apiVersion: v1
kind: Service
metadata:
  name: {{ include "app.fullname" . }}
  namespace: {{ .Release.Namespace }}
  labels:
    {{- include "app.labels" . | nindent 4 }}

spec:
  ports:
    - protocol: TCP
      name: http
      port: {{ .Values.service.port }}
      targetPort: http
  selector:
    {{- include "app.selectorLabels" . | nindent 4 }}
ingressroute.yaml
apiVersion: traefik.containo.us/v1alpha1
kind: IngressRoute
metadata:
  name: {{ include "app.fullname" . }}-http
  namespace: {{ .Release.Namespace }}
  labels:
    {{- include "app.labels" . | nindent 4 }}

spec:
  entryPoints:
    - web
  routes:
    - match: Host(`{{ .Values.domain}}`)
      kind: Rule
      services:
        - name: {{ include "app.fullname" . }}
          port: {{ .Values.service.port }}
      middlewares:
      - name: https-only
        namespace: kube-system
---
{{- if eq .Values.tls true }}
apiVersion: traefik.containo.us/v1alpha1
kind: IngressRoute
metadata:
  name: {{ include "app.fullname" . }}-https
  namespace: {{ .Release.Namespace }}
  labels:
    {{- include "app.labels" . | nindent 4 }}

spec:
  entryPoints:
    - websecure
  routes:
    - match: Host(`{{ .Values.domain}}`)
      kind: Rule
      services:
        - name: {{ include "app.fullname" . }}
          port: {{ .Values.service.port }}
      middlewares:
      - name: public-secured
        namespace: kube-system
  tls:
    secretName: secure-{{ include "app.fullname" . }}-cert
{{ end }}

Helper

Bei Template-Dateien handelt es sich um YAML-Dateien, in denen über Variablen und Bedinungen der Inhalt dynamisch angepasst werden kann. Einige dieser Variablen werden über einen Helper (_helpers.tpl) generiert, wie z.B. die selectorLabels oder der App-Name. Die anderen Variablen werden in einer Values-YAML definiert.

_helpers.tpl
{{/*
Expand the name of the chart.
*/}}
{{- define "app.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}

{{/*
Create a default fully qualified app name.
We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec).
If release name contains chart name it will be used as a full name.
*/}}
{{- define "app.fullname" -}}
{{- if .Values.fullnameOverride }}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- $name := default .Chart.Name .Values.nameOverride }}
{{- if contains $name .Release.Name }}
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- end }}
{{- end }}

{{/*
Create chart name and version as used by the chart label.
*/}}
{{- define "app.chart" -}}
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
{{- end }}

{{/*
Common labels
*/}}
{{- define "app.labels" -}}
helm.sh/chart: {{ include "app.chart" . }}
{{ include "app.selectorLabels" . }}
{{- if .Chart.AppVersion }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
{{- end }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}

{{/*
Selector labels
*/}}
{{- define "app.selectorLabels" -}}
app.kubernetes.io/name: {{ include "app.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}

Values-YAML

Für unser Setup legen wir für die Prod, Stage und Review Umgebung(en) jeweils eine eigene Values-YAML Datei an:

values.prod.yaml
autoscaling:
  enabled: true
  minReplicas: 1
  maxReplicas: 3
  targetCPUUtilizationPercentage: 80

resources:
  limits:
    cpu: 300m
    memory: 256Mi
  requests:
    cpu: 100m
    memory: 128Mi

image:
  repository: registry.gitlab.com/USERNAME/REPO
  pullPolicy: Always
  tag: "latest"

domain: example.com

imagePullSecrets:
  - name: gitlab-registry

tls: true

service:
  port: 80

gitlab:
  env:
  app:
values.stage.yaml
replicaCount: 1

autoscaling:
  enabled: false

resources:
  limits:
    cpu: 100m
    memory: 128Mi
  requests:
    cpu: 100m
    memory: 64Mi

image:
  repository: registry.gitlab.com/USERNAME/REPO
  pullPolicy: Always
  tag: ""

domain: stage.example.com

imagePullSecrets:
  - name: gitlab-registry

tls: true

service:
  port: 80

gitlab:
  env:
  app:
values.yaml
replicaCount: 1

autoscaling:
  enabled: false

resources:
  limits:
    cpu: 100m
    memory: 128Mi
  requests:
    cpu: 100m
    memory: 64Mi

image:
  repository: registry.gitlab.com/USERNAME/REPO
  pullPolicy: Always
  tag: ""

imagePullSecrets:
  - name: gitlab-registry

domain:

tls: true

service:
  port: 80

gitlab:
  env:
  app:

Der Großteil der drei Dateien ist identisch, sie unterscheiden sich lediglich in den replicaCount, autoscaling, resources und domain Einstellungen.

Pipeline erstellen

GitLab Pipelines werden in der .gitlab-ci.yml definiert, welche im Root-Verzeichnis des Git-Repository liegen muss. Als erstes definieren wir einige Variablen, unter anderem eine für unsere Ziel-Platform Arm64:

variables:
  DOCKER_HOST: tcp://docker:2375/
  DOCKER_DRIVER: overlay2
  DOCKER_TLS_CERTDIR: ""
  PLATFORM: linux/arm64/v8

GitLab bietet einige vordefinierte Templates, wie z.B. für eine Code-Quality Analyse oder Sicherheitsscans, welche wir in unsere Pipeline integrieren können. Ein weiteres nützliches Template ist das Template Workflows/MergeRequest-Pipelines.gitlab-ci.yml, welches dafür sorgt, dass die Pipeline nur getriggert wird, wenn für ein Branch ein Merge Request existiert oder die Änderung auf dem main Branch durchgeführt wurde:

include:
  - template: Workflows/MergeRequest-Pipelines.gitlab-ci.yml
  - template: Code-Quality.gitlab-ci.yml
  - template: Dependency-Scanning.gitlab-ci.yml
  - template: Security/SAST.gitlab-ci.yml
  - template: Security/Secret-Detection.gitlab-ci.yml

Für die Pipeline definieren wir 7 Stages, welche der Reihe nach durchlaufen werden:

stages:
  - .pre
  - test
  - generate
  - build
  - release
  - deploy
  - verify

Um das Container-Image mit der passenden Versionsnummer zu taggen, extrahieren wir die Versionsnummer aus der package.json Datei und speichern diese in der Datei build.env, welche nachfolgenden Jobs zur Verfügung steht:

version:
  stage: .pre
  image: node:lts
  script:
    - export VERSION=$(node -p "require('./package.json').version")
    - echo "BUILD_VERSION=$VERSION" >> build.env
  artifacts:
    reports:
      dotenv: build.env

Als nächstes definieren wir die Test Stage, in welcher wir einige Linter-Checks und Tests durchführen:

lint:eslint:
  stage: test
  image: node:lts
  script:
    - npm ci
    - npm run lint

lint:helm:
  image: dtzar/helm-kubectl
  stage: test
  script:
    - helm lint ./helm-data

test:nuxt-components:
  image: node:lts
  stage: test
  script:
    - npm ci
    - npm run test

Nach erfolgreichen Tests generieren wir die statischen Seiten:

generate:
  stage: generate
  image: node:lts
  script:
    - npm ci
    - NODE_ENV=production
    - npm run generate
  artifacts:
    paths:
      - dist/
    expire_in: 2 days

Nachdem die statischen Seiten generiert wurden, erstellen wir das Container-Image. Hierfür haben wir zwei verschiedene Jobs, welche über unterschiedliche Regeln verfügen. Der erste Job wird nur auf dem Main Branch ausgeführt und generiert das Stage/Prod Image und der zweite Job wird nur ausgeführt wenn ein Merge Request exisiert und erstellt das Image für die Review Umgebungen. Das Stage/Prod Image erhält die Versionsnummer als Tag, sowie das Tag latest. Für die Review Umgebungen nutzen wir den Commit-Ref-Slug als Image Tag. Damit das Image auf der Arm64 Plattform läuft, nutzen wir anstatt den normalen build Command von Docker den experimentellen buildx Command, welcher uns die Möglichkeit bietet Images für mehrere Plattformen zu bauen. Nachdem bauen des Images, pushen wir dieses in die interne Container-Registry von GitLab:

build:stage:
  stage: build
  image: jonoh/docker-buildx-qemu
  services:
    - docker:dind
  before_script:
    - docker login -u gitlab-ci-token -p $CI_BUILD_TOKEN registry.gitlab.com
    - docker buildx create --driver docker-container --use
  script:
    - update-binfmts --enable
    - docker buildx build
      --push
      -t $CI_REGISTRY_IMAGE:v$BUILD_VERSION
      --platform $PLATFORM .
    - docker pull $CI_REGISTRY_IMAGE:v$BUILD_VERSION
    - docker tag $CI_REGISTRY_IMAGE:v$BUILD_VERSION $CI_REGISTRY_IMAGE:latest
    - docker push $CI_REGISTRY_IMAGE:latest
  needs: 
    - generate
  rules:
    - if: '$CI_COMMIT_BRANCH == "main"'

build:review-app:
  stage: build
  image: jonoh/docker-buildx-qemu
  services:
    - docker:dind
  before_script:
    - docker login -u gitlab-ci-token -p $CI_BUILD_TOKEN registry.gitlab.com
    - docker buildx create --driver docker-container --use
  script:
    - update-binfmts --enable
    - docker buildx build
      --push
      -t "$CI_REGISTRY_IMAGE:review-$CI_COMMIT_REF_SLUG"
      --platform $PLATFORM .
  needs: 
    - generate
  rules:
    - if: $CI_MERGE_REQUEST_ID

Das erstellte Image deployen wir nun auf Kubernetes, wofür wir das zuvor erstellte Helm Chart nutzen. Wir nutzen als Basis die erstellten Value-YAML Dateien, überschreiben aber einige Werte wie den Image-Tag und die Domain. Über die Environment Einstellungen teilen wir GitLab den Namen und die URL der Umgebung mit. Bei dem Deployment für die Review Umgebungen definieren wir zudem, welcher Job die Umgebung beendet und dass die Umgebung automatisch nach 3 Wochen beendet werden soll. Da das Prod Deployment erst nach einer Qualitätskontrolle auf der Stage Umgebung deployed werden sollen, definieren wir für das Prod Deployment eine Regel, welche den Job in einen manuellen Job verwandelt:

deploy:review-app:
  image: lwolf/helm-kubectl-docker
  stage: deploy
  environment:
    name: review/$CI_COMMIT_REF_NAME
    on_stop: stop_review_app
    auto_stop_in: 3 week
    url: https://$CI_COMMIT_REF_SLUG.review.example.com
  script:
    - echo "$KUBE_NAMESPACE"
    - helm upgrade
      --install
      --namespace $KUBE_NAMESPACE
      --create-namespace
      --atomic
      --timeout 3m
      --set image.tag=review-$CI_COMMIT_REF_SLUG
      --set domain=$CI_COMMIT_REF_SLUG.review.example.com
      --set gitlab.app=$CI_PROJECT_PATH_SLUG
      --set gitlab.env=$CI_ENVIRONMENT_SLUG
      homepage-$CI_COMMIT_REF_SLUG
      ./helm-data
    - helm test --namespace $KUBE_NAMESPACE homepage-$CI_COMMIT_REF_SLUG
  needs: 
    - build:review-app
  rules:
    - if: $CI_MERGE_REQUEST_ID

deploy:stage:
  image: lwolf/helm-kubectl-docker
  stage: deploy
  environment:
    name: stage
    url: https://stage.example.com
  script:
    - helm upgrade
      --install
      --namespace $KUBE_NAMESPACE
      --create-namespace
      --atomic
      --timeout 60s
      -f ./helm-data/values.stage.yaml
      --set image.tag=v$BUILD_VERSION
      --set gitlab.app=$CI_PROJECT_PATH_SLUG
      --set gitlab.env=$CI_ENVIRONMENT_SLUG
      homepage-stage
      ./helm-data
    - helm test --namespace $KUBE_NAMESPACE homepage-stage
  needs: 
    - build:stage
  rules:
    - if: '$CI_COMMIT_BRANCH == "main"'

deploy:prod:
  image: lwolf/helm-kubectl-docker
  stage: deploy
  environment:
    name: production
    url: https://example.com
  script:
    - helm upgrade
      --install
      --namespace $KUBE_NAMESPACE
      --create-namespace
      --atomic
      --timeout 60s
      -f ./helm-data/values.prod.yaml
      --set image.tag=v$BUILD_VERSION
      --set gitlab.app=$CI_PROJECT_PATH_SLUG
      --set gitlab.env=$CI_ENVIRONMENT_SLUG
      homepage-prod
      ./helm-data
    - helm test --namespace $KUBE_NAMESPACE homepage-prod
  needs: 
    - build:stage
  rules:
    - if: '$CI_COMMIT_BRANCH == "main"'
      when: manual
      allow_failure: true

Nach dem Deployment wird dieses durch einige Cypress E2E-Frontend Tests verifiziert. Für die Tests nutzen wir die Browser Chrome und Firefox:

cypress:chrome:review-app: 
  stage: verify
  image: cypress/browsers:node12.18.3-chrome87-ff82
  script: 
    - export CYPRESS_BASE_URL="https://$CI_COMMIT_REF_SLUG.review.example.com"
    - npx cypress run -b chrome --headless
  artifacts:
    name: "$CI_JOB_NAME-$CI_COMMIT_REF_NAME"
    when: on_failure
    expire_in: 3 days
    paths:
      - $PWD/cypress/screenshots
  rules:
    - if: $CI_MERGE_REQUEST_ID

cypress:firefox:review-app: 
  stage: verify
  image: cypress/browsers:node12.18.3-chrome87-ff82
  script: 
    - export CYPRESS_BASE_URL="https://$CI_COMMIT_REF_SLUG.review.example.com"
    - npx cypress run -b firefox --headless
  artifacts:
    name: "$CI_JOB_NAME-$CI_COMMIT_REF_NAME"
    when: on_failure
    expire_in: 3 days
    paths:
      - $PWD/cypress/screenshots
  rules:
    - if: $CI_MERGE_REQUEST_ID

cypress:chrome:stage: 
  stage: verify
  image: cypress/browsers:node12.18.3-chrome87-ff82
  script: 
    - export CYPRESS_BASE_URL="https://stage.example.com"
    - npx cypress run -b chrome --headless
  artifacts:
    name: "$CI_JOB_NAME-$CI_COMMIT_REF_NAME"
    when: on_failure
    expire_in: 3 days
    paths:
      - $PWD/cypress/screenshots
  rules:
    - if: '$CI_COMMIT_BRANCH == "main"'

cypress:firefox:stage: 
  stage: verify
  image: cypress/browsers:node12.18.3-chrome87-ff82
  script: 
    - export CYPRESS_BASE_URL="https://stage.example.com"
    - npx cypress run -b firefox --headless
  artifacts:
    name: "$CI_JOB_NAME-$CI_COMMIT_REF_NAME"
    when: on_failure
    expire_in: 3 days
    paths:
      - $PWD/cypress/screenshots
  rules:
    - if: '$CI_COMMIT_BRANCH == "main"'

cypress:chrome:prod: 
  stage: verify
  image: cypress/browsers:node12.18.3-chrome87-ff82
  script: 
    - export CYPRESS_BASE_URL="https://example.com"
    - npx cypress run -b chrome --headless
  artifacts:
    name: "$CI_JOB_NAME-$CI_COMMIT_REF_NAME"
    when: on_failure
    expire_in: 3 days
    paths:
      - $PWD/cypress/screenshots
  rules:
    - if: '$CI_COMMIT_BRANCH == "main"'
  needs:
    - deploy:prod

cypress:firefox:prod: 
  stage: verify
  image: cypress/browsers:node12.18.3-chrome87-ff82
  script: 
    - export CYPRESS_BASE_URL="https://example.com"
    - npx cypress run -b firefox --headless
  artifacts:
    name: "$CI_JOB_NAME-$CI_COMMIT_REF_NAME"
    when: on_failure
    expire_in: 3 days
    paths:
      - $PWD/cypress/screenshots
  rules:
    - if: '$CI_COMMIT_BRANCH == "main"'
  needs:
    - deploy:prod

Der letzte Job in der Pipeline dient dem Stoppen der Review Umgebung, auf welchem wir bereits im Deployment-Job der Review Umgebung referenziert haben:

stop_review_app:
  image: lwolf/helm-kubectl-docker
  allow_failure: true
  variables:
    GIT_STRATEGY: none
  stage: verify
  environment:
    name: review/$CI_COMMIT_REF_NAME
    action: stop
    url: $CI_COMMIT_REF_SLUG.review.example.com
  script:
    - helm delete homepage-$CI_COMMIT_REF_SLUG 
  rules:
    - if: $CI_MERGE_REQUEST_ID
      when: manual

Geschafft 🎉 Ab sofort wird jeder Release-, Feature-, Bugfix-Branch auf seine eigene Review Umgebung deployed, sobald ein Merge Request für diesen Branch erstellt wurde. Die Sub-Subdomain für diese Umgebung bildet sich aus dem Slug-Text des Branch-Namen und der Subdomain review.example.com zusammen, z.B. feature-contact-form.review.example.com.

MR Pipeline Environments

Schlusswort

Kubernetes nimmt einen immer größeren Stellenwert beim Betreiben von Webanwendungen ein und auch im Bereich der Software-Entwicklung bietet Kubernetes ganz neue Möglichkeiten. Das Bereitstellen von Review Umgebungen on Demand ist dabei nur eine dieser Möglichkeiten, welche einige Vorteile in der Webentwicklung mit sich bringt. Gerade für größere Projekte/Teams können Review Umgebungen einige Vorteile haben, wie das isolierte Testen von neuen Releases oder neuen Features.

Natürlich ist nicht immer alles so einfach, wie es auf den ersten Blick scheint. Bei unserem Beispiel handelte es sich um ein sehr einfaches Deployment und in der Realität sehen solche Deployments sehr viel komplexer aus und verbrauchen sehr viel mehr Ressourcen als eine einfache statische Seite. Das Erzeugen von neuen Umgebungen on Demand kann schnell dazu führen, dass die Last/Kosten in die Höhe schießen. Es empfiehlt sich die nutzbaren Ressourcen für ein Deployment oder einen Namespace zu limitieren, um eine bessere Kontrolle zu haben. Ob die Vorteile solch einer Review Umgebung sich für einen lohnen, muss jeder für sich selbst entscheiden, dieser Post sollte aber einen guten ersten Einblick geboten habe, wie solch ein Setup aussehen kann und welche Möglichkeiten so ein Setup bietet.