Agent Sandbox
SandboxWarmPool Scale-to-Zero with KEDA on GKE
This example uses KEDA to scale a SandboxWarmPool down to zero (and back
up) based on the rate of sandbox claims, using the KEDA Prometheus scaler against GKE Managed
Service for Prometheus (GMP).
Note: The walkthrough targets Google Kubernetes Engine (GKE) — it uses GKE Managed Service for Prometheus (GMP) and Workload Identity Federation for GKE, and queries GMP’s hosted Prometheus endpoint directly (no in-cluster proxy). The Prometheus scaler is portable: off-GKE, point its
serverAddressat your own Prometheus-compatible query endpoint.
Why KEDA instead of HPA?
A native Horizontal Pod Autoscaler cannot scale to zero:
- The native Kubernetes HPA enforces
minReplicas >= 1. - The only escape, the
HPAScaleToZerofeature gate, is alpha and unavailable on managed GKE.
A KEDA ScaledObject supports minReplicaCount: 0:
- KEDA performs the 0 → 1 “activation” itself, scaling the warm pool directly through its
/scalesubresource when the metric crossesactivationThreshold. - It delegates the 1 → N range to a
HorizontalPodAutoscalerit creates and manages. - When activity stops, KEDA scales the pool back to 0, so an idle pool costs nothing.
The SandboxWarmPool CRD is already compatible: it exposes a /scale subresource and allows
spec.replicas: 0. KEDA also ships its own external metrics server, so this needs no Custom
Metrics Stackdriver Adapter.
Overview
We scale a pool of warm sandboxes on the rate of sandbox claims being created. With no claims the pool drains to zero; as soon as claims arrive KEDA activates the pool and scales it up to keep a ready supply ahead of demand.
Prerequisites
- A Google Kubernetes Engine (GKE) cluster.
- Workload Identity Federation for GKE enabled (lets KEDA read GMP).
- GKE Managed Service for Prometheus enabled.
- The agent sandbox controller installed (in the
agent-sandbox-systemnamespace).
Steps to Run
-
Install KEDA into its own namespace. Follow the GKE KEDA scale-to-zero tutorial to install it using Helm or
kubectl.Once installed, verify that KEDA is running:
kubectl get pods -n keda # wait for keda-operator + metrics-apiserver to be Running -
Set up the Agent Sandbox resources. Define names, namespace, and project, then apply the templated manifests with
envsubst:export NAMESPACE="keda-test" export TEMPLATE_NAME="python-sandbox-template" export WARM_POOL_NAME="python-sdk-warmpool" export PROJECT_ID="$(gcloud config get-value project)" kubectl create namespace $NAMESPACE envsubst < python-sandbox-template.yaml | kubectl apply -f - envsubst < sandboxwarmpool.yaml | kubectl apply -f -The warm pool starts at
replicas: 1(the default when omitted) before KEDA’s ScaledObject is applied. Once KEDA is applied, it takes ownership of this field. We omitreplicasin the manifest to avoid stomping the autoscaled count back to 0 on subsequent updates. -
Expose the controller metric via GMP. Apply
pod-monitoring.yamlto scrape the controller’s/metricsendpoint. This ingestsagent_sandbox_claim_creation_total{warmpool_name="..."}into Cloud Monitoring.kubectl apply -f pod-monitoring.yaml -
Authorize KEDA to query GMP (Workload Identity). The scaler authenticates to the hosted GMP endpoint as the
keda-operatorservice account. Grant itroles/monitoring.viewer:gcloud projects add-iam-policy-binding $PROJECT_ID \ --role=roles/monitoring.viewer \ --member="principal://iam.googleapis.com/projects/$(gcloud projects describe $PROJECT_ID --format='value(projectNumber)')/locations/global/workloadIdentityPools/$PROJECT_ID.svc.id.goog/subject/ns/keda/sa/keda-operator"The
TriggerAuthentication(podIdentity: gcp) that wires this identity in is bundled inscaledobject-prometheus.yaml, so there’s nothing else to apply here. -
Apply the ScaledObject:
envsubst < scaledobject-prometheus.yaml | kubectl apply -f -It points the Prometheus scaler at GMP’s hosted endpoint (
https://monitoring.googleapis.com/v1/projects/$PROJECT_ID/location/global/prometheus). Key fields:minReplicaCount: 0— true scale-to-zero.maxReplicaCount— hard budget ceiling.threshold— the 1 → N target, as anAverageValue(KEDA’s defaultmetricType), sodesired = claim_rate / threshold. Read1/thresholdas “seconds of demand buffered as ready sandboxes”; set it ≥ your per-sandbox replenishment time.activationThreshold— the 0 ↔ 1 gate, compared to the raw total rate.- query —
sum(rate(...) >= 0) or vector(0); the>= 0per-series filter drops staleNaNsub-series.
-
Generate load to trigger scaling:
python3 create-claim.py # reads NAMESPACE / WARM_POOL_NAME from the env you exported -
Verify scale-to-zero in both directions:
kubectl get scaledobject -n $NAMESPACE kubectl get hpa -n $NAMESPACE -w # the KEDA-managed HPA persists even at zero replicas kubectl get swp -n $NAMESPACE -w # pool: 0 -> N under load, back to 0 after load + cooldownPeriod
How scale-to-zero works here
- Activation vs. target:
activationThresholddecides whether the pool runs (0 ↔ 1); it’s compared to the raw query result.thresholddecides how many replicas once active (1 → N); it’s an averaged target. KEDA handles activation directly and creates the HPA only for 1 → N. - Why
AverageValue(KEDA default), notValue: withAverageValuethe HPA divides the metric by replicas, so the math collapses todesired = total_rate / threshold— self-regulating, independent of current replicas.Valuecomputescurrent × metric/target, which compounds every cycle and runs away tomaxReplicaCount. The claim metric is a cluster-wide total with 1:1 claim→sandbox, so this is buffer sizing andAverageValuegives the rightpool ∝ rate. - Choosing
threshold:pool = claim_rate / threshold, so1/threshold= seconds of demand held as ready sandboxes. Setthreshold ≈ 1/T, whereTis the per-sandbox replenishment time (Sandbox → PodReady, fromagent_sandbox_creation_latency_ms). Lowerthreshold→ bigger pool. Becausepool = rate × T, cuttingT(pre-pull the image) helps more than shrinkingthreshold. - Activation is not instant; full size takes time too: The first-ever activation takes ~1 scrape
interval for the counter series to be born. KEDA wakes
0 → 1once the metric is scraped and queryable, but the HPA scales1 → Nonly oncerate(...[1m])fills its window — so the first burst after idle ramps over ~1–2 min and may cold-start meanwhile. - Idle is free: with no claims the rate falls to 0 and KEDA scales the pool back to 0 after
cooldownPeriod.
Sources
- KEDA Prometheus scaler (see the “Google Managed Prometheus” section for the hosted-endpoint +
podIdentity: gcpsetup): https://keda.sh/docs/2.20/scalers/prometheus/ - GMP hosted query endpoint (
monitoring.googleapis.com/v1/projects/PROJECT_ID/location/global/prometheus): https://cloud.google.com/stackdriver/docs/managed-prometheus/query-api-ui - KEDA
metricTypedefault (AverageValue): https://keda.sh/docs/2.20/reference/scaledobject-spec/