Ceph CSI in Kubernetes: Bare-Metal Storage

Connecting a Ceph cluster to Kubernetes via the CSI driver for dynamic RBD provisioning — including the Cilium native routing gotcha that blocks OSD traffic.

When you run Kubernetes on bare metal, you lose the managed storage that cloud providers give you for free. No EBS volumes that just appear, no GCP Persistent Disks you can ignore until they're needed. You have to bring your own.

For my on-prem cloud, I chose Ceph: a distributed storage system that turns a pool of disks across multiple nodes into a single, redundant storage layer. Combined with the Ceph CSI driver, it gives Kubernetes dynamic PVC provisioning backed by real replicated block storage.

This post covers how I connected my Ceph cluster to Kubernetes, what the configuration looks like, and the one Cilium-specific gotcha that isn't documented anywhere and will block all volume mounts if you hit it.


Architecture

My Ceph cluster runs directly on the three Proxmox hypervisors, not inside Kubernetes. Each host contributes two disks to the OSD pool. The Ceph monitors — which coordinate cluster state — run at 192.168.22.11-13:6789.

The Ceph CSI driver runs inside Kubernetes as a DaemonSet (node plugin) and Deployment (controller), and it talks to the Ceph cluster over the network. This separation is intentional: if the Kubernetes cluster is unhealthy, the storage layer keeps running independently.

Proxmox (192.168.22.x)          Kubernetes (192.168.33.x)
──────────────────────          ──────────────────────────
Ceph monitors + OSDs     ←──→   Ceph CSI DaemonSet + Controller
pve1 .11  pve2 .12  pve3 .13          ↓
                                 StorageClass: ceph-rbd
                                 PVC → RBD image → pod volume
Physical topology: three Proxmox hosts each with two Ceph OSDs, six Kubernetes VMs distributed across them
Physical layout: three Proxmox hosts (pve1–pve3) each running two Ceph OSDs, with Kubernetes control-plane and worker VMs distributed across them

When a pod requests a PVC, the CSI controller creates an RBD image in Ceph. When the pod is scheduled, the node plugin mounts that image as a block device. When the pod is deleted, the image stays until the PVC is deleted.


Installing the CSI Driver

Install via Helm with your cluster's monitor addresses:

# ceph-csi-rbd/values.yaml
csiConfig:
  - clusterID: "<output of: ceph fsid>"
    monitors:
      - "192.168.22.11:6789"
      - "192.168.22.12:6789"
      - "192.168.22.13:6789"

The driver needs two Secrets — one for the controller (admin credentials to create and delete volumes) and one for nodes to authenticate when mounting:

apiVersion: v1
kind: Secret
metadata:
  name: csi-rbd-secret
  namespace: ceph-csi
stringData:
  userID: admin
  userKey: "<output of: ceph auth get-key client.admin>"

You can use a dedicated Ceph user with narrower permissions instead of admin — ceph auth get-or-create client.k8s with osd 'profile rbd pool=kubernetes' is the production-correct approach.


Preparing Ceph

Before the StorageClass can provision volumes, you need a pool on the Ceph side:

ceph osd pool create kubernetes 32
rbd pool init kubernetes

The 32 is the PG count — scale it up for larger clusters. For a 6-OSD on-prem cloud cluster, 32 is reasonable.


StorageClass

apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
  name: ceph-rbd
  annotations:
    storageclass.kubernetes.io/is-default-class: "true"
provisioner: rbd.csi.ceph.com
parameters:
  clusterID: "<your-cluster-id>"
  pool: kubernetes
  imageFeatures: layering
  csi.storage.k8s.io/provisioner-secret-name: csi-rbd-secret
  csi.storage.k8s.io/provisioner-secret-namespace: ceph-csi
  csi.storage.k8s.io/node-stage-secret-name: csi-rbd-secret
  csi.storage.k8s.io/node-stage-secret-namespace: ceph-csi
reclaimPolicy: Delete
allowVolumeExpansion: true
volumeBindingMode: Immediate

imageFeatures: layering is important — without it, some Ceph versions fail to map the RBD image.


The Cilium Native Routing Gotcha

This is the part that isn't in any Ceph CSI tutorial, and it cost me a few hours.

When a Kubernetes node mounts an RBD volume, the CSI node plugin communicates with Ceph OSD daemons. OSDs don't use the monitor port (6789) — they have their own port range: 6802–6809.

In Cilium's native routing mode with NetworkPolicies applied, egress to external CIDRs is governed by policy. You might expect that a standard NetworkPolicy ipBlock rule covers it:

egress:
  - to:
      - ipBlock:
          cidr: 192.168.22.0/24
    ports:
      - port: 6789
        protocol: TCP

It doesn't — not reliably. The issue is that standard NetworkPolicy ipBlock operates at the IP layer, but Cilium in native routing mode uses eBPF maps that don't always apply these rules consistently for traffic that originates from the node plugin running as a privileged host-network pod.

The fix is a CiliumNetworkPolicy with toCIDR:

apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
  name: ciliumnetpol-ceph-osd
  namespace: kube-system
spec:
  endpointSelector: {}
  egress:
    - toCIDR:
        - 192.168.22.0/24
      toPorts:
        - ports:
            - port: "6789"
              protocol: TCP
            - port: "6800"
              protocol: TCP
            - port: "6801"
              protocol: TCP
            - port: "6802"
              protocol: TCP
            - port: "6803"
              protocol: TCP
            - port: "6804"
              protocol: TCP
            - port: "6805"
              protocol: TCP
            - port: "6806"
              protocol: TCP
            - port: "6807"
              protocol: TCP
            - port: "6808"
              protocol: TCP
            - port: "6809"
              protocol: TCP

Apply this cluster-wide (empty endpointSelector: {} matches all pods). Without it, RBD volume mounts hang at ContainerCreating with no error message — the pod just waits indefinitely while the CSI plugin times out trying to map the image.


Verifying It Works

kubectl apply -f - <<'EOF'
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: test-rbd-pvc
spec:
  accessModes: [ReadWriteOnce]
  resources:
    requests:
      storage: 1Gi
EOF

kubectl get pvc test-rbd-pvc
# Expected: Bound

If it stays Pending, check the CSI controller logs:

kubectl logs -n ceph-csi \
  -l app=csi-rbdplugin-provisioner \
  -c csi-provisioner --tail=50

The most common failure messages and their causes:

MessageCause
failed to connect to monMonitor unreachable — check firewall on Proxmox
unknown image feature 'fast-diff'imageFeatures: layering missing
context deadline exceeded on mountOSD ports blocked — apply the CiliumNetworkPolicy
invalid argument on poolRun rbd pool init kubernetes

Once PVCs provision and bind reliably, Ceph RBD becomes the invisible foundation that everything else sits on — StatefulSets, databases, media libraries. The next piece is making sure the network policies that protect those workloads actually work the way you expect in Cilium native routing mode.

No ratings yet

Comments

0/2000

Connect