Kubernetes auf OpenStack: Warum Cluster API der richtige Ansatz ist
Wer Kubernetes auf eigener OpenStack-Infrastruktur betreiben will, hat im Grunde drei Optionen: OpenStack Magnum, ein manuelles Setup mit kubeadm oder Cluster API mit dem OpenStack-Provider CAPO. Alle drei funktionieren technisch. Aber nur eine davon hält, was man von einer modernen Infrastruktur erwartet.
Was an den anderen Ansätzen nicht stimmt
OpenStack Magnum gibt es seit Jahren. Das Projekt ist in die Jahre gekommen, die Entwicklung bewegt sich langsam, und wer jemals versucht hat, ein Magnum-Problem zu debuggen, weiß, wie undurchsichtig das System unter der Haube ist. Cluster-Upgrades sind mühsam, die Flexibilität begrenzt, und tiefe Integration mit GitOps-Workflows ist kaum möglich.
Manuelle kubeadm-Setups sind das andere Extrem. Man hat volle Kontrolle, zahlt dafür aber mit erheblichem Aufwand. Upgrades müssen Node für Node geplant und ausgeführt werden. Wer drei oder vier Cluster betreibt, verbringt mehr Zeit mit Clusterverwaltung als mit der eigentlichen Arbeit.
Was Cluster API anders macht
Cluster API ist ein CNCF-Projekt, das Kubernetes-Cluster über die gleichen Mechanismen verwaltet, die Kubernetes für alles andere nutzt: Deklarative Ressourcen, Controller, Reconciliation-Loops. Man beschreibt den gewünschten Zustand eines Clusters in YAML und das System sorgt dafür, dass die Realität diesem Zustand entspricht.
CAPO ist der Provider, der Cluster API mit OpenStack verbindet. Er nutzt die OpenStack-APIs, um Maschinen zu provisionieren, Netzwerke einzurichten und den Cluster-Lifecycle zu steuern. Neuinstallation, Upgrade, Scale-out: alles läuft über dieselbe API, alles ist versionierbar und reproduzierbar.
Das Ergebnis ist, dass sich Kubernetes-Cluster auf OpenStack genauso verwalten lassen wie andere Kubernetes-Ressourcen. Man kann sie in Git ablegen, sie mit Flux oder ArgoCD reconcilen lassen, und man hat eine nachvollziehbare Historie aller Änderungen.
Jede OpenStack-Umgebung ist ein bisschen anders
CAPO ist flexibel, aber keine Black Box, die sich selbst konfiguriert. Netzwerk-Topologien, Flavor-Konfigurationen, der Umgang mit Floating IPs oder Security Groups, das variiert von Umgebung zu Umgebung. Wer Cluster-API-Manifeste einfach aus einem Tutorial übernimmt, wird fast immer irgendwo anecken.
Ein gut strukturiertes Cluster-Template hilft dabei, die Konfiguration nachvollziehbar und reproduzierbar zu halten. Hier ein minimales Beispiel für CAPO v1beta2 mit drei Control-Plane-Nodes und drei Workern. Der erste Block ist das Secret mit den OpenStack-Zugangsdaten, das CAPO über identityRef referenziert:
apiVersion: v1
kind: Secret
metadata:
name: mein-cluster-cloud-config
namespace: default
stringData:
clouds.yaml: |
clouds:
openstack:
auth:
auth_url: https://keystone.example.com/v3
username: "mein-benutzer"
password: "mein-passwort"
project_name: "mein-projekt"
user_domain_name: "Default"
project_domain_name: "Default"
region_name: "RegionOne"
interface: "public"
identity_api_version: 3
---
apiVersion: cluster.x-k8s.io/v1beta1
kind: Cluster
metadata:
name: mein-cluster
namespace: default
spec:
clusterNetwork:
pods:
cidrBlocks: ["10.244.0.0/16"]
services:
cidrBlocks: ["10.96.0.0/12"]
serviceDomain: cluster.local
controlPlaneRef:
apiVersion: controlplane.cluster.x-k8s.io/v1beta1
kind: KubeadmControlPlane
name: mein-cluster-control-plane
infrastructureRef:
apiVersion: infrastructure.cluster.x-k8s.io/v1beta2
kind: OpenStackCluster
name: mein-cluster
---
apiVersion: infrastructure.cluster.x-k8s.io/v1beta2
kind: OpenStackCluster
metadata:
name: mein-cluster
spec:
identityRef:
cloudName: openstack
name: mein-cluster-cloud-config
apiServerLoadBalancer:
enabled: true
externalNetwork:
id: <external-network-id>
managedSecurityGroups:
allowAllInClusterTraffic: true
managedSubnets:
- cidr: "10.6.0.0/24"
dnsNameservers:
- "1.1.1.1"
controlPlaneAvailabilityZones:
- nova
---
apiVersion: controlplane.cluster.x-k8s.io/v1beta1
kind: KubeadmControlPlane
metadata:
name: mein-cluster-control-plane
spec:
replicas: 3
version: v1.35.3
machineTemplate:
infrastructureRef:
apiVersion: infrastructure.cluster.x-k8s.io/v1beta2
kind: OpenStackMachineTemplate
name: mein-cluster-control-plane
kubeadmConfigSpec:
clusterConfiguration:
controllerManager:
extraArgs:
- name: cloud-provider
value: external
initConfiguration:
nodeRegistration:
name: "{{ local_hostname }}"
kubeletExtraArgs:
- name: cloud-provider
value: external
- name: provider-id
value: "openstack:///{{ instance_id }}"
joinConfiguration:
nodeRegistration:
name: "{{ local_hostname }}"
kubeletExtraArgs:
- name: cloud-provider
value: external
- name: provider-id
value: "openstack:///{{ instance_id }}"
---
apiVersion: infrastructure.cluster.x-k8s.io/v1beta2
kind: OpenStackMachineTemplate
metadata:
name: mein-cluster-control-plane
spec:
template:
spec:
flavor: SCS-2V-4
rootVolume:
sizeGiB: 50
type: ceph-ssd
image:
filter:
name: ubuntu-2404-kube-v1.35.3
sshKeyName: mein-ssh-key
---
apiVersion: cluster.x-k8s.io/v1beta1
kind: MachineDeployment
metadata:
name: mein-cluster-worker
spec:
clusterName: mein-cluster
replicas: 3
selector:
matchLabels: {}
template:
spec:
clusterName: mein-cluster
version: v1.35.3
failureDomain: nova
bootstrap:
configRef:
apiVersion: bootstrap.cluster.x-k8s.io/v1beta1
kind: KubeadmConfigTemplate
name: mein-cluster-worker
infrastructureRef:
apiVersion: infrastructure.cluster.x-k8s.io/v1beta2
kind: OpenStackMachineTemplate
name: mein-cluster-worker
---
apiVersion: bootstrap.cluster.x-k8s.io/v1beta1
kind: KubeadmConfigTemplate
metadata:
name: mein-cluster-worker
spec:
template:
spec:
joinConfiguration:
nodeRegistration:
name: "{{ local_hostname }}"
kubeletExtraArgs:
- name: cloud-provider
value: external
- name: provider-id
value: "openstack:///{{ instance_id }}"
---
apiVersion: infrastructure.cluster.x-k8s.io/v1beta2
kind: OpenStackMachineTemplate
metadata:
name: mein-cluster-worker
spec:
template:
spec:
flavor: SCS-4V-8
rootVolume:
sizeGiB: 50
type: ceph-ssd
image:
filter:
name: ubuntu-2404-kube-v1.35.3
sshKeyName: mein-ssh-key
Ein paar Felder verdienen eine kurze Erklärung:
rootVolume ist notwendig, wenn der Flavor keinen lokalen Speicher mitbringt. SCS-Flavors ohne Storage-Suffix wie SCS-2V-4 oder SCS-4V-8 sind diskless, CAPO legt dann ein Cinder-Volume als Bootdisk an. Der Volume-Typ ist umgebungsabhängig und muss an die verfügbaren Cinder-Backends angepasst werden.
provider-id in den kubeletExtraArgs verknüpft jeden Kubernetes-Node mit der entsprechenden OpenStack-Instanz. Ohne diesen Parameter kann der Cloud Controller Manager Nodes nicht korrekt zuordnen. Der Wert wird zur Laufzeit automatisch aus dem cloud-init-Kontext der VM bezogen.
apiServerLoadBalancer und externalNetwork steuern den vorgelagerten Load Balancer für den Kubernetes API-Server. Die externalNetwork-ID ist die UUID des externen Netzwerks in OpenStack, über das der Load Balancer eine Floating IP erhält.
controlPlaneAvailabilityZones und failureDomain legen fest, in welchen OpenStack Availability Zones Control-Plane-Nodes und Worker gestartet werden.
Pivot: Den Management-Cluster auf den Workload-Cluster verschieben
Cluster API wird typischerweise auf einem temporären Bootstrap-Cluster gestartet, oft einem lokalen kind-Cluster. Sobald der eigentliche Workload-Cluster stabil läuft, lässt sich die CAPI-Steuerungsebene dorthin verschieben. Das nennt sich Pivot.
Der Befehl dafür ist clusterctl move. Er überträgt alle CAPI-Objekte vom Bootstrap-Cluster in den Zielcluster:
# CAPI und CAPO im Zielcluster installieren
clusterctl init \
--kubeconfig=<zielcluster-kubeconfig> \
--infrastructure openstack
# Pivot durchführen
clusterctl move \
--to-kubeconfig=<zielcluster-kubeconfig>
Nach dem Pivot verwaltet der Workload-Cluster sich selbst. Der Bootstrap-Cluster kann danach abgebaut werden. Was vorher ein Hilfsmittel war, ist jetzt nicht mehr Teil der Infrastruktur.
Kubeconfig-Management nach dem Pivot
Hier liegt eine der häufig übersehenen Herausforderungen: Die Kubeconfig für den Management-Cluster muss langfristig gültig bleiben. kubeadm stellt Zertifikate standardmäßig mit einer Laufzeit von einem Jahr aus. Das gilt auch für die eingebetteten Zugangsdaten in der Kubeconfig. Wer das nicht im Blick hat, verliert eines Tages den Zugriff auf seinen eigenen Management-Cluster.
Zwei Wege, das zu lösen:
Option 1: Jährliche Erneuerung mit kubeadm certs renew all. Funktioniert zuverlässig, setzt aber einen automatisierten Prozess voraus, damit die Erneuerung nicht vergessen wird.
Option 2: Service Account mit dauerhaft gültigem Token. Seit Kubernetes 1.24 werden keine automatischen Token-Secrets für Service Accounts mehr angelegt. Man legt das Secret explizit an:
apiVersion: v1
kind: ServiceAccount
metadata:
name: capi-admin
namespace: kube-system
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: capi-admin-binding
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: cluster-admin
subjects:
- kind: ServiceAccount
name: capi-admin
namespace: kube-system
---
apiVersion: v1
kind: Secret
metadata:
name: capi-admin-token
namespace: kube-system
annotations:
kubernetes.io/service-account.name: capi-admin
type: kubernetes.io/service-account-token
Das Token aus diesem Secret läuft nicht ab, solange Secret und Service Account existieren. Es sollte geschützt aufbewahrt und nur für den Zugriff auf den Management-Cluster verwendet werden. Wer auf Nummer sicher gehen will: Zertifikatserneuerung automatisieren und das Service-Account-Token als robusteren Fallback bereithalten.
Air-gapped Deployments als Sonderfall
Eine der interessanteren Herausforderungen in letzter Zeit war ein Deployment in einer Umgebung ohne regulären Internetzugang. Kein direktes Pulling von Container-Images, keine externe Registry, kein einfacher Weg nach draußen.
Das ist lösbar, aber es erfordert Vorbereitung. Man braucht eine interne Registry, alle benötigten Images müssen vorher gespiegelt werden, und containerd muss so konfiguriert sein, dass es ausschließlich diese interne Quelle nutzt. CAPO ermöglicht es, diese Konfiguration direkt über KubeadmConfigTemplate auf jeden Node zu legen:
apiVersion: bootstrap.cluster.x-k8s.io/v1beta1
kind: KubeadmConfigTemplate
metadata:
name: mein-cluster-worker
spec:
template:
spec:
files:
- path: /etc/containerd/certs.d/docker.io/hosts.toml
owner: root:root
permissions: "0644"
content: |
server = "https://docker.io"
[host."https://registry-mirror.example.com"]
capabilities = ["pull", "resolve"]
- path: /etc/containerd/certs.d/registry.k8s.io/hosts.toml
owner: root:root
permissions: "0644"
content: |
server = "https://registry.k8s.io"
[host."https://registry-mirror.example.com"]
capabilities = ["pull", "resolve"]
- path: /etc/containerd/certs.d/ghcr.io/hosts.toml
owner: root:root
permissions: "0644"
content: |
server = "https://ghcr.io"
[host."https://registry-mirror.example.com"]
capabilities = ["pull", "resolve"]
- path: /etc/containerd/certs.d/quay.io/hosts.toml
owner: root:root
permissions: "0644"
content: |
server = "https://quay.io"
[host."https://registry-mirror.example.com"]
capabilities = ["pull", "resolve"]
joinConfiguration:
nodeRegistration:
name: "{{ local_hostname }}"
kubeletExtraArgs:
- name: cloud-provider
value: external
- name: provider-id
value: "openstack:///{{ instance_id }}"
Das gleiche files-Array gehört auch in den KubeadmControlPlane-Block unter kubeadmConfigSpec, damit die Control-Plane-Nodes dieselbe Mirror-Konfiguration erhalten. Wer das nicht von Anfang an plant, stößt spätestens beim ersten Node-Upgrade auf ein Problem.
Solche Umgebungen sind bei Behörden und in sicherheitskritischen Bereichen keine Seltenheit. Sie brauchen keine Internetzugang-Ausnahme, sie brauchen ein Deployment, das von vornherein ohne ihn auskommt.
Für wen das relevant ist
CAPO eignet sich überall dort, wo man Kubernetes auf eigener OpenStack-Infrastruktur produktiv betreiben will und dabei Wert auf wartbare, reproduzierbare Setups legt. Das gilt für größere Unternehmen mit eigenem Rechenzentrum genauso wie für Behörden, die auf souveräne Infrastruktur setzen, oder für KMUs, die aus Datenschutzgründen keine Public Cloud nutzen wollen.
Was man mitbringen sollte: solide Kubernetes-Kenntnisse und ein gutes Verständnis der eigenen OpenStack-Umgebung. CAPO nimmt einem die operative Arbeit ab, aber die initiale Konfiguration erfordert, dass man versteht, was man tut. Mit der richtigen Vorbereitung zahlt sich das schnell aus.
Sie betreiben OpenStack und überlegen, wie Sie Kubernetes sauber darauf aufsetzen? Sprechen Sie mich gerne an.