A Multi-Node k3s Cluster, Load Tested to Saturation

June 2026 · k3s · Kubernetes · libvirt · netfilter · k6

I wanted a Kubernetes setup I could break on purpose. Not a managed cluster where the interesting failures hide behind a control plane I never touch, but something I provisioned from bare VMs, wired up myself, and then hit hard enough to watch it bend.

Two k3s nodes on libvirt VMs, a deliberately CPU-heavy Go service, a horizontal pod autoscaler, and a k6 ramp fired from a separate desktop. It topped out at 93 req/s with zero errors. Getting there meant first losing an evening to a three-way fight between Docker, firewalld, and libvirt over who owns the host firewall.

The Shape of It

Three machines, each with a distinct job. The load generator has to be stronger than the target, or you end up measuring the generator instead of the service.

Desktop k6 · 8c/16t LAN Laptop 192.168.1.12:8080 socat forward :30080 libvirt NAT · 192.168.122.0/24 k3s-server .10 control plane Service (ClusterIP) pod 10.42.0.x on .10 pod 10.42.1.x on .11

The desktop cannot reach the pods directly. Pods live on libvirt's NAT network, which is internal to the laptop, and libvirt NAT is one way: VMs reach out, nothing reaches in. So the laptop forwards a LAN port into the NodePort, acting as a small load balancer.

RoleMachineNotes
Cluster hostLaptop, 8 GB, DebianRuns both VMs under libvirt/KVM, on WiFi
Load generatorDesktop, Ryzen 7 5700X, 16 GBRuns k6, fires across the LAN
Nodes2 VMs, 2 GB / 2 vCPU each.122.10 server, .122.11 agent

That node sizing is tight, and the tightness helps: it puts the scaling wall close enough to hit inside a five minute test.

Pinning Addresses

My first runs addressed the VMs by DHCP-assigned IPs. Leases expire, VMs reacquire different addresses, and an IP you have been treating as fixed points at nothing. I spent real time SSHing to a dead address a previous VM incarnation had held. Fix each VM to a fixed MAC and pin a DHCP reservation to it.

# Fixed MAC per VM, pinned to a fixed IP, on both the live and persistent planes.
sudo virsh net-update default add ip-dhcp-host \
  "<host mac='52:54:00:aa:bb:10' name='k3s-server' ip='192.168.122.10'/>" \
  --live --config

sudo virsh net-update default add ip-dhcp-host \
  "<host mac='52:54:00:aa:bb:11' name='k3s-agent' ip='192.168.122.11'/>" \
  --live --config

The --live --config pair is the recurring lesson of the whole project. One flag changes running state, the other changes the persistent definition. Apply only one and you get the classic "it works until reboot" confusion. Same thinking applies to iptables and sysctls.

Related: set virsh net-autostart and virsh autostart on the network and both domains. I rebooted the laptop once and everything was gone. Not corrupted, just off. A service that isn't enabled to start on boot quietly disappears on the next reboot.

The Netfilter Fight

This is the part that actually took the evening, and the most useful thing in the post. The VMs came up fine, SSH worked, but they had no internet. The gateway was reachable, DNS resolved, raw outbound packets died.

$ ssh ubuntu@192.168.122.10 'ping -c2 192.168.122.1'   # gateway, replies fine
$ ssh ubuntu@192.168.122.10 'ping -c2 8.8.8.8'         # internet, 100% loss
$ ssh ubuntu@192.168.122.10 'ping -c2 google.com'      # resolves to an IP, still 100% loss

Gateway reachable, plus DNS resolving, plus no route out, points squarely at host-side NAT rather than the VM. So I looked at what the host was doing to forwarded packets.

$ sudo nft list ruleset | grep -i 'masquerade\|192.168.122'
  ip saddr 172.17.0.0/16 oifname != "docker0" counter masquerade
  ip saddr 172.18.0.0/16 oifname != "br-762974512e58" counter masquerade
  # rules for Docker's networks present, nothing for 192.168.122.0/24

Masquerade rules for Docker's networks, nothing for libvirt's. Docker was installed on the host, and when Docker starts it installs its own netfilter rules and sets the FORWARD chain policy to drop. Doing so had evicted libvirt's masquerade rule. VM packets hit a FORWARD policy of drop with no masquerade to rewrite their source address, and quietly died.

docker FORWARD → drop firewalld zone rules libvirt masquerade .122 all write here netfilter one shared ruleset last writer wins VM packet, 192.168.122.x no masquerade rule FORWARD policy drop dropped Docker's rules evict libvirt's. Cycling the network makes them re-fight.

The root cause is structural: three things on this host all want to manage netfilter, and the more aggressive ones stomp the others. Every time I cycled the network they re-fought and the NAT rule fell over again. I went around that loop more than once before naming it properly.

The habit worth building: when host networking misbehaves for no obvious reason, the first question is not "what is the right rule" but "who else is writing rules here." Inventory the netfilter managers before touching anything.

I don't need Docker on the host at all. The cluster's containers run inside the VMs under containerd. So the clean fix was to remove the most aggressive actor from the fight entirely.

# The cluster's containers run inside the VMs, not on the host.
# Take the most aggressive netfilter actor out of the fight.
sudo systemctl disable --now docker docker.socket

# Then let libvirt reinstall its NAT rules unopposed (a reboot is cleanest,
# since stopping Docker does not flush rules it already loaded).
sudo systemctl restart libvirtd

Installing k3s

Server first, because it generates the token the agent needs to join.

curl -sfL https://get.k3s.io | INSTALL_K3S_EXEC="\
  --node-ip=192.168.122.10 \
  --tls-san=192.168.122.10 \
  --write-kubeconfig-mode=644 \
  --disable=traefik \
  --disable=servicelb" sh -

# Then read the join token:
sudo cat /var/lib/rancher/k3s/server/node-token
# On the agent. K3S_URL makes the installer set up a worker that joins the server.
curl -sfL https://get.k3s.io | \
  K3S_URL="https://192.168.122.10:6443" \
  K3S_TOKEN="<the K10... token from the server>" \
  INSTALL_K3S_EXEC="--node-ip=192.168.122.11" sh -
$ sudo k3s kubectl get nodes -o wide
NAME         STATUS   ROLES           VERSION        INTERNAL-IP      OS-IMAGE
k3s-server   Ready    control-plane   v1.35.5+k3s1   192.168.122.10   Ubuntu 24.04.4 LTS
k3s-agent    Ready    <none>          v1.35.5+k3s1   192.168.122.11   Ubuntu 24.04.4 LTS

One thing that looks like an error and is not: kubectl on the agent fails with connection to localhost:8080 refused. That's correct. Only the control plane node runs an API server; the worker has nothing local to talk to.

The Service Under Test

The application is intentionally boring so the operational layer stays the interesting part. A small Go service with /healthz, /readyz, /metrics, and the one endpoint that matters, /work?ms=200, which burns real CPU by hashing in a tight loop.

// burnCPU does real, un-optimizable work for roughly ms milliseconds by hashing
// in a tight loop. Hitting /work pegs a CPU core, which is what HPA scales on.
func burnCPU(ms int) {
	deadline := time.Now().Add(time.Duration(ms) * time.Millisecond)
	var x [32]byte
	for time.Now().Before(deadline) {
		for i := 0; i < 1000; i++ {
			x = sha256.Sum256(x[:])
		}
	}
	_ = x
}

It builds as a static binary on a distroless base. The wrinkle is getting that image onto the cluster with no registry and Docker now gone from the host. Podman is daemonless and touches no firewall rules, so it doesn't reopen the fight. Import into both nodes, because a pod can be scheduled onto either and a node without the image fails to start it.

# Build with Podman (daemonless, no firewall rules), import into BOTH nodes' containerd.
podman build -t localhost/loadlab:v1 .
podman save -o /tmp/loadlab-v1.tar localhost/loadlab:v1

for node in ubuntu@192.168.122.10 ubuntu@192.168.122.11; do
  scp /tmp/loadlab-v1.tar "${node}:/tmp/loadlab-v1.tar"
  ssh "$node" 'sudo k3s ctr images import /tmp/loadlab-v1.tar'
done

Requests are the denominator

The deployment sets resource requests, and this is not optional hygiene. The HPA computes CPU utilization as a percentage of the request. With no request there is no denominator and the autoscaler does nothing. This is the single most common reason a first HPA demo quietly refuses to scale.

          resources:
            # REQUESTS = what the scheduler reserves, and what HPA measures against.
            # Omit requests and HPA has no denominator and will not scale.
            requests:
              cpu: "100m"
              memory: "32Mi"
            limits:
              cpu: "500m"
              memory: "64Mi"
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: loadlab
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: loadlab
  minReplicas: 2
  maxReplicas: 10        # on 2GB nodes this cap usually binds before node capacity
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 50   # scale up when pods average above 50m (50% of request)
  behavior:
    scaleUp:
      stabilizationWindowSeconds: 0    # react immediately
    scaleDown:
      stabilizationWindowSeconds: 60   # wait 60s of calm before scaling down, avoids flapping
pods req 100m cpu cpu metrics-server aggregates usage HPA avg vs 50% target 2 → 10 replicas scales the Deployment, which changes the pods, a closed loop utilisation = usage / request. No request, no denominator, no scaling

Applied, the cluster reports a real HPA reading rather than <unknown>, which means metrics-server is feeding it and it will react to load. Note the pod IPs: each node owns a slice of the pod network and the CNI routes between them.

$ kubectl get pods -o wide
NAME                      READY   STATUS    IP          NODE
loadlab-b6ddd7b86-cchg5   1/1     Running   10.42.0.5   k3s-server
loadlab-b6ddd7b86-wpfwl   1/1     Running   10.42.1.2   k3s-agent

$ kubectl get svc loadlab
NAME      TYPE       CLUSTER-IP      PORT(S)        AGE
loadlab   NodePort   10.43.123.145   80:30080/TCP   98s

$ kubectl get hpa loadlab
NAME      REFERENCE            TARGETS       MINPODS   MAXPODS   REPLICAS
loadlab   Deployment/loadlab   cpu: 1%/50%   2         10        2

Firing Load

# On the laptop. Listens on the LAN, forwards every connection into the NodePort.
socat TCP-LISTEN:8080,fork,reuseaddr TCP:192.168.122.10:30080

# Three-hop test:
curl http://192.168.122.10:30080/work?ms=50   # laptop straight to NodePort
curl http://localhost:8080/work?ms=50          # laptop through socat
curl http://192.168.1.12:8080/work?ms=50       # desktop across the LAN

Test the path in escalating hops, so that if something breaks you know which segment failed. Repeated calls show the response alternating between pod hostnames, the Service load balancing in real time.

The load is a k6 ramping arrival-rate test. Arrival rate, not a fixed number of virtual users, because the right model is requests per second the way real traffic arrives, with k6 adding workers as needed to sustain the rate. SLO thresholds are baked in so the run passes or fails outright instead of just printing numbers.

import http from 'k6/http';
import { check } from 'k6';

// Ramping arrival-rate: control REQUESTS PER SECOND, not just virtual users.
// k6 adds VUs as needed to sustain the rate, which models real arriving traffic.
export const options = {
  scenarios: {
    ramp: {
      executor: 'ramping-arrival-rate',
      startRate: 10,
      timeUnit: '1s',
      preAllocatedVUs: 50,
      maxVUs: 500,
      stages: [
        { target: 20,  duration: '30s' },  // warm up
        { target: 50,  duration: '1m'  },  // cross the 50% HPA threshold
        { target: 100, duration: '1m'  },  // expect scale-up
        { target: 200, duration: '2m'  },  // hammer, expect the ceiling
        { target: 0,   duration: '30s' },  // ramp down, watch HPA scale back
      ],
    },
  },
  // SLOs: pass/fail instead of vibes.
  thresholds: {
    http_req_duration: ['p(95)<500', 'p(99)<1000'],
    http_req_failed:   ['rate<0.01'],
  },
};

const BASE = __ENV.TARGET || 'http://192.168.1.12:8080';

export default function () {
  const res = http.get(`${BASE}/work?ms=200`);
  check(res, { 'status 200': (r) => r.status === 200 });
}

Results

I ran it twice. First with k6 on the laptop, then from the desktop, expecting the desktop run to go higher now that the generator wasn't competing with the VMs for CPU. It didn't. The two runs were nearly identical, which is the finding in itself: it ruled out the generator as the bottleneck and pointed at the cluster as the real limit.

  THRESHOLDS
    http_req_duration
    x 'p(95)<500'  p(95)=671.05ms
    v 'p(99)<1000' p(99)=912.31ms
    http_req_failed
    v 'rate<0.01'  rate=0.00%

  TOTAL RESULTS
    checks_succeeded...: 100.00%  28015 out of 28015
    http_req_duration..: avg=375.19ms  med=355.02ms  max=1.29s  p(90)=575.09ms  p(95)=671.05ms
    http_req_failed....: 0.00%   0 out of 28015
    http_reqs..........: 28015   93.383226/s
    dropped_iterations.: 34      0.113333/s
    vus_max............: 84
  # HPA scaled 2 -> 10 pods, all Running, CPU pegged.
req/s 200 100 0 requested 200 achieved 93 saturation ceiling the gap is load the cluster was asked for and could not take 0s 270s 0% errors throughout: it got slow, it did not fall over
MetricLaptop runDesktop run
Requests completed27,97628,015
Throughput achieved93.25 req/s93.38 req/s
Throughput requested (peak)200 req/s200 req/s
Latency avg / p95386 / 652 ms375 / 671 ms
Latency p99 (SLO 1000)795 ms pass912 ms pass
Error rate0.00%0.00%
Peak VUs8284
HPA scaled to10 pods (max), all Running, CPU pegged

Reading the Numbers

Saturation, not failure

Zero errors, but the p95 SLO was breached. The system stayed correct under load and got slow. That is graceful degradation, and it's a more common real-world shape than an outright fall over. The decisive number is the gap between requested and achieved throughput: I asked for 200 req/s and the system topped out around 93, with k6 unable to even launch some iterations on schedule.

The HPA scaled to its maximum of 10 pods, all Running, all CPU pegged, so the cluster wasn't idle behind a starving funnel, it was genuinely maxed. Each request is 200 ms of pure CPU. Two nodes of 2 vCPUs can only hash so fast. Moving the generator to a 16-thread desktop changed nothing because the generator was never the constraint.

Throughput is not concurrency

It's tempting to read "peak 84 VUs" as "we handled about 100 concurrent users." That conflates two things. The honest figures are 93 requests per second of throughput and a peak of 84 in-flight connections. The relationship is Little's Law:

concurrency = throughput x latency
            = 93 req/s x 0.375 s
            ~ 35 requests in flight at any instant
            (k6 held up to 84 workers to sustain that through the slow tail)

k6 needed up to 84 workers to sustain 35 in-flight requests because latency was high and each worker spends most of its time waiting. A virtual user with zero think-time also stands in for many real humans, who read and pause between clicks. "Concurrent users" is the wrong unit.

The number travels with its workload

93 req/s is not this cluster's capacity in general. It's its capacity for 200 ms of pure CPU per request. A real service doing 5 ms database lookups would push vastly higher rates on the same hardware, because it isn't burning a core per request.

HPA scales pods, nothing scaled nodes

The autoscaler hit maxReplicas of 10 and stopped. That cap bound the test, not node capacity, because these pods are tiny. The HPA only ever scales pods. Nothing here scales the cluster itself. In a cloud environment you'd pair it with a cluster autoscaler that adds nodes when pods can't be scheduled. With a fixed two nodes, the ceiling is hard.

After scale-down the surviving pods clustered on the agent, which is expected: the server node is also running the API server, scheduler, controller-manager, datastore, CoreDNS, and metrics-server, so it has less free capacity. In production you often taint the control plane so workloads never land on it at all.

What I Would Do Next

Summary

LayerWhat I learned by breaking it
Host networkingDocker, firewalld and libvirt all manage netfilter and stomp each other. Inventory the managers before debugging the rules.
AddressingNever treat a DHCP-assigned IP as fixed. Pin by MAC, and always set both the live and persistent planes.
HPAResource requests are mandatory: they are the denominator. The HPA scales pods, never nodes.
Load testingSaturation is not failure. Throughput is not concurrency. Every capacity number travels with its workload.
This cluster~93 req/s at 200 ms CPU-bound work, 0% errors, p95 degrading to ~670 ms once the HPA hit its 10-pod cap.

None of this is a production deployment, and it was never meant to be. It's a place to watch a system bend under load I generated, on hardware I can point at, with numbers I measured. The fights it picked along the way taught me more than a clean run would have.

Further Reading