Raspberry Pi Kubernetes Cluster (Teil 2)
02.05.2021Im 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/whoamikubectl 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: DeploymentapiVersion: apps/v1metadata: name: hello-world labels: app: hello-worldspec: 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: v1kind: Servicemetadata: name: hello-world labels: app: hello-worldspec: 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: 3000ingress: 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: falsemariadb: 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.yamlkubectl apply -f https://raw.githubusercontent.com/metallb/metallb/main/manifests/metallb.yamlkubectl 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: v1kind: ConfigMapmetadata: namespace: metallb-system name: configdata: 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/traefikhelm repo update
Bevor wir Traefik installieren, erstellen wir eine YAML-Datei, um ein paar Einstellungen zu definieren:
deployment: enabled: true kind: DaemonSetingressRoute: dashboard: enabled: falselogs: general: level: ERROR access: enabled: trueadditionalArguments: - "--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: trueservice: 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/v1alpha1kind: IngressRoutemetadata: name: traefik-dashboard namespace: kube-systemspec: 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 base64apiVersion: v1kind: Secretmetadata: name: traefik-basic-auth namespace: kube-systemdata: 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/v1alpha1kind: Middlewaremetadata: name: basic-auth namespace: kube-systemspec: basicAuth: secret: traefik-basic-auth
Als letzten Schritt müssen wir in der zuvor erstellen IngressRoute die Middleware mit aufnehmen:
apiVersion: traefik.containo.us/v1alpha1kind: IngressRoutemetadata: name: traefik-dashboard namespace: kube-systemspec: 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/v1alpha1kind: Middlewaremetadata: namespace: kube-system name: private-ipsspec: 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/v1alpha1kind: IngressRoutemetadata: name: traefik-dashboard namespace: kube-systemspec: 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-managerhelm repo add jetstack https://charts.jetstack.iohelm repo updatehelm 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/v1alpha2kind: ClusterIssuermetadata: name: letsencrypt-prodspec: 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: <email-address> 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/v1alpha2kind: ClusterIssuermetadata: name: letsencrypt-stagingspec: 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: <email-address> 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/v1alpha2kind: Certificatemetadata: name: secure-whoami-certspec: 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/v1kind: Deploymentmetadata: name: whoamispec: 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: v1kind: Servicemetadata: name: whoami labels: app: whoamispec: 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/v1alpha1kind: IngressRoutemetadata: name: whoami-https labels: app: whoamispec: 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/v1alpha1kind: Middlewaremetadata: name: https-only namespace: kube-systemspec: redirectScheme: scheme: https
Web Endpoint IngressRoute
apiVersion: traefik.containo.us/v1alpha1kind: IngressRoutemetadata: name: whoami-http labels: app: whoamispec: 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/v1alpha1kind: Middlewaremetadata: namespace: kube-system name: security-headerspec: headers: frameDeny: true sslRedirect: true browserXssFilter: true contentTypeNosniff: true stsIncludeSubdomains: true stsPreload: true stsSeconds: 31536000
IngressRoute
apiVersion: traefik.containo.us/v1alpha1kind: IngressRoutemetadata: name: whoami-https labels: app: whoamispec: 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-systemkubectl 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: v1kind: PersistentVolumeClaimmetadata: name: data-pvcspec: 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/v1alpha1kind: IngressRoutemetadata: name: longhorn-frontend namespace: longhorn-systemspec: 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: v1kind: PersistentVolumeClaimmetadata: name: pihole-pvc-dataspec: accessModes: - ReadWriteOnce storageClassName: longhorn resources: requests: storage: 1Gi---apiVersion: v1kind: PersistentVolumeClaimmetadata: name: pihole-pvc-dnsmasqspec: 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: v1kind: Secretmetadata: name: pihole-secrettype: OpaquestringData: password: <TBD>
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/v1kind: Deploymentmetadata: name: pihole-deployment labels: app: piholespec: 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: v1kind: Servicemetadata: name: pihole-admin-servicespec: type: ClusterIP selector: app: pihole ports: - protocol: TCP name: pihole-admin port: 80 targetPort: pi-admin---apiVersion: v1kind: Servicemetadata: name: pihole-service-udp annotations: metallb.universe.tf/allow-shared-ip: pihole-svcspec: type: LoadBalancer selector: app: pihole ports: - protocol: UDP name: dns-udp port: 53 targetPort: dns-udp loadBalancerIP: 192.168.178.21 externalTrafficPolicy: Local---apiVersion: v1kind: Servicemetadata: name: pihole-service-tcp annotations: metallb.universe.tf/allow-shared-ip: pihole-svcspec: 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/v1alpha1kind: IngressRoutemetadata: name: pihole-ingress-routespec: 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: v1kind: PersistentVolumeClaimmetadata: name: homeassistant-pvc-dataspec: 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: latestenv: TZ: Europe/BerlincontrollerType: statefulsetstrategy: type: RollingUpdateresources: limits: cpu: 500m memory: 512Mi requests: cpu: 250m memory: 256Miingress: enabled: falsehostNetwork: trueservice: port: port: 8123dnsPolicy: ClusterFirstWithHostNetpersistence: 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 updatehelm 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/v1alpha1kind: IngressRoutemetadata: name: home-assistant-ingressspec: 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: v1kind: PersistentVolumeClaimmetadata: name: mariadb-pvcspec: 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 requestsresources: limits: cpu: 1000m memory: 1024Mi requests: cpu: 300m memory: 512Mi## Database configurationsettings: ## 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 passworduserDatabase: { name: wordpress, user: wordpress, password: <Change-me>}## Storage parametersstorage: ## 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 updatehelm 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: v1kind: PersistentVolumeClaimmetadata: name: wordpress-pvcspec: 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 deploymentstrategy: type: Recreateservice: type: ClusterIP port: 80## Wordpress specific settingssettings: ## Database table name prefix tablePrefix: ## Maximum file upload size (default: 64M) maxFileUploadSize: 200M ## PHP memory limit (default: 128M) memoryLimit: 512Mresources: limits: cpu: 500m memory: 512Mi requests: cpu: 250m memory: 256Mi## Storage parametersstorage: ## Set persistentVolumenClaimName to reference an existing PVC persistentVolumeClaimName: wordpress-pvcexternalDatabase: ## Name of the database (default: wordpress) name: wordpress ## Database user user: wordpress ## Database password password: <change-me> ## 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/v1alpha2kind: Certificatemetadata: name: secure-wordpress-certspec: 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/v1alpha1kind: IngressRoutemetadata: name: wordpress-http labels: app: wordpressspec: 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/v1alpha1kind: IngressRoutemetadata: name: wordpress-https labels: app: wordpressspec: 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.