Why the “smart” autoscaler isn’t always smart
Many cloud‑native teams have embraced AI‑powered autoscaling extensions that claim to predict GPU demand based on historic
usage patterns. The promise is alluring: automatically spin up a nvidia.com/gpu node when a spike is detected,
then tear it down as soon as the workload settles. In practice, the algorithm often over‑reacts to transient spikes,
inflates the pod‑count, and leaves expensive GPU instances running idle for hours.
The hidden liability isn’t just the extra dollar bill; it’s the impact on latency‑sensitive inference jobs that share the same node pool. A sudden, unnecessary GPU allocation can starve critical pods, leading to missed SLA windows and degraded model accuracy (e.g., batch‑size mismatches). The following tutorial shows how to replace a black‑box AI autoscaler with a deterministic, rule‑based controller that respects both cost caps and latency budgets.
Understanding the baseline – the default Horizontal Pod Autoscaler (HPA)
Before adding any AI layer, you need a solid baseline. The standard HPA can scale CPU‑ or memory‑bound workloads,
but it does not understand GPU metrics out of the box. We will first expose GPU utilization via the
kube‑metrics‑server and then use a CustomMetric object that the HPA can consume.
# Deploy the NVIDIA device plugin
apiVersion: apps/v1
kind: DaemonSet
metadata:
name: nvidia-device-plugin-daemonset
namespace: kube-system
spec:
selector:
matchLabels:
name: nvidia-device-plugin-ds
template:
metadata:
labels:
name: nvidia-device-plugin-ds
spec:
containers:
- name: nvidia-device-plugin-ctr
image: nvidia/k8s-device-plugin:latest
securityContext:
privileged: true
env:
- name: FAIL_ON_INIT_ERROR
value: "false"
volumeMounts:
- name: device-plugin
mountPath: /var/lib/kubelet/device-plugins
volumes:
- name: device-plugin
hostPath:
path: /var/lib/kubelet/device-plugins
With the device plugin running, install kube‑metrics‑server and enable the GPU collector:
# kube‑metrics‑server deployment (partial)
apiVersion: apps/v1
kind: Deployment
metadata:
name: metrics-server
namespace: kube-system
spec:
template:
spec:
containers:
- name: metrics-server
image: k8s.gcr.io/metrics-server/metrics-server:v0.6.2
args:
- --kubelet-insecure-tls
- --metric-resolution=15s
- --kubelet-preferred-address-types=InternalIP,Hostname,InternalDNS,ExternalDNS,ExternalIP
- --requestheader-allowed-names=system:serviceaccount:kube-system:metrics-server
- --gpu-collector-enabled=true # <‑‑ enable GPU metrics
Verify that GPU utilization appears in the metrics API:
$ kubectl get --raw "/apis/custom.metrics.k8s.io/v1beta1/namespaces/default/pods/*/gpu_utilization"
{
"items": [
{
"metricName": "gpu_utilization",
"timestamp": "2026-08-24T12:00:00Z",
"value": "23"
}
]
}
Implementing a deterministic rule‑based scaling controller
The controller runs as a sidecar in the kube‑system namespace, watches the custom metric, and decides
whether to add or remove GPU nodes based on three configurable thresholds:
- UpperUtil – if average GPU utilization > 80% for 5 minutes, consider scaling up.
- LowerUtil – if average GPU utilization < 20% for 10 minutes, consider scaling down.
- CostCap – never exceed a budget of $2,500 per day for GPU instances.
Below is a minimal Python controller that uses the kubernetes client library. It demonstrates the core logic;
production code should add retries, logging, and leader election.
#!/usr/bin/env python3
import os
import time
from datetime import datetime, timedelta
from kubernetes import client, config
# Load in‑cluster config
config.load_incluster_config()
v1 = client.CoreV1Api()
custom = client.CustomObjectsApi()
# Configurable thresholds
UPPER_UTIL = 80 # percent
LOWER_UTIL = 20 # percent
COST_CAP_DAILY = 2500 # USD
# Helper to fetch average GPU utilization across the cluster
def get_average_gpu_util():
resp = custom.list_cluster_custom_object(
group="custom.metrics.k8s.io",
version="v1beta1",
plural="pods",
label_selector="app=gpu-workload"
)
values = [int(item["value"]) for item in resp.get("items", [])]
return sum(values) / len(values) if values else 0
# Helper to compute daily GPU cost from node labels (simplified)
def get_daily_gpu_cost():
nodes = v1.list_node(label_selector="cloud.google.com/gke-accelerator")
total = 0
for n in nodes.items:
# Assume $3 per GPU‑hour, 24 h per day
gpu_count = int(n.metadata.labels.get("cloud.google.com/gke-accelerator-count", "0"))
total += gpu_count * 3 * 24
return total
def scale_up():
# Example: add a node‑pool via GKE API (placeholder)
print("[INFO] Scaling up – requesting new GPU node")
# In real world you would call GKE REST API or use Terraform
def scale_down():
# Example: cordon and delete the least‑utilized node
nodes = v1.list_node()
gpu_nodes = [n for n in nodes.items if "nvidia.com/gpu" in n.status.allocatable]
if not gpu_nodes:
return
target = min(gpu_nodes, key=lambda n: int(n.status.allocatable["nvidia.com/gpu"]))
name = target.metadata.name
print(f"[INFO] Scaling down – deleting node {name}")
v1.delete_node(name)
def main():
high_util_start = None
low_util_start = None
while True:
avg_util = get_average_gpu_util()
daily_cost = get_daily_gpu_cost()
now = datetime.utcnow()
print(f"[{now}] Avg GPU Util: {avg_util:.1f}% | Daily Cost: ${daily_cost}")
# Scale‑up logic
if avg_util
♥ Enjoyed this article? Use the like banner at the top
of the page to let us know — you can like it more than once! Each
additional like from the same reader carries a little less weight than
the first, so our appreciation scores reflect genuine enthusiasm rather
than accidental clicks. Your feedback helps us understand which topics
resonate most.