DevOps & SRE No. 159

Kubernetes Autoscaling skill for Claude Code

Scale on the right signal, before latency breaks. Has Claude treat autoscaling as one system: requests set from measured usage, a scaling signal that leads saturation, HPA behavior tuned against flapping, KEDA for queues and scale to zero, and Karpenter or Cluster Autoscaler sized to provisioning time. It keeps VPA and HPA off the same metric, writes PDBs that let nodes drain, and ships a simulator that replays a load curve through the HPA math.

  • namekubernetes-autoscaling
  • size331 lines · ≈3,900 tokens
  • files2
  • licenseMIT
  • updated

What Claude sees before loading it

This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.

description: Designs and tunes Kubernetes autoscaling end to end - HPA autoscaling/v2 metrics, targets, tolerance and behavior stabilization, VPA modes including in-place resize and its conflict with HPA, KEDA ScaledObjects for queues and event-driven scale to zero, node autoscaling with Cluster Autoscaler or Karpenter, requests as the prerequisite, PodDisruptionBudgets that do not block scale-down, and load-testing the policy before production. Use when pods do not scale or scale too late, replicas flap, nodes stay underused, a queue backlog grows, or when adding HPA, VPA, KEDA or Karpenter to a cluster.

Try asking

  • Our API pods scale up too late during traffic spikes, tune the HPA
  • Add KEDA so the order worker scales on SQS backlog and down to zero at night
  • Replicas keep flapping between 4 and 12 every few minutes, find out why

Use it when

  • Adding or tuning an HPA, VPA, or KEDA ScaledObject
  • Pods scale too late, too early, or flap
  • Nodes stay underused or pending pods wait minutes for capacity
  • Choosing between Cluster Autoscaler and Karpenter
  • Validating an autoscaling policy before a launch or peak event

Skip it when

  • You are writing the Deployment, probes, or Service manifests themselves - use kubernetes-deploy
  • You need a long-range forecast of capacity and cost - use capacity-planning

How to check the result

hpa_simulate.py shows zero saturated seconds on a production load curve, and a staging ramp test keeps p95 within the SLO with no long-pending pods and no errors on scale-down.

The common mistake it prevents

Running VPA and HPA on CPU for the same Deployment: VPA raises requests, utilization falls, the HPA scales in, and the two controllers fight indefinitely.

~/.claude/skills/kubernetes-autoscaling/SKILL.md

--- name: kubernetes-autoscaling description: Designs and tunes Kubernetes autoscaling end to end - HPA autoscaling/v2 metrics, targets, tolerance and behavior stabilization, VPA modes… license: MIT ---

Autoscaling is done when every scaled workload has requests set from measured usage, scales on the signal that actually precedes saturation, adds capacity faster than load arrives (pods and nodes), removes it without dropping requests, and the policy has been replayed against a real load curve before production.

First, inspect

kubectl version                                       # server minor matters for feature states
kubectl get hpa,vpa,scaledobject,scaledjob,pdb -A 2>/dev/null
kubectl describe hpa <name> -n <ns>                   # conditions + events explain every decision
kubectl top pods -n <ns> --containers                 # needs metrics-server
kubectl get apiservice | grep -E 'metrics.k8s.io|custom.metrics|external.metrics'
kubectl get nodepools,nodeclaims 2>/dev/null || kubectl -n kube-system get deploy cluster-autoscaler

In kubectl describe hpa, read the conditions: AbleToScale, ScalingActive (false means metrics are missing), ScalingLimited (pinned at min or max). Events show each rescale and why.

Workflow

- [ ] 1. Measure: per-pod throughput at the latency SLO, startup time, node provisioning time
- [ ] 2. Set requests from observed usage (VPA in Off mode or metrics history)
- [ ] 3. Pick the scaling signal (table below) and the target per pod
- [ ] 4. Configure HPA or KEDA with min/max and behavior
- [ ] 5. Configure node autoscaling so pending pods get nodes fast enough
- [ ] 6. Add PDBs and graceful shutdown so scale-down is safe
- [ ] 7. Replay a load curve (scripts/hpa_simulate.py) and run a real load test
- [ ] 8. Alert on pinned-at-max, pending pods, and flapping

Prerequisite: requests

  • HPA Utilization targets are a percentage of the container request. A pod whose containers lack the relevant request has undefined utilization, and the HPA takes no action for that metric.
  • Set CPU requests near steady-state p90 usage and memory requests near peak working set (kubernetes-deploy covers sizing and limits in depth).
  • Get recommendations without changing anything: a VPA with updateMode: "Off" and kubectl describe vpa <name> (target, lowerBound, upperBound).

Choose the signal

WorkloadScale onTool
CPU-bound HTTP/gRPC serviceCPU utilization, target 60-75% of requestHPA Resource or ContainerResource
I/O-bound or async service where CPU stays lowRequests per second or in-flight requests per podHPA Pods/Object metric via an adapter, or KEDA Prometheus trigger
Queue consumers (SQS, Kafka, RabbitMQ, Pub/Sub, Redis streams)Backlog per replica (lag / messages per pod)KEDA ScaledObject
Run-to-completion work itemsOne Job per batch of messagesKEDA ScaledJob
Idle most of the day, must scale to zeroEvent source with activation thresholdKEDA (minReplicaCount: 0)
Memory-bound with steady loadUsually right-size with VPA instead of scaling outVPA
Stateful single-writerDo not autoscale horizontallyVPA recommendations, manual

Memory is a poor HPA signal for most runtimes: usage does not fall when load does (JVM heaps, caches), so replicas ratchet up and never come down.

HPA (autoscaling/v2)

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata: { name: api, namespace: shop }
spec:
  scaleTargetRef: { apiVersion: apps/v1, kind: Deployment, name: api }
  minReplicas: 3                       # survive one zone loss and absorb the first spike
  maxReplicas: 40                      # bounded by DB connections and budget
  metrics:
    - type: ContainerResource          # ignore sidecar CPU
      containerResource:
        name: cpu
        container: app
        target: { type: Utilization, averageUtilization: 65 }
  behavior:
    scaleUp:
      stabilizationWindowSeconds: 0
      policies:
        - { type: Percent, value: 100, periodSeconds: 15 }
        - { type: Pods, value: 4, periodSeconds: 15 }
      selectPolicy: Max
    scaleDown:
      stabilizationWindowSeconds: 300
      policies:
        - { type: Percent, value: 20, periodSeconds: 60 }

How it decides:

  • desiredReplicas = ceil(currentReplicas * currentMetric / targetMetric), evaluated every 15 s (--horizontal-pod-autoscaler-sync-period).
  • No change while the ratio is within the tolerance (default 0.1, so 59-71% does nothing at a 65% target). Per-HPA tolerance under behavior.scaleUp / behavior.scaleDown is stable as of Kubernetes 1.37 (introduced 1.33 behind HPAConfigurableTolerance); on older clusters it may be unavailable.
  • With several metrics, the HPA computes a count per metric and uses the largest.
  • Not-yet-ready pods are set aside; during scale-up they are assumed to use 0%, which dampens the step. Slow readiness therefore slows scaling; fix startup time first.
  • Scale-down uses the highest recommendation within the stabilization window (default 300 s). Scale-up has no window by default and is limited to the larger of +100% or +4 pods per 15 s.

Behavior tuning:

SymptomChange
Too slow to catch spikesLower target (more headroom), raise minReplicas, add a Pods policy allowing bigger steps; cut pod startup time
Flapping up and downLonger scaleDown.stabilizationWindowSeconds; slower scale-down policy (for example 20% per 60 s); check the metric is not noisy
Never scales downMetric does not fall with load (memory), or minReplicas too high, or a stuck high recommendation from a noisy metric
Scale-down drops requestsGraceful shutdown: preStop sleep plus terminationGracePeriodSeconds longer than the longest request
Never scale down during an incidentbehavior.scaleDown.selectPolicy: Disabled temporarily

Rules:

  • Remove spec.replicas from the Deployment manifest once an HPA owns it, or every kubectl apply resets the count. Removing it is a one-time scale to 1 unless done carefully; apply when the HPA is already active and at low traffic.
  • One autoscaler per workload: never an HPA and a KEDA ScaledObject on the same Deployment (KEDA creates its own HPA).
  • maxReplicas must respect downstream limits: replicas x pool size must stay under the database connection budget.
  • Custom and external metrics need an adapter. Only one service can serve external.metrics.k8s.io per cluster; KEDA and prometheus-adapter conflict there.

VPA

updateModeBehaviorUse
OffRecommendations onlyDefault starting point; feed requests in your manifests
InitialSets requests at pod creation onlyBatch and jobs
InPlaceOrRecreateResizes running pods in place, evicts if it cannot (GA in VPA 1.6; needs Kubernetes 1.33+, in-place resize is GA in 1.35)Long-running services that tolerate occasional restarts
InPlaceIn-place only, never evicts (alpha, VPA 1.7)Disruption-intolerant workloads, once stable
RecreateEvicts pods to apply new requestsRarely; respects PDBs
AutoDeprecated alias of RecreateReplace with an explicit mode
  • Never run VPA and HPA on the same resource metric (CPU or memory) for one workload: VPA raises requests, utilization drops, HPA scales in, and they fight. Allowed combinations: HPA on a custom or external metric (RPS, queue lag) plus VPA on CPU and memory, or VPA in Off mode alongside any HPA.
  • Set minAllowed and maxAllowed in resourcePolicy so a bad recommendation cannot request a whole node or starve the pod.
  • VPA is not compatible with pod-level resources stanzas yet.

KEDA

apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata: { name: order-worker, namespace: shop }
spec:
  scaleTargetRef: { name: order-worker }
  minReplicaCount: 0
  maxReplicaCount: 50
  pollingInterval: 30          # default 30 s
  cooldownPeriod: 300          # only governs the last replica -> 0
  fallback: { failureThreshold: 3, replicas: 5 }
  advanced:
    horizontalPodAutoscalerConfig:
      behavior:
        scaleDown: { stabilizationWindowSeconds: 120 }
  triggers:
    - type: aws-sqs-queue
      authenticationRef: { name: aws-workload-identity }
      metadata:
        queueURL: https://sqs.us-east-1.amazonaws.com/123456789012/orders
        queueLength: "20"           # target messages per replica
        activationQueueLength: "1"  # wake from zero
        awsRegion: us-east-1
  • KEDA handles 0 <-> 1 (activation); the HPA it creates handles 1 <-> N. cooldownPeriod does not slow normal scale-down; tune that through horizontalPodAutoscalerConfig.
  • Target per replica = what one replica drains in your latency budget. If a pod handles 10 messages/s and backlog must clear within 60 s, queueLength near 600 is the ceiling, lower for headroom.
  • Always set fallback, or a broken metric source (expired credentials, Prometheus down) leaves the workload at whatever it had.
  • Long jobs that must not be killed mid-item: use ScaledJob, or set terminationGracePeriodSeconds above the longest item and make work idempotent.
  • Scale to zero means a cold start on the first event; measure it and confirm the latency is acceptable before enabling for user-facing paths.
  • Native HPA minReplicas: 0 (HPAScaleToZero, beta and on by default in 1.37) works only with Object or External metrics; KEDA remains the portable choice.

Node autoscaling

Pods scale in seconds; nodes take minutes. Total reaction time = metric delay + HPA sync + pod scheduling + node provisioning + image pull + app startup.

SituationChoose
EKS, or a cloud with a Karpenter provider (AWS, Azure)Karpenter: provisions right-sized nodes per pending pod, consolidates underused nodes
GKEBuilt-in cluster autoscaler or node auto-provisioning
Other clouds, fixed node group shapesCluster Autoscaler with node groups

Karpenter (v1 API):

apiVersion: karpenter.sh/v1
kind: NodePool
metadata: { name: general }
spec:
  template:
    spec:
      nodeClassRef: { group: karpenter.k8s.aws, kind: EC2NodeClass, name: default }
      requirements:
        - { key: karpenter.sh/capacity-type, operator: In, values: ["spot", "on-demand"] }
        - { key: kubernetes.io/arch, operator: In, values: ["arm64", "amd64"] }
        - { key: karpenter.k8s.aws/instance-category, operator: In, values: ["c", "m", "r"] }
      expireAfter: 720h
  limits: { cpu: "2000" }
  disruption:
    consolidationPolicy: WhenEmptyOrUnderutilized
    consolidateAfter: 2m
    budgets:
      - nodes: "10%"
      - nodes: "0"
        schedule: "0 9 * * mon-fri"
        duration: 8h
        reasons: ["Underutilized"]
  • consolidateAfter defaults to 0s; a few minutes avoids churn after every deploy. Recent releases also offer consolidationPolicy: Balanced to skip low-value moves.
  • Default budget is nodes: 10%. Block consolidation during business hours with a scheduled budget if churn hurts.
  • Protect a pod from voluntary disruption with the annotation karpenter.sh/do-not-disrupt: "true" (or a duration such as "30m").
  • Set limits on every NodePool; without them a runaway HPA buys nodes without bound.

Cluster Autoscaler: scale-down after --scale-down-unneeded-time (default 10m) below --scale-down-utilization-threshold (default 0.5 of requests); use --balance-similar-node-groups across zones and an --expander (least-waste or priority). Pods with cluster-autoscaler.kubernetes.io/safe-to-evict: "false" block their node’s removal.

For instant headroom, run a low-priority placeholder Deployment (negative PriorityClass) that real pods preempt while new nodes come up.

PodDisruptionBudgets

apiVersion: policy/v1
kind: PodDisruptionBudget
metadata: { name: api, namespace: shop }
spec:
  maxUnavailable: 1                    # or a percentage; scales with replica count
  unhealthyPodEvictionPolicy: AlwaysAllow
  selector: { matchLabels: { app: api } }
  • Prefer maxUnavailable over minAvailable; minAvailable equal to the replica count (or 100%) blocks every node drain and consolidation forever.
  • unhealthyPodEvictionPolicy: AlwaysAllow lets drains evict crash-looping pods that would otherwise block them.
  • Single-replica workloads with a PDB of maxUnavailable: 0 block node scale-down; run two replicas or accept the disruption.
  • PDBs cover voluntary evictions only; they do not protect against node failure.

Test the policy

  1. Replay a production load curve through the HPA math before touching the cluster:
# load.csv: t_seconds,load  (total CPU millicores, RPS, or queue rate across all pods)
python3 scripts/hpa_simulate.py load.csv --target 650 --min 3 --max 40 \
  --ready-delay 45 --capacity 900
python3 scripts/hpa_simulate.py load.csv --target 650 --min 3 --max 40 \
  --ready-delay 45 --down-window 600 --down-percent 20 --down-period 60 --capacity 900

It prints each rescale, peak replicas, flaps, pod-hours (cost proxy), and seconds where per-pod load exceeded the target and the measured --capacity (exit 1 if saturated). Add node provisioning time to --ready-delay when nodes must be created. Compare policies on the same curve; pick the one with zero saturated seconds at the lowest pod-hours.

  1. Run a real ramp test (k6, Locust, or Vegeta) in staging at production shape: a step from baseline to 3-5x within one minute, a hold, and a drop. Watch:
kubectl get hpa api -n shop -w
kubectl get pods -n shop -l app=api -w
kubectl get nodeclaims -w                  # Karpenter

Pass criteria: p95 latency stays within the SLO during the ramp, no pod stays Pending longer than the measured node provisioning time, scale-down completes without 5xx, and replicas do not oscillate after the drop.

Alerts

  • HPA at maxReplicas for more than 15 minutes (ScalingLimited true) while the metric is above target.
  • Pods Pending longer than expected node provisioning time.
  • More than N rescale events per hour (flapping).
  • KEDA scaler errors or fallback active.

Gotchas

  • metrics-server reports CPU averaged over its scrape window; very short spikes never reach the HPA. Scale on a leading signal (RPS, queue length) for bursty traffic.
  • Init containers and sidecars count toward Pod-level Resource metrics; use ContainerResource for the app container.
  • CPU limits cause throttling that looks like low utilization at high latency; the HPA then under-scales. Prefer no CPU limit (or a generous one) for latency-sensitive pods.
  • JVM and other slow-starting apps: use a startupProbe and keep readiness honest, or the HPA counts cold pods as capacity.
  • Changing maxReplicas or node limits in an incident without checking the database connection budget moves the outage downstream.
  • Spot interruptions are involuntary disruptions; spread across instance types and zones and keep enough on-demand baseline for minReplicas.

Output

**Workload:** shop/api (Deployment), HPA on container CPU 65%, min 3 / max 40
**Behavior:** up +100% or +4 pods per 15 s; down 20% per 60 s after a 300 s window
**Nodes:** Karpenter NodePool general (spot + on-demand, c/m/r, arm64+amd64), consolidateAfter 2m, limit 2000 CPU
**Safety:** PDB maxUnavailable 1 (AlwaysAllow), preStop 10 s, grace 40 s
**Simulation:** Black Friday curve: 0 s saturated, peak 31 replicas, 182 pod-hours (vs 214 with defaults)
**Load test:** 4x step in 60 s, p95 180 ms (SLO 300 ms), max Pending 70 s, no 5xx on scale-down

Bundled files

Loaded only when a step in the skill needs them. Review them here before installing.

scripts/hpa_simulate.py 221 lines · 9.3 KB
#!/usr/bin/env python3
"""Replay a load curve through an approximation of the HPA (autoscaling/v2) algorithm.

Usage:
    python3 scripts/hpa_simulate.py load.csv --target 600 [options]

load.csv has a header and two columns: t_seconds,load
  load = total demand in the metric's unit across all pods, e.g. total CPU millicores
  used, or total requests per second. Values hold until the next row (step function).

--target is the per-pod target in the same unit (e.g. 600 = 60% of a 1000m CPU request,
or 50 = 50 RPS per pod for an AverageValue metric).

Modeled: the core formula ceil(replicas * current/target), tolerance, not-yet-ready pods
counted at zero usage during scale-up, scale-up and scale-down stabilization windows,
Percent and Pods rate policies with selectPolicy Max, min/max replicas, pod readiness
delay. Not modeled: multiple metrics, missing metrics, node provisioning time (add it to
--ready-delay), VPA, KEDA activation. Treat the output as a policy comparison tool, not
an exact prediction.

Options (defaults match Kubernetes defaults):
  --min 1 --max 10 --initial <min>   replica bounds and starting replicas
  --sync 15                          controller sync period, seconds
  --tolerance 0.1                    no action while |ratio - 1| <= tolerance
  --ready-delay 30                   seconds from pod creation to ready (add node startup if needed)
  --up-window 0 --down-window 300    stabilizationWindowSeconds
  --up-percent 100 --up-pods 4 --up-period 15
  --down-percent 100 --down-pods 0 --down-period 15   (0 disables that policy)
  --capacity X                       per-pod load at which latency breaks (from a load test);
                                     reports seconds above it and exits 1 if any
  --every N                          print a row every N seconds (default: on change)

Exit codes: 0 = simulated (and never above --capacity), 1 = saturation seen, 2 = bad input.
"""
from __future__ import annotations

import argparse
import csv
import math
import sys


def load_curve(path: str) -> list[tuple[float, float]]:
    rows: list[tuple[float, float]] = []
    try:
        with open(path, newline="", encoding="utf-8") as fh:
            reader = csv.reader(fh)
            header = next(reader, None)
            if header is None:
                raise ValueError("empty file")
            for i, row in enumerate(reader, start=2):
                if not row or not row[0].strip():
                    continue
                if len(row) < 2:
                    raise ValueError(f"line {i}: expected t_seconds,load")
                rows.append((float(row[0]), float(row[1])))
    except (OSError, ValueError) as exc:
        print(f"error: {path}: {exc}", file=sys.stderr)
        sys.exit(2)
    if not rows:
        print("error: no data rows", file=sys.stderr)
        sys.exit(2)
    rows.sort()
    return rows


def load_at(curve: list[tuple[float, float]], t: float) -> float:
    value = curve[0][1]
    for ts, v in curve:
        if ts <= t:
            value = v
        else:
            break
    return value


def main() -> int:
    p = argparse.ArgumentParser(description="HPA policy simulator")
    p.add_argument("csv")
    p.add_argument("--target", type=float, required=True)
    p.add_argument("--min", type=int, default=1)
    p.add_argument("--max", type=int, default=10)
    p.add_argument("--initial", type=int)
    p.add_argument("--sync", type=float, default=15)
    p.add_argument("--tolerance", type=float, default=0.1)
    p.add_argument("--ready-delay", type=float, default=30)
    p.add_argument("--up-window", type=float, default=0)
    p.add_argument("--down-window", type=float, default=300)
    p.add_argument("--up-percent", type=float, default=100)
    p.add_argument("--up-pods", type=int, default=4)
    p.add_argument("--up-period", type=float, default=15)
    p.add_argument("--down-percent", type=float, default=100)
    p.add_argument("--down-pods", type=int, default=0)
    p.add_argument("--down-period", type=float, default=15)
    p.add_argument("--capacity", type=float)
    p.add_argument("--every", type=float)
    a = p.parse_args()

    if a.target <= 0 or a.min < 0 or a.max < max(a.min, 1) or a.sync <= 0:
        print("error: need --target > 0, 0 <= --min <= --max, --max >= 1, --sync > 0", file=sys.stderr)
        return 2
    curve = load_curve(a.csv)
    end = curve[-1][0]
    replicas = a.initial if a.initial is not None else max(a.min, 1)
    replicas = min(max(replicas, a.min), a.max)
    pods = [-a.ready_delay] * replicas          # creation times; initial pods already ready
    history: list[tuple[float, int]] = []       # (t, raw recommendation)
    events: list[tuple[float, int]] = []        # (t, delta replicas)

    stats = {"pod_seconds": 0.0, "above_target_s": 0.0, "saturated_s": 0.0, "peak": replicas,
             "scale_ups": 0, "scale_downs": 0, "flaps": 0}
    last_dir = 0
    last_dir_t = -1e9
    last_print = None
    prev_state = None

    print(f"{'t':>7} {'load':>10} {'ready/total':>11} {'per_pod':>9} {'raw':>4} {'desired':>7}")
    t = 0.0
    while t <= end:
        load = load_at(curve, t)
        ready = sum(1 for c in pods if t - c >= a.ready_delay)
        total = len(pods)
        not_ready = total - ready

        # raw recommendation (HPA formula with readiness handling)
        if ready == 0:
            raw = replicas
            per_pod = float("inf") if load > 0 else 0.0
        else:
            per_pod = load / ready
            ratio = per_pod / a.target
            if abs(ratio - 1) <= a.tolerance:
                raw = replicas
            elif ratio > 1 and not_ready > 0:
                ratio2 = (load / total) / a.target          # not-ready pods assumed at 0 usage
                raw = replicas if (ratio2 <= 1 or abs(ratio2 - 1) <= a.tolerance) else math.ceil(ratio2 * total)
            else:
                raw = math.ceil(ratio * ready)
        history.append((t, raw))
        keep = max(a.up_window, a.down_window)
        history = [(ht, r) for ht, r in history if t - ht <= keep]

        # stabilization: up uses min over up window, down uses max over down window
        up_rec = min(r for ht, r in history if t - ht <= a.up_window)
        down_rec = max(r for ht, r in history if t - ht <= a.down_window)
        desired = replicas
        if desired < up_rec:
            desired = up_rec
        if desired > down_rec:
            desired = down_rec

        # rate policies (selectPolicy Max = most permissive)
        if desired > replicas:
            added = sum(d for et, d in events if d > 0 and t - et < a.up_period)
            start = replicas - added
            limits = []
            if a.up_percent > 0:
                limits.append(math.ceil(start * (1 + a.up_percent / 100)))
            if a.up_pods > 0:
                limits.append(start + a.up_pods)
            if limits:
                desired = min(desired, max(limits))
        elif desired < replicas:
            removed = -sum(d for et, d in events if d < 0 and t - et < a.down_period)
            start = replicas + removed
            limits = []
            if a.down_percent > 0:
                limits.append(math.ceil(start * (1 - a.down_percent / 100)))
            if a.down_pods > 0:
                limits.append(start - a.down_pods)
            if limits:
                desired = max(desired, min(limits))
        desired = min(max(desired, a.min), a.max)

        if desired != replicas:
            delta = desired - replicas
            direction = 1 if delta > 0 else -1
            if last_dir and direction != last_dir and t - last_dir_t < 600:
                stats["flaps"] += 1
            last_dir, last_dir_t = direction, t
            stats["scale_ups" if delta > 0 else "scale_downs"] += 1
            events.append((t, delta))
            events = [(et, d) for et, d in events if t - et < max(a.up_period, a.down_period)]
            if delta > 0:
                pods.extend([t] * delta)
            else:
                pods.sort()                       # remove newest (not-ready first)
                del pods[len(pods) + delta:]
            replicas = desired
            stats["peak"] = max(stats["peak"], replicas)

        changed = (raw, replicas, ready) != prev_state
        prev_state = (raw, replicas, ready)
        show = last_print is None or (a.every and t - last_print >= a.every) or (not a.every and changed)
        if show:
            pp = "inf" if per_pod == float("inf") else f"{per_pod:.1f}"
            print(f"{t:>7.0f} {load:>10.1f} {ready:>5}/{total:<5} {pp:>9} {raw:>4} {replicas:>7}")
            last_print = t

        stats["pod_seconds"] += len(pods) * a.sync
        if per_pod > a.target * (1 + a.tolerance):
            stats["above_target_s"] += a.sync
        if a.capacity is not None and per_pod > a.capacity:
            stats["saturated_s"] += a.sync
        t += a.sync

    print()
    print(f"peak replicas        {stats['peak']}")
    print(f"scale ups / downs    {stats['scale_ups']} / {stats['scale_downs']}")
    print(f"direction flips <10m {stats['flaps']}")
    print(f"pod-hours            {stats['pod_seconds'] / 3600:.2f}")
    print(f"above target         {stats['above_target_s']:.0f} s (per-ready-pod load > target + tolerance)")
    if a.capacity is not None:
        print(f"saturated            {stats['saturated_s']:.0f} s (per-ready-pod load > capacity {a.capacity:g})")
        if stats["saturated_s"] > 0:
            return 1
    return 0


if __name__ == "__main__":
    sys.exit(main())

About this skill

Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.

esc