Home · About · Services · Industries · Projects · Products · Process · Blog · FAQ · Contact
Kubernetes requests and limits: setting CPU and memory right What requests and limits really do, why CPU limits throttle and memory limits kill, and a measured way to size pods, with real examples and commands.
A team moves a Node.js API to Kubernetes and copies resource settings from a blog post: 100m CPU request, 200m CPU limit, 256Mi memory. In staging everything looks fine. In production, response times double at busy hours and pods restart a few times a day with exit code 137. Nothing is wrong with the code. The numbers are wrong, and they were wrong in two different ways.
Kubernetes requests and limits do different jobs. Requests are what the scheduler reserves for a container on a node. Limits are the ceiling the kernel enforces while it runs. Exceed a CPU limit and the container is throttled. Exceed a memory limit and it is killed (OOMKilled, exit code 137). Set requests from measured usage, set memory limits, and use CPU limits with care.
Below is what each setting does in practice, and a sizing method you can repeat for every service.
What requests do: scheduling and guarantees Each node has allocatable CPU and memory: its capacity minus what is reserved for the kubelet and operating system. The scheduler only places a pod on a node if the sum of requests already on that node, plus the new pod's requests, fits inside allocatable. It does not look at actual usage. A node can be 10% busy and still refuse a pod because its requests are fully booked.
You can see this booking directly:
kubectl describe node worker-1 | grep -A 8 "Allocated resources"
# Allocated resources:
# Resource Requests Limits
# -------- -------- ------
# cpu 3650m (93%) 6 (153%)
# memory 6912Mi (91%) 9Gi (121%)When no node has room, the pod stays Pending and kubectl describe pod shows an event such as "0/3 nodes are available: 3 Insufficient cpu". The usual fix is not a bigger cluster. It is requests that reflect what the pods really use.
Requests matter at runtime too. CPU requests become relative weights in the kernel's scheduler, so when a node is busy, each container gets CPU time in proportion to its request. When a node runs short of memory, the kubelet evicts pods whose usage exceeds their memory request first. Two more things depend on requests: the Horizontal Pod Autoscaler calculates utilisation as a percentage of the request, and a ResourceQuota on requests counts them against the namespace.
On units: 1 CPU is one vCPU or core, and 250m is a quarter of one. 512Mi is 512 mebibytes. 512M is decimal megabytes, about 5% less, and 512m means 0.512 bytes, a mistake we have caught in review more than once.
What limits do: throttling and OOM kills CPU and memory limits behave very differently, and most confusion comes from treating them as the same thing.
A CPU limit is enforced through the kernel's CFS quota. Kubernetes sets a 100ms period, and a 500m limit allows 50ms of CPU time in each period, summed across every thread in the container. Take an API with four busy threads handling a burst of requests. Together they use up the 50ms quota in about 12.5ms of wall-clock time, then the whole container waits for the remaining 87.5ms of the period. The node can have idle cores the whole time. Users see random latency spikes, while the averaged CPU graph looks comfortable.
A memory limit is a hard ceiling. Memory cannot be throttled, so when a container tries to go over its limit the kernel's OOM killer ends a process in it. Kubernetes records it like this:
kubectl describe pod api-7c9f8d6b5-x2kqp
# Last State: Terminated
# Reason: OOMKilled
# Exit Code: 137
# Restart Count: 6If you set a limit and no request, Kubernetes copies the limit into the request. If you set neither, the container can use whatever the node has free and has nothing reserved for it.
QoS classes and who gets evicted first From your requests and limits, Kubernetes gives every pod a quality of service class. It matters when a node runs low on memory and the kubelet has to evict pods.
Guaranteed: every container has CPU and memory limits, with requests equal to them. These are normally the last to be evicted. Burstable: at least one container has a request or limit, but the pod does not meet the Guaranteed rule. Pods using more memory than they requested are evicted before those within their requests. BestEffort: no requests or limits on any container. Any memory they use is above their (zero) request, so they are usually evicted first, and they have no reserved capacity at all. kubectl get pods -n production \
-o custom-columns=NAME:.metadata.name,QOS:.status.qosClassThis quick audit often finds BestEffort pods in production: a cron job nobody gave resources, or a one-off debugging pod. They go first under pressure, and because they reserve nothing, they make the scheduler's view of the node optimistic.
A worked example: sizing a real API Here is how we would size the Node.js API from the opening. First, measure. With metrics-server installed, kubectl top gives a snapshot. For decisions, use a week of Prometheus history, which covers peak hours and batch jobs.
kubectl top pod -n production -l app=api --containers
# p95 memory per container over 7 days (Prometheus)
quantile_over_time(0.95,
container_memory_working_set_bytes{namespace="production", container="api"}[7d])
# Share of CPU periods where the container was throttled
rate(container_cpu_cfs_throttled_periods_total{container="api"}[5m])
/ rate(container_cpu_cfs_periods_total{container="api"}[5m])Say the data shows working set memory at around 300Mi on a normal day with a p95 of 410Mi, CPU around 150m with bursts above 600m, and a throttled ratio of 40% at peak hours against the old 200m limit. That explains both symptoms. The pods were throttled for much of the busy period, and 256Mi was below normal peak memory.
The settings we would apply:
resources:
requests:
cpu: 250m
memory: 512Mi
limits:
memory: 512MiThe memory request equals the limit, with headroom above the measured p95. The node reserves exactly what the container may use, so its usage never exceeds its request and it sits near the back of the eviction queue. It also cannot take memory that other pods rely on. The CPU request covers typical use, so the scheduler places pods sensibly and the HPA has a meaningful baseline. There is no CPU limit, so bursts can use idle cores instead of waiting out the quota period.
The runtime has to agree with the limit. Recent Node.js versions take the cgroup limit into account when sizing the heap, but we still set it explicitly, around 75% of the container limit, so garbage collection works harder before the kernel steps in. The same applies to other runtimes:
Node.js: NODE_OPTIONS="--max-old-space-size=384" for a 512Mi limit. JVM: -XX:MaxRAMPercentage=75 instead of a fixed -Xmx, so heap follows the limit. Go: GOMEMLIMIT a little below the limit. Since Go 1.25, GOMAXPROCS also respects the CPU limit by default. Python workers: size the number of Gunicorn or Uvicorn workers to the CPU request, because each one is a separate process with its own memory. CPU limits: when to keep them Leaving out CPU limits is our default for latency-sensitive services, not a universal rule. We keep them when:
The namespace has a ResourceQuota on limits.cpu. The API server will then reject pods without a CPU limit, so a LimitRange default or an explicit value is needed. The cluster is shared with tenants you do not control, and one noisy workload must not take every spare core. You need predictable performance for benchmarking or load testing, where borrowed idle CPU would flatter the results. A batch job should be capped so that it cannot slow the services around it. When you do set a CPU limit, set it well above the request (two to four times is a reasonable start) and watch the throttling ratio. A sustained ratio above a few percent on a request-serving container is worth fixing. Without CPU limits, requests must be honest, because they are the only thing deciding each container's share when a node is busy.
For namespace defaults, a LimitRange fills in values for containers that arrive without them, so nothing lands as BestEffort by accident:
apiVersion: v1
kind: LimitRange
metadata:
name: defaults
namespace: production
spec:
limits:
- type: Container
defaultRequest:
cpu: 100m
memory: 256Mi
default:
memory: 256MiIn-place pod resize lets you change CPU and memory on a running pod through the resize subresource without recreating it. It has been enabled by default since Kubernetes 1.33 and is stable in 1.35. Most teams still change resources through the Deployment, which rolls new pods, and that remains the safer habit.
A checklist for every service No BestEffort pods in production namespaces. Add a LimitRange so defaults apply. Requests come from at least a week of measured usage, including peak hours, not from a template. Memory request equals memory limit, with headroom above p95 working set. Runtime heap or memory settings sit below the container limit. CPU limits are either absent with honest requests, or set well above the request with throttling monitored. Alerts exist for OOMKilled restarts and for sustained CPU throttling. HPA targets are reviewed after requests change, because utilisation is a percentage of the request. Requests are revisited each quarter or after major releases. The Vertical Pod Autoscaler with updateMode "Off" gives recommendations without changing anything. If pods restart with exit code 137, latency spikes do not line up with CPU graphs, or nodes look idle while pods sit Pending, the cause is often in these few lines of YAML. Spend an afternoon measuring before anyone adds nodes.
Infigini IT Services · 11 Kanwali Road, Dehradun 248001, India · info@infigini.com · +91 95684 87789