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}')