agentleFS
PixiBixiPixiBixi/claude-skills/skills/k8s-rightsizing/SKILL.md

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.

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
  1. Distinctive:

    Check the oldest point before claiming a 30-day window: retention differs per store.

    line 24
  2. Distinctive:

    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 …

    line 26
  3. Very specific:

    Always filter on cluster and namespace (and container on raw series): an unfiltered [7d] range over a multi-cluster store can OOM the …

    line 30
  4. Distinctive:

    Run the simulator on a live snapshot.

    line 74

≈ “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.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

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__]'
NeedSource
Pod-level usage, single-container pods (most cases)the recording rules on the global store
Per-container split (sidecars), requests, raw OOM reasonthe 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.

SignalPromQL (instant query)
Memory peakmax by (pod, container) (max_over_time(container_memory_working_set_bytes{C,N,X}[7d]))
Memory p99max by (container) (quantile_over_time(0.99, container_memory_working_set_bytes{C,N,X}[7d]))
CPU p95 of the 5m ratemax 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 peaksame with max_over_time
Current requests / limitsmax by (container, resource) (kube_pod_container_resource_requests{C,N,X}), same on ..._limits
OOMKillsmax by (pod) (max_over_time(kube_pod_container_status_last_terminated_reason{C,N,X,reason="OOMKilled"}[7d]))
Restartsmax 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

ValueDefault rule (state the factors in the answer; a team standard overrides them)
Memory requestp99 working set x 1.15, rounded up to 64Mi
Memory limitmax(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 requestp95 of the 5m rate x 1.2, rounded to 50m
CPU limitfollow the repo (unset stays unset); if set, at least 2 x the request
Any OOMKill or memory restart in the windowno memory cut, report it first
Under 7 days of data, a recent rollout, or a known peak outside the windowsay so, widen the window, or do not recommend
HPA on Utilization for that resourcethe 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:

ConstraintWhere to read itEffect
Compute class, node pool, Karpenter NodePoolnodeSelector / node labelspods only pack with their group
Spot vs on-demandnode label (cloud.google.com/gke-spot, karpenter.sh/capacity-type)separate groups, separate prices
Architecturekubernetes.io/arch node label, arm64 taint, image manifestan amd64-only image never packs onto arm64 nodes
Taints / tolerationsnode.spec.taintsdedicated pools stay dedicated
Required anti-affinity, topologySpreadConstraintspod specfloor of one node per replica (hostname) or per zone
DaemonSets, kube-reservednode allocatable minus DaemonSet requeststhe room left per node
Local SSD, GPU, max pods per nodemachine type, allocatable.podssome pods need a specific shape
Zonal PV (StatefulSet)PV nodeAffinitythe 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). --fill defaults 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:

  1. One line: environment, window (start date and why), pod- or container-level.
  2. 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.
  3. The node table per group: today, after (fill to ideal), machine type, list price per month today and after.
  4. 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.
  5. 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.
  6. 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.

Download now (ZIP)

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
What did you use it for? e.g. Reviewing pull requests in a Rails app

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.