Raspberry Pi Kubernetes Cluster (Teil 2)
home

Raspberry Pi Kubernetes Cluster (Teil 2)



02.05.2021

Im ersten Teil dieser Serie haben wir ein k3s Kubernetes Cluster installiert, auf welchem jedoch noch keine Anwendung läuft. In diesem Teil beschäftigen wir uns damit, wie man Anwendungen dem Cluster hinzufügt und welche Dienste benötigt werden, um Anwendung öffentlich per HTTPs oder privat im eigenen Netzwerk zu betreiben. Hierbei werden Themen wie Load-Balancing, TLS Zertifikate und persistenter Speicher eine Rolle spielen. Das Ziel ist es, einen öffentlichen Wordpress-Blog, den DNS Dienst pi.hole und die Smart Home Anwendung Home Assistant im Cluster zu betreiben. Wie im ersten Teil bereits erwähnt, werde ich nicht auf die Theorie zu Kubernetes eingehen und verweise an dieser Stelle auf die offizielle Dokumentation von Kubernetes: kubernetes.io

Anwendungen dem Cluster hinzufügen

Wie im ersten Teil beschrieben, kümmert sich ein Server Node um die Verwaltung des Clusters, weshalb wir auch mit einem Server Node kommunizieren müssen, um etwas im Cluster zu machen. Für die Kommunikation stellt ein Server Node eine API bereit mit welcher wir, z.B. über das CLI-Tool kubectl, kommunizieren können.

Kubectl

Kubectl haben wir bereits im ersten Teil installiert und benutzt, um uns die Nodes im Cluster anzeigen zu lassen. Wir nutzen kubectl auch um neue Kubernetes Objekte zu erstellen, wie in diesem Beispiel, bei dem ein Deployment und ein Service erstellt wird:

kubectl create deployment hello-world --image=containous/whoami
kubectl expose deployment/hello-world --port=8080

Es wäre jedoch sehr aufwendig das gewünschte Setup mit einzelnen Befehlen zu erstellen, weshalb man das gewünschte Setup, wie z.B. bei Docker-Compose, in einer YAML-Datei beschreibt und kubectl nutzt, um die Konfiguration zu übernehmen. Die nachfolgende YAML-Config erstellt, wie das Beispiel oben, ein Deployment und einen Service (Hinweis: Jedes Kubernetes Objekt muss in einer separaten Datei beschrieben werden, das Einfügen von --- erlaubt das zusammenfügen mehrerer Dateien in einer Datei):

kind: Deployment
apiVersion: apps/v1
metadata:
  name: hello-world
  labels:
    app: hello-world
spec:
  replicas: 1
  selector:
    matchLabels:
      app: hello-world
  template:
    metadata:
      labels:
        app: hello-world
    spec:
      containers:
        - name: hello-world
          image: datenfahrt/aarch64-hello-world
          ports:
            - name: http
              containerPort: 8080
---
apiVersion: v1
kind: Service
metadata:
  name: hello-world
  labels:
    app: hello-world
spec:
  ports:
    - protocol: TCP
      port: 8080
      targetPort: web
  selector:
    app: hello-world

Mit dem nachfolgenden Befehl übernehmen wir die YAML-Config:

kubectl apply -f hello-world.yaml

Möchte man für eine bessere Übersicht die Kubernetes Objekte in separaten YAML-Dateien spezifizieren, bietet es sich an diese in einem gemeinsamen Ordner abzulegen und die Dateien in einer logischen Reihenfolge durchzunummerieren. Eine YAML die einen Namespace spezifiziert, sollte mit 1 anfangen, da der Namespace vor allem anderen erstellt werden sollte. Dasselbe gilt für Secrets, Configmaps oder PVC auf die z.B. ein Deployment zugreift. Hat man alles logisch durchnummeriert, kann man den vollständigen Ordner an kubectl übergeben

kubectl apply -f hello-world/

Helm Chart

Möchte man eine neue Anwendung dem Cluster hinzufügen, bietet es sich an vorab zu recherchieren, ob es bereits YAML-Beispiele im Internet gibt, welches man wiederverwenden kann. Verwendet man YAML Dateien von anderen Personen wieder, muss man jede Einstellung prüfen, ob diese für einen auch gilt oder ob man sie ggf. Ändern muss.

An dieser Stelle kommt Helm ins Spiel. Helm ist ein Package-Manager für Kubernetes, welcher einem viel Arbeit bei der Installation von Anwendungen abnimmt. Mit Helm ist es möglich, dass andere Personen sogenannte Helm Charts bereitstellen, die von anderen verwendet werden können, um eine Anwendung zu installieren. Technisch besteht ein Helm Chart aus mehreren YAML Dateien, die als Template fungieren in welchen Variablen oder Bedingungen definiert sind. In einer YAML-Datei (values.yaml) legt man die gewünschten Einstellungen fest, die auf das Template angewendet werden soll. Installiert man nun einen Helm Chart nutzt Helm die values.yaml und die Templates und generiert daraus die eigentlichen YAML-Konfigurationen die anschließend an das Kubernetes Cluster gesendet werden. Bei der Installation kann man eine eigene values.yaml Datei mitgeben, um so das Setup anzupassen. In dieser values.yaml müssen auch nur die gewünschten Einstellungen definiert werden, da Helm die eigene values.yaml mit der Standard values.yaml des Helm Charts zusammenführt.

Nach der Installation, kann man über artifacthub.io nach einem Helm Chart suchen. Hat man das gewünschte Helm Chart gefunden, muss man das Repo des Helm Chart Anbieters hinzufügen:

helm repo add nicholaswilde https://nicholaswilde.github.io/helm-charts/
helm repo update

Hat man das Repo hinzugefügt, kann man die Standard values.yaml lokal speichern:

helm show values nicholaswilde/hedge > values.yaml

In der values.yaml findet man nun alle Einstellungen die man vornehmen kann:

image:
  repository: ghcr.io/linuxserver/hedgedoc
  pullPolicy: IfNotPresent
  tag: "1.7.2-ls11"
secret: {}
env:
  TZ: "America/Los_Angeles"
service:
  port:
    port: 3000
ingress:
  enabled: true
  hosts:
    - host: "hedgedoc.192.168.1.203.nip.io"
      paths:
        - path: /
          pathType: Prefix
  tls: []
persistence:
  config:
    enabled: false
    emptyDir: false
    mountPath: /config
    accessMode: ReadWriteOnce
    size: 1Gi
    skipuninstall: false
mariadb:
  enabled: false
  secret: {}
  env: {}
  persistence:
    config:
      enabled: false
      emptyDir: false
      mountPath: /config
      accessMode: ReadWriteOnce
      size: 1Gi
      skipuninstall: false

Möchte man nur bestimmte Einstellung ändern und den Rest beim Standard belassen, kann man nur diese Einstellungen in der values.yaml belassen. Möchte man z.B. nur die Timezone anpassen und das Einrichten eines Ingress unterbinden, reicht eine values.yaml mit folgendem Inhalt:

env:
  TZ: "Europe/Berlin"
ingress:
  enabled: false

Hat man die gewünschten Anpassungen vorgenommen, kann man das Helm Chart installieren:

helm install hedgedoc nicholaswilde/hedgedoc --values values.ml

Alternativ zum install Befehl kann man auch den upgrade Befehl zusammen mit dem --install Parameter nutzen:

helm upgrade --install hedgedoc --values values.yaml nicholaswilde/hedgedoc

Der Vorteil beim upgrade Befehl ist, dass man die value.yaml nachträglich anpassen kann und denselben Befehl nutzt, um die Änderung an einem bereits installierten Helm Chart vorzunehmen.

MetalLB als Load-Balancer

Betreibt man Kubernetes in der Cloud oder einem Rechenzentrum, verfügt man in der Regel über einen Software oder Hardware Load-Balancer, welcher die Last auf alle Nodes verteilt. Im privaten Bereich hat man hingegen selten solch einen Load-Balancer, weshalb wir zu einer anderen Lösung greifen müssen.

Natürlich stellt sich die Frage, warum man überhaupt einen Load-Balancer in diesem Setup braucht, denn eigentlich kümmert sich Kubernetes um die Lastverteilung zwischen den Pods welche ggfs. auf verschiedenen Nodes laufen. Neben der Lastverteilung sorgt ein Load-Balancer auch dafür, dass eingehende Netzwerk-Anfragen nur an verfügbare Nodes gesendet werden und stellt so die Verfügbarkeit sicher. Fällt einer der Nodes aus, können die verbleibenden Nodes die Anfragen entgegennehmen. Zu diesem Zweck werden die Netzwerk-Anfragen nicht an eine der Node IP-Adressen direkt gesendet, sondern an die IP-Adresse des Load-Balancer.

Um solche eine IP-Adresse auch ohne Software oder Hardware Load-Balancer zu erhalten, nutzen wir den Dienst MetalLB, welcher uns virtuelle IP-Adressen bereitstellt. Diese virtuellen IP-Adressen können genutzt werden, um Netzwerk-Anfragen an einen der verfügbaren Cluster Nodes zu senden. Um solche eine virtuelle IP-Adresse zu erzeugen, installiert MetalLB auf jedem Node einen Speaker-Pod. Kommt nun ein ARP-Request für eine virtuelle IP-Adresse bei den Nodes an, antwortet MetalLB auf diesen Request mit einer der MAC-Adressen der Cluster-Nodes. Hierdurch empfängt der Node, welcher die zurückgegeben MAC-Adresse besitzt, zukünftig Netzwerk-Pakete für die virtuelle IP-Adresse. Ein Node erhält hierdurch mehrere IP-Adressen im Netzwerk. Fällt dieser Node aus, informiert MetalLB alle Clients im Netzwerk, dass nun eine anderer Node die virtuellen IP besitzt (mehr zum Thema hier).

MetalLB installieren wir mit kubectl und verwenden hierfür die YAML-Dateien aus dem offiziellen GitHub-Repo:

kubectl apply -f https://raw.githubusercontent.com/metallb/metallb/main/manifests/namespace.yaml

kubectl apply -f https://raw.githubusercontent.com/metallb/metallb/main/manifests/metallb.yaml

kubectl create secret generic -n metallb-system memberlist --from-literal=secretkey="$(openssl rand -base64 128)"

Nach der Installation müssen wir MetalLB noch den IP-Adressen Bereich mitteilen, für welches es zuständig ist. Dies geschieht durch eine ConfigMap welche wir in einer YAML-Datei spezifizieren und anschließend per kubectl übernehmen:

apiVersion: v1
kind: ConfigMap
metadata:
  namespace: metallb-system
  name: config
data:
  config: |
    address-pools:
    - name: default
      protocol: layer2
      addresses:
      - 192.168.178.20-192.168.178.39

Hinweis: Im Router sollte der DHCP Dienst keine IP-Adresse im selben IP-Adressen Bereich vergeben, da es sonst zu Konflikten kommt

kubectl apply -f address-pool.yaml

Nach der Installation sollte man einen Controler-Pod und pro Node einen Speaker-Pod sehen:

Traefik v2 als Ingress

Traefik ist ein Application Proxy, welchen ich schon länger in Zusammenhang mit Docker nutze. Besonders schätze ich die einfache Handhabung von Traefik was das Definieren von Routing Regeln angeht und dass Traefik sich selbst um TLS Zertifikate kümmert. So konnte ich in meinem alten Docker-Compose Setup durch das Einfügen von nur 5 Labels einen Docker-Service per HTTPS öffentlich bereitstellen. Wir nutzen Traefik in unserem Kubernetes Setup als Ingress Controller, welcher basierend auf Routing-Regeln eingehende Requests zu den entsprechenden Services routet und sich auch um die TLS Terminierung kümmert. Zusätzlich nutzen wir einige der mitgelieferten Middlewares von Traefik um die Sicherheit zu erhöhen.

Für die Installation von Traefik nutzen wir das offizielle Helm Chart:

helm repo add traefik https://helm.traefik.io/traefik
helm repo update

Bevor wir Traefik installieren, erstellen wir eine YAML-Datei, um ein paar Einstellungen zu definieren:

deployment:
  enabled: true
  kind: DaemonSet

ingressRoute:
  dashboard:
    enabled: false

logs:
  general:
    level: ERROR
  access:
    enabled: true

additionalArguments:
 - "--api.dashboard=true"
 - "--providers.kubernetesingress.ingressclass=traefik-cert-manager"

ports:
  traefik:
    port: 9000
    expose: false
    exposedPort: 9000
    protocol: TCP
  web:
    port: 8000
    expose: true
    exposedPort: 80
    protocol: TCP
  websecure:
    port: 8443
    expose: true
    exposedPort: 443
    protocol: TCP
    tls:
      enabled: true
service:
  enabled: true
  type: LoadBalancer
  externalTrafficPolicy: Local
  externalIPs:
    - 192.168.178.20

Viele der definierten Einstellungen entsprechen der Standard-Einstellung des Helm Charts. Ich lasse sie aber bewusst in der YAML-Datei, um eine gute Sicht darüber zu erhalten wie mein Setup aussieht.

Ich installiere Traefik als DaemonSet um auf jedem Node einen Traefik Pod laufen zu haben. Alternativ kann man Traefik auch als Deployment installieren. Die Pro und Cons zwischen beiden Varianten findet man hier. Das automatische Erstellen einer Ingress-Route für das Traefik-Dashboard habe ich deaktiviert, da ich später selbst eine Ingress-Route definieren möchte. In den zusätzlichen Argumenten habe ich einen Namen für die Ingress-Class definiert, welchen wir später für den Cert-Manager benötigen. Beim Service nutze ich den Type "Load-Balancer", welchem ich die virtuelle IP 192.168.178.20 zuweise. Über diese IP ist später Traefik erreichbar.

Sind alle Einstellungen wie gewünscht definiert, können wir Traefik per Helm installieren:

helm upgrade --install --values=config.yaml --namespace kube-system traefik traefik/traefik

Um das Setup zu testen, kann man im Browser die definierte IP-Adresse eingeben. Es sollte eine "404 page not found" Fehlermeldung erscheinen. Diese Fehlermeldung ist vollkommen in Ordnung, da noch keine Routing-Regeln existieren. Sie zeigt aber, dass der Request bei Traefik ankommt.

Erste Anwendung

Nach der Installation von Traefik und MetalLB sind alle Voraussetzungen gegeben, um HTTP-Anwendungen im Cluster aufzurufen. Da wir uns noch nicht mit dem Themen Port-Forwarding, TLS-Zertifikate und persistenter Speicher beschäftigt haben, ist das Dashboard von Traefik unsere erste Anwendung, die wir für uns verfügbar machen. Das Dashboard wird von Traefik mitgeliefert und muss keine Daten speichern, weshalb das Einrichten sehr einfach ist.

Um HTTP-Anfragen an das Dashboard zu routen, fehlt eine IngressRoute welche Routing-Regeln definiert.

Die nachfolgende YAML-Datei definiert eine Routing-Regel für den Entry-Point web (http) und hat nur die Bedingung, dass der Host-Header im HTTP-Request traefik ist. Da wir das Dashboard nur über das eigene Netzwerk verfügbar machen, können wir den Hostname frei wählen. Bei öffentlichen Anwendungen muss man als Hostname die eigene (Sub-)Domain eintragen. Neben dem Prüfen des Hostnames gibt es noch weitere Regeln, welche auch kombiniert werden können.

apiVersion: traefik.containo.us/v1alpha1
kind: IngressRoute
metadata:
  name: traefik-dashboard
  namespace: kube-system
spec:
  entryPoints:
    - web
  routes:
  - match: Host(`traefik`)
    kind: Rule
    services:
    - name: api@internal
      kind: TraefikService

Die YAML-Datei wird wieder wie gewohnt mit kubectl übernommen.

Um das Dashboard vom lokalen Computer aufzurufen, muss der Hostname mit der virtuellen IP verknüpft werden. Hierfür wird vorerst die lokale Host Datei (Windows: C:\Windows\System32\drivers\etc\hosts und MacOS/Linux: /etc/hosts) verwendet. In dieser Datei trägt man die virtuelle IP gefolgt vom definierten Hostname ein.

Tipp man nun in den Browser http://traefik ein, erscheint das Dashboard von Traefik

Basic Auth Middleware

Das Dashboard von Traefik kommt ohne Passwortschutz und ist für jedem im Netzwerk aufrufbar. Zwar lassen sich über das Dashboard keine Änderungen vornehmen, aber ein Passwortschutz ist nicht verkehrt.

Um das Dashboard mit einem Passwort zu schützen, nutzen wir die Basic-Auth Middleware von Traefik. Diese Middleware erwartet die Basic-Auth Zugangsdaten als Secret:

# https://docs.traefik.io/middlewares/basicauth/#users
# Note: in a kubernetes secret the string (e.g. generated by htpasswd) must be base64-encoded first.
# To create an encoded user:password pair, the following command can be used:
# htpasswd -nb user password | openssl base64
apiVersion: v1
kind: Secret
metadata:
  name: traefik-basic-auth
  namespace: kube-system
data:
  users: dXNlcjokYXByMSRtVy5UTjR3ZyRCTW1nZExSZ0FJNkNQWmtXb09oOUkvCgo=

Nach dem hinzufügen des secrets via kubectl können wir die Middleware definieren und über kubectl dem Cluster hinzufügen:

apiVersion: traefik.containo.us/v1alpha1
kind: Middleware
metadata:
  name: basic-auth
  namespace: kube-system
spec:
  basicAuth:
    secret: traefik-basic-auth

Als letzten Schritt müssen wir in der zuvor erstellen IngressRoute die Middleware mit aufnehmen:

apiVersion: traefik.containo.us/v1alpha1
kind: IngressRoute
metadata:
  name: traefik-dashboard
  namespace: kube-system
spec:
  entryPoints:
    - web
  routes:
  - match: Host(`traefik`)
    kind: Rule
    services:
    - name: api@internal
      kind: TraefikService
    middlewares:
    - name: traefik-basic-auth

Sind die Änderungen übernommen, sollte eine Benutzernamen + Passwort Abfrage beim öffnen des Dashboards erscheinen.

IPWhiteList Middleware

Im nächsten Schritt werden wir unser Cluster für das Internet freigeben, wodurch auch Dritte das Cluster erreichen. Da ich im Cluster auch Anwendungen betreibe, die nicht über das Internet aufgerufen werden sollen, nutze ich die IPWhiteList Middleware um den Zugriff auf bestimmte IPs einzugrenzen:

apiVersion: traefik.containo.us/v1alpha1
kind: Middleware
metadata:
  namespace: kube-system
  name: private-ips
spec:
  ipWhiteList:
    sourceRange:
      - 127.0.0.1/32
      - 192.168.178.0/24
      - 10.42.0.0/16

Diese Middleware blockiert alle Netzwerk-Anfragen von IP-Adressen, die nicht in der Liste definiert sind. Der dritte IP-Bereich (10.42.0.0/16) in der Liste, ist der von Kubernetes selbst und ich habe diesen hinzugefügt, falls andere Anwendungen im Cluster (z.B. Heimdall) das Dashboard aufrufen wollen.

Nach dem Anlegen der Middleware kann diese wie die Basic-Auth Middleware der IngressRoute hinzugefügt werden:

apiVersion: traefik.containo.us/v1alpha1
kind: IngressRoute
metadata:
  name: traefik-dashboard
  namespace: kube-system
spec:
  entryPoints:
    - web
  routes:
  - match: Host(`traefik`)
    kind: Rule
    services:
    - name: api@internal
      kind: TraefikService
    middlewares:
    - name: private-ips
    - name: traefik-basic-auth

Cert-Manager

Für öffentliche Anwendungen empfiehlt es sich eine HTTPS-Verbindung zu nutzen, was in Zeiten von Let's Encrypt ohne großen Aufwand oder hohen Kosten funktioniert.

Bisher habe ich Traefik genutzt, um die Let's Encrypt TLS-Zertifikate zu generieren und zu verwalten. Dies funktionierte in meinem alten Docker-Compose Setup ohne Probleme. In einem Cluster Setup steht man aber vor der Herausforderung die generierten Zertifikate so zu speichern, dass sie für alle Nodes verfügbar sind. Anfänglich wollte ich einen verteilten Block-Storage nutzen, um die Zertifikate zu speichern. Der Block-Storage läuft aber im Read-Write-Once Modus, wodurch nur ein Pod Lesen und Schreiben Zugriff erhalten kann. Traefik läuft bei mir aber im DaemonSet Modus, wodurch ich mehr als einen Pod habe. Nach einer kurzen Recherche bin ich auf den Cert-Manager gestoßen, welcher ebenfalls Zertifikate über Let's Encrypt generiert und verwaltet. Anders als Traefik legt der Cert-Manager die Zertifikate als Secret ab, wodurch sie von allen Pods gelesen werden können.

Die Installation vom Cert-Manager findet über das offizielle Helm Chart statt:

kubectl create namespace cert-manager
helm repo add jetstack https://charts.jetstack.io
helm repo update

helm install cert-manager jetstack/cert-manager --namespace cert-manager --version v1.2.0 --create-namespace --set installCRDs=true

Nach der Installation muss ein ClusterIssuer erstellt werden, in welchem man eine E-Mail-Adresse für Let's Encrypt und eine Ingress-Class definiert. Den Namen der Ingress-Class wurde bereits bei der Installation von Traefik festgelegt (additionalArguments Option).

apiVersion: cert-manager.io/v1alpha2
kind: ClusterIssuer
metadata:
  name: letsencrypt-prod
spec:
  acme:
    # You must replace this email address with your own.
    # Let's Encrypt will use this to contact you about expiring
    # certificates, and issues related to your account.
    email: 
    server: https://acme-v02.api.letsencrypt.org/directory
    privateKeySecretRef:
      # Secret resource used to store the account's private key.
      name: letsencrypt-prod
    solvers:
      - http01:
          ingress:
            class: traefik-cert-manager

Aufgrund des Rate-Limitings von Let's Encrypt empfiehlt es sich, einen zusätzlichen ClusterIssuer zu definieren welcher genutzt wird bis das Setup erfolgreich funktioniert. Dieser ClusterIssue nutzt die Stagging-Umgebung von Let's Encrypt, welche nicht in der Anzahl an Anfragen limitiert ist.

apiVersion: cert-manager.io/v1alpha2
kind: ClusterIssuer
metadata:
  name: letsencrypt-staging
spec:
  acme:
    # You must replace this email address with your own.
    # Let's Encrypt will use this to contact you about expiring
    # certificates, and issues related to your account.
    email: 
    server: https://acme-staging-v02.api.letsencrypt.org/directory
    privateKeySecretRef:
      # Secret resource used to store the account's private key.
      name: letsencrypt-staging
    solvers:
      - http01:
          ingress:
            class: traefik-cert-manager

Erste öffentliche Anwendung via HTTPS

Das Cluster ist soweit vorbereitet, um öffentliche Anwendungen per HTTPS zu betreiben, was jedoch noch fehlt ist das Einrichten von Port-Forwarding am Router und eine (Dyn)DNS-Adresse. Auf beides werde ich nicht im Detail eingehen, da das Setup sich je nach Router bzw. DNS-Anbieter unterscheidet. Wichtig ist, dass am Ende eingehender Datenverkehr über Port 80 und 443 an das Cluster weitergeleitet werden. Als Ziel-IP nutzt man die virtuelle Loadbalancer IP von Traefik. Auch wenn man nicht vorhat Anwendungen über HTTP zu betreiben, muss man dennoch den Port 80 für das Generieren von TLS Zertifikaten freigegeben. Anders als Traefik bietet der Cert Manager nämlich nur die Möglichkeit das Zertifikat über eine HTTP-Challenge zu generieren anstatt über eine TLS-Challenge.

Ist das Cluster öffentlich über Port 80 und 443 erreichbar, kann die erste öffentliche Anwendung installiert werden. Wir nutzen als Anwendung whoami von Traefik. Bei Whoami handelt es sich um einen kleinen Webserver, welcher HTTP Informationen zurückgibt.

Als erstes definieren wir ein Certificate-Objekt, welches die Generierung eines TLS-Zertifikats per Cert-Manager anstößt. Wurde das Zertifikat generiert, wird es als Secret mit dem Namen secure-whoami-cert gespeichert. Nutzt man den Cert-Manager das erste Mal, um ein Zertifikat zu generieren, sollte man erstmal den ClusterIssuer für die Staging Umgebung (letsencrypt-staging) nutzen anstatt direkt die Prod Umgebung (letsencrypt-prod) von Let's Encrypt zu nutzen.

Certificate

apiVersion: cert-manager.io/v1alpha2
kind: Certificate
metadata:
  name: secure-whoami-cert
spec:
  commonName: demo.hierl.dev
  secretName: secure-whoami-cert
  dnsNames:
    - demo.hierl.dev
  issuerRef:
    name: letsencrypt-prod
    kind: ClusterIssuer

Als nächstes wird das Deployment und der dazugehörige Service generiert

Deployment

apiVersion: apps/v1
kind: Deployment
metadata:
  name: whoami
spec:
  replicas: 1
  selector:
    matchLabels:
      app: whoami
  template:
    metadata:
      labels:
        app: whoami
    spec:
      containers:
      - image: containous/whoami
        name: whoami-container
        ports:
        - containerPort: 80
          name: web

Service

apiVersion: v1
kind: Service
metadata:
  name: whoami
  labels:
    app: whoami
spec:
  ports:
    - protocol: TCP
      name: web
      port: 8080
      targetPort: web
  selector:
    app: whoami

Als letztes definieren wir eine IngressRoute um Whoami über Traefik nach außen verfügbar zu machen. Wichtig ist, dass die Domain in der Routing-Regel dieselbe ist wie im Certificate-Objekt und das beim Secret-Namen für TLS der definierte Secret-Name vom Certificate-Objekt eingetragen ist, da unter diesem Namen das TLS Zertifikat abgelegt wird.

IngressRoute

apiVersion: traefik.containo.us/v1alpha1
kind: IngressRoute
metadata:
  name: whoami-https
  labels:
    app: whoami
spec:
  entryPoints:
    - websecure
  routes:
    - match: Host(`demo.hierl.dev`)
      kind: Rule
      services:
        - name: whoami
          port: 8080
  tls:
    secretName: secure-whoami-cert

Hat man alle YAML Dateien über kubectl übernommen, kann Whoami über eine HTTPS-Verbindung aufgerufen werden

http to https redirect

Ruft man die Domain über HTTP anstatt HTTPS auf, erhält man einen 404 Fehler. Grund hierfür ist, dass die definierte IngressRoute nur für den Web Secure Endpoint (Port 443) gilt. Traefik bietet die Option an, alle Anfrage auf dem Web Endpoint (Port 80) auf den Web Secure Endpoint umzuleiten. Da ich aber interne Anwendungen nicht ohne größeren Aufwand über HTTPS betreiben kann und hier HTTP nutzen muss, kann ich diese Option nicht nutzen. Statt diese Umleitung zentral festzulegen, kann man mit einer Middleware pro IngressRoute festlegen ob auf HTTPS umgeleitet werden soll. Diese Middleware wendet man auf einer IngressRoute für den Web Endpoint an:

Middleware

apiVersion: traefik.containo.us/v1alpha1
kind: Middleware
metadata:
  name: https-only
  namespace: kube-system
spec:
  redirectScheme:
    scheme: https

Web Endpoint IngressRoute

apiVersion: traefik.containo.us/v1alpha1
kind: IngressRoute
metadata:
  name: whoami-http
  labels:
    app: whoami

spec:
  entryPoints:
    - web
  routes:
    - match: Host(`demo.hierl.dev`)
      kind: Rule
      services:
        - name: whoami
          port: 8080
      middlewares:
      - name: https-only
        namespace: kube-system

Security-Headers

Eine weitere interessante Middleware ist die Header Middleware, welche es erlaubt Security Response-Header zu setzen:

Middleware

apiVersion: traefik.containo.us/v1alpha1
kind: Middleware
metadata:
  namespace: kube-system
  name: security-header
spec:
  headers:
    frameDeny: true
    sslRedirect: true
    browserXssFilter: true
    contentTypeNosniff: true
    stsIncludeSubdomains: true
    stsPreload: true
    stsSeconds: 31536000

IngressRoute

apiVersion: traefik.containo.us/v1alpha1
kind: IngressRoute
metadata:
  name: whoami-https
  labels:
    app: whoami
spec:
  entryPoints:
    - websecure
  routes:
    - match: Host(`demo.hierl.dev`)
      kind: Rule
      services:
        - name: whoami
          port: 8080
      middlewares:
      - name: security-header
        namespace: kube-system
  tls:
    secretName: secure-whoami-cert

Longhorn als verteilter Block-Storage

In einem Cluster-Setup steht man vor der Herausforderung Daten persistent zu speichern, den anders als bei einem Single-Node-Setup kann man nicht einfach einen lokalen Pfad auf dem Node definieren, unter welchem die Daten gespeichert werden soll. Denn Kubernetes kontrolliert welcher Pod auf welchem Node läuft und wenn Kubernetes einen Pod auf einen anderen Node verschiebt, weil der ursprüngliche Node nicht mehr verfügbar ist oder um eine bessere Lastverteilung zu haben, wandern die Daten nicht mit auf den neuen Node und der Pod kann nicht mehr auf die Daten zugreifen.

Abhilfe schafft an dieser Stelle Longhorn. Longhorn ist ein verteilter Block-Storage, welcher das Speicher-Management im Cluster übernimmt. Die Daten werden dabei auf mehreren Nodes repliziert und die Pods greifen indirekt über Longhorn auf die Daten zu. Für den Pod spielt es keine Rolle auf welchen Nodes die physischen Daten. Fällt ein Node aus, stehen die Daten durch die Replizierung weiterhin zur Verfügung und Pods die auf den noch verfügbaren Nodes hochgefahren werden, können weiterhin auf die Daten zugreifen. Steht der ausgefallene Node wieder zur Verfügung, kümmert sich Longhorn darum, dass die Daten zwischen allen Replicas syncronisiert werden, um wieder einen synchronen Zustand zu erreichen. Bis zu Version 1.1 unterstützte Longhorn nur den Modus Read-Write-Once, bei welchem nur ein Pod auf die Daten zugreifen darf. Wollen mehrere Pods auf denselben definierten Speicher-Volume zugreifen, kommt es zu einem Fehler. Seit Version 1.1 bietet Longhorn einen Read-Write-Many Modus an. Dieser Modus ist jedoch etwas komplizierter, weshalb wir erstmal beim Read-Write-Once Modus bleiben.

Longhorn lässt sich einfach über die offizielle YAML-Konfiguration installieren:

kubectl create namespace longhorn-system
kubectl apply -f https://raw.githubusercontent.com/longhorn/longhorn/master/deploy/longhorn.yaml

Ist Longhorn installiert, kann man ein Persistent-Volume-Claim (PVC) Objekt nutzen, um ein Volume für einen Pod zu definieren. In diesem Beispiel legen wir einen PVC mit dem Namen data-pvc an, welchem wir die Storage-Class "Longhorn" und 20 GB Speicher zuweisen:

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: data-pvc
spec:
  accessModes:
    - ReadWriteOnce
  storageClassName: longhorn
  resources:
    requests:
      storage: 20Gi

Longhorn legt daraufhin ein Replica an, welcher auf drei Nodes ein Volume anlegt. In einem Deployment können wir nun auf diesen PVC verweisen und Longhorn übernimmt das Speichern und Replizieren der Daten.

Longhorn bringt ein Web-Frontend mit, über welchen man Longhorn verwalten kann. Neben dem Anpassen von Einstellung, dem Verwalten von Volumes, hat man auch die Möglichkeit Snapshots oder Backups anzulegen bzw. kann für die automatische Erstellung von Snapshots und/oder Backups einen Job einrichten.

Um auf das Web-Frontend zugreifen zu können, muss noch eine IngressRoute angelegt werden. Da das Web-Frontend von Longhorn keinerlei Schutz mit sich, müssen wir in der IngressRoute wieder die Basic-Auth-Middleware nutzen und zusätzlich den Zugriff auf interne IP-Adressen begrenzen:

apiVersion: traefik.containo.us/v1alpha1
kind: IngressRoute
metadata:
  name: longhorn-frontend
  namespace: longhorn-system
spec:
  entryPoints:
    - web
  routes:
    - match: Host(`longhorn`)
      kind: Rule
      services:
        - name: longhorn-frontend
          port: 80
      middlewares:
      - name: private-ips
        namespace: kube-system
      - name: traefik-basic-auth
        namespace: kube-system

Damit man das Web-Frontend von Longhorn vom eigenen Computer aufrufen kann, muss man den Hostname und IP-Adresse wie bereits bei Traefik zur lokalen Host-Datei hinzufügen. Da wir im nächsten Schritt aber auch einen DNS-Server installieren, kann man diesen Schritt erstmal überspringen.

Pi.Hole

Pi-hole ist ein Werbeblocker, welcher auf Netzwerk-Ebene operiert und DNS-Anfragen für Werbenetzwerke etc. blockiert. Dies hat den Vorteil, dass Werbung für alle Geräte im Netzwerk blockiert wird und auf den Geräten keine Software installiert werden muss. Neben Werbung lassen sich auch Tracking, Phishing, SPAM oder Malware Seiten blockieren. Pi-hole nimmt hierfür die Rolle eines DNS-Servers im Netzwerk ein und verarbeitet alle DNS-Anfragen aus dem Netzwerk. Fragt ein Gerät eine Domain, ab die auf einer Blockliste steht, wird die Anfrage mit einer ungültigen IP beantwortet, wodurch verhindert wird, dass mit dieser Domain kommuniziert werden kann. Alle erlaubten DNS-Abfragen werden an einen öffentlichen DNS-Server weitergeleitet, welcher dann die IP zur Domain zurückgibt. Neben dem Blocken von Werbung, Tracking etc. kann man Pi-hole aber auch nutzen, um eigene DNS-Einträge festzulegen, um so die Anwendungen im Cluster für das ganze Netzwerk erreichbar zu machen. Es ist dann nicht mehr notwendig auf dem eigenen Gerät die lokale Host-Datei anzupassen.

Das Setup von Pi-hole ist etwas umfangreicher als die vorherigen Setups, es werden aber nur bekannte Komponenten verwendet.

Persistent-Volume-Claim

Pi-Hole benötigt für den DNS-Cache und das Speichern der Einstellung zwei persistente Speicher:

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: pihole-pvc-data
spec:
  accessModes:
    - ReadWriteOnce
  storageClassName: longhorn
  resources:
    requests:
      storage: 1Gi
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: pihole-pvc-dnsmasq
spec:
  accessModes:
    - ReadWriteOnce
  storageClassName: longhorn
  resources:
    requests:
      storage: 1Gi

Secret

Das Web-Interface von Pi-Hole wird durch ein Passwort geschützt, welches in einem Secret definiert wird:

apiVersion: v1
kind: Secret
metadata:
  name: pihole-secret
type: Opaque
stringData:
  password: 

Deployment

In der Deployment-Definition legen wir fest, dass nur 1 Pod von Pi-Hole existieren darf (replicas = 1 und strategy = Recreate), da unser Speicher im ReadWriteOnce Modus läuft und mehrere Instanzen von PiHole zu einem Konflikt führen würden. Für das Web-Interface muss der Port 80 freigeben werden und für die DNS-Anfragen wird Port 53 benötigt. Da das DNS-Protokoll TCP und UDP verwendet, müssen wir für beide Protokolle ein Port-Mapping definieren. Über die Umgebungsvariablen teilen wir Pi-hole mit über welche IP der Dienst erreichbar ist, über welchen DNS-Namen wir das Web-Interface aufrufen wollen, welche Timezone verwendet werden soll und wie der Secret-Name für das Web-Interface-Passwort ist. Als letztes müssten noch die Mount Punkte definiert werden und mit den Persistent-Volume-Claims verknüpft werden und die Berechtigungen um die Rolle "NET_ADMIN" erweitert werden:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: pihole-deployment
  labels:
    app: pihole
spec:
  replicas: 1
  strategy:
    type: Recreate
  selector:
    matchLabels:
      app: pihole
  template:
    metadata:
      labels:
        app: pihole
    spec:
      containers:
      - name: pihole
        image: pihole/pihole:latest
        imagePullPolicy: Always
        ports:
        - name: pi-admin
          containerPort: 80
          protocol: TCP
        - containerPort: 53
          name: dns-tcp
          protocol: TCP
        - containerPort: 53
          name: dns-udp
          protocol: UDP
        env:
        - name: ServerIP
          value: "192.168.178.20"
        - name: VIRTUAL_HOST
          value: pi.hole
        - name: TZ
          value: 'Europe/Berlin'
        - name: WEBPASSWORD
          valueFrom:
            secretKeyRef:
              name: pihole-secret
              key: password
        volumeMounts:
        - name: pihole-data
          mountPath: /etc/pihole
        - name: pihole-dnsmasq
          mountPath: /etc/dnsmasq.d
        securityContext:
          capabilities:
            add:
              - NET_ADMIN
      volumes:
      - name: pihole-data
        persistentVolumeClaim:
          claimName: pihole-pvc-data
      - name: pihole-dnsmasq
        persistentVolumeClaim:
          claimName: pihole-pvc-dnsmasq

Services

Für das Setup benötigen wir drei Services. Der erste Service ist für das Web-Interface, welches wir im Anschluss über eine IngressRoute erreichbar machen. Für den DNS-Service erstellen wir einen Service vom Typ "LoadBalancer" um wie bei Traefik eine virtuelle IP zu erhalten. Leider erlaubt es Kubernetes nicht in einem Service das TCP und UDP Protokoll zu nutzen, weshalb wir für beide einen eigenen Service definieren müssen. Damit beide Services dieselbe IP nutzen können, müssen wir MetalLB über eine Annotation mitteilen, dass definierte IP geteilt werden darf.

apiVersion: v1
kind: Service
metadata:
  name: pihole-admin-service
spec:
  type: ClusterIP
  selector:
    app: pihole
  ports:
    - protocol: TCP
      name: pihole-admin
      port: 80
      targetPort: pi-admin
---
apiVersion: v1
kind: Service
metadata:
  name: pihole-service-udp
  annotations:
    metallb.universe.tf/allow-shared-ip: pihole-svc
spec:
  type: LoadBalancer
  selector:
    app: pihole
  ports:
    - protocol: UDP
      name: dns-udp
      port: 53
      targetPort: dns-udp
  loadBalancerIP: 192.168.178.21
  externalTrafficPolicy: Local
---
apiVersion: v1
kind: Service
metadata:
  name: pihole-service-tcp
  annotations:
    metallb.universe.tf/allow-shared-ip: pihole-svc
spec:
  type: LoadBalancer
  selector:
    app: pihole
  ports:
    - protocol: TCP
      name: dns-tcp
      port: 53
      targetPort: dns-tcp
  loadBalancerIP: 192.168.178.21
  externalTrafficPolicy: Local

IngressRoute

Die IngressRoute für das Web-Interface nutzt den Web Endpoint (HTTP / Port 80) und verwendet die private-ips Middleware, damit es nur aus dem internen Netzwerk aufgerufen werden kann.

apiVersion: traefik.containo.us/v1alpha1
kind: IngressRoute
metadata:
  name: pihole-ingress-route
spec:
  entryPoints:
    - web
  routes:
    - match: Host(`pi.hole`)
      kind: Rule
      services:
        - name: pihole-admin-service
          port: 80
      middlewares:
        - name: private-ips
          namespace: kube-system

Sind alle Konfigurationen per kubectl übernommen worden, kann man die definierte IP-Adresse auf dem lokalen Computer als DNS-Server hinzufügen. Anschließend kann man das Web-Interface über http://pi.hole aufrufen. Hat man sich im Web-Interface angemeldet, stellt man im Bereich "Settings > DNS" den Upstream DNS-Server sein Wahl und die 'Conditional forwarding' Einstellungen ein. Anschließend kann man im Bereich "Local DNS > DNS Records" eigene DNS-Einträge definieren:

Sind die lokalen DNS-Einträge definiert, kann man die Einträge aus der lokalen Host-Datei wieder löschen. Im Bereich "Group Management > Adlists" kann man öffentliche Filter-Listen, die zum Blocken von Werbung, Tracking etc. genutzt werden, hinzufügen. Eine Sammlung von Filter-Listen findet man auf firebog.net.

Hat man Pi-hole nach den eigenen Wünschen eingerichtet und das Setup funktioniert auf dem lokalen Computer ohne Probleme, kann man im Router einstellen, dass alle Geräte im Netzwerk die IP Adresse von Pi-hole als DNS-Server nutzen sollen. Hierbei empfiehlt es sich, dass man nicht den Upstream DNS es Routers auf die IP von Pi-hole stellt, sondern in den DHCP Einstellungen den DNS-Server einträgt. So kommunizieren alle Geräte direkt mit Pi-hole und nicht erst mit dem Router, welcher dann mit Pi-hole kommuniziert.

Home Assistant

Home Assistant ist eine quelloffene Home-Automation Lösung, welche man selbst hosten kann und viele Automatisierungsmöglichkeiten bietet. Die Installation findet über einen Helm Chart statt.

Persistent-Volume-Claim

Vor der Installation von Helm-Chart lege ich einen Persistent-Volume-Claim an, in welchem die Daten und die SQLite Datenbank gespeichert werden. Zwar lässt sich der Persistent-Volume-Claim auch mittels Helm Chart direkt mit anlegen, aber falls ich Home-Assistant mal löschen müsste, würde der Persistent-Volume-Claim mit gelöscht werden. Um dies zu verhindern, lege ich den Persistent-Volume-Claim selbst an und verweise im Helm-Chart auf den existierenden Persistent-Volume-Claim.

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: homeassistant-pvc-data
spec:
  accessModes:
    - ReadWriteOnce
  storageClassName: longhorn
  resources:
    requests:
      storage: 1Gi

Helm Chart

Um die Installation anzupassen, wird wieder eine YAML-Datei benötigt. In dieser definieren wir die Timezone, deaktivieren das Anlegen einer IngressRoute, verweisen auf unseren Persistent-Volume-Claim und definieren unsere DNS-Server:

image:
  repository: homeassistant/home-assistant
  pullPolicy: IfNotPresent
  tag: latest
env:
  TZ: Europe/Berlin

controllerType: statefulset

strategy:
  type: RollingUpdate

resources:
  limits:
    cpu: 500m
    memory: 512Mi
  requests:
    cpu: 250m
    memory: 256Mi

ingress:
  enabled: false

hostNetwork: true

service:
  port:
    port: 8123

dnsPolicy: ClusterFirstWithHostNet

persistence:
  config:
    enabled: true
    emptyDir: false
    existingClaim: "homeassistant-pvc-data"

dnsConfig:
  nameservers:
    - 192.168.178.21
    - 192.168.178.1

Ist die YAML-Datei erstellt, können wir das Repo für den Helm-Chart hinzufügen und das Helm-Chart installieren:

helm repo add k8s-at-home https://k8s-at-home.com/charts/
helm repo update
helm upgrade --install home-assistant k8s-at-home/home-assistant -f values.yaml

IngressRoute

In der IngressRoute für Home-Assistant legen wir wieder fest, dass nur interne IP-Adressen auf den Service hinzugreifen dürfen.

apiVersion: traefik.containo.us/v1alpha1
kind: IngressRoute
metadata:
  name: home-assistant-ingress
spec:
  entryPoints:
  - web
  routes:
  - kind: Rule
    match: Host(`home-assistant`)
    middlewares:
      - name: private-ips
        namespace: kube-system
    services:
    - name: home-assistant
      port: 8123

Ist die IngressRoute angelegt, muss in Pi-hole noch ein lokaler DNS-Eintrag für Home-assistant angelegt werden, um anschließend den Service über den festgelegten Hostname aufzurufen.

Wordpress Blog

Die nächste Anwendung, die wir im Cluster installieren ist ein öffentlicher WordPress-Blog. Das Setup für den Blog teilen wir in zwei Teile auf: Erst installieren wir die Datenbank Maria-DB und anschließend die WordPress-Anwendung. Der Grund für die Aufteilung ist, dass wir die Datenbank auch für andere Anwendungen im Cluster mit verwenden wollen.

MariaDB

Für die Datenbank legen wir als Erstes einen Persistent-Volume-Claim an, in welchem später die Daten gespeichert werden:

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: mariadb-pvc
spec:
  accessModes:
    - ReadWriteOnce
  storageClassName: longhorn
  resources:
    requests:
      storage: 5Gi

Die Installation findet wieder per Helm Chart statt, weshalb wieder eine YAML-Datei mit den Einstellungen erstellt werden muss. In der YAML Datei legen wir das root-passwort und verweisen auf unseren Persistent-Volume-Claim. Das Helm-Chart bietet die Möglichkeit bei der Installation eine Datenbank inkl. dazugehörigen Benutzer anzulegen. Alternativ kann man die Datenbank und den Benutzer später selbst anlegen.

service:
  type: ClusterIP
  port: 3306

# Resource limits and requests
resources:
  limits:
    cpu: 1000m
    memory: 1024Mi
  requests:
    cpu: 300m
    memory: 512Mi

## Database configuration
settings:
  ## The root user password (default: a 10 char. alpahnumerical random password will be generated)
  rootPassword: rootPasswort

## Optional user database which is created during first startup with user and password
userDatabase: {
  name: wordpress,
  user: wordpress,
  password: 
}

## Storage parameters
storage:
  ##  Set persistentVolumenClaimName to reference an existing PVC
  persistentVolumeClaimName:  mariadb-pvc

Ist die YAML-Datei erstellt, kann man die Datenbank mit Helm Chart installieren:

helm repo add groundhog2k https://groundhog2k.github.io/helm-charts/
helm repo update
helm upgrade --install mariadb --values=mariadb-values.yaml groundhog2k/mariadb

Anwendung

Für die Wordpress-Anwendung selbst benötigen wir ebenfalls einen Persistent-Volume-Claim, in welchem später Plugins, Fotos etc. gespeichert werden:

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: wordpress-pvc
spec:
  accessModes:
    - ReadWriteOnce
  storageClassName: longhorn
  resources:
    requests:
      storage: 20Gi

Ist der Persistent-Volume-Claim angelegt, nutzen wir auch hier wieder einen Helm-Chart, um die Anwendung zu installieren. Und wie auch bei der Datenbank legen wir vorher eine YAML-Datei an. In dieser legen wir die Rollout-Strategie, einige WordPress-Einstellungen, den Persistent-Volume-Claim und die Zugangsdaten für die Datenbank fest. Benutzernamen und Passwort für die Datenbank müssen den vorher festgelegten Daten entsprechen:

## Default values for Wordpress deployment
strategy:
  type: Recreate
service:
  type: ClusterIP
  port: 80

## Wordpress specific settings
settings:
  ## Database table name prefix
  tablePrefix:
  ## Maximum file upload size (default: 64M)
  maxFileUploadSize: 200M
  ## PHP memory limit (default: 128M)
  memoryLimit: 512M

resources:
  limits:
    cpu: 500m
    memory: 512Mi
  requests:
    cpu: 250m
    memory: 256Mi

## Storage parameters
storage:
  ## Set persistentVolumenClaimName to reference an existing PVC
  persistentVolumeClaimName: wordpress-pvc

externalDatabase:
  ## Name of the database (default: wordpress)
  name: wordpress

  ## Database user
  user: wordpress

  ## Database password
  password: 

  ## Database host
  host: mariadb

Ist die YAML-Datei erstellt, kann die Anwendung installiert werden:

helm upgrade --install blog --values=wordpress-values.yaml groundhog2k/wordpress

Um den Blog per HTTPS zu betreiben muss noch ein Zertifikat über den Cert-Manager generiert werde, wofür wir ein Certificate-Objekt definieren:

apiVersion: cert-manager.io/v1alpha2
kind: Certificate
metadata:
  name: secure-wordpress-cert
spec:
  commonName: example-blog.com
  secretName: secure-wordpress-cert
  dnsNames:
    - example-blog.com
  issuerRef:
    name: letsencrypt-prod
    kind: ClusterIssuer

Nun ist alles so weit vorbereitet, dass man für den WordPress-Blog die Ingress Routen (HTTP und HTTPS) anlegen kann, um den Blog von außen zu erreichen:

apiVersion: traefik.containo.us/v1alpha1
kind: IngressRoute
metadata:
  name: wordpress-http
  labels:
    app: wordpress

spec:
  entryPoints:
    - web
  routes:
    - match: Host(`example-blog.com`)
      kind: Rule
      services:
        - name: blog-wordpress
          port: 80
      middlewares:
      - name: https-only
        namespace: kube-system
---
apiVersion: traefik.containo.us/v1alpha1
kind: IngressRoute
metadata:
  name: wordpress-https
  labels:
    app: wordpress

spec:
  entryPoints:
    - websecure
  routes:
    - match: Host(`example-blog.com`)
      kind: Rule
      services:
        - name: blog-wordpress
          port: 80
      middlewares:
      - name: security-header
        namespace: kube-system
  tls:
    secretName: secure-wordpress-cert

Der WordPress-Blog sollte sich jetzt über die definierte Domain aufrufen lassen.

Zusammenfassung

In diesem Beitrag haben wir die Grundlagen für eine Handvoll von Anwendungen geschaffen und auch ein paar Beispiel-Anwendungen installiert. Die meisten Anwendungen lassen sich nach demselben Schema installieren und die Installation mit einem Helm-Chart nimmt einem viel Arbeit ab. In weiteren Beiträgen zu meinem Raspberry Pi Kubernetes Cluster werde ich auf Themen wie Function-as-a-Service (FaaS), Monitoring und Security eingehen.