K8s Cluster mit vorgelagertem redundanten Load Balancer bei Hetzner Cloud

Im Rahmen eines Kundenprojekts wurde bei Hetzner Cloud ein neuer Kubernetes Cluster in Betrieb genommen.
Das System besteht aus folgenden Komponenten:
3x K8s Manager/Controller (k8scontrol[01-03].skgm.de), installiert auf 8 Core Instanzen mit Ceph Storage Backend
2x K8s Node (k8node[01+02].skgm.de), installiert auf 16 Core dedicated Instanzen
2x Load Balancer (k8slb[01+02].skgm.de), installiert auf 8 Core Instanzen

Hinweis: Es hat sich über die Zeit als schwierige Entscheidung erwiesen, die Manager auf Instanzen mit Ceph Storage zu legen. Die Idee dahinter war es, die Manager resistent gegen einen Hardwareausfall bei Hetzner zu machen – was gut ist – allerdings ist die I/O Performance des Ceph Backends nicht mit lokalen SSDs zu vergleichen, was wiederum Anpassungen bez. TimeOuts in etcd erforderlich macht.

Grundinstallation

Die Grundinstallation erfolgte weitestgehend wie bereits in K8s Cluster Setup beschrieben, da die Load Balancer einen Cluster bilden sollen, kommt noch Heartbeat dazu.

Auf den Managern und Nodes:

apt update && apt upgrade -y
reboot
curl -s https://packages.cloud.google.com/apt/doc/apt-key.gpg | sudo apt-key add
apt-add-repository "deb http://apt.kubernetes.io/ kubernetes-xenial main"
apt install docker.io kubeadm -y
swapoff -a
systemctl enable docker

Auf den Load Balancern:

apt update && apt upgrade -y
reboot
apt install haproxy heartbeat -y

Netzwerk

Das Ziel ist es, den K8s Cluster weitestgehend vom Internet zu entkoppeln und sämtlichen Traffic zwischen den Nodes über ein internes Netzwerk abzuwickeln. Ferner sollen später diverse Datenströme über einen separaten Server verschlüsselt via Internet angeliefert und dann unverschlüsselt im internen Netzwerk bereitgestellt werden.

Es ist somit in Hetzner Cloud im entsprechenden Projekt ein internes Netzwerk anzulegen und den Servern zuzuweisen. Durch die Zuweisung erhalten die Server ein zusätzlichen Netzwerk Interface mit einer internen IP, die per DHCP verteilt wird. Daran sollte man nichts ändern, da sonst das Routing nicht mehr ohne weiteres funktioniert. Es gab dazu einen längeren Kontakt mit dem Hetzner Support.

Bei Anlegen des Netzwerks gilt es weiterhin zu beachten, dass man zunächst einen Netzwerk Adressbereich anlegt und dann ein Subnet in selbigem definiert.

Achtung: Das Routing innerhalb des von Hetzner bereitgestellten virtuellen Netzwerks läuft auf den einzelnen Hosts nicht innerhalb der Subnet Grenzen, sondern auf den gesamten Netzwerk Adressbereich! Legt man also bspw. einen Adressbereich mit 10.0.0.0/8 an, und darin ein Subnet 10.143.0.0/16, kommt es, auch wenn für K8s intern bspw. 10.32.0.0/16 genutzt wird, zu massiven Routing Problemen, die sich in der Umsetzung primär durch in CrashLoop hängend CoreDNS Pods bemerkbar machen, was dann wiederum diverse Folgen in verschiedensten Logs hat. Dieses Verhalten ist nachvollziehbar, wenn man bedenkt, dass alle Pakete für das 10.0.0.0/8er Netz, also alles was mit 10. beginnt, an das Hetzner Gateway geroutet werden, das allerdings mit 10.32.x überhaupt nichts anfangen kann.

Nach Neudefinition des internen Netzwerks mit 10.143.0.0/16 und einem Subnet 10.143.0.0/24 ist dieses Problem keines mehr, solange man das 10.143er Netz nicht in irgendeiner Form innerhalb von K8s benutzt.

Load Balancer

Im nächsten Schritt werden die Load Balancer vorbereitet, da diese später als API Endpunkt in K8s konfiguriert werden sollen.

Die Load Balancer sollen einen Cluster bilden, aber über eine eindeutige IP erreichbar sein, sowohl intern als auch extern. Für intern funktioniert dies über eine Alias IP, die via Hetzner Cloud API, oder über das Web Interface in den Details der Adressbereich Definition einem bereits dem internen Subnet hinzufügten Server zugewiesen werden kann. Sinnigerweise sollte das erstmal der erste Load Balancer sein. Die externe IP wird als Floating IP dem ersten Load Balancer zugewiesen.

Sowohl die AliasIP als auch die FloatingIP werden später als Endpunkte genutzt, keinesfalls die IP der Instanz – da dann ein Failover nicht möglich wäre.

Da der HAproxy in der Lage sein soll, Services direkt an eine IP zu binden (statt immer alle zu binden), was die Flexibilität erhöht und auch aus Sicherheitsgründen sinnvoll sein kann, diese IP aber nicht permanent an der Instanz anliegen muss (beide können ja von einem Load Balancer zum anderen wandern) muss an beiden Load Balancern in /etc/sysctl.conf der Eintrag net.ipv4.ip_nonlocal_bind=1 gesetzt werden.

 

echo "net.ipv4.ip_nonlocal_bind=1">>/etc/sysctl.conf
sysctl -p

Heartbeat

Als weitere Vorbereitung muss heartbeat konfiguriert werden. Heartbeat möchte gerne ein gemeinsames Secret haben, um sicher zustellen, dass der empfangene Heartbeat auch tatsächlich von einem Server des Clusters stammt.

echo -n fgcvjkjskdhcfghasdjfhcjsldfdbsmydks| md5sum
# Die Rückgabe kopieren
# Auf beiden Load Balancern ausführen:
echo "auth 1">/etc/ha.d/authkeys
echo "1 md5 RÜCKGABEHIEREINFÜGEN">>/etc/ha.d/authkeys
chmod 600 /etc/ha.d/authkeys

Nun /etc/hosts anpassen, idealerweise gleich so, dass alle wichtigen Einträge vorhanden sind, bspw.:

10.143.0.5 K8sLB01
10.143.0.6 K8sLB02
10.143.0.2 K8sControl01
10.143.0.3 K8sControl02
10.143.0.4 K8sControl03
10.143.0.7 K8sNode01
10.143.0.8 K8sNode02
10.143.0.50 K8sAPI
127.0.0.1 localhost

Die Namen der beiden Load Balancer mit korrekten IPs sollten auf jedenfall darin stehen.

Um die HA Resource anzulegen und dann auch entsprechend zu behandeln sind mehrere Schritte nötig, zunächst die Resource auf beiden Servern anlegen:

echo "K8sLB01 IPaddr2::10.143.0.50/32/ens10:0/10.143.0.50 HCaliasIP">/etc/ha.d/haresources

Wobei K8sLB01 der Name des primären Load Balancers ist, 10.143.0.50 die gemeinsame AliasIP und HCaliasIP ein kleines Script, um bei Hetzner eine Neuzuweisung der AliasIP und der FloatingIP zu triggern.

Das Script sieht wie folgt aus und wird auf beiden Servern unter dem Namen HCaliasIP mit den Rechten 755 in /etc/ha.d/resource.d erwartet :

#! /bin/bash

### BEGIN INIT INFO
# Provides:          virtIP
# Required-Start:    $local_fs $network
# Required-Stop:     $local_fs
# Default-Start:     2 3 4 5
# Default-Stop:      0 1 6
# Short-Description: Virt IP Service
# Description:       Virtual IP in Hetzner Cloud (de)attacher
### END INIT INFO

# Interne Variablen, die Werte können über die Hetzner Cloud API abgefragt werden. Der API Key muss im Projekt erstellt und hier eingetragen werden.
# Die ServerIDs sind die beiden HetznerIDs der Load Balancer, sie müssen logischerweise auf den beiden Servern gegenläufig nummeriert sein.
NETWORKID=7473
FLOATID=88762
SERVERID2=3010897
SERVERID1=3050515
API_TOKEN=FEs7b5L3WDtPix57pzvhzBJ1komc3XYOvTPjuMC20rt51x09BtfpYMjCdZZuKaZ9

case "$1" in
  start)
    echo "Starting Virtual IP in Hetzner Cloud (de)attacher..."

    echo "Freeing alias IP..."
    /usr/bin/curl -X POST -H "Content-Type: application/json" -H "Authorization: Bearer $API_TOKEN" -d "{\"network\": $NETWORKID, \"alias_ips\": []}" "https://api.hetzner.cloud/v1/servers/$SERVERID2/actions/change_alias_ips"

    echo "Freeing float IP..."
    /usr/bin/curl -X POST -H "Content-Type: application/json" -H "Authorization: Bearer $API_TOKEN" "https://api.hetzner.cloud/v1/floating_ips/$FLOATID/actions/unassign"
    /sbin/ip addr del "116.202.6.21/32" dev eth0

    echo "Setting alias IP..."
    /usr/bin/curl -X POST -H "Content-Type: application/json" -H "Authorization: Bearer $API_TOKEN" -d "{\"network\": $NETWORKID, \"alias_ips\": [\"10.143.0.50\"]}" "https://api.hetzner.cloud/v1/servers/$SERVERID1/actions/change_alias_ips"

    echo "Setting float IP..."
    /usr/bin/curl -X POST -H "Content-Type: application/json" -H "Authorization: Bearer $API_TOKEN" -d "{\"server\": $SERVERID1}" "https://api.hetzner.cloud/v1/floating_ips/$FLOATID/actions/assign"
    /sbin/ip addr add "116.202.6.21/32" dev eth0
    ;;
  stop)
    echo "Stopping Virtual IP in Hetzner Cloud (de)attacher..."
    /usr/bin/curl -X POST -H "Content-Type: application/json" -H "Authorization: Bearer $API_TOKEN" -d "{\"network\": $NETWORKID, \"alias_ips\": []}" "https://api.hetzner.cloud/v1/servers/$SERVERID1/actions/change_alias_ips"

    /usr/bin/curl -X POST -H "Content-Type: application/json" -H "Authorization: Bearer $API_TOKEN" "https://api.hetzner.cloud/v1/floating_ips/$FLOATID/actions/unassign"
    /sbin/ip addr del "116.202.6.21/32" dev eth0
    sleep 2
    ;;
  *)
    echo "Usage: ./HCaliasIP {start|stop}"
    unset NETWORKID
    unset SERVERID
    unset API_TOKEN
    exit 1
    ;;
esac

unset API_TOKEN
unset SERVERID1
unset SERVERID2
unset FLOATID
unset NETWORKID
exit 0

Die ID eines Servers kann man bei der Hetzner Cloud API bspw. wie folgt abfragen (vorher einen API Token in der Hetzner Cloud WebUI erzeugen und sicher aufbewahren):

API_TOKEN=jsdgfjdgshfjdshkdjaslkdhiashfiasdcksajdklaslöldasöldsas
curl -H "Authorization: Bearer $API_TOKEN" 'https://api.hetzner.cloud/v1/servers?name=NAMEDESSERVERSINHETZNERCLOUD'

Link zu kompletten API Doku: https://docs.hetzner.cloud/#overview

 

Um den Cluster zu formen, muss jetzt nur noch die Config Datei ha.cf auf beiden Servern angepasst werden, sie kann bspw. so aussehen:

#       keepalive: how many seconds between heartbeats
# 
keepalive 1
#
#       deadtime: seconds-to-declare-host-dead
#
deadtime 3
#
#       What UDP port to use for udp or ppp-udp communication?
#
udpport        694
ucast ens10 10.143.0.5 # IP der Gegenstelle
#       What interfaces to heartbeat over?
udp     ens10
#
#       Facility to use for syslog()/logger (alternative to log/debugfile)
#
#logfacility     local0
use_logd yes
#
#       Tell what machines are in the cluster
#       node    nodename ...    -- must match uname -n
node    K8sLB01
node    K8sLB02

Nach einem

systemctl restart heartbeat

ist der Heartbeat Cluster einsatzbereit.

Zur Prüfung kann man einen tail auf den syslog des sekundären Servers starten und die Meldungen beobachten, während man gleichzeitig den primären Server herunterfährt. Sobald im Log die Meldungen über einen Failover erscheinen, werden die AliasIP und die FloatingIP dem sekundären Server zugewiesen. Pingt man gegen die IPs sind ca. 5 Pings Verlust zu erkennen, während der Failover erkannt wird, die IPs neu zugewiesen, und auf dem sekundären Server angelegt werden.

Für die FloatingIP sollte bei Hetzner noch ein entsprechender Eintrag im DNS gesetzt werden, damit die LoadBalancer aus dem Internet per Name angesprochen werden können.

HAProxy

Die HAProxy Config kann im Prinzip aus dem Post K8s Cluster Setup  übernommen werden. Sie wurde im Zuge der Installation noch um weitere Services erweitert, bspw. um einen unverschlüsselten, nicht öffentlich zugänglichen Zugriff auf die K8s API. Auszug aus der /etc/haproxy/haproxy.cfg:

global
        log /dev/log    local0
        log /dev/log    local1 notice
        chroot /var/lib/haproxy
        stats socket /run/haproxy/admin.sock mode 660 level admin expose-fd listeners
        stats timeout 30s
        user haproxy
        group haproxy
        daemon

        # Default SSL material locations
        ca-base /etc/ssl/certs
        crt-base /etc/ssl/private

        # Default ciphers to use on SSL-enabled listening sockets.
        # For more information, see ciphers(1SSL). This list is from:
        #  https://hynek.me/articles/hardening-your-web-servers-ssl-ciphers/
        # An alternative list with additional directives can be obtained from
        #  https://mozilla.github.io/server-side-tls/ssl-config-generator/?server=haproxy
        ssl-default-bind-ciphers ECDH+AESGCM:DH+AESGCM:ECDH+AES256:DH+AES256:ECDH+AES128:DH+AES:RSA+AESGCM:RSA+AES:!aNULL:!MD5:!DSS
        ssl-default-bind-options no-sslv3

defaults
        log     global
        mode    http
        option  httplog
        option  dontlognull
        timeout connect 5000
        timeout client  50000
        timeout server  50000
        errorfile 400 /etc/haproxy/errors/400.http
        errorfile 403 /etc/haproxy/errors/403.http
        errorfile 408 /etc/haproxy/errors/408.http
        errorfile 500 /etc/haproxy/errors/500.http
        errorfile 502 /etc/haproxy/errors/502.http
        errorfile 503 /etc/haproxy/errors/503.http
        errorfile 504 /etc/haproxy/errors/504.http

frontend kubernetesSECURE
        bind :6443
        option tcplog
        mode tcp
        default_backend kubernetes-master-nodesSECURE

frontend kubernetes
        bind 10.143.0.50:6080
        option tcplog
        mode tcp
        default_backend kubernetes-master-nodes

backend kubernetes-master-nodes
        mode tcp
        balance roundrobin
        option tcp-check
        server k8scontrol01 10.143.0.2:6080 check fall 3 rise 2
        server k8scontrol02 10.143.0.3:6080 check fall 3 rise 2
        server k8scontrol03 10.143.0.4:6080 check fall 3 rise 2

backend kubernetes-master-nodesSECURE
        mode tcp
        balance roundrobin
        option tcp-check
        server k8scontrol01 10.143.0.2:6443 check fall 3 rise 2
        server k8scontrol02 10.143.0.3:6443 check fall 3 rise 2
        server k8scontrol03 10.143.0.4:6443 check fall 3 rise 2

Es existiert in der Gesamtinstallation auch noch ein weiterer HAProxy, der  – mit einem Lets Encrypt Zertifikat bestückt – SSL terminiert und plain an das Backend weiterleitet womit Pull Requests, die SSL erfordern, bedient werden. Details dazu:  ## LINK EINFÜGEN ##

K8s Config Yaml

Da bereits im Post K8s Cluster Setup ein Design mit drei Master Nodes beschrieben wird, an dieser Stelle nur einige Ergänzungen, bez. Anpassungen. Diese dienen primär dazu K8s interne Dienste auch tätsächlich an die internen Interfaces der Instanzen zu binden. Außerdem werden IP Ranges spezifiziert, um Kollisionen mit dem Routing von Hetzner zu vermeiden, und es wird ein unverschlüsselter API Endpunkt bereitgestellt.

apiVersion: kubeadm.k8s.io/v1beta1
kind: InitConfiguration
localAPIEndpoint:
  advertiseAddress: "10.143.0.2" # Interne IP des ersten Masters
  bindPort: 6443
---
apiVersion: kubeadm.k8s.io/v1beta1
kind: ClusterConfiguration
kubernetesVersion: stable
controlPlaneEndpoint: "10.143.0.50:6443" # AliasIP der Load Balancer 
networking:
  podSubnet: "10.144.0.0/16"
  serviceSubnet: "10.145.0.0/16"
  dnsDomain: "k8s.skgm.de" # DNS Name auf den der Cluster hören soll
apiServer:
  certSANs: ["116.202.6.21","api.k8s.skgm.de"] # Zusätzliche IPs und Namen, die im Zertifikat gelistet werden sollen
  extraArgs:
    insecure-port: "6080" # Dies ist im Deployment Automatismus nicht mehr vorgesehen, die Zeile kann wenn nicht gewünscht einfach gelöscht werden
    insecure-bind-address: "10.143.0.2" # Dies ist nicht mehr vorgesehen, kann auch einfach gelöscht werden, erfordert manuell Anpassung nach Hinzufügen weiterer Manager
    service-cluster-ip-range: "10.145.0.0/24"
    kubelet-preferred-address-types: "Hostname,InternalDNS,InternalIP"
    anonymous-auth: "true"
    log-dir: "/var/log/k8s"
    log-file: "/var/log/k8s/apiserver.log"
controllerManager:
  extraArgs:
    log-dir: "/var/log/k8s"
    log-file: "/var/log/k8s/controller.log"

Achtung: Der Eintrag insecure-bind-address: „10.143.0.2“ in der Config wird beim Hinzufügen eines weiteren Managers nicht sauber geparst, weswegen der apiserver auf dem hinzugefügten Node in einer CrashLoop endet. Auf dem neuen Server muss daher der Eintrag in der Datei /etc/kubernetes/manifests/kube-apiserver.yaml auf die lokale, interne IP angepasst werden.

K8s Cluster initialisieren

kubeadm init --config=k8s-config.yml.insecure --upload-certs

Beim Einhängen zusätzlicher Master, den bei der Initialisierung ausgegebenen Join Befehl noch um den Parameter–apiserver-advertise-address erweitern, bspw.:

kubeadm join 10.143.0.50:443 --token op5wu8.i9k49zkm0x2ox9ql \
    --discovery-token-ca-cert-hash sha256:c88385044068fd2f02ae9adc454f2a4922595fdd2b9d23f8ba3af81d6af9bd27 \
    --control-plane --certificate-key cec0845c03357e72ad4b90ad8eeb21b67972fabb0998d32018ba5cc41c30a22c \
    --apiserver-advertise-address 10.143.0.6

Nach dem Einhängen wird der API Server trotzdem crashen bis /etc/kubernetes/manifests/kube-apiserver.yaml angepasst wurde.

Netzwerk ausrollen (in diesem Fall Weave), wenn gewünscht vorher anpassen:

wget "https://cloud.weave.works/k8s/net?k8s-version=$(kubectl version | base64 | tr -d '\n')" -O kube-weave.yaml
# vi kube-weave.yaml
kubectl apply -f kube-weave.yaml

Mit kubectl checken, ob die Nodes korrekt funktionieren:

kubectl get nodes

Zertifikate einsammeln:

cd ${HOME}/skgm
grep 'client-certificate-data' ~/.kube/config | head -n 1 | awk '{print $2}' | base64 -d >> kubecfg.crt
grep 'client-key-data' ~/.kube/config | head -n 1 | awk '{print $2}' | base64 -d >> kubecfg.key
openssl pkcs12 -export -clcerts -inkey kubecfg.key -in kubecfg.crt -out kubecfg.p12 -name "kubernetes-client"

Admin User yaml erstellen:

apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: skgm-admin-user
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: ClusterRole
  name: cluster-admin
subjects:
- kind: ServiceAccount
  name: skgm-admin-user
  namespace: kube-system

apiVersion: v1
kind: ServiceAccount
metadata:
  name: skgm-admin-user
  namespace: kube-system

User mit kubectl apply -f anlegen.

Auth Token ziehen:

kubectl -n kube-system describe secret $(kubectl -n kube-system get secret | grep skgm-admin-user | awk '{print $1}')

 

 

Related Post

HardlinksHardlinks

Vereinfacht: Hardlinks sind Zeiger auf den I-Node eines Files. D.h. die Rechte aller Hardlinks sind identisch (genau genommen ist auch der erste Zeiger auf den I-Node, der beim Anlegen eines