Building a production-grade Kubernetes cluster as the foundation for an OpenStack-Helm cloud involves far more than running kubeadm init. Multi-NIC node topologies, CNI interface selection around OVS/OVN bridges, bare-metal load balancing, distributed block storage, Gateway API TLS routing, and out-of-the-box observability all need to be wired together reproducibly.
This guide walks through my openstack-k8s-cluster ↗ Ansible automation, which provisions a full Kubernetes 1.34.9 cluster on Ubuntu nodes from bare OS to ready-to-use dashboards and OpenStack prerequisites.
Component Versions#
| Component | Version |
|---|---|
Kubernetes (kubelet, kubeadm, kubectl) | 1.34.9 (1.34.9-1.1) |
| containerd | > 1.6 (latest from Docker APT repository) |
| Calico | 3.32.0 |
| Helm | 3.21.2 |
| MetalLB | v0.16.1 |
| cert-manager | v1.13.0 |
| Rook-Ceph (Operator & Cluster) | 1.19.7 |
| FluxCD (Flux2) | 2.18.4 |
| Gateway API CRDs | latest release (not pinned) |
| Envoy Gateway | v1.8.1 |
| kube-prometheus-stack (Prometheus + Grafana) | 87.2.1 |
| Kubernetes Dashboard | 7.14.0 |
Architecture Overview#
Ingress Path#
- MetalLB (
v0.16.1) assigns a virtual IP (gateway_ip, default192.168.64.100) from the configured address pool (192.168.64.100-192.168.64.110) using L2 (ARP) or BGP mode. - Envoy Gateway (
v1.8.1) binds to that VIP through anEnvoyProxyresource (gateway-proxy-defaultinenvoy-gateway-system) and exposes a sharedGateway(gateway-default) listening on:80(HTTP) and:443(HTTPS, TLS termination). HTTPRouteresources route traffic by hostname (*.<gateway_ip>.nip.io) to the backends:grafana.<gw-ip>.nip.io→kube-prometheus-stack-grafana:80(monitoring)dashboard.<gw-ip>.nip.io→kubernetes-dashboard-auth:8000(/auth),kubernetes-dashboard-api:8000(/api), andkubernetes-dashboard-web:8000(/) (kubernetes-dashboard)ceph.<gw-ip>.nip.io→rook-ceph-mgr-dashboard:7000(rook-ceph)
TLS#
- cert-manager (
v1.13.0) bootstraps theopenstack-caPKI hierarchy (identical toclusters/prod/region1/openstack/pki/in openstack-helm ↗):selfsigned-bootstrap(ClusterIssuer) →openstack-ca(Certificateincert-manager, RSA 4096, 10y,secretName: openstack-ca-secret) →openstack-ca(ClusterIssuer) →gateway-openstack-ca-cert(Certificateinenvoy-gateway-system,*.<public_domain>,secretName: gateway-openstack-ca-tls). - The shared
Gateway(gateway-default) servesgateway-openstack-ca-tlsfrom the very first run so Grafana, Kubernetes Dashboard, and Rook-Ceph Dashboard are immediately reachable over HTTPS, and FluxCD later adopts the exact same PKI resources in-place without regenerating the CA or switching the Gateway certificate. - Supports multi-region CA sharing via
openstack_ca_bootstrap: false(verifies an importedopenstack-ca-secretincert-managerinstead of generating a new root CA) and automatically cleans up legacy self-signed certificates (legacy_selfsigned_certs).
Monitoring#
- kube-prometheus-stack (
87.2.1) deploys Prometheus (30dretention) and Grafana (admin/grafana_admin_password). - Rook-Ceph
ServiceMonitorresources are applied and labeled (release: kube-prometheus-stack) so Prometheus automatically scrapes Ceph metrics. - Three Ceph Grafana dashboards (Cluster, OSD, Pools) are downloaded on the control plane directly from the official Rook repository (
v1.19.7) and injected as labeledConfigMaps (grafana_dashboard: "1"), which Grafana’s sidecar (searchNamespace: ALL) auto-discovers.
Storage#
- Rook-Ceph (
1.19.7) deploys the operator and aCephClusteracross worker nodes, exposing ageneralRBDStorageClassbacked byceph-blockpool(allowVolumeExpansion: true) for persistent volumes.
Platform#
- Calico (
3.32.0) provides pod networking (10.244.0.0/16) using VXLAN encapsulation pinned to the management interface viacan-reach=<control_plane_ip>. - FluxCD (
2.18.4) runs influx-systemfor GitOps continuous delivery. - Gateway API standard CRDs and Envoy Gateway CRDs are installed cluster-wide.
Network Configuration#
Every node attaches to the same set of L2 networks. Netplan renames the hypervisor-assigned NICs (enp0s1, enp0s2, …) by matching their MAC addresses so interface names are identical across all nodes (ens160, ens192, ens224, ens256).
Interfaces#
| Interface | Network | Subnet | Gateway | Addressing | Present on | Used by |
|---|---|---|---|---|---|---|
ens160 | Management / Kubernetes | 192.168.64.0/24 | 192.168.64.1 | Static IP, DNS 8.8.8.8 / 8.8.4.4 | Masters & Workers | SSH / Ansible, Kubernetes API (6443), Calico VXLAN (can-reach=<control_plane_ip>), MetalLB L2 (metallb_l2_interface) |
ens192 | Internal | 10.0.0.0/24 | — | Static IP | Masters & Workers | Internal node-to-node traffic |
ens224 | External flat (provider) | — | — | No IP (dhcp4: false) | Masters & Workers | OpenStack external flat network (consumed later by OVS/Neutron br-ex) |
ens256 | Octavia | — | — | No IP (dhcp4: false) | Workers only | OpenStack Octavia Load Balancer management network |
Why
ens224andens256have no IP: Both interfaces are raw L2 ports bridged into OpenStack virtual switches (br-exand the Octavia bridge) by the companion OpenStack deployment. Calico is explicitly pinned toens160(IP_AUTODETECTION_METHOD=can-reach={{ control_plane_ip }}) and MetalLB L2 advertisements are restricted toens160so neither component accidentally binds to these unnumbered interfaces.
Node IP Assignment (inventory/dev)#
| Node | Role | ens160 (Management / K8s) | ens192 (Internal) | ens224 (External Flat) | ens256 (Octavia) |
|---|---|---|---|---|---|
openstack-master1 | Control Plane | 192.168.64.150/24 | 10.0.0.1/24 | L2 only (no IP) | — (not attached) |
openstack-worker1 | Worker | 192.168.64.151/24 | 10.0.0.2/24 | L2 only (no IP) | L2 only (no IP) |
openstack-worker2 | Worker | 192.168.64.152/24 | 10.0.0.3/24 | L2 only (no IP) | L2 only (no IP) |
openstack-worker3 | Worker | 192.168.64.153/24 | 10.0.0.4/24 | L2 only (no IP) | L2 only (no IP) |
| MetalLB Pool | LoadBalancer VIPs | 192.168.64.100-192.168.64.110 | — | — | — |
| Envoy Gateway VIP | Ingress (gateway_ip) | 192.168.64.100 | — | — | — |
Netplan Configuration (/etc/netplan/50-cloud-init.yaml)#
Control Plane (openstack-master1 — 3 NICs)#
network:
version: 2
ethernets:
ens160:
match:
macaddress: '5a:96:25:a0:20:fb' # replace with node's NIC 1 MAC
set-name: 'ens160'
addresses:
- 192.168.64.150/24
nameservers:
addresses:
- 8.8.8.8
- 8.8.4.4
routes:
- to: default
via: 192.168.64.1
ens192:
match:
macaddress: '26:e4:40:b9:1b:0d' # replace with node's NIC 2 MAC
set-name: 'ens192'
addresses:
- 10.0.0.1/24
ens224:
match:
macaddress: 'da:cb:03:ae:57:d7' # replace with node's NIC 3 MAC
set-name: 'ens224'
dhcp4: falseyamlWorker Nodes (openstack-worker1..3 — 4 NICs, showing openstack-worker1)#
network:
version: 2
ethernets:
ens160:
match:
macaddress: 'fa:f0:a2:bf:73:a9' # replace with node's NIC 1 MAC
set-name: 'ens160'
addresses:
- 192.168.64.151/24 # .152 on worker2, .153 on worker3
nameservers:
addresses:
- 8.8.8.8
- 8.8.4.4
routes:
- to: default
via: 192.168.64.1
ens192:
match:
macaddress: 'be:fc:0e:7b:28:18' # replace with node's NIC 2 MAC
set-name: 'ens192'
addresses:
- 10.0.0.2/24 # .3 on worker2, .4 on worker3
ens224:
match:
macaddress: 'ce:fd:c0:be:84:88' # replace with node's NIC 3 MAC
set-name: 'ens224'
dhcp4: false
ens256:
match:
macaddress: 'ae:77:f5:16:65:6f' # replace with node's NIC 4 MAC
set-name: 'ens256'
dhcp4: falseyamlApply the configuration on each node with:
sudo netplan applybashAnsible Roles#
The repository is structured into five Ansible roles under roles/, each mapped to a dedicated playbook under playbooks/:
1. common (playbooks/common.yaml)#
Runs on all nodes (control planes and workers) to prepare the OS for Kubernetes:
- Disables swap at runtime (
swapoff -a) and removes swap entries from/etc/fstab. - Loads required kernel modules (
overlay,br_netfilter) and persists them in/etc/modules-load.d/k8s.conf. - Configures sysctl networking parameters in
/etc/sysctl.d/99-kubernetes-cri.conf(net.bridge.bridge-nf-call-iptables = 1,net.ipv4.ip_forward = 1,net.bridge.bridge-nf-call-ip6tables = 1). - Installs
containerd(containerd.io > 1.6) from the official Docker Ubuntu APT repository, generates/etc/containerd/config.toml, and enables theSystemdCgroup = truedriver required bykubelet. - Adds the official Kubernetes
v1.34APT repository (pkgs.k8s.io), installskubelet,kubeadm, andkubectlpinned to1.34.9-1.1, and placeskubelet,kubeadm,kubectl, andcontainerd.ioon hold viaapt-mark hold.
2. control_planes (playbooks/control_planes.yaml)#
Runs on the control_planes group to bootstrap the Kubernetes control plane:
- Initializes the cluster with
kubeadm initusing--apiserver-advertise-address={{ control_plane_ip }},--pod-network-cidr={{ pod_network_cidr }}(10.244.0.0/16),--kubernetes-version={{ kube_version }}, and--cri-socket /var/run/containerd/containerd.sock(skipped idempotently if/etc/kubernetes/admin.confalready exists). - Sets up
/root/.kube/configfor therootuser. - Installs the Calico (
3.32.0) CNI plugin, pinsIP_AUTODETECTION_METHOD=can-reach={{ control_plane_ip }}ondaemonset/calico-node, patches the defaultIPPoolto useVXLANencapsulation, and waits forcalico-nodeto roll out. - Patches the CoreDNS
ConfigMap(kube-system/coredns) to addendpoint_pod_namesunderpods insecureand restarts CoreDNS (required for OpenStack-Helm OVN RAFT clustering).
3. nodes (playbooks/nodes.yaml)#
Runs on the nodes group to join worker nodes to the cluster:
- Checks whether
/etc/kubernetes/kubelet.confalready exists on each worker. - Generates a fresh join token on
control_planes[0](kubeadm token create --print-join-command) and joins unjoined workers with--cri-socket /var/run/containerd/containerd.sock.
4. cluster_addons (playbooks/cluster_addons.yaml)#
Runs once against control_planes[0] to deploy the full platform stack in 11 ordered stages:
- Python Kubernetes client: Installs
python3-pipandpython3-kubernetesvia APT on the control plane node sokubernetes.core.k8stasks execute natively on the remote host. - MetalLB (
v0.16.1): EnablesstrictARP: truein thekube-proxyConfigMap(whenmetallb_mode == 'l2'), deploys MetalLB (metallb-frr.yaml), waits for the controller pods, and creates theIPAddressPool(default-pool) plus anL2Advertisement(pinned tometallb_l2_interface: ens160) orBGPPeer/BGPAdvertisement(whenmetallb_mode == 'bgp'). - Helm (
3.21.2) & CNI binaries: Copies Calico CNI binaries from/opt/cni/bin/*to/usr/lib/cniand installs the Helm CLI binary (automatically detectingamd64vsarm64). - cert-manager (
v1.13.0): Installscert-managerwith CRDs via Helm and waits for the deployment to become ready. - Rook-Ceph (
1.19.7): Deploys therook-cephoperator androok-ceph-cluster(withtoolbox.enabled: true) via Helm in therook-cephnamespace, then creates thegeneralRBDStorageClassbacked byceph-blockpool. - FluxCD (
2.18.4): Installsflux2in theflux-systemnamespace via Helm. - Gateway API & Envoy Gateway (
v1.8.1): Applies standard Gateway API CRDs, installs Envoy Gateway CRDs fromoci://docker.io/envoyproxy/gateway-crds-helmvia server-side apply, deploys Envoy Gateway (v1.8.1), and creates thedefaultGatewayClassandgateway-proxy-defaultEnvoyProxypinned togateway_ip(192.168.64.100). - kube-prometheus-stack (
87.2.1): Installs Prometheus and Grafana inmonitoring(30dretention, Grafana dashboard sidecar scanning all namespaces), applies Rook-Ceph Prometheus RBAC andServiceMonitormanifests, labels therook-ceph-mgrServiceMonitorwithrelease: kube-prometheus-stack, and imports three Ceph Grafana dashboards from the official Rook repository (v1.19.7) as labeledConfigMaps. - Kubernetes Dashboard (
7.14.0): Installs Kubernetes Dashboard inkubernetes-dashboardwith HTTP enabled onkong-proxy, and creates anadmin-userServiceAccountbound tocluster-admin. - PKI (
openstack-ca) & SharedGateway: BootstrapsClusterIssuer/selfsigned-bootstrap→Certificate/openstack-ca(cert-manager/openstack-ca-secret, RSA 4096, 10y) →ClusterIssuer/openstack-ca→Certificate/gateway-openstack-ca-cert(envoy-gateway-system/gateway-openstack-ca-tls,*.<public_domain>), waits for each resource to reportReady, creates the sharedgateway-defaultGatewayinenvoy-gateway-systemservinggateway-openstack-ca-tls, and removes any legacy self-signed certificates (legacy_selfsigned_certs). HTTPRoutes & Access Summary: Creates HTTPSHTTPRoutes for Grafana (grafana.<gw-ip>.nip.io), Kubernetes Dashboard (dashboard.<gw-ip>.nip.io), and Rook-Ceph Dashboard (ceph.<gw-ip>.nip.io, after settingdashboard.ssl: falseon theCephClustersorook-ceph-mgr-dashboardserves HTTP on port7000), then prints the complete cluster access summary with URLs, credentials, and tokens.
5. update-and-reboot (playbooks/update-and-reboot.yaml)#
- Runs
apt update && apt dist-upgradeacross all nodes and reboots any node where/var/run/reboot-requiredis present.
Prerequisites#
System Requirements#
| Requirement | Minimum Specification |
|---|---|
| OS | Ubuntu 22.04 LTS or 24.04 LTS (64-bit) |
| Control Plane | 2+ vCPUs, 4 GB+ RAM, 30 GB+ OS disk, 3 NICs (ens160, ens192, ens224) |
| Worker Nodes | 4+ vCPUs, 8 GB+ RAM, 20 GB+ OS disk, 100 GB dedicated raw (unformatted) disk per worker for Rook-Ceph OSDs, 4 NICs (ens160..ens256) |
| Access | Root SSH access enabled (PermitRootLogin yes) from the Ansible control machine to all nodes |
Control Machine Setup#
Install Ansible and the kubernetes.core collection on your workstation or bastion host:
# macOS
brew install ansible
# Ubuntu / Debian
sudo apt update && sudo apt install -y ansible
# Install the required Ansible Kubernetes collection
ansible-galaxy collection install kubernetes.corebashNote: You do not need to install the Python
kuberneteslibrary on your local machine—thecluster_addonsrole automatically installspython3-kubernetesvia APT on the remote control plane node where allkubernetes.core.k8stasks are delegated.
SSH Key Configuration#
Copy your SSH public key to the root user on every node and verify connectivity:
# Generate an SSH key pair if you do not have one
ssh-keygen -t ed25519
# Copy the key to the control plane and worker nodes
ssh-copy-id root@192.168.64.150
ssh-copy-id root@192.168.64.151
ssh-copy-id root@192.168.64.152
ssh-copy-id root@192.168.64.153
# Clone the repository and verify Ansible connectivity
git clone https://gitlab.com/openstack7461002/openstack-k8s-cluster.git
cd openstack-k8s-cluster
ansible -i inventory/dev all -m pingbashRequired Ports#
Ensure the following ports are open between nodes on the management network (ens160):
| Node Group | Protocol | Port Range | Purpose |
|---|---|---|---|
| Control Plane | TCP | 6443 | Kubernetes API server |
| Control Plane | TCP | 2379-2380 | etcd server client API |
| Control Plane | TCP | 10250, 10257, 10259 | kubelet, kube-controller-manager, kube-scheduler |
| Workers | TCP | 10250 | kubelet API |
| Workers | TCP/UDP | 30000-32767 | NodePort services |
| All Nodes | UDP | 4789 | Calico VXLAN overlay network |
| All Nodes | TCP/UDP | 7946 | MetalLB memberlist speaker communication |
| All Nodes | TCP | 6789, 3300, 6800-7300 | Rook-Ceph MONs, MGRs, and OSDs |
Installation#
Step 1: Configure the Inventory (inventory/dev)#
Update inventory/dev with your node hostnames and management IPs (ens160):
[control_planes]
openstack-master1 ansible_host=192.168.64.150
[nodes]
openstack-worker1 ansible_host=192.168.64.151
openstack-worker2 ansible_host=192.168.64.152
openstack-worker3 ansible_host=192.168.64.153iniStep 2: Review Cluster Variables (inventory/group_vars/all.yaml)#
All versions, network CIDRs, MetalLB settings, PKI settings, and add-on chart versions are centralized in inventory/group_vars/all.yaml:
# --- Kubernetes ---
kube_version: '1.34.9'
k8s_apt_version: 'v1.34'
service_cidr: '10.96.0.0/12'
pod_network_cidr: '10.244.0.0/16'
control_plane_ip: "{{ hostvars[groups['control_planes'][0]]['ansible_default_ipv4'].address | default(groups['control_planes'][0]) }}"
# --- Component versions ---
calico_version: '3.32.0'
calico_encapsulation: 'VXLAN'
metallb_version: '0.16.1'
cert_manager_version: 'v1.13.0'
rook_ceph_version: '1.19.7'
fluxcd_version: '2.18.4'
envoy_gateway_version: '1.8.1'
prometheus_stack_version: '87.2.1'
k8s_dashboard_version: '7.14.0'
helm_version: '3.21.2'
# --- Gateway API & PKI (openstack-ca) ---
gateway_ip: '192.168.64.100'
public_domain: '{{ gateway_ip }}.nip.io'
openstack_ca_bootstrap: true
# --- MetalLB ---
metallb_mode: 'l2'
metallb_ip_range: '192.168.64.100-192.168.64.110'
metallb_l2_interface: 'ens160'
# --- Monitoring ---
grafana_admin_password: 'ChangeMe123!'yamlStep 3: Run the Playbooks#
You can run the entire deployment in one command using playbooks/k8s_all.yaml (note that k8s_all.yaml also runs update-and-reboot at the end, which upgrades APT packages and reboots nodes if required):
ansible-playbook -i inventory/dev playbooks/k8s_all.yamlbashOr execute each stage step by step:
# 1. Prepare all nodes (swap, kernel modules, sysctl, containerd, kubeadm/kubelet/kubectl)
ansible-playbook -i inventory/dev playbooks/common.yaml
# 2. Initialize the control plane, Calico (VXLAN), and CoreDNS
ansible-playbook -i inventory/dev playbooks/control_planes.yaml
# 3. Join worker nodes to the cluster
ansible-playbook -i inventory/dev playbooks/nodes.yaml
# 4. Deploy MetalLB, cert-manager, Rook-Ceph, FluxCD, Envoy Gateway, PKI, Prometheus/Grafana, and K8s Dashboard
ansible-playbook -i inventory/dev playbooks/cluster_addons.yamlbashStep 4: Fetch kubeconfig and Verify the Cluster#
Copy /root/.kube/config from the control plane to your local workstation:
mkdir -p ~/.kube
scp root@192.168.64.150:~/.kube/config ~/.kube/config
kubectl get nodes -o widebashExpected output:
NAME STATUS ROLES AGE VERSION INTERNAL-IP OS-IMAGE KERNEL-VERSION CONTAINER-RUNTIME
openstack-master1 Ready control-plane 12m v1.34.9 192.168.64.150 Ubuntu 24.04.2 LTS 6.8.0-57-generic containerd://1.7.27
openstack-worker1 Ready <none> 10m v1.34.9 192.168.64.151 Ubuntu 24.04.2 LTS 6.8.0-57-generic containerd://1.7.27
openstack-worker2 Ready <none> 10m v1.34.9 192.168.64.152 Ubuntu 24.04.2 LTS 6.8.0-57-generic containerd://1.7.27
openstack-worker3 Ready <none> 10m v1.34.9 192.168.64.153 Ubuntu 24.04.2 LTS 6.8.0-57-generic containerd://1.7.27textStep 5: Verify the Rook-Ceph Cluster#
Verify that Rook-Ceph has discovered the 100 GB raw disk on each of the three worker nodes (3 osds: 3 up, 3 in, 300 GiB total capacity) and that the cluster status is HEALTH_OK:
kubectl -n rook-ceph exec -it deploy/rook-ceph-tools -- ceph -sbashExpected output:
cluster:
id: 85cbf791-3802-4b59-9abc-9c1dda79759b
health: HEALTH_OK
services:
mon: 3 daemons, quorum a,b,c (age 19s)
mgr: b(active, since 5m), standbys: a
mds: 1/1 daemons up, 1 hot standby
osd: 3 osds: 3 up (since 11s), 3 in (since 9m)
rgw: 1 daemon active (1 hosts, 1 zones)
data:
volumes: 1/1 healthy
pools: 12 pools, 241 pgs
objects: 297 objects, 619 KiB
usage: 148 MiB used, 300 GiB / 300 GiB avail
pgs: 240 active+clean
1 active+clean+scrubbing
io:
recovery: 6.3 KiB/s, 1 keys/s, 38 objects/stextDeep Dive: Networking, CoreDNS, and Gateway API Logic#
Several subtle networking problems surface when preparing a multi-NIC Kubernetes cluster for OpenStack-Helm. Below is how the playbooks solve each one automatically.
1. Pinning Calico to ens160 and Enforcing VXLAN#
By default, Calico’s first-found IP autodetection scans node interfaces and can pick up internal networks (ens192) or OpenStack OVS/OVN bridges (ens224, ens256, br-ex, br-int), breaking cross-node pod routing. Furthermore, Calico’s default manifest enables IP-in-IP (IPIP) encapsulation (IP protocol 4), which is silently dropped by several hypervisor virtual networks (including Apple vmnet on macOS).
In roles/control_planes/tasks/main.yaml, the playbook:
- Pins
daemonset/calico-nodeto the interface that routes tocontrol_plane_ip(ens160):
bashkubectl set env daemonset/calico-node -n kube-system \ IP_AUTODETECTION_METHOD=can-reach={{ control_plane_ip }} - Directly patches the
default-ipv4-ippoolCRD (ippools.crd.projectcalico.org) to enforceVXLAN(UDP4789) instead of relying onCALICO_IPV4POOL_*environment variables, whichcalico-nodeignores once the initial pool already exists:
bashkubectl patch ippools.crd.projectcalico.org default-ipv4-ippool --type=merge -p \ '{"spec":{"ipipMode":"Never","vxlanMode":"Always"}}'
2. CoreDNS endpoint_pod_names for OpenStack OVN RAFT#
OpenStack-Helm’s ovn chart deploys Northbound and Southbound ovsdb-server StatefulSets that form RAFT clusters using headless Service DNS records (<pod-name>.<service>.openstack.svc.cluster.local). By default, CoreDNS resolves endpoint records using IP dashes (192-168-x-x) rather than pod names, causing ovsdb-server RAFT initialization to fail.
The control_planes role patches the kube-system/coredns ConfigMap to insert endpoint_pod_names inside the kubernetes cluster.local block right after pods insecure and triggers a rolling restart of deployment/coredns.
3. MetalLB strictARP and Interface Pinning#
When running MetalLB in Layer 2 mode (metallb_mode: "l2"), kube-proxy in IPVS mode requires strictARP: true so that only the elected MetalLB speaker node answers ARP requests for LoadBalancer VIPs. The cluster_addons role patches configmap/kube-proxy in kube-system to set strictARP: true and pins the L2Advertisement to ens160 (interfaces: [ens160]) so ARP announcements never leak onto ens224 (br-ex).
4. Gateway API Routing & Unified openstack-ca PKI#
Instead of using the legacy NGINX Ingress Controller, the cluster uses Envoy Gateway (v1.8.1) implementing the Kubernetes Gateway API. Three key details are handled in roles/cluster_addons/tasks/main.yaml:
- Unified
openstack-caPKI hierarchy:cluster_addonsbootstraps the exact same CA hierarchy asclusters/prod/region1/openstack/pki/inopenstack-helm:ClusterIssuer/selfsigned-bootstrap(self-signed, used only to sign the root CA whenopenstack_ca_bootstrap: true)Certificate/openstack-caincert-manager(isCA: true, RSA 4096, 10 years, secretopenstack-ca-secret)ClusterIssuer/openstack-ca(CA issuer backed byopenstack-ca-secret)Certificate/gateway-openstack-ca-certinenvoy-gateway-system(*.{{ public_domain }}and{{ public_domain }}, 1 year, secretgateway-openstack-ca-tls) Every task waits forReady=Truebefore creatingGateway/gateway-default, so HTTPS works immediately after the playbook finishes. When FluxCD later reconciles theopenstack-pkiKustomization, it adopts these resources as-is without regenerating the CA or re-issuing the Gateway certificate. For secondary regions (region2) that share an imported CA (imported-ca-secret.yaml), settingopenstack_ca_bootstrap: falseskips creating a new root CA and verifies thatcert-manager/openstack-ca-secretis already present. Any legacy self-signed resources (legacy_selfsigned_certsinroles/cluster_addons/vars/main.yaml) are also automatically removed.
- Kubernetes Dashboard v7 split architecture: Kubernetes Dashboard
7.14.0separates authentication (kubernetes-dashboard-auth:8000), API (kubernetes-dashboard-api:8000), and the Angular SPA frontend (kubernetes-dashboard-web:8000). Whilekong-proxyis enabled on HTTP port80, theHTTPRouteexplicitly matches/auth,/api, and/so login requests (POST /api/v1/login) and static assets route cleanly without nested reverse-proxy redirects. - Rook-Ceph Dashboard HTTP backend: By default,
rook-ceph-mgr-dashboarduses self-signed HTTPS on port8443, which causes Envoy Gateway to return503 Service Unavailableunless custom BackendTLSPolicies are configured. The playbook patches theCephClusterspec withdashboard.ssl: false, waits forrook-ceph-mgr-dashboardto re-bind on HTTP port7000, and attaches theHTTPRouteto port7000behind Envoy Gateway’s HTTPS listener.
Deep Dive: Monitoring Stack & Ceph Dashboards#
The cluster_addons role deploys kube-prometheus-stack (87.2.1) in the monitoring namespace with a 30-day Prometheus retention policy and Grafana’s dashboard sidecar (kiwigrid/k8s-sidecar) configured with searchNamespace: ALL.
Why Standard Grafana Dashboard Provisioning Failed#
Getting official Ceph dashboards into Grafana reliably inside an Ansible playbook required working around three common pitfalls:
- Helm
gnetIdprovider: Downloads run inside the pod at Helm install time into anemptyDirvolume and are lost on every Grafana pod restart. - Ansible
lookup('url', ...): Runs on the Ansible controller rather than the target control plane node and embeds large JSON payloads inline inside Ansible variables. kubectl port-forward+curl: Background port-forwards inside non-interactive Ansible tasks suffer from race conditions and drop connections beforecurlfinishes.
The Idempotent ConfigMap Sidecar Solution#
The playbook downloads each dashboard JSON directly on the control plane node from the official Rook GitHub repository (https://raw.githubusercontent.com/rook/rook/v{{ rook_ceph_version }}/deploy/examples/monitoring/grafana/) into a temporary file (mktemp), and pipes kubectl create configmap --dry-run=client -o yaml | kubectl apply -f - before labeling it with grafana_dashboard=1:
| ConfigMap Name | Rook Source JSON | Description |
|---|---|---|
grafana-dashboard-ceph-cluster | Ceph Cluster Dashboard.json | Ceph Cluster — overall health, capacity, IOPS, and throughput |
grafana-dashboard-ceph-osd | Ceph OSD Single Dashboard.json | Ceph OSD (Single) — per-OSD latency, apply/commit times, and disk utilization |
grafana-dashboard-ceph-pools | Ceph Pools Dashboard.json | Ceph Pools — per-pool usage, object counts, and read/write throughput |
Additionally, Rook-Ceph’s ServiceMonitor (rook-ceph-mgr in rook-ceph) is patched with the label release: kube-prometheus-stack so the Prometheus Operator includes Ceph metrics automatically.


Accessing Dashboards#
At the end of playbooks/cluster_addons.yaml, Ansible prints a complete summary with URLs and credentials. With the default gateway_ip (192.168.64.100), the services are available over HTTPS (signed by openstack-ca) at:
| Service | URL | Credentials |
|---|---|---|
| Grafana | https://grafana.192.168.64.100.nip.io | User: admin / Password: grafana_admin_password (ChangeMe123! by default) |
| Kubernetes Dashboard | https://dashboard.192.168.64.100.nip.io | Bearer token printed by playbook (or run kubectl -n kubernetes-dashboard create token admin-user) |
| Rook-Ceph Dashboard | https://ceph.192.168.64.100.nip.io | User: admin / Password from rook-ceph-dashboard-password secret |
To retrieve the credentials or export the openstack-ca certificate for browser/CLI trust at any time:
# Export the openstack-ca root certificate for browser/CLI trust
kubectl get secret openstack-ca-secret -n cert-manager \
-o jsonpath='{.data.ca\.crt}' | base64 --decode > /tmp/openstack-ca.crt
# Kubernetes Dashboard token
kubectl -n kubernetes-dashboard create token admin-user
# Rook-Ceph Dashboard password
kubectl -n rook-ceph get secret rook-ceph-dashboard-password \
-o jsonpath='{.data.password}' | base64 --decode && echobashMaintenance and Troubleshooting#
Inspecting Rook-Ceph Health with the Toolbox#
The rook-ceph-cluster Helm release enables toolbox.enabled: true. You can run ceph CLI commands directly inside the toolbox pod:
kubectl -n rook-ceph exec -it deploy/rook-ceph-tools -- ceph status
kubectl -n rook-ceph exec -it deploy/rook-ceph-tools -- ceph osd statusbashChecking Gateway API & Envoy Gateway Status#
Verify that the Gateway has been assigned the MetalLB VIP (192.168.64.100) and that all HTTPRoutes are accepted:
kubectl get gateway -n envoy-gateway-system
kubectl get httproute -AbashUpgrading System Packages and Rebooting Nodes#
To apply Ubuntu security updates and automatically reboot nodes that require a kernel restart:
ansible-playbook -i inventory/dev playbooks/update-and-reboot.yamlbash