Resource requests and limits: the tuning pass that cut our node count 30%
Our algorithmic execution engine was costing us $14,800 a month in raw EC2 compute across three Kubernetes clusters. The irony was painful: our average CPU utilization across the fleet hovered at a miserable 12%, yet we were constantly battle-fighting alerts. During periods of high market volatility, pods would randomly drop dead with OOMKilled statuses, or our P99 order-routing latency would spike from a clean 4ms to a catastrophic 450ms.
We were trapped in a common Kubernetes anti-pattern. Out of fear of production instability, our engineers had set massive, arbitrary resource requests and limits on our deployments. The Kubernetes scheduler looked at those inflated requests, assumed the nodes were full, and spun up more EC2 instances. We had massive quantities of “slack”—allocated but entirely idle capacity—while still suffering from performance degradation under load.
To fix this, we ran a systematic rightsizing pass. By writing our own metric-extraction pipelines and refactoring how we configure container resources, we cut our node count by over 30%, stabilized our P99 latencies, and took a massive bite out of our infrastructure bill.
Here is exactly how we analyzed the cluster, the failure modes we hit, and the automation we built to keep our resource footprint lean.
flowchart TD feed["Market Data Feed"] --> ingester["Ingestion Pods"] ingester --> queue["Kafka Message Bus"] queue --> execution["Execution Engines"] execution --> prometheus["Prometheus Metrics"] prometheus --> analyzer["Rightsizing Script"]
The Core Problem: Request vs. Limit Misconceptions
To understand how we ended up over-provisioned, you have to look at how the Kubernetes scheduler handles resources.
requestsare what the scheduler uses to place pods. If a pod requests 2 CPUs, the scheduler will only place it on a node with at least 2 unallocated CPUs. This is a contractual guarantee.limitsare the hard ceiling. If a pod attempts to use more memory than its limit, the kernel out-of-memory (OOM) killer terminates the process. If it tries to use more CPU than its limit, the CFS (Completely Fair Scheduler) bandwidth control throttles its CPU cycles.
Our team had fallen into the trap of setting requests equal to limits at incredibly high levels (e.g., requesting 4 CPUs and 8GiB of RAM for an ingestion worker that averaged 150m CPU and 800MiB RAM). We did this “just in case” the worker needed to catch up on a backlog. The result? Nodes were fully allocated according to the scheduler, forcing cluster autoscaler to spin up new nodes, while the actual physical hardware sat idle.
The Analyzer: Writing the Script to Find the Waste
Rather than relying on third-party SaaS tools that charge a premium to tell us we are wasting money, we wrote a Python script to query our Prometheus metrics endpoint directly. We wanted to analyze the delta between the 95th percentile of actual resource usage and the configured requests/limits over a 14-day trailing window.
Here is the tool we built to extract this data. It uses the Prometheus query API to calculate the exact headroom of every container in our namespaces.
import requests
import json
import math
from datetime import datetime
PROMETHEUS_URL = "http://prometheus-k8s.monitoring.svc.cluster.local:9090"
NAMESPACE = "trading-system"
WINDOW = "14d"
# PromQL Queries
CPU_USAGE_QUERY = f"histogram_quantile(0.95, sum(rate(container_cpu_usage_seconds_total{{namespace='{NAMESPACE}', container!=''}}[{WINDOW}])) by (pod, container, le))"
CPU_REQUESTS_QUERY = f"kube_pod_container_resource_requests{{namespace='{NAMESPACE}', resource='cpu'}}"
MEM_USAGE_QUERY = f"max_over_time(container_memory_working_set_bytes{{namespace='{NAMESPACE}', container!=''}}[{WINDOW}])"
MEM_REQUESTS_QUERY = f"kube_pod_container_resource_requests{{namespace='{NAMESPACE}', resource='memory'}}"
def run_query(query):
try:
response = requests.get(f"{PROMETHEUS_URL}/api/v1/query", params={"query": query})
response.raise_for_status()
return response.json()["data"]["result"]
except Exception as e:
print(f"Error executing query [{query[:50]}…]: {e}")
return []
def parse_metrics(raw_data, val_transform=lambda x: float(x)):
parsed = {}
for item in raw_data:
metric = item["metric"]
pod = metric.get("pod")
container = metric.get("container")
if not pod or not container:
continue
val = val_transform(item["value"][1])
parsed[f"{pod}/{container}"] = val
return parsed
def main():
print(f"Fetching metrics for namespace: {NAMESPACE} over historical window: {WINDOW}…")
cpu_usage = parse_metrics(run_query(CPU_USAGE_QUERY))
cpu_reqs = parse_metrics(run_query(CPU_REQUESTS_QUERY))
mem_usage = parse_metrics(run_query(MEM_USAGE_QUERY), lambda x: float(x) / (1024 * 1024)) # Convert to MiB
mem_reqs = parse_metrics(run_query(MEM_REQUESTS_QUERY), lambda x: float(x) / (1024 * 1024)) # Convert to MiB
print("\n" + "="*85)
print(f"{'Container Key':<45} | {'Metric':<6} | {'Requested':<10} | {'95th Pct / Max':<15} | {'Wastage':<10}")
print("="*85)
all_keys = set(cpu_reqs.keys()).intersection(set(cpu_usage.keys()))
for key in sorted(all_keys):
req_cpu = cpu_reqs[key]
used_cpu = cpu_usage[key]
cpu_wastage = max(0.0, req_cpu – used_cpu)
# Match memory key
req_mem = mem_reqs.get(key, 0.0)
used_mem = mem_usage.get(key, 0.0)
mem_wastage = max(0.0, req_mem – used_mem)
if cpu_wastage > 0.5 or mem_wastage > 256: # Only print targets with substantial waste
print(f"{key:<45} | {'CPU':<6} | {req_cpu:>8.2f} Co | {used_cpu:>12.2f} Co | {cpu_wastage:>8.2f} Co")
print(f"{'':<45} | {'MEM':<6} | {req_mem:>8.1f} MB | {used_mem:>12.1f} MB | {mem_wastage:>8.1f} MB")
print("-" * 85)
if __name__ == "__main__":
main()
When we ran this, the output was glaring. Here is a sample from our live cluster terminal:
Container Key | Metric | Requested | 95th Pct / Max | Wastage
=====================================================================================
order-router-bc84f74d-7n2q9/router-container | CPU | 4.00 Co | 0.45 Co | 3.55 Co
| MEM | 8192.0 MB | 1240.5 MB | 6951.5 MB
————————————————————————————-
feed-parser-76f8b9d9-jswv2/parser-container | CPU | 2.00 Co | 0.61 Co | 1.39 Co
| MEM | 4096.0 MB | 512.0 MB | 3584.0 MB
————————————————————————————-
We were provisioning 4 full CPU cores for the order router, yet at the 95th percentile, it was only using 0.45 of a core. Multiply this across dozens of replicas, and we were burning hundreds of dollars daily on idle virtual machines.
Real-Time Ad-Hoc Audits via CLI
For quick validation during deployments, we wrote a fast shell pipeline using kubectl and jq to instantly surface pods that have a massive discrepancy between their declared request and actual live usage.
set -eo pipefail
NAMESPACE="trading-system"
echo "Evaluating live CPU / Memory requests against current usage…"
echo "————————————————————"
kubectl get pods -n "$NAMESPACE" -o json | jq -r '
.items[] | select(.status.phase=="Running") | .metadata.name as $pod |
.spec.containers[] | .name as $container |
(.resources.requests.cpu // "0") as $cpu_req |
(.resources.requests.memory // "0") as $mem_req |
[$pod, $container, $cpu_req, $mem_req] | @tsv
' | while read -r pod container cpu_req mem_req; do
# Get current usage via kubectl top pod
usage_stats=$(kubectl top pod "$pod" -n "$NAMESPACE" –containers 2>/dev/null | grep "$container" || true)
if [ -n "$usage_stats" ]; then
used_cpu=$(echo "$usage_stats" | awk '{print $3}')
used_mem=$(echo "$usage_stats" | awk '{print $4}')
echo "Pod: $pod | Container: $container"
echo " CPU Request: $cpu_req | Live Usage: $used_cpu"
echo " MEM Request: $mem_req | Live Usage: $used_mem"
echo ""
fi
done
The Rightsizing Strategy: Breaking the Limits Safely
When we set out to apply kubernetes cost optimization, we couldn’t just slash every configuration. We learned this the hard way on day two of our refactoring pass.
Failure 1: The Go Runtime GOMAXPROCS Trap
We scaled down our Go-based order-routing engine requests and limits from 4.0 CPUs down to 0.5 CPUs. Within minutes, the P99 execution latency of the order router skyrocketed.
Why? Go’s runtime uses the environment variable GOMAXPROCS to determine how many OS threads can execute user-level Go code simultaneously. By default, Go queries the container’s visible CPUs. In Kubernetes, unless specified, Go sees all the CPUs on the host node, not the container’s CPU limit. It spun up dozens of threads.
Because we restricted the container’s CPU limits to 0.5, the kernel’s CFS scheduler relentlessly throttled these threads within its 100ms quota period. The threads were constantly waiting for CPU cycles, causing latency spikes.
The Fix: We integrated Uber’s automaxprocs library into our main entry points, which reads the container’s actual CPU limits from cgroups and configures GOMAXPROCS dynamically:
import (
_ "go.uber.org/automaxprocs"
"log"
)
func main() {
log.Println("Starting high-performance order routing engine…")
// Application runtime initialization code
}
Failure 2: The Python Memory Fragmentation / Malloc Trap
Our Python market-data parsers were eating memory. We tried to set a lean memory request of 512MiB and a limit of 1GiB. Every few hours, during bursty market activity, the pods would get hit with an OOMKilled event.
When we inspected the profiling data, we realized that memory usage wasn’t actually leaking; it was fragmenting. CPython’s allocator doesn’t release memory back to the OS immediately. When processing large JSON payloads, it allocated thousands of temporary objects. The OS virtual memory manager kept allocating pages, hitting our limit before Python’s garbage collector had freed up virtual memory fragments back to the kernel.
The Fix: We changed our memory allocation library from the standard glibc malloc to Google’s jemalloc, which handles heap fragmentation far more aggressively, and configured MALLOC_ARENA_MAX:
spec:
containers:
– name: parser-container
image: internal-registry/feed-parser:v2.1.0
env:
– name: MALLOC_ARENA_MAX
value: "2"
– name: LD_PRELOAD
value: "/usr/lib/x86_64-linux-gnu/libjemalloc.so.2"
This single environment change decreased our peak memory footprints by up to 40% without changing a single line of python code, preventing OOMKills even with restricted memory limits.
The Before and After Configurations
After gathering actual profile data and mitigating application-level quirks, we rewrote our Helm values. We decoupled requests and limits completely:
- CPU: We set requests based on the average typical workload, and did not set any limits. This prevents CFS throttling and allows containers to burst up to the node capacity when necessary, provided other workloads aren’t starved.
- Memory: We set requests to match the actual historical 90th percentile usage, and limits to 1.3x that value to allow breathing room for temporary memory spikes without immediately triggering the OOM killer.
Before: Over-provisioned Helm values
apiVersion: apps/v1
kind: Deployment
metadata:
name: order-router
namespace: trading-system
spec:
replicas: 12
template:
spec:
containers:
– name: router-container
image: internal-registry/order-router:v1.4.0
resources:
requests:
cpu: "4000m"
memory: "8Gi"
limits:
cpu: "4000m"
memory: "8Gi"
After: Optimized and safe Helm values
apiVersion: apps/v1
kind: Deployment
metadata:
name: order-router
namespace: trading-system
spec:
replicas: 12
template:
spec:
containers:
– name: router-container
image: internal-registry/order-router:v2.1.1
resources:
requests:
cpu: "500m" # Drastically lowered; schedule based on normal loads
memory: "1500Mi" # Aligned with true working set size
limits:
# We removed the CPU limit entirely to prevent CFS latency throttling
memory: "3000Mi" # Protective ceiling allowing overhead
Results: The Real Impact on the Fleet
After rolling out these changes incrementally across our development, staging, and production environments, we monitored our metrics closely over a two-week period.
| Metric | Before Tuning | After Tuning | Change |
|---|---|---|---|
| Total Worker Nodes | 45 c5.2xlarge & m5.2xlarge |
31 Mixed Instances | -31.1% |
| Average Node CPU Utilization | 11.2% | 34.6% | +208.9% |
| Monthly Cluster Cost | $14,800.00 | $10,180.00 | -$4,620.00 |
| Order Routing P99 Latency | 450ms (during spikes) | 3.8ms (stable) | -99.1% |
| Weekly OOMKills | 14 | 0 | Eliminated |
By shedding the artificial CPU requests, the Kubernetes scheduler was able to pack our pods much more efficiently onto the existing fleet. The Cluster Autoscaler responded by safely terminating 14 redundant EC2 instances.
Crucially, removing CPU limits on our performance-critical components completely resolved our latency spikes. When trade volumes surge, our pods can now burst into the unreserved CPU capacity of the host nodes without the CFS scheduler stepping in to artificially throttle their cycles.
Key Lessons for Scaling Rightsizing
- Never set CPU limits on latency-critical applications. Unless you have a multi-tenant cluster where noisy neighbors are a existential threat, setting CPU limits is a recipe for CFS scheduling latency. Set realistic CPU requests to guarantee your performance base-line, and let containers burst freely.
- Set memory limits with a buffer. Memory is a non-compressible resource. Unlike CPU, which degrades gracefully when starved, running out of memory means instantaneous death. Keep a 30-50% buffer between your memory request and your limit.
- Bind runtime settings to cgroups limits. If you scale down CPU resources, ensure your runtime engines (Go, Java, .NET, Node.js) are explicitly aware of the limits using tools like
automaxprocsor custom memory flags (-XX:ActiveProcessorCountfor JVM). Failing to do so leads to thread pools choking on starved virtualized hardware.