k8s-rightsizing
Skill0 starsChanged 11 days agoApache-2.0View on GitHub
Use when sizing CPU/memory requests, limits or replicas of a Kubernetes workload from its real usage, and before any edit of a `resources:` block in Helm values: "on peut réduire cette RAM ?", "combien consomme <workload>", "il a déjà été OOMKilled ?", "combien de nodes on gagne ?", "ça coûte combien ?", a FinOps cut, a cluster with many idle nodes. Not for CI runners or a live OOM incident.
In this repository19 other files it gives its agents.
- AGENTS.mdAGENTS.md
- addressing-gitlab-review-commentsSkill
- auditing-mobile-layoutSkill
- charting-grafana-metricsSkill
- claude-usage-auditSkill
- confluenceSkill
- gitlab-job-triageSkill
- grafana-cloud-monitoringSkill
- grafana-dashboardsSkill
- handoffSkill
- incident-responseSkill
- jira-cliSkill
- mr-openSkill
- pixibixi-wiki-articleSkill
- rss-digestSkill
- scoping-a-websiteSkill
- tf-module-releaseSkill
- ticket-conventionsSkill
- wiki-fact-checkSkill
The file
We split this skill into the rules it gives an agent
4 rules. About 0 are common, 3 are distinctive, and 1 is very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈3
Distinctive · estimate
This author's own choices. Worth a look.
1
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Distinctive: line 24
Check the oldest point before claiming a 30-day window: retention differs per store.
- Distinctive: line 26
The window starts when the workload took its real traffic: After a migration, a cutover or a ramp-up, find that date first (traffic series …
- Very specific: line 30
Always filter on cluster and namespace (and container on raw series): an unfiltered [7d] range over a multi-cluster store can OOM the …
- Distinctive: line 74
Run the simulator on a live snapshot.
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
K8s rightsizing
A recommendation is the peak of real usage over a representative window plus headroom: never the average, never a number read off someone else's dashboard. A requests cut is only worth an MR if it removes nodes: the bill is nodes, not requests.
1. Find the series
Federated or global stores usually drop the raw container_* and kube_pod_container_* series and keep recording rules instead (pod-level CPU rate, memory usage, OOM counters). If the project CLAUDE.md names them, use those first. Otherwise list what exists for the target cluster before any real query: an empty result means wrong series or datasource, not zero usage.
gcx metrics query -d <uid> 'count by (__name__) ({__name__=~".*(cpu_usage|memory_usage|working_set|oom).*", <cluster_label>="<cluster>"})' -o json --jq '[.data.result[].metric.__name__]'| Need | Source |
| Pod-level usage, single-container pods (most cases) | the recording rules on the global store |
| Per-container split (sidecars), requests, raw OOM reason | the raw or per-cluster datasource |
Check the oldest point before claiming a 30-day window: retention differs per store.
The window starts when the workload took its real traffic. After a migration, a cutover or a ramp-up, find that date first (traffic series of the app, or the old platform going to zero) and measure from it: idle days before it dilute every quantile.
2. Measure
Always filter on cluster and namespace (and container on raw series): an unfiltered [7d] range over a multi-cluster store can OOM the queriers. Window [7d] by default, [14d] when the load is weekly-cyclic. Raw series shown; swap in the recording rule names where they exist.
| Signal | PromQL (instant query) |
| Memory peak | max by (pod, container) (max_over_time(container_memory_working_set_bytes{C,N,X}[7d])) |
| Memory p99 | max by (container) (quantile_over_time(0.99, container_memory_working_set_bytes{C,N,X}[7d])) |
| CPU p95 of the 5m rate | max by (container) (quantile_over_time(0.95, rate(container_cpu_usage_seconds_total{C,N,X}[5m])[7d:5m])) (on a rate recording rule, drop the inner rate) |
| CPU peak | same with max_over_time |
| Current requests / limits | max by (container, resource) (kube_pod_container_resource_requests{C,N,X}), same on ..._limits |
| OOMKills | max by (pod) (max_over_time(kube_pod_container_status_last_terminated_reason{C,N,X,reason="OOMKilled"}[7d])) |
| Restarts | max by (pod) (increase(kube_pod_container_status_restarts_total{C,N,X}[7d])) |
Live spec for comparison: kubectl klens reqlim --context <ctx> -n <ns> --by-owner.
3. Recommend
| Value | Default rule (state the factors in the answer; a team standard overrides them) |
| Memory request | p99 working set x 1.15, rounded up to 64Mi |
| Memory limit | max(peak x 1.25, request); equal to the request for a fixed heap (JVM, GOMEMLIMIT); never below the window peak |
Memory with a RAM-backed volume (emptyDir.medium: Memory, tmpfs cache) | request and limit >= its sizeLimit + process RSS peak, whatever the measured peak: a cache not yet full has not shown its size |
| CPU request | p95 of the 5m rate x 1.2, rounded to 50m |
| CPU limit | follow the repo (unset stays unset); if set, at least 2 x the request |
| Any OOMKill or memory restart in the window | no memory cut, report it first |
| Under 7 days of data, a recent rollout, or a known peak outside the window | say so, widen the window, or do not recommend |
HPA on Utilization for that resource | the target is a % of the request: lowering the request scales the workload out, recompute the target |
Also check the replicas and the spread rules: a required podAntiAffinity on kubernetes.io/hostname makes the node count >= replicas, whatever the requests. It is often the biggest lever and no requests cut can beat it.
4. Node impact: the gate
Nodes come from requests, through the placement constraints. Before proposing an MR, simulate the node count with the constraints that decide where each pod may land:
| Constraint | Where to read it | Effect |
| Compute class, node pool, Karpenter NodePool | nodeSelector / node labels | pods only pack with their group |
| Spot vs on-demand | node label (cloud.google.com/gke-spot, karpenter.sh/capacity-type) | separate groups, separate prices |
| Architecture | kubernetes.io/arch node label, arm64 taint, image manifest | an amd64-only image never packs onto arm64 nodes |
| Taints / tolerations | node.spec.taints | dedicated pools stay dedicated |
Required anti-affinity, topologySpreadConstraints | pod spec | floor of one node per replica (hostname) or per zone |
| DaemonSets, kube-reserved | node allocatable minus DaemonSet requests | the room left per node |
| Local SSD, GPU, max pods per node | machine type, allocatable.pods | some pods need a specific shape |
| Zonal PV (StatefulSet) | PV nodeAffinity | the pod is pinned to its zone |
Run the simulator on a live snapshot. It groups nodes by class, capacity type, arch and taints, packs each group into its most common machine type, and prices with the GCE catalog (list price, no CUD: a rough order of magnitude):
S=<scratchpad>
kubectl --context <ctx> get nodes -o json > "$S/nodes.json"
kubectl --context <ctx> get pods -A -o json > "$S/pods.json"
SHAPES=c4d-highmem-2,c4d-highmem-8,c4d-standard-8 # candidates: same arch as the group, allowed by its compute class
python3 scripts/fetch_prices.py "$S/nodes.json" --shapes "$SHAPES" > "$S/prices.json" # GKE only, see its docstring for the format
python3 scripts/node_impact.py --nodes "$S/nodes.json" --pods "$S/pods.json" \
--changes "$S/changes.json" --prices "$S/prices.json" --fill 0.75 --max-share 0.25 --zones auto --shapes "$SHAPES"Machine types. --shapes repacks each group after all changes into other machine types of the same arch. Pick candidates from the workload profile: memory-bound pods want a high GiB/vCPU ratio, a pod bigger than half a node wastes the rest. A cheaper shape only counts if the compute class (or node pool) can provision it: changing its priorities is a separate, platform-wide change. Moving to arm64 needs every image of the group to be multi-arch.
Blast radius. --max-share (default 0.25) caps the share of one workload's replicas on a single node, at least 1 pod, and a required hostname anti-affinity means 1. Never raise it to make bigger nodes win: a few big nodes holding every replica of an app is a single point of failure, whatever it saves. Read three columns: the worst share of one workload on a node, in a zone, and the share of the group one node loss takes. Calibration ignores the cap; a BLAST RADIUS line says how many nodes today's state lacks to respect it, which the after includes.
Zones. --zones auto (default) spreads each workload evenly over the zones of today's nodes, as the default zone spread does, keeps a pod with a PVC in its zone (zonal PV), and packs each zone apart: small workloads then cost one node per zone. Zone spread only matters on a multi-zone cluster: a single-zone cluster is a design choice (no inter-zone egress), so its 100% zone share is expected, not a finding, and adding zones is not a rightsizing proposal. --zones a,b,c simulates other node locations only when the user asks for it.
changes.json is keyed <namespace>/<owner> (Deployment name, StatefulSet name): {"ns/app": {"cpu": "250m", "memory": "2Gi", "replicas": 10, "spread": "soft"}}.
- Calibration first. "Model today" must equal "Today" per group. A gap means a constraint the model misses (mixed shapes, pending pods, scale-down lag): explain it before trusting the after.
- Range, not a point. The ideal packing is a floor; real schedulers and the cluster autoscaler pack worse (spread preferences, 50% scale-down threshold).
--filldefaults to 0.75; calibrate it on a cluster already in the target state when one exists. - Structural changes first. Simulate the spread and replicas changes alone, then add the requests cuts on top: under a hard one-node-per-replica floor every requests cut shows 0, which hides what it saves once the floor is gone.
- A change with 0 in "Nodes saved by this change alone" gets no MR. Report it in the table as no gain and stop there, unless the user asks for it anyway.
- Scale-down is progressive. Soft spread and lower requests do not move running pods: the autoscaler drains underused nodes over hours.
5. Output
The answer is, in this order:
- One line: environment, window (start date and why), pod- or container-level.
- The workload table from
node_impact.py, with the measured p95/p99/peak added per row: replicas, CPU req/limit, memory req/limit, before → after, and the nodes each change saves alone. - The node table per group: today, after (fill to ideal), machine type, list price per month today and after.
- The machine-type table: nodes, USD/month, worst share of one workload per node and per zone, share of the group lost with one node. Recommend another shape only if it is cheaper and keeps both blast-radius columns at or below the current shape's.
- The gain per change (one MR each), from the "Gain per change" table: nodes and USD/month each MR adds on top of the previous ones, structural changes first, so the rows sum to the total. A 1-node row is within bin-packing noise: say so.
- The total saving in USD/month (list price), and the changes rejected for zero node gain.
6. Ship
Values change in the owning GitOps repo (mr-open), one MR per workload with a node gain; its description carries the node and cost lines. Then check the rollout. Offer a reminder at +7 days (tickler) to re-measure, count the nodes and look for OOMKills.
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

