Autoscaling with KEDA

Kubernetes has built-in HPA (Horizontal Pod Autoscaler), but it only scales on CPU and memory. The component I most wanted to scale, the ArgoCD repo-server, has a different bottleneck: it spends its time cloning and fetching Git repositories during reconciliation. The signal that matters is the Git request rate, which is a Prometheus metric, not CPU. That's where KEDA comes in.


What KEDA does

KEDA (Kubernetes Event-driven Autoscaling) extends the HPA with custom triggers. Instead of just CPU and memory, you can scale on Prometheus queries, queue depth, cron schedules, HTTP traffic, and dozens of other sources. Under the hood it still creates a regular HPA; KEDA just feeds it the external metrics.

The repo-server is the right target for this. When several Applications reconcile at once, or someone hard-refreshes a handful of apps, the repo-server does a burst of Git fetches and becomes the slow part of a sync. The rest of the time it sits mostly idle.


The ScaledObject

One ScaledObject, on the argocd-repo-server Deployment, with two triggers:

apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
  name: argocd-repo-server
  namespace: argocd
spec:
  scaleTargetRef:
    kind: Deployment
    name: argocd-repo-server
  pollingInterval: 30
  cooldownPeriod: 300
  minReplicaCount: 1
  maxReplicaCount: 2
  advanced:
    horizontalPodAutoscalerConfig:
      behavior:
        scaleUp:
          stabilizationWindowSeconds: 30
        scaleDown:
          stabilizationWindowSeconds: 300
  triggers:
    - type: cpu
      metricType: Utilization
      metadata:
        value: "70"
    - type: prometheus
      metadata:
        serverAddress: http://thanos-query-frontend.metrics.svc.cluster.local:9090
        threshold: "10"
        query: sum(rate(argocd_git_request_total{namespace="argocd"}[2m]))

It scales from 1 to 2 replicas. The Prometheus trigger is the interesting one: it watches the rate of Git requests over a 2-minute window and scales up past 10 req/s. The CPU trigger at 70% utilization is a fallback for the cases where the repo-server is busy but not because of Git.

KEDA reads the metric through Thanos Query Frontend rather than Prometheus directly, so the same query path the dashboards use also drives autoscaling.


Tuning the threshold

The threshold started at 5 req/s, and that was a mistake. Baseline reconcile load alone, with around 15 Applications polling every 3 minutes, already sits at roughly 5 to 6 req/s. So the trigger was permanently crossed: both replicas stayed up around the clock, which defeats the whole point of scaling on spikes.

Raising it to 10 req/s leaves headroom above the steady-state churn. Now the second replica only comes up when something actually bursts (a wave of syncs, a manual refresh across many apps), and the cluster idles back to a single replica the rest of the time.

This is the kind of number you can't guess up front. You set it, watch the replica count over a few days, and find the line that sits above normal noise.


How the metric gets there

The repo-server doesn't expose argocd_git_request_total by default. The chart turns on its metrics endpoint and a ServiceMonitor so kube-prometheus-stack discovers and scrapes it:

repoServer:
  metrics:
    enabled: true
    serviceMonitor:
      enabled: true
      additionalLabels:
        release: kube-prometheus-stack

From there the path is:

flowchart TD
    RS["repo-server emits argocd_git_request_total"] -->|ServiceMonitor scrape| P[Prometheus]
    P --> QF[Thanos Query Frontend]
    QF -->|"sum(rate(...[2m])), polled every 30s"| KA[KEDA metrics adapter]
    KA -->|"git rate > 10 req/s OR CPU > 70%"| HPA["HPA scales argocd-repo-server 1 ↔ 2"]

Scale up is quick (a 30-second stabilization window), scale down is deliberately slow (a 5-minute cooldownPeriod plus a 300-second stabilization window). A short dip in traffic shouldn't kill the extra replica only to recreate it a minute later.


KEDA config

KEDA runs on the small tier node. Three deployments: the operator, the metrics adapter (which bridges KEDA metrics to the Kubernetes external-metrics API), and the admission webhooks for validation.

All three have ServiceMonitors enabled, so Prometheus scrapes KEDA's own metrics. No CPU limits (only memory limits), following the same pattern as the rest of the cluster. The ScaledObject itself is opt-in per environment and enabled on both develop and production.


Why not just HPA

Plain HPA on CPU would miss the actual bottleneck. A repo-server can be slow on Git I/O (waiting on remote fetches, unpacking refs) while its CPU looks unremarkable, so CPU-based scaling reacts late or not at all. Scaling on the request rate that causes the load is more direct: when reconciles pile up, the Git rate climbs, and that's exactly when a second replica helps clear the queue.