Phase 8 – Kubernetes on Homelab OpenStack

Target topology

Recommended first Kubernetes tenant cluster:

OpenStack Project: k8s-lab

Network:
public1 Existing OpenStack external/provider network
k8s-net Private Kubernetes tenant network
k8s-subnet 10.20.0.0/24
k8s-router Router to public1

VMs:
k8s-cp-01 Control plane
k8s-worker-01 Worker
k8s-worker-02 Worker
k8s-gpu-01 Optional later GPU worker

Kubernetes:
Pod CIDR: 10.244.0.0/16
Service CIDR: 10.96.0.0/12
CNI: Cilium
Ingress: ingress-nginx or Cilium ingress
LB learning path: MetalLB
Storage: Ceph CSI if Ceph is reachable
Observability: kube-prometheus-stack + Loki + Tempo + Mimir/Grafana

Assumptions:

OpenStack VIP:         192.168.1.50
OpenStack admin RC: /etc/kolla/admin-openrc.sh
External network: public1
Floating IP range: already configured in OpenStack
Tenant VM OS: Ubuntu 24.04 cloud image
Kubernetes version: use one supported version consistently across all nodes

Step 1 — Create a Kubernetes Project in OpenStack

Load admin credentials on ctrl:

source /etc/kolla/admin-openrc.sh

Create a dedicated project:

openstack project create k8s-lab \
--description "Tenant project for Kubernetes on OpenStack homelab"

Create a user:

openstack user create k8s-admin \
--project k8s-lab \
--password-prompt

Give the user project admin rights:

openstack role add \
--project k8s-lab \
--user k8s-admin \
admin

Optional but useful: create a normal member user later for testing:

openstack user create k8s-user \
--project k8s-lab \
--password-prompt

openstack role add \
--project k8s-lab \
--user k8s-user \
member

Check:

openstack project list
openstack user list
openstack role assignment list --project k8s-lab --names

Create a project RC file:

cat > ~/k8s-lab-openrc.sh <<'EOF'
export OS_AUTH_URL=http://192.168.1.50:5000/v3
export OS_PROJECT_NAME=k8s-lab
export OS_PROJECT_DOMAIN_NAME=Default
export OS_USERNAME=k8s-admin
export OS_USER_DOMAIN_NAME=Default
export OS_IDENTITY_API_VERSION=3
export OS_INTERFACE=public
export OS_REGION_NAME=RegionOne
echo "Enter OpenStack password for k8s-admin:"
read -sr OS_PASSWORD_INPUT
export OS_PASSWORD="$OS_PASSWORD_INPUT"
unset OS_PASSWORD_INPUT
EOF

chmod 600 ~/k8s-lab-openrc.sh

Use it:

source ~/k8s-lab-openrc.sh
openstack token issue

Step 2 — Build Networking

Create the Kubernetes tenant network:

openstack network create k8s-net

Create the subnet:

openstack subnet create k8s-subnet \
--network k8s-net \
--subnet-range 10.20.0.0/24 \
--gateway 10.20.0.1 \
--dns-nameserver 192.168.1.1

Create router:

openstack router create k8s-router

Attach router to external network:

openstack router set k8s-router --external-gateway public1

Attach Kubernetes subnet:

openstack router add subnet k8s-router k8s-subnet

Create security group:

openstack security group create k8s-sg \
--description "Kubernetes tenant cluster security group"

Allow SSH:

openstack security group rule create k8s-sg \
--protocol tcp \
--dst-port 22 \
--ingress

Allow ICMP:

openstack security group rule create k8s-sg \
--protocol icmp \
--ingress

Allow Kubernetes API from your LAN or admin host. For lab simplicity:

openstack security group rule create k8s-sg \
--protocol tcp \
--dst-port 6443 \
--ingress

Allow node-to-node traffic inside the project subnet:

openstack security group rule create k8s-sg \
--protocol tcp \
--remote-ip 10.20.0.0/24 \
--ingress

openstack security group rule create k8s-sg \
--protocol udp \
--remote-ip 10.20.0.0/24 \
--ingress

openstack security group rule create k8s-sg \
--protocol icmp \
--remote-ip 10.20.0.0/24 \
--ingress

Cilium with VXLAN typically uses UDP 8472; Kubernetes also needs kubelet TCP 10250. Add explicit rules:

openstack security group rule create k8s-sg \
--protocol udp \
--dst-port 8472 \
--remote-ip 10.20.0.0/24 \
--ingress

openstack security group rule create k8s-sg \
--protocol tcp \
--dst-port 10250 \
--remote-ip 10.20.0.0/24 \
--ingress

Verify:

openstack network list
openstack subnet list
openstack router list
openstack security group rule list k8s-sg

Step 3 — Upload Kubernetes Images

Download an Ubuntu cloud image on ctrl:

mkdir -p ~/openstack-images
cd ~/openstack-images

wget -nc https://cloud-images.ubuntu.com/releases/24.04/release/ubuntu-24.04-server-cloudimg-amd64.img

Upload to Glance:

source /etc/kolla/admin-openrc.sh

openstack image create ubuntu-24.04-k8s \
--file ~/openstack-images/ubuntu-24.04-server-cloudimg-amd64.img \
--disk-format qcow2 \
--container-format bare \
--public

Verify:

openstack image list

Recommended flavors:

openstack flavor create k8s.control.small \
--ram 4096 \
--disk 40 \
--vcpus 2

openstack flavor create k8s.worker.medium \
--ram 8192 \
--disk 60 \
--vcpus 4

openstack flavor create k8s.worker.large \
--ram 16384 \
--disk 80 \
--vcpus 6

For your Dell T5500 homelab, start modestly:

Control plane: 2 vCPU / 4 GB RAM / 40 GB disk
Workers: 4 vCPU / 8 GB RAM / 60 GB disk
GPU node: later, after PCI passthrough is stable

Step 4 — Build Kubernetes VMs

Create an SSH keypair:

source ~/k8s-lab-openrc.sh

ssh-keygen -t ed25519 -f ~/.ssh/k8s_openstack -C "k8s-openstack"

openstack keypair create \
--public-key ~/.ssh/k8s_openstack.pub \
k8s-key

Create a cloud-init file:

cat > ~/k8s-cloud-init.yaml <<'EOF'
#cloud-config
package_update: true
package_upgrade: false

users:
- default

ssh_pwauth: false

packages:
- qemu-guest-agent
- curl
- wget
- vim
- jq
- net-tools
- iproute2
- ca-certificates
- gnupg
- lsb-release
- apt-transport-https
- software-properties-common

runcmd:
- systemctl enable --now qemu-guest-agent
- modprobe br_netfilter
- modprobe overlay
- |
cat >/etc/modules-load.d/k8s.conf <<EOM
overlay
br_netfilter
EOM
- |
cat >/etc/sysctl.d/99-kubernetes-cri.conf <<EOM
net.bridge.bridge-nf-call-iptables = 1
net.bridge.bridge-nf-call-ip6tables = 1
net.ipv4.ip_forward = 1
EOM
- sysctl --system
EOF

Create ports with fixed IPs. Fixed IPs make kubeadm and OpenStack CCM configuration easier:

openstack port create k8s-cp-01-port \
--network k8s-net \
--fixed-ip subnet=k8s-subnet,ip-address=10.20.0.11 \
--security-group k8s-sg

openstack port create k8s-worker-01-port \
--network k8s-net \
--fixed-ip subnet=k8s-subnet,ip-address=10.20.0.21 \
--security-group k8s-sg

openstack port create k8s-worker-02-port \
--network k8s-net \
--fixed-ip subnet=k8s-subnet,ip-address=10.20.0.22 \
--security-group k8s-sg

Boot the VMs:

openstack server create k8s-cp-01 \
--image ubuntu-24.04-k8s \
--flavor k8s.control.small \
--port k8s-cp-01-port \
--key-name k8s-key \
--user-data ~/k8s-cloud-init.yaml

openstack server create k8s-worker-01 \
--image ubuntu-24.04-k8s \
--flavor k8s.worker.medium \
--port k8s-worker-01-port \
--key-name k8s-key \
--user-data ~/k8s-cloud-init.yaml

openstack server create k8s-worker-02 \
--image ubuntu-24.04-k8s \
--flavor k8s.worker.medium \
--port k8s-worker-02-port \
--key-name k8s-key \
--user-data ~/k8s-cloud-init.yaml

Assign a floating IP to the control plane:

openstack floating ip create public1

CP_FIP=$(openstack floating ip list -f value -c "Floating IP Address" | head -1)

openstack server add floating ip k8s-cp-01 "$CP_FIP"

echo "$CP_FIP"

Optional: assign floating IPs to workers for easier initial setup:

openstack floating ip create public1
openstack floating ip create public1

openstack floating ip list

Then associate them manually:

openstack server add floating ip k8s-worker-01 <WORKER_01_FLOATING_IP>
openstack server add floating ip k8s-worker-02 <WORKER_02_FLOATING_IP>

Check:

openstack server list

SSH to the control plane:

ssh -i ~/.ssh/k8s_openstack ubuntu@"$CP_FIP"

Step 5 — Install Kubernetes

Do this on all Kubernetes VMs: k8s-cp-01, k8s-worker-01, k8s-worker-02.

Kubernetes requires a container runtime on every node; containerd is the straightforward CRI-compatible choice. The Kubernetes docs note that Kubernetes uses CRI-compatible runtimes and that dockershim was removed after Kubernetes 1.24.

5.1 Prepare all nodes

On each VM:

sudo swapoff -a

sudo sed -i '/ swap / s/^/#/' /etc/fstab

sudo modprobe overlay
sudo modprobe br_netfilter

cat <<EOF | sudo tee /etc/modules-load.d/k8s.conf
overlay
br_netfilter
EOF

cat <<EOF | sudo tee /etc/sysctl.d/99-kubernetes-cri.conf
net.bridge.bridge-nf-call-iptables = 1
net.bridge.bridge-nf-call-ip6tables = 1
net.ipv4.ip_forward = 1
EOF

sudo sysctl --system

Install containerd:

sudo apt-get update

sudo apt-get install -y containerd

sudo mkdir -p /etc/containerd

containerd config default | sudo tee /etc/containerd/config.toml >/dev/null

sudo sed -i 's/SystemdCgroup = false/SystemdCgroup = true/' /etc/containerd/config.toml

sudo systemctl restart containerd
sudo systemctl enable containerd
sudo systemctl status containerd --no-pager

Install Kubernetes packages:

sudo apt-get update

sudo apt-get install -y apt-transport-https ca-certificates curl gpg

sudo mkdir -p /etc/apt/keyrings

curl -fsSL https://pkgs.k8s.io/core:/stable:/v1.36/deb/Release.key \
| sudo gpg --dearmor -o /etc/apt/keyrings/kubernetes-apt-keyring.gpg

echo 'deb [signed-by=/etc/apt/keyrings/kubernetes-apt-keyring.gpg] https://pkgs.k8s.io/core:/stable:/v1.36/deb/ /' \
| sudo tee /etc/apt/sources.list.d/kubernetes.list

sudo apt-get update

sudo apt-get install -y kubelet kubeadm kubectl

sudo apt-mark hold kubelet kubeadm kubectl

The official kubeadm installation page currently documents Kubernetes v1.36; use the same minor version across all nodes unless you deliberately pin another supported series.

5.2 Initialise the control plane

On k8s-cp-01 only:

sudo kubeadm init \
--control-plane-endpoint 10.20.0.11 \
--apiserver-advertise-address 10.20.0.11 \
--pod-network-cidr 10.244.0.0/16 \
--service-cidr 10.96.0.0/12 \
--upload-certs

Set up kubeconfig:

mkdir -p ~/.kube

sudo cp /etc/kubernetes/admin.conf ~/.kube/config

sudo chown "$(id -u):$(id -g)" ~/.kube/config

kubectl get nodes

Your node will show NotReady until Cilium is installed.

Get the worker join command:

kubeadm token create --print-join-command

Run the join command on each worker, for example:

sudo kubeadm join 10.20.0.11:6443 \
--token <TOKEN> \
--discovery-token-ca-cert-hash sha256:<HASH>

Back on the control plane:

kubectl get nodes -o wide

Step 6 — Install Cilium

Cilium provides Kubernetes networking using eBPF and is commonly installed with Helm. The Cilium docs provide Helm-based installation guidance and require you to choose the datapath/IPAM mode appropriate to your environment.

Install Helm on k8s-cp-01:

curl https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash

Install Cilium CLI:

CILIUM_CLI_VERSION=$(curl -s https://raw.githubusercontent.com/cilium/cilium-cli/main/stable.txt)

CLI_ARCH=amd64

curl -L --fail --remote-name-all \
"https://github.com/cilium/cilium-cli/releases/download/${CILIUM_CLI_VERSION}/cilium-linux-${CLI_ARCH}.tar.gz"

sudo tar xzvf "cilium-linux-${CLI_ARCH}.tar.gz" -C /usr/local/bin

rm "cilium-linux-${CLI_ARCH}.tar.gz"

Install Cilium with kube-proxy replacement disabled initially for the simplest first deployment:

helm repo add cilium https://helm.cilium.io/
helm repo update

helm install cilium cilium/cilium \
--namespace kube-system \
--set kubeProxyReplacement=false \
--set ipam.mode=kubernetes \
--set tunnelProtocol=vxlan

Wait:

kubectl -n kube-system rollout status ds/cilium
kubectl get nodes
cilium status --wait

Expected:

All nodes Ready
Cilium OK
CoreDNS Running

Optional Hubble:

helm upgrade cilium cilium/cilium \
--namespace kube-system \
--reuse-values \
--set hubble.enabled=true \
--set hubble.relay.enabled=true \
--set hubble.ui.enabled=true

kubectl -n kube-system rollout status deploy/hubble-relay
kubectl -n kube-system rollout status deploy/hubble-ui

Step 7 — Install Cloud Controller Manager

Kubernetes cloud-controller-manager links Kubernetes to the cloud provider API and separates cloud-specific control logic from core Kubernetes. For OpenStack, use cloud-provider-openstack.

This gives Kubernetes awareness of OpenStack instances, node addresses, and, if configured with the right OpenStack services, load balancer and route integration.

7.1 Create OpenStack application credentials

From ctrl, using the k8s-lab user:

source ~/k8s-lab-openrc.sh

openstack application credential create k8s-ccm

Save:

id
secret

7.2 Create cloud.conf

On k8s-cp-01:

mkdir -p ~/openstack-ccm

cat > ~/openstack-ccm/cloud.conf <<'EOF'
[Global]
auth-url=http://192.168.1.50:5000/v3
application-credential-id=<APP_CRED_ID>
application-credential-secret=<APP_CRED_SECRET>
region=RegionOne
domain-name=Default
tenant-name=k8s-lab

[LoadBalancer]
use-octavia=false

[BlockStorage]
bs-version=v3
EOF

Because your Kolla config did not enable Octavia, use-octavia=false is important. OpenStack CCM LoadBalancer services will not behave like a public cloud without Octavia. You will use Ingress/NodePort/MetalLB for lab exposure instead.

Create the secret:

kubectl -n kube-system create secret generic cloud-config \
--from-file=cloud.conf=~/openstack-ccm/cloud.conf

7.3 Deploy OpenStack CCM

Create manifest:

cat > ~/openstack-ccm/openstack-ccm.yaml <<'EOF'
apiVersion: v1
kind: ServiceAccount
metadata:
name: openstack-cloud-controller-manager
namespace: kube-system
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: openstack-cloud-controller-manager
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: system:cloud-controller-manager
subjects:
- kind: ServiceAccount
name: openstack-cloud-controller-manager
namespace: kube-system
---
apiVersion: apps/v1
kind: DaemonSet
metadata:
name: openstack-cloud-controller-manager
namespace: kube-system
spec:
selector:
matchLabels:
k8s-app: openstack-cloud-controller-manager
template:
metadata:
labels:
k8s-app: openstack-cloud-controller-manager
spec:
hostNetwork: true
serviceAccountName: openstack-cloud-controller-manager
tolerations:
- key: node-role.kubernetes.io/control-plane
effect: NoSchedule
- key: node.cloudprovider.kubernetes.io/uninitialized
value: "true"
effect: NoSchedule
containers:
- name: openstack-cloud-controller-manager
image: registry.k8s.io/provider-os/openstack-cloud-controller-manager:latest
args:
- /bin/openstack-cloud-controller-manager
- --v=2
- --cloud-provider=openstack
- --cloud-config=/etc/kubernetes/cloud.conf
- --use-service-account-credentials=true
volumeMounts:
- name: cloud-config
mountPath: /etc/kubernetes
readOnly: true
volumes:
- name: cloud-config
secret:
secretName: cloud-config
EOF

Apply:

kubectl apply -f ~/openstack-ccm/openstack-ccm.yaml

kubectl -n kube-system get pods -l k8s-app=openstack-cloud-controller-manager
kubectl get nodes -o wide

If the image tag changes in your environment, pin a known compatible release from the cloud-provider-openstack project instead of latest.


Step 8 — Install Ceph CSI

Ceph CSI provides Kubernetes CSI drivers for RBD and CephFS; the Ceph CSI project supports RBD, CephFS and Kubernetes sidecar deployment YAMLs. Ceph documentation describes using Ceph RBD with Kubernetes through ceph-csi for dynamic provisioning.

Important: this step assumes your Kubernetes VMs can route to Ceph MONs and that you have Ceph credentials. If your Ceph cluster is only on the OpenStack host side and not reachable from tenant VMs, fix routing/firewalling first.

8.1 On Ceph cluster: create pool and user

On a Ceph admin node:

ceph osd pool create k8s-rbd 32

rbd pool init k8s-rbd

ceph auth get-or-create client.k8s \
mon 'profile rbd' \
osd 'profile rbd pool=k8s-rbd' \
mgr 'profile rbd pool=k8s-rbd' \
-o ceph.client.k8s.keyring

Get values:

ceph fsid
ceph mon dump
ceph auth get-key client.k8s

You need:

clusterID = Ceph FSID
monitors = MON IPs, e.g. 192.168.1.10:6789
userID = k8s
userKey = client.k8s key
pool = k8s-rbd

8.2 Install Ceph CSI RBD with Helm

On k8s-cp-01:

helm repo add ceph-csi https://ceph.github.io/csi-charts
helm repo update

kubectl create namespace ceph-csi-rbd

Create values file:

cat > ~/ceph-csi-rbd-values.yaml <<'EOF'
csiConfig:
- clusterID: "<CEPH_FSID>"
monitors:
- "<MON1_IP>:6789"
- "<MON2_IP>:6789"
- "<MON3_IP>:6789"

secret:
create: true
userID: "k8s"
userKey: "<CEPH_CLIENT_K8S_KEY>"

storageClass:
create: true
name: ceph-rbd
clusterID: "<CEPH_FSID>"
pool: k8s-rbd
imageFeatures: layering
csi.storage.k8s.io/fstype: ext4
reclaimPolicy: Delete
allowVolumeExpansion: true
EOF

Install:

helm install ceph-csi-rbd ceph-csi/ceph-csi-rbd \
--namespace ceph-csi-rbd \
-f ~/ceph-csi-rbd-values.yaml

Check:

kubectl -n ceph-csi-rbd get pods
kubectl get storageclass

Test PVC:

cat > ~/test-rbd-pvc.yaml <<'EOF'
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: test-rbd-pvc
spec:
accessModes:
- ReadWriteOnce
storageClassName: ceph-rbd
resources:
requests:
storage: 1Gi
EOF

kubectl apply -f ~/test-rbd-pvc.yaml
kubectl get pvc

Step 9 — Install Ingress

Use ingress-nginx first because it is familiar and easy to test.

helm repo add ingress-nginx https://kubernetes.github.io/ingress-nginx
helm repo update

kubectl create namespace ingress-nginx

helm install ingress-nginx ingress-nginx/ingress-nginx \
--namespace ingress-nginx \
--set controller.service.type=NodePort

Check:

kubectl -n ingress-nginx get pods
kubectl -n ingress-nginx get svc

Get NodePorts:

kubectl -n ingress-nginx get svc ingress-nginx-controller

You can access via:

http://<node-floating-ip>:<http-nodeport>
https://<node-floating-ip>:<https-nodeport>

Later, after MetalLB or Octavia is working, change to:

helm upgrade ingress-nginx ingress-nginx/ingress-nginx \
--namespace ingress-nginx \
--reuse-values \
--set controller.service.type=LoadBalancer

Step 10 — Install MetalLB

MetalLB is designed for bare-metal Kubernetes load-balancing and supports manifest, Kustomize and Helm installation methods. Its own docs warn to check cloud compatibility; on OpenStack tenant networks, L2 mode may require Neutron port security and allowed-address-pair adjustments.

For your homelab, treat MetalLB as a learning step. Because the Kubernetes nodes are VMs on Neutron VXLAN/private networking, MetalLB L2 may not work until Neutron allows the VIP/MAC behavior.

10.1 Install MetalLB

helm repo add metallb https://metallb.github.io/metallb
helm repo update

kubectl create namespace metallb-system

helm install metallb metallb/metallb \
--namespace metallb-system

Wait:

kubectl -n metallb-system get pods

10.2 Choose an internal LB range

Use unused IPs from k8s-subnet, for example:

10.20.0.200 - 10.20.0.220

Create MetalLB pool:

cat > ~/metallb-pool.yaml <<'EOF'
apiVersion: metallb.io/v1beta1
kind: IPAddressPool
metadata:
name: k8s-lb-pool
namespace: metallb-system
spec:
addresses:
- 10.20.0.200-10.20.0.220
---
apiVersion: metallb.io/v1beta1
kind: L2Advertisement
metadata:
name: k8s-l2
namespace: metallb-system
spec:
ipAddressPools:
- k8s-lb-pool
EOF

kubectl apply -f ~/metallb-pool.yaml

10.3 OpenStack Neutron allowed address pairs

For each Kubernetes node port, allow the MetalLB range. On ctrl with OpenStack credentials:

source ~/k8s-lab-openrc.sh

openstack port list --server k8s-cp-01
openstack port list --server k8s-worker-01
openstack port list --server k8s-worker-02

For each port ID:

openstack port set <PORT_ID> \
--allowed-address ip-address=10.20.0.200

openstack port set <PORT_ID> \
--allowed-address ip-address=10.20.0.201

For a range, you may need to add individual IPs or disable port security for lab testing:

openstack port set <PORT_ID> --disable-port-security

For learning, disabling port security is acceptable; for production, prefer specific allowed-address-pairs.


Step 11 — Install Metrics

Install metrics-server:

helm repo add metrics-server https://kubernetes-sigs.github.io/metrics-server/
helm repo update

kubectl create namespace metrics-server

helm install metrics-server metrics-server/metrics-server \
--namespace metrics-server \
--set args="{--kubelet-insecure-tls}"

Check:

kubectl -n metrics-server get pods
kubectl top nodes
kubectl top pods -A

If kubectl top works, scheduling labs and HPA tests become easier.


Step 12 — Install LGTM

For your observability stack, install:

Grafana
Loki
Tempo
Mimir or Prometheus
kube-prometheus-stack
Alloy or Promtail equivalent

For a first Kubernetes-on-OpenStack lab, use kube-prometheus-stack plus Loki and Tempo. You can add Mimir after Prometheus is working.

Create namespace:

kubectl create namespace observability

Install kube-prometheus-stack:

helm repo add prometheus-community https://prometheus-community.github.io/helm-charts
helm repo update

helm install kube-prometheus-stack prometheus-community/kube-prometheus-stack \
--namespace observability \
--set grafana.service.type=NodePort \
--set prometheus.prometheusSpec.storageSpec.volumeClaimTemplate.spec.storageClassName=ceph-rbd \
--set prometheus.prometheusSpec.storageSpec.volumeClaimTemplate.spec.accessModes[0]=ReadWriteOnce \
--set prometheus.prometheusSpec.storageSpec.volumeClaimTemplate.spec.resources.requests.storage=20Gi

Install Loki:

helm repo add grafana https://grafana.github.io/helm-charts
helm repo update

helm install loki grafana/loki \
--namespace observability \
--set deploymentMode=SingleBinary \
--set loki.auth_enabled=false \
--set singleBinary.replicas=1

Install Grafana Alloy for log collection:

helm install alloy grafana/alloy \
--namespace observability

Install Tempo:

helm install tempo grafana/tempo \
--namespace observability

Check:

kubectl -n observability get pods
kubectl -n observability get svc

Expose Grafana:

kubectl -n observability get svc kube-prometheus-stack-grafana

If NodePort:

http://<node-floating-ip>:<grafana-nodeport>

Get Grafana password:

kubectl -n observability get secret kube-prometheus-stack-grafana \
-o jsonpath="{.data.admin-password}" | base64 -d
echo

Step 13 — Deploy Sample Applications

Create namespace:

kubectl create namespace demo

Deploy nginx:

cat > ~/demo-nginx.yaml <<'EOF'
apiVersion: apps/v1
kind: Deployment
metadata:
name: nginx
namespace: demo
spec:
replicas: 3
selector:
matchLabels:
app: nginx
template:
metadata:
labels:
app: nginx
spec:
containers:
- name: nginx
image: nginx:stable
ports:
- containerPort: 80
---
apiVersion: v1
kind: Service
metadata:
name: nginx
namespace: demo
spec:
type: ClusterIP
selector:
app: nginx
ports:
- port: 80
targetPort: 80
EOF

kubectl apply -f ~/demo-nginx.yaml

Test:

kubectl -n demo get pods -o wide
kubectl -n demo get svc

Ingress test:

cat > ~/demo-nginx-ingress.yaml <<'EOF'
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: nginx
namespace: demo
spec:
ingressClassName: nginx
rules:
- host: nginx.k8s.lab
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: nginx
port:
number: 80
EOF

kubectl apply -f ~/demo-nginx-ingress.yaml
kubectl -n demo get ingress

On your workstation, add /etc/hosts pointing nginx.k8s.lab to the ingress NodePort/floating IP path.


Step 14 — Learn Kubernetes Scheduling

Label nodes:

kubectl label node k8s-worker-01 workload=general
kubectl label node k8s-worker-02 workload=observability

Deploy pod with nodeSelector:

cat > ~/schedule-node-selector.yaml <<'EOF'
apiVersion: apps/v1
kind: Deployment
metadata:
name: scheduled-nginx
namespace: demo
spec:
replicas: 2
selector:
matchLabels:
app: scheduled-nginx
template:
metadata:
labels:
app: scheduled-nginx
spec:
nodeSelector:
workload: general
containers:
- name: nginx
image: nginx:stable
EOF

kubectl apply -f ~/schedule-node-selector.yaml
kubectl -n demo get pods -o wide

Taint a node:

kubectl taint node k8s-worker-02 dedicated=observability:NoSchedule

Deploy toleration example:

cat > ~/schedule-toleration.yaml <<'EOF'
apiVersion: apps/v1
kind: Deployment
metadata:
name: tolerated-nginx
namespace: demo
spec:
replicas: 1
selector:
matchLabels:
app: tolerated-nginx
template:
metadata:
labels:
app: tolerated-nginx
spec:
tolerations:
- key: dedicated
operator: Equal
value: observability
effect: NoSchedule
nodeSelector:
workload: observability
containers:
- name: nginx
image: nginx:stable
EOF

kubectl apply -f ~/schedule-toleration.yaml
kubectl -n demo get pods -o wide

Remove taint later:

kubectl taint node k8s-worker-02 dedicated=observability:NoSchedule-

Step 15 — GPU Nodes

This is a later sub-phase because it requires OpenStack/Nova PCI passthrough first.

15.1 OpenStack side

On your OpenStack compute host that has the GPU, you need:

IOMMU enabled in BIOS
IOMMU enabled in Proxmox/OpenStack host kernel
GPU bound to vfio-pci
Nova configured for PCI passthrough
Flavor with PCI alias
VM scheduled onto GPU compute node

Create a GPU flavor after passthrough is working:

source /etc/kolla/admin-openrc.sh

openstack flavor create k8s.gpu.medium \
--ram 16384 \
--disk 80 \
--vcpus 6

openstack flavor set k8s.gpu.medium \
--property "pci_passthrough:alias"="gpu:1"

Boot GPU VM:

source ~/k8s-lab-openrc.sh

openstack server create k8s-gpu-01 \
--image ubuntu-24.04-k8s \
--flavor k8s.gpu.medium \
--network k8s-net \
--security-group k8s-sg \
--key-name k8s-key \
--user-data ~/k8s-cloud-init.yaml

15.2 Kubernetes side

Install GPU drivers on the VM, then NVIDIA container toolkit, then NVIDIA device plugin:

kubectl create namespace gpu-operator

For NVIDIA GPU Operator:

helm repo add nvidia https://helm.ngc.nvidia.com/nvidia
helm repo update

helm install gpu-operator nvidia/gpu-operator \
--namespace gpu-operator

Check:

kubectl get nodes "-o=custom-columns=NAME:.metadata.name,GPU:.status.allocatable.nvidia\.com/gpu"
kubectl -n gpu-operator get pods

Step 16 — Install Cluster API

Cluster API is the next automation layer: it lets Kubernetes create and manage Kubernetes clusters declaratively.

Use a management cluster first. Your manually built cluster can act as the management cluster.

Install clusterctl:

curl -L https://github.com/kubernetes-sigs/cluster-api/releases/latest/download/clusterctl-linux-amd64 \
-o clusterctl

chmod +x clusterctl

sudo mv clusterctl /usr/local/bin/

Initialize core Cluster API:

clusterctl init

For OpenStack provider, you need environment variables and provider config. The Cluster API quickstart/provider documentation should be followed closely because provider variables change by release; use a pinned compatible cluster-api-provider-openstack release for repeatability.

Conceptually:

export OPENSTACK_AUTH_URL=http://192.168.1.50:5000/v3
export OPENSTACK_USERNAME=k8s-admin
export OPENSTACK_PASSWORD='<password>'
export OPENSTACK_PROJECT_NAME=k8s-lab
export OPENSTACK_DOMAIN_NAME=Default
export OPENSTACK_REGION=RegionOne
export OPENSTACK_CLOUD=openstack

Then:

clusterctl init --infrastructure openstack

Later goal:

Use Kubernetes manifests to create OpenStack-backed Kubernetes clusters.

This becomes your bridge from manually built clusters to self-service clusters.


Step 17 — Install ArgoCD

Argo CD is a declarative GitOps CD system. Its getting-started docs install it into an argocd namespace using the official manifests; for production, pin a release version instead of tracking the stable branch.

Install:

kubectl create namespace argocd

kubectl apply -n argocd \
--server-side \
--force-conflicts \
-f https://raw.githubusercontent.com/argoproj/argo-cd/stable/manifests/install.yaml

Check:

kubectl -n argocd get pods

Expose with NodePort:

kubectl -n argocd patch svc argocd-server \
-p '{"spec": {"type": "NodePort"}}'

Get NodePort:

kubectl -n argocd get svc argocd-server

Get initial password:

kubectl -n argocd get secret argocd-initial-admin-secret \
-o jsonpath="{.data.password}" | base64 -d
echo

Access:

https://<node-floating-ip>:<argocd-nodeport>

Login:

User: admin
Password: output from secret

Step 18 — Install Crossplane

Crossplane installs into an existing Kubernetes cluster and can be installed using its published Helm chart. The Crossplane docs list Helm-based install as the standard path and require an actively supported Kubernetes version and Helm.

Install:

helm repo add crossplane-stable https://charts.crossplane.io/stable
helm repo update

kubectl create namespace crossplane-system

helm install crossplane crossplane-stable/crossplane \
--namespace crossplane-system

Check:

kubectl -n crossplane-system get pods
kubectl get crds | grep crossplane

Install providers for learning:

cat > ~/crossplane-providers.yaml <<'EOF'
apiVersion: pkg.crossplane.io/v1
kind: Provider
metadata:
name: provider-kubernetes
spec:
package: xpkg.upbound.io/crossplane-contrib/provider-kubernetes:latest
---
apiVersion: pkg.crossplane.io/v1
kind: Provider
metadata:
name: provider-helm
spec:
package: xpkg.upbound.io/crossplane-contrib/provider-helm:latest
EOF

kubectl apply -f ~/crossplane-providers.yaml

Later you can add an OpenStack provider if you want Crossplane to directly provision OpenStack resources. For the internal developer platform path, start with Kubernetes and Helm providers.


Step 19 — Build an Internal Developer Platform

This is the platform engineering goal of Phase 8.

You now have:

OpenStack = IaaS substrate
Kubernetes = tenant workload platform
Cilium = pod networking
Ceph CSI = persistent storage
Ingress = HTTP entry point
MetalLB = lab load balancer model
Metrics/LGTM = observability
ArgoCD = GitOps delivery
Crossplane = platform API/control plane
Cluster API = Kubernetes cluster lifecycle

19.1 Create platform namespaces

kubectl create namespace platform-system
kubectl create namespace platform-apps
kubectl create namespace platform-observability
kubectl create namespace platform-dev

19.2 Define developer golden paths

Create three initial developer offerings:

1. Static website app
2. HTTP API app
3. Stateful app with PVC

Each should provide:

Git repository template
Dockerfile
Helm chart
ArgoCD Application
Ingress
Resource requests/limits
ServiceMonitor
Dashboard
Alerts

19.3 GitOps repository layout

Recommended Git repo:

platform-gitops/
clusters/
k8s-openstack-lab/
namespaces/
ingress/
observability/
storage/
apps/
apps/
demo-nginx/
Chart.yaml
values.yaml
templates/
demo-api/
demo-stateful/
platform/
crossplane/
compositions/
claims/
argocd/
root-app.yaml

19.4 ArgoCD app-of-apps

Create root app:

cat > ~/argocd-root-app.yaml <<'EOF'
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: platform-root
namespace: argocd
spec:
project: default
source:
repoURL: https://example.com/your/platform-gitops.git
targetRevision: main
path: clusters/k8s-openstack-lab
destination:
server: https://kubernetes.default.svc
namespace: platform-system
syncPolicy:
automated:
prune: true
selfHeal: true
EOF

kubectl apply -f ~/argocd-root-app.yaml

Replace repoURL with your actual Git repository.

19.5 Crossplane developer claim model

Example future abstraction:

apiVersion: platform.blusas.local/v1alpha1
kind: WebApp
metadata:
name: demo-api
spec:
image: registry.local/demo-api:v1
replicas: 3
ingress:
host: demo-api.k8s.lab
observability:
dashboard: true
alerts: true

The goal is that a developer asks for a WebApp, not a Deployment, Service, Ingress, ServiceMonitor, Grafana dashboard and alerts separately.


Phase 8 Validation Commands

Run these regularly.

OpenStack layer

source ~/k8s-lab-openrc.sh

openstack server list
openstack network list
openstack router list
openstack floating ip list
openstack security group list

Kubernetes layer

kubectl get nodes -o wide
kubectl get pods -A
kubectl get svc -A
kubectl get ingress -A
kubectl get pvc -A
kubectl top nodes

Cilium

cilium status
kubectl -n kube-system get pods -l k8s-app=cilium

Storage

kubectl get storageclass
kubectl get pvc -A

Ingress

kubectl -n ingress-nginx get svc
kubectl get ingress -A

Observability

kubectl -n observability get pods
kubectl -n observability get svc

GitOps and platform

kubectl -n argocd get applications
kubectl -n crossplane-system get pods
kubectl get providers.pkg.crossplane.io

Recommended Phase 8 Execution Order

Do not try to do all 19 steps in one run. Use this milestone order.

Milestone 1 — Kubernetes VMs boot

Complete:

Step 1
Step 2
Step 3
Step 4

Success:

openstack server list
ssh ubuntu@<control-plane-floating-ip>

Milestone 2 — Basic Kubernetes works

Complete:

Step 5
Step 6

Success:

kubectl get nodes
cilium status
kubectl get pods -A

Milestone 3 — OpenStack integration

Complete:

Step 7

Success:

kubectl get nodes -o wide
kubectl -n kube-system get pods | grep openstack

Milestone 4 — Storage and ingress

Complete:

Step 8
Step 9
Step 10

Success:

kubectl get storageclass
kubectl get pvc
kubectl get ingress -A

Milestone 5 — Observability and apps

Complete:

Step 11
Step 12
Step 13
Step 14

Success:

kubectl top nodes
kubectl -n observability get pods
kubectl -n demo get pods,svc,ingress

Milestone 6 — Advanced platform engineering

Complete:

Step 15
Step 16
Step 17
Step 18
Step 19

Success:

kubectl -n argocd get applications
kubectl -n crossplane-system get pods
clusterctl describe cluster <cluster-name>

Key Design Notes

Because your OpenStack deployment currently has Octavia disabled, Kubernetes Service type=LoadBalancer through OpenStack CCM will not behave like a managed public cloud load balancer. Use NodePort, Ingress on NodePort, or MetalLB as a lab exercise until Octavia is added.

Because your OpenStack deployment currently has Cinder disabled, Kubernetes persistent storage should come from Ceph CSI directly only if the tenant VMs can reach the Ceph MONs. If you later enable Cinder with Ceph RBD underneath, you can also test OpenStack Cinder CSI as the cloud-native OpenStack storage integration.

Phase 8 is complete when you can:

1. Create Kubernetes VMs through OpenStack
2. Build a kubeadm cluster
3. Install Cilium
4. Run pods across worker nodes
5. Expose apps through ingress
6. Provision persistent volumes
7. Observe the cluster in Grafana
8. Deploy apps through ArgoCD
9. Start modelling platform abstractions with Crossplane