Before using a staging cluster as the blueprint for production, I wanted to know what it could actually carry. So I ran a full stress-test suite against it: CPU, memory, disk, network, control plane, pod density, and finally a combined soak.
Seven of the eight load phases produced usable numbers. The eighth I had to abort, because it took the Rancher management cluster down for about 75 minutes. That incident turned out to be the most valuable result of the day.
The headline: this cluster is storage-bound, not CPU- or network-bound. Every hard wall traced back to one place — a single-node Ceph cluster on two spinning disks, shared by every pool.
What was tested
| Layer | Detail |
|---|---|
| Cloud | OpenStack 2026.1 (Kolla-Ansible), Ceph RBD as the backing store |
| Compute | 3 hosts, 64 vCPU / 191 GiB each |
| Ceph | One node, 2 × HDD, 257 PGs. Pools vms (size 2), vms-rep1 (size 1), volumes, images, backups — all on the same two OSDs |
| Kubernetes | RKE2 v1.34.6, Calico v3.31.4 (VXLAN), 3 nodes, each control-plane + etcd + worker, 4 vCPU / 7.75 GiB / 110 pods |
| Storage class | None — no Longhorn, no Ceph CSI |
| Management | Rancher on a separate 3-node RKE2 cluster whose boot disks live on the same Ceph |
Two facts drive almost every result below:
- Control plane, etcd and workload share the same 4 vCPU, so any load competes with the thing being measured.
- The cluster nodes boot from Ceph — and so does Rancher. There is no wall between them.
Method. kubectl ran from my admin host through the Rancher proxy, so client-side latency includes that hop. Load came from stress-ng and fio in Alpine pods. Server-side API latency came from apiserver_request_duration_seconds; etcd health from etcdctl endpoint status and etcdctl check perf; real node memory from a privileged hostPID DaemonSet reading /proc/meminfo.
Scorecard
| Phase | Test | Verdict | Headline |
|---|---|---|---|
| 0–1 | Preflight + baseline | Pass | 46 pods, API p50 0.17 s, etcd DB 12 MB |
| 2 | CPU | Pass | Graceful degradation; one clear knee |
| 3 | Memory | Pass | 4 GiB held, no OOM, no eviction |
| 4 | Disk I/O | Baseline only | fsync 4K = 23 IOPS |
| 5 | Network | Concern | Cross-node pod traffic 0.83 Gbit/s vs 15.7 same-node |
| 6 | Control plane | Fail | etcd: 104 writes/s vs 150 required |
| 7 | Density | Pass | 60 pods Ready in 56 s; ceiling ≈ 81 pods |
| 8 | Combined soak | Aborted | Took down Rancher for ~75 min |
CPU: the interesting part is the knee
I ramped load rather than saturating everything at once.
| Load | API p50 | API max |
|---|---|---|
| Idle | 173 ms | 328 ms |
| One node, 2 workers | 157 ms | 263 ms |
| One node, 4 workers | 450 ms | 1008 ms |
| All three nodes, 4 workers each | 282 ms | 473 ms |
| After load removed | 229 ms | 347 ms |
At two workers the API is unaffected (157 ms is noise next to 173 ms). At four workers on the same node it jumps 2.6×. The cluster can absorb roughly half a node’s spare CPU before the control plane starts queueing.
The counter-intuitive result: saturating one node was 59% worse for the API than saturating all three. That node hosted the etcd leader, and every Kubernetes write goes through the leader. Starve it and you starve every write in the cluster while two nodes sit idle. Spread the contention evenly and nothing is disproportionately starved.
The rule I took from it: the worst neighbour for this cluster is a CPU-hungry pod next to the etcd leader. Where the load lands matters more than how much of it there is.
Memory: fine, but not for the reason you’d hope
Holding 4 GiB in one pod (out of ~6.5 GiB available) caused no OOMKill, no eviction and no MemoryPressure. API p50 stayed at 155 → 156 ms. Memory is not what breaks this cluster.
But inspecting the nodes turned up a configuration gap:
- There is no swap, and
failSwapOn: false. - kubelet’s
evictionHardcontains onlyimagefs.availableandnodefs.available(both 5%) — nomemory.availablethreshold at all. The upstream default isn’t in effect. vm.overcommit_memory = 1, so the kernel accepts any allocation.
So real memory exhaustion here would be handled by the kernel OOM killer, not by graceful kubelet eviction. Worth fixing before production (add a memory.available hard threshold, plus a soft one with a grace period).
Disk: the number that explains everything else
I did not complete the pod-level disk phase, and I’m not going to pretend otherwise: the fio pod is what caused the incident below. These are VM-level baselines on the same Ceph-backed storage, which already establish the ceiling.
| Test | Result | Verdict |
|---|---|---|
| Sequential write 4M | 110.8 MiB/s | Acceptable for HDD |
| Sequential read 4M | 682.7 MiB/s | Cache-assisted |
| Random 4K write, qd32×4 | 5,009 IOPS | HDD ceiling |
| Random 4K read, qd32×4 | 802 IOPS | HDD ceiling |
| fsync 4K, iodepth 1 | 23 IOPS | The bottleneck |
A 4K fsync costs roughly 43 ms here, and this path manages only 23 of them per second. etcd commits every write with an fsync. That one number predicts the control-plane failure, the apiserver probe flaps and the management outage that follow.
It also can’t be tuned away today: no StorageClass exists (a PVC would sit Pending forever), the media is two HDDs, the root pool is size=2 on a single host (every write goes to the same two disks twice), and every pool shares the same OSDs with no QoS.
Network: the overlay, not the wire
| Test | Throughput |
|---|---|
| Cross-node pod → pod, 1 stream | 766 Mbit/s |
| …4 streams | 839 Mbit/s |
| …8 streams | 826 Mbit/s |
| Same-node pod → pod | 15.7 Gbit/s |
hostNetwork → hostNetwork, cross-node (no VXLAN) | 13.4 Gbit/s |
| Pod → pod, cross-node (VXLAN) | 748 Mbit/s |
Going from 1 to 8 streams changed nothing, so this isn’t a congestion or per-connection limit — it’s a hard per-packet cost. The A/B pair (same nodes, same NIC, only encapsulation differs) isolates it: an 18× penalty from VXLAN alone.
Why: the virtio NIC can’t offload VXLAN segmentation, so Calico detects unreliable checksum offload and sets ChecksumOffloadBroken=true. Felix then turns off TSO and tx-checksumming on vxlan.calico, which also has a single queue. Every tunnelled packet is segmented and checksummed in software on one core. At ~1450 bytes per packet that is about 64,500 packets/s ≈ 0.8 Gbit/s — which matches what I measured.
I ruled out a configured rate limit: overlimits 0, ecn_mark 0, and drops of 215 packets out of 44.7 million.
A validity problem in my own numbers. I checked the Nova database: all three nodes were scheduled on the same compute host. So “cross-node” traffic never touched the 10G switch; it stayed on the local OVS bridge. That’s why the no-VXLAN leg reached 13.4 Gbit/s, which a 10G line physically can’t carry. This phase measured encapsulation cost, not the 10G fabric, and 0.8 Gbit/s should be read as a best case. (It also means losing that one host loses the whole cluster.)
Control plane: etcd fails its own minimum
| Metric | Value |
|---|---|
| API read p50 (server-side) | 25 ms |
| API write p50 (server-side) | 100 ms |
etcd check perf run 1 / run 2 | 104 / 105 writes/s |
| Required by etcd’s small load model | ≥ 150 writes/s |
| Slowest request | ~0.9 s |
Reads are comfortable; writes are 4× slower, which is the fsync signature. The cluster sustains about 70% of etcd’s own minimum bar, reproducibly.
Two side effects worth recording:
check perfratchets your database up. Two runs wrote ~12,000 keys. I deleted them and the prefix reads back empty, but etcd doesn’t return space without compaction. The DB went from 12 MB to 39 MB and stayed there. Pair this test with compact + defrag.- Write bursts trip the apiservers’ own health probes. During a 60-pod burst all three
kube-apiserverpods loggedUnhealthyon the etcd probe for about six seconds, then recovered with zero restarts. That’s a real capacity signal: the control plane will flap readiness under write bursts.
Density and scheduling
I used pause pods so the test measures scheduler, kubelet and CNI rather than application load — and pre-warmed the image on every node. A cold pull took 21 s and would have been counted as “scheduling latency”.
| Measurement | Result |
|---|---|
| 0 → 30 pods Ready | 53 s |
| 30 → 60 pods Ready | 56 s |
| Pending pods | 0 |
| Distribution at 60 | 21 / 19 / 20 |
| CPU requests at 60 pods | 82% per node |
| Memory requests at 60 pods | ~36% |
The ceiling is CPU requests (~81 pods at 100 m each), not memory or the 110-pod limit.
My own gate was wrong. My runbook said “if total p50 exceeds 30 s, the scheduler or apiserver is drowning”. Total p50 was 31 s. But split into legs: scheduler p50 was 1 s, and the apiserver was healthy. All the cost was in kubelet + CNI (p50 30 s), which serialises at roughly 0.36 pods/s per node. The two legs point to completely different fixes, so I now measure them separately.
The soak, and the incident
The plan: run everything at once for 30 minutes — CPU stress on all nodes, 4 GiB memory stress, fio, and 30 pause pods — and watch for stability rather than peaks.
| Time | What happened |
|---|---|
| 17:11 | Soak starts |
| 17:12 | All nodes at 100% CPU; kubectl latency 3 s |
| 17:15 | One API call takes 117 s, then 502. My guard fires, but its cleanup can’t run — the API is what died |
| 17:15–17:45 | fio pushes Ceph to ~1,300 read IOPS on two HDDs (roughly 150 each) |
| 17:35 | Management node’s RKE2 in a restart loop, 118 restarts (context deadline exceeded) |
| 18:00 | Disk latency recovered — but etcd stayed wedged |
| 18:15 | Found the real blocker: stale etcd processes holding ports 2379/2380 for 4 days |
| 18:17 | Killed them, started all three management nodes simultaneously → quorum back |
| 18:28 | Rancher still 0/3: management etcd log shows slow fdatasync took=1.258s, leader is overloaded likely from slow disk |
| 18:35 | openstack server stop on the three benchmark VMs → Rancher 3/3 within one 25 s check |
| 18:41–18:50 | Restarted benchmark nodes one at a time, deleted the load the moment the API answered |
Root cause: no storage isolation
The benchmark VMs’ disks and Rancher’s disks sat on different pools — but the same two OSDs. fio queued every other pool behind it, including the one holding the management etcd’s WAL. etcd’s fdatasync hit 1.258 s, its leader was overloaded, readiness probes failed and quorum collapsed. It couldn’t recover on its own because the load kept the disks saturated.
And the load was self-sustaining: the stress workload was a Deployment, so every time its container hit its timeout, Kubernetes restarted it. “Wait for it to finish” was never an option.
What didn’t work
| Attempt | Why it failed |
|---|---|
| Restart RKE2 on one node | etcd needs 2 of 3 members; one alone can never elect a leader |
| Wait for Ceph to calm down | It did calm down, but the wedged etcd still held its ports |
Plain systemctl restart on all three | The old etcd processes were still alive and listening |
kubectl delete via Rancher | Rancher itself was 0/3. The tool for the fix was the casualty |
What did
# 1) Stop the restart churn on every management node
for h in $MGMT_NODES; do ssh "$h" 'sudo systemctl stop rke2-server'; done
# 2) The critical step: kill the stale etcd and orphaned shims
for h in $MGMT_NODES; do
ssh "$h" 'sudo pkill -TERM -x etcd; sleep 6; sudo pkill -KILL -x etcd;
sudo pkill -KILL -f containerd-shim;
sudo rm -f /run/k3s/containerd/containerd.sock'
done
# 3) Start all three at the same time — quorum needs 2 of 3
for h in $MGMT_NODES; do ssh "$h" 'sudo systemctl start rke2-server' & done; wait
Then, and only then, remove the storage contention — the management etcd can’t stabilise while the OSDs are saturated.
The diagnostic I wish I’d run first:
sudo ss -lntp | grep -E ':(2379|2380)'
If an etcd PID with days of uptime is still listening while every client request times out, it’s wedged, not slow. You can’t tell the difference from logs alone, because a wedged process has gone quiet. That one check would have saved about 40 minutes.
The access-design failure
While Rancher was down, the Kubernetes API was gone, the benchmark VMs used Rancher-generated keypairs (so my lab key didn’t work), my admin host had no route to the node VLAN, and there was no guest agent or console login. The only lifeline was an SSH path into a management node I happened to have. Without it the outage would have been unrecoverable from my host. A recovery path that depends on one unrecorded credential isn’t a recovery path.
Findings and what I’m doing about them
| # | Severity | Finding | Fix |
|---|---|---|---|
| F1 | Critical | No storage isolation: one workload can take down management | Separate pool/SSD for etcd WAL; per-pool I/O QoS (mclock); second Ceph node |
| F2 | Critical | Control plane is storage-bound; etcd below its own minimum | etcd WAL on a fast local or dedicated SSD device |
| F3 | High | Software VXLAN caps pod traffic at ~0.8 Gbit/s | Spread nodes across compute hosts; evaluate dropping the overlay on a flat L2 |
| F4 | High | No dependable out-of-band access to the management plane | Record and test a break-glass path with the API deliberately down |
| F5 | Medium | No kubelet memory eviction threshold | Add memory.available hard and soft thresholds |
| F6 | Medium | All nodes on one compute host | Anti-affinity across at least two hosts |
| F7 | Low | No StorageClass | Install a CSI before any stateful workload |
| F8 | Low | check perf enlarges the DB | Compact and defrag afterwards |
Immediate rules: no storage benchmarks against shared Ceph pools, ever; any long-running load must be a Job with a deadline, not a Deployment; and any abort path must work without the Kubernetes API.
What held up
Scheduling (p50 1 s under a 60-pod burst), pod density, memory headroom, the API read path (p50 25 ms, 90% of reads under 100 ms) and Calico stability all held. Both clusters came back with no data loss and no rebuilds.
Takeaway
The cluster is healthy and predictable for read-mostly workloads and bursts up to about 80 pods. It isn’t ready for control-plane-heavy work, and it has no storage isolation between tenants. Everything I worried about beforehand — CPU, memory, pod count — was fine. The thing that broke was the one thing every layer was quietly sharing.
Leave a Comment / Ask a Question
Have thoughts or questions on this article? Drop a message below and Faiez will receive an instant notification.