Skip to content
Go back

K8s Requests vs Limits in a Home Lab

By KingPin 14 min read
K8s Requests vs Limits in a Home Lab
Contents

Your 8 GB mini PC just OOM-killed your DNS

It’s 2 AM. Pi-hole is down, Home Assistant is restarting, and a Jellyfin transcode ate the RAM that three other things were counting on. You never set a single resource field, so Kubernetes had no idea any of this was a problem.

Requests and limits look like one setting with two numbers. They are two different mechanisms. A request is a promise the scheduler uses to place your pod, and it also sets your CPU priority. A limit is a ceiling the kernel enforces, and what happens at the ceiling differs completely between CPU and memory. My opinion for a home lab: always set a memory request, set the memory limit equal to it for anything that matters, set a CPU request, and skip CPU limits on most things. The rest of this post is the reasoning, with output from a real k3s run.

Full example: Clone the working files at github.com/KingPin/sumguy-examples/devops/k8s-requests-vs-limits-home-lab

Everything below ran on k3s v1.37.1+k3s1 (Kubernetes v1.37.1) in a Docker container on a cgroup v2 host. The host had 16 CPUs and about 27 GiB of RAM, so the absolute numbers are bigger than your mini PC. The behavior is the same.

What a request does, and what a limit does

A request does two jobs. The scheduler adds up the requests of pods already on a node and only places a new pod if its request fits in what’s left. Then the kubelet translates the CPU request into a cgroup cpu.weight, which only matters when the CPU is contested. In the test, a pod with cpu: 100m got cpu.weight 17 and a pod with no request got 1. Idle CPU is free for anyone. Busy CPU is divided by weight.

A limit is a wall. A memory limit becomes memory.max (the Guaranteed test pod showed 67108864 for 64Mi). A CPU limit becomes a CFS quota in cpu.max: the same pod showed 10000 100000, meaning 10 ms of CPU time per 100 ms period.

The two resources fail differently at the wall:

Picture the forklift and the couch: CPU throttling is the forklift driver waiting for the lane to clear. An OOMKill is the couch going through the wall.

QoS classes: the label Kubernetes hands out for free

You never set a QoS class. Kubernetes derives it from what you wrote:

Three pods from the kit show it:

qos-demo.yaml
apiVersion: v1
kind: Pod
metadata: {name: qos-guaranteed}
spec:
containers:
- name: app
image: busybox:1.37
command: ["sleep", "3600"]
resources:
requests: {cpu: 100m, memory: 64Mi}
limits: {cpu: 100m, memory: 64Mi}
Terminal window
kubectl get pod -o custom-columns=NAME:.metadata.name,QOS:.status.qosClass,PHASE:.status.phase
NAME QOS PHASE
cpu-throttle-demo Burstable Running
oom-demo Burstable Failed
qos-besteffort BestEffort Running
qos-burstable Burstable Running
qos-guaranteed Guaranteed Running

Note that oom-demo and cpu-throttle-demo are Burstable. Each sets only one resource type, so neither meets the Guaranteed rules. This is a common reason a pod you meant to protect isn’t Guaranteed: you set the memory pair and forgot CPU.

What QoS really controls

This part is often misread. The kubelet’s node-pressure eviction does not rank pods by QoS class. The Kubernetes docs say it ranks by whether usage exceeds requests, then by pod priority, then by how far usage exceeds the request. QoS only gives you an estimate of the likely order: a BestEffort pod has a request of zero, so any usage exceeds it, and it goes first.

On a stock k3s node this ranking never runs for memory, because k3s sets no memory.available threshold (see the node allocatable section below). Until you add one, the kernel OOM killer and oom_score_adj decide.

QoS does directly control oom_score_adj, which the kernel OOM killer reads when the kubelet is too slow to evict anything. From inside the three pods:

qos-besteffort oom_score_adj=1000
qos-burstable oom_score_adj=998
qos-guaranteed oom_score_adj=-997

The Burstable value follows the documented formula, min(max(2, 1000 - (1000 × memoryRequestBytes) / machineMemoryCapacityBytes), 999). A 64Mi request on a 27.2 GiB host rounds to 998. A bigger memory request pulls the number down, which makes the pod a less attractive victim. That is the practical reason to set memory requests honestly.

Memory: OOMKilled, exit code 137

Memory is where a limit bites hardest. This pod allocates 200 MiB under a 64Mi limit:

oom-demo.yaml
apiVersion: v1
kind: Pod
metadata: {name: oom-demo}
spec:
restartPolicy: Never
containers:
- name: hog
image: python:3.13-alpine
command: ["python", "-c", "b = bytearray(200 * 1024 * 1024); b[::4096] = b'x' * len(b[::4096]); print('allocated')"]
resources:
requests: {memory: 64Mi}
limits: {memory: 64Mi}
Terminal window
kubectl get pod oom-demo -o jsonpath='{.status.containerStatuses[0].state.terminated.reason} {.status.containerStatuses[0].state.terminated.exitCode}{"\n"}'
OOMKilled 137

Exit code 137 is 128 plus signal 9. On a pod with restartPolicy: Always you’d see the reason under lastState.terminated.reason after the restart, which is the field to check when a pod has a restart count and no useful logs.

My rule: set the memory limit equal to the memory request for anything you care about. Memory you’ve promised is memory you can’t be surprised by. A Burstable pod with a request of 256Mi and no limit can grow to eat the node, and when the node runs dry, the kernel picks a victim by score, which may not be the hog.

CPU: throttling and the limits debate

Now the CPU side. A busy loop with a 100m limit:

cpu-throttle-demo.yaml
apiVersion: v1
kind: Pod
metadata: {name: cpu-throttle-demo}
spec:
containers:
- name: burn
image: busybox:1.37
command: ["sh", "-c", "while true; do :; done"]
resources:
requests: {cpu: 100m}
limits: {cpu: 100m}
Terminal window
kubectl exec cpu-throttle-demo -- cat /sys/fs/cgroup/cpu.stat

Five seconds apart, the interesting lines were:

nr_periods 492
nr_throttled 492
throttled_usec 34160756
...
nr_periods 545
nr_throttled 545
throttled_usec 38176554

Every period was throttled (nr_throttled equals nr_periods), and throttled_usec grew by about 4 seconds in 5. That is what a loop that wants a whole core under a tenth of a core looks like.

The debate is about bursty apps. A web service with a 200m limit that idles at 5% and spikes to a full core for 30 ms per request gets throttled on every spike: 200m is 20 ms of CPU per 100 ms period, so the last 10 ms of the spike waits up to 80 ms for the next period. Multi-threaded apps burn the quota even faster. The request latency gets a nasty tail and the CPU graph looks calm. That mismatch has eaten many afternoons.

My opinion, and it is an opinion: on a home lab, set CPU requests and skip CPU limits for most workloads. The requests already give each pod its fair share when the CPU is busy, through cpu.weight. A limit only adds a ceiling on a node that would otherwise sit idle. Keep a CPU limit for the few things that misbehave on purpose: a transcoder, a build runner, anything you’d rather run slowly than let it starve Home Assistant. What protects Home Assistant from a hungry neighbor is its own CPU request, so make sure the important pods have one.

Node allocatable: the reservation your 8 GB box needs

The scheduler doesn’t hand out the node’s full capacity. It hands out Allocatable, which is Capacity minus reservations. In k3s, the defaults are thin. I checked the kubelet’s effective config (via the configz endpoint) on v1.37.1:

evictionHard {'imagefs.available': '5%', 'nodefs.available': '5%'}
evictionMinimumReclaim {'imagefs.available': '10%', 'nodefs.available': '10%'}
kubeReserved None
systemReserved None
enforceNodeAllocatable ['pods']

That matches the k3s source (pkg/daemons/agent/agent.go), which sets only those two eviction thresholds and no reservations. Two gotchas follow:

  1. Upstream Kubernetes defaults include memory.available<100Mi. The docs say those defaults are only set if you change none of the eviction parameters. k3s sets two, so on a stock k3s node there is no memory eviction threshold. Memory pressure goes straight to the kernel OOM killer.
  2. Nothing is reserved for k3s itself, containerd, or the OS. On an 8 GB mini PC running etcd or SQLite, a few pods, and your own SSH session, pods can request every byte and leave the node nothing.

Fix both in /etc/rancher/k3s/config.yaml. Setting any eviction-hard key drops the upstream defaults for the other keys, so list every threshold you want:

/etc/rancher/k3s/config.yaml
kubelet-arg:
- "kube-reserved=cpu=200m,memory=512Mi"
- "system-reserved=cpu=200m,memory=512Mi"
- "eviction-hard=memory.available<256Mi,nodefs.available<5%,imagefs.available<5%"

Restart k3s after editing (sudo systemctl restart k3s). Before and after, from kubectl describe node:

Capacity:
cpu: 16
memory: 28506216Ki
Allocatable (before):
cpu: 16
memory: 28506216Ki
Allocatable (after):
cpu: 15600m
memory: 27195496Ki

The memory arithmetic: 28506216Ki minus 1 GiB of reservations minus the 256Mi eviction threshold equals 27195496Ki. The 400m CPU came from the two CPU reservations. Allocatable subtracts the eviction threshold too, which surprises people.

One more thing I confirmed in the same output: enforceNodeAllocatable stays ['pods']. The kubelet caps the parent kubepods cgroup at Capacity minus the reservations (memory.max was 28116623360 bytes, exactly 1 GiB under Capacity), so pods as a group can’t eat the reserved memory. The system daemons themselves get no cgroup cap. That’s fine for a home lab.

LimitRange and ResourceQuota

BestEffort pods sneak in whenever someone writes a manifest without resources. A LimitRange fixes that per namespace by supplying defaults at admission time:

limitrange.yaml
apiVersion: v1
kind: LimitRange
metadata: {name: defaults, namespace: quota-demo}
spec:
limits:
- type: Container
defaultRequest: {cpu: 50m, memory: 64Mi}
default: {memory: 128Mi}

defaultRequest fills in requests and default fills in limits. Here the default has only a memory limit, matching the opinion above: no CPU limit.

A ResourceQuota caps the namespace total:

resourcequota.yaml
apiVersion: v1
kind: ResourceQuota
metadata: {name: caps, namespace: quota-demo}
spec:
hard:
requests.cpu: "1"
requests.memory: 1Gi
limits.memory: 2Gi

The gotcha is the order. A quota on requests.memory makes the field mandatory. Apply a bare pod to that namespace before the LimitRange exists and the API server says no:

Error from server (Forbidden): error when creating "/kit/no-resources-pod.yaml": pods "bare" is forbidden: failed quota: caps: must specify limits.memory for: app; requests.cpu for: app; requests.memory for: app

Apply the LimitRange and the same manifest goes through. The defaults land in the pod spec:

Burstable
{"limits":{"memory":"128Mi"},"requests":{"cpu":"50m","memory":"64Mi"}}
Resource Used Hard
-------- ---- ----
limits.memory 128Mi 2Gi
requests.cpu 50m 1
requests.memory 64Mi 1Gi

If a Deployment applies fine but its pods never show up, the quota is the usual suspect. Run kubectl describe replicaset and look in Events for the same “failed quota” message. On the Deployment it only shows up as a ReplicaFailure condition, which is easy to miss.

Sizing for a Pi or a mini PC

Real numbers beat guesses, so start from kubectl top pod and round up. A quiet service like Pi-hole or a small Go API fits in a 64Mi to 128Mi request. A database wants whatever its cache setting says plus headroom. Do the sum before you deploy: on an 8 GB node with the reservations above, Allocatable memory is roughly 6.5 GiB (the kernel usually reports about 7.7 GiB, minus 1 GiB of reservations and the 256Mi threshold), and the requests of all your pods have to fit in that.

Overcommit memory limits if you like, but never overcommit requests. Limits summing past 100% is a bet that pods don’t spike together, and you can survive losing that bet if the important pods sit at request equals limit. Requests summing past 100% is impossible, because the scheduler refuses. That refusal is the feature. A pod stuck in Pending with Insufficient memory is the cluster telling you the truth before the OOM killer does.

How to see what’s going on

A short list for 2 AM:

Terminal window
# what the scheduler has handed out, and the Allocated resources table
kubectl describe node <node>
# live usage (needs metrics-server; k3s ships it by default)
kubectl top pod -A --sort-by=memory
# QoS class of every pod
kubectl get pod -A -o custom-columns=NS:.metadata.namespace,NAME:.metadata.name,QOS:.status.qosClass
# why did it restart?
kubectl get pod <pod> -o jsonpath='{.status.containerStatuses[0].lastState.terminated.reason}{"\n"}'
# is it being throttled? (cgroup v2)
kubectl exec <pod> -- cat /sys/fs/cgroup/cpu.stat

The Allocated resources section of kubectl describe node shows requests and limits as a percentage of Allocatable. Requests should stay below 100%, because the scheduler enforces that. Limits are allowed to exceed 100% and the header says so: “Total limits may be over 100 percent, i.e., overcommitted.” Memory limits well over 100% means you’re betting no two pods spike together.

In-place resize is stable now

You used to need a pod restart to change resources. The Kubernetes docs list in-place pod resize as stable since v1.35, so on the k3s release I tested it just works. I patched the Guaranteed pod’s CPU from 100m to 200m through the resize subresource:

Terminal window
kubectl patch pod qos-guaranteed --subresource resize --patch \
'{"spec":{"containers":[{"name":"app","resources":{"requests":{"cpu":"200m"},"limits":{"cpu":"200m"}}}]}}'

The pod reported restarts 0, stayed Guaranteed, and cpu.max inside it changed to 20000 100000. Handy for a one-off adjustment. For a managed workload, the Deployment template is still the source of truth, so change it there too. For recommendations on what numbers to pick, a tool like Goldilocks reads real usage and suggests them. That’s a separate post.

The SumGuy Take

Set these and go to bed:

  1. Reserve memory for the node in config.yaml and add a memory.available eviction threshold. A stock k3s node has none.
  2. A LimitRange in every namespace, with a memory request default and a memory limit default.
  3. Memory request equals memory limit for databases, DNS, and anything that hurts when it restarts.
  4. CPU requests everywhere, CPU limits only on the noisy neighbors.
  5. Check lastState.terminated.reason before blaming the app.

Your 2 AM self will thank you.

Common Questions

Should I set CPU limits on Kubernetes pods?

No, not by default. A CPU request already gives a pod its fair share through cgroup cpu.weight when the node is busy. A CPU limit adds a CFS quota that throttles bursty apps even when average usage is low. Keep CPU limits only for noisy workloads like transcoders or build runners.

What happens if a pod exceeds its memory limit?

The kernel OOM killer ends the container, and Kubernetes records OOMKilled with exit code 137. The container then restarts according to the pod’s restartPolicy. A memory limit is enforced through the cgroup memory.max file, so the process gets no warning and no grace period.

Do Guaranteed pods get evicted last?

Usually, but QoS class is not the ranking key. The kubelet evicts pods whose usage exceeds their requests first, then ranks by priority. A Guaranteed pod never exceeds its request, so it lands near the end. Memory eviction only runs once a memory.available threshold is set, and stock k3s sets none.

How much RAM should I reserve for k3s on a mini PC?

Reserve about 1 GiB in total on an 8 to 16 GB node: 512Mi for kube-reserved and 512Mi for system-reserved. Add a memory.available<256Mi hard eviction threshold. That combination is what I used in the test, and it removed roughly 1.25 GiB from Allocatable.


Share this post on:

Send a Webmention

Written about this post on your own site? Send a webmention and it'll show up above once verified.


Next Post
sudo vs doas vs run0 on Linux

Discussion

Powered by Garrul . Sign in with GitHub or Google, or post anonymously.

Related Posts