Review Umgebungen
back

Review Umgebungen

03.06.2021

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:

apiVersion: v1kind: Secretmetadata:  name: gitlab-registry  namespace: exampleapp-123456type: kubernetes.io/dockerconfigjsondata:  .dockerconfigjson: abdasdasd...

GitLab 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:

: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:

apiVersion: v2name: homepagedescription: A Helm chart for Kubernetestype: applicationversion: 1.0.0appVersion: "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

{{- if eq .Values.tls true }}apiVersion: cert-manager.io/v1alpha2kind: Certificatemetadata:  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.

kind: DeploymentapiVersion: apps/v1metadata:  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.

{{- if .Values.autoscaling.enabled }}apiVersion: autoscaling/v2beta1kind: HorizontalPodAutoscalermetadata:  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.

apiVersion: v1kind: Servicemetadata:  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 }}
apiVersion: traefik.containo.us/v1alpha1kind: IngressRoutemetadata:  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/v1alpha1kind: IngressRoutemetadata:  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 Bedingungen 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.

{{/*
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:

autoscaling:  enabled: true  minReplicas: 1  maxReplicas: 3  targetCPUUtilizationPercentage: 80resources:  limits:    cpu: 300m    memory: 256Mi  requests:    cpu: 100m    memory: 128Miimage:  repository: registry.gitlab.com/USERNAME/REPO  pullPolicy: Always  tag: "latest"domain: example.comimagePullSecrets:  - name: gitlab-registrytls: trueservice:  port: 80gitlab:  env:  app:
replicaCount: 1autoscaling:  enabled: falseresources:  limits:    cpu: 100m    memory: 128Mi  requests:    cpu: 100m    memory: 64Miimage:  repository: registry.gitlab.com/USERNAME/REPO  pullPolicy: Always  tag: ""domain: stage.example.comimagePullSecrets:  - name: gitlab-registrytls: trueservice:  port: 80gitlab:  env:  app:
replicaCount: 1autoscaling:  enabled: falseresources:  limits:    cpu: 100m    memory: 128Mi  requests:    cpu: 100m    memory: 64Miimage:  repository: registry.gitlab.com/USERNAME/REPO  pullPolicy: Always  tag: ""imagePullSecrets:  - name: gitlab-registrydomain:tls: trueservice:  port: 80gitlab:  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 lintlint:helm:  image: dtzar/helm-kubectl  stage: test  script:    - helm lint ./helm-datatest: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_IDdeploy: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_IDcypress: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_IDcypress: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:prodcypress: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.

MRPipelineEnvironments

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.