KEDA scale to zero¶
KEDA scales app Deployments on schedules and on HTTP traffic. The KEDA HTTP add-on puts an interceptor proxy in front of idle apps. Traefik sends their traffic to the interceptor instead of the app Service. The interceptor holds the request, KEDA scales the Deployment from zero, and the interceptor forwards the request when a pod is ready.
Install¶
Two Argo CD Applications from the infra ApplicationSet, both in namespace keda.
| App | Chart | Version | Values |
|---|---|---|---|
keda |
kedacore/keda |
kustomization.yaml |
values.yaml: one operator replica, operator and metrics server ServiceMonitors |
keda-http-add-on |
kedacore/keda-add-ons-http |
kustomization.yaml |
values.yaml: interceptor between 2 and 5 replicas |
The keda Application owns the keda namespace
(namespace.yaml). The add-on deploys
into it and must not declare its own Namespace. Two Applications that claim the same
resource fight over its ownership. loki and tempo follow the same rule for monitoring.
The add-on directory also carries:
referencegrant.yaml, which lets HTTPRoutes in the listed app namespaces reference a Service inkeda.servicemonitor.yaml, which scrapes the external scaler of the add-on.
The chart's own ScaledObject scales the interceptor (keda-add-ons-http-interceptor
in keda) between the min and max in the values file.
Request path¶
- The app's HTTPRoute keeps its hostname and filters, but its
backendRefis the Servicekeda-add-ons-http-interceptor-proxyin namespacekeda, port8080. Middlewares such as Authentik forward auth run at Traefik first. Only requests that pass them reach the interceptor and wake the pod (homepage/httproute.yaml). - The interceptor picks the target by the
Hostheader of the request. The app's InterceptorRoute lists that host underspec.rules[].hostsand names the app Service and port underspec.target. - The app's ScaledObject has an
external-pushtrigger that points atkeda-add-ons-http-external-scaler.keda:9090withinterceptorRoute: <name>. The external scaler reports pending and in-flight requests for that route to KEDA. KEDA scales the Deployment. Every route uses a concurrency target of 100. - The app's CiliumNetworkPolicy allows ingress on the container port from pods in
kedalabelledapp.kubernetes.io/component: interceptorandapp.kubernetes.io/part-of: keda-add-ons-http.
Argo CD ignores /spec/replicas on every Deployment in the apps ApplicationSet
(apps.yaml). A replicas: value in a
manifest therefore does not fight KEDA.
Apps¶
| App | Namespace | Host | Min / max | Triggers |
|---|---|---|---|---|
| audiobookshelf | audiobookshelf |
audiobookshelf.local.bigd.no |
0 / 1 | cron, HTTP |
| homepage | homepage |
hub.bigd.no |
0 / 1 | cron, HTTP |
| omni-tools | omni-tools |
omni-tools.bigd.no |
0 / 1 | cron, HTTP |
| trek | trek |
trek.bigd.no |
0 / 1 | cron, HTTP |
| headroom | headroom |
headroom.local.bigd.no |
0 / 1 | cron, HTTP |
| open-webui | open-webui |
open-webui.local.bigd.no |
0 / 1 | cron, HTTP |
| portfolio-stage | stage-portfolio |
portfolio-stage.local.bigd.no |
0 / 1 | HTTP |
| blog-stage | stage-blog |
blog-stage.local.bigd.no |
0 / 1 | HTTP |
| ollama | ollama |
ollama-wake (in-cluster only) |
0 / 1 | HTTP |
| portfolio | portfolio |
none | 1 / 3 | cron |
| blog | blog |
none | 1 / 3 | cron |
The first five are on traefik-gateway-public. headroom, open-webui and the two stage
apps are on traefik-gateway-private. Each app's files are scaledobject.yaml and,
for the HTTP-triggered ones, interceptorroute.yaml under k8s/talos/apps/<app>/.
portfolio and blog prod do not go through the interceptor. Their ScaledObjects
(portfolio/scaledobject.yaml)
only use cron: 3 replicas from 07:00 to 23:00 Europe/Oslo, 1 outside that window.
Cron warm plus HTTP wake¶
Most HTTP-triggered apps carry two triggers
(kro/rgd-static-site.yaml):
triggers:
- type: cron
metadata:
timezone: Europe/Oslo
start: "0 7 * * *"
end: "0 23 * * *"
desiredReplicas: "1"
- type: external-push
metadata:
scalerAddress: keda-add-ons-http-external-scaler.keda:9090
interceptorRoute: omni-tools
KEDA scales to the highest value that any trigger asks for. From 07:00 to 23:00 Oslo
time the cron holds one replica, so the app never cold-starts during the day. Outside
that window only the HTTP trigger counts. The app stays at zero, and the first request
wakes it. cooldownPeriod: 300 keeps a woken pod for five minutes after the last
request.
The stage apps and ollama have only the HTTP trigger. They stay at zero until used.
Waking ollama from open-webui¶
ollama has no HTTPRoute. open-webui calls it in-cluster, and a direct call to
ollama-service would not wake a pod that is at zero. Instead:
open-webui/ollama-wake.yamlis anExternalNameService namedollama-wakeinopen-webui. It resolves tokeda-add-ons-http-interceptor-proxy.keda.svc.cluster.local.- open-webui sets
OLLAMA_BASE_URLtohttp://ollama-wake:8080(deployment.yaml). Its requests therefore reach the interceptor withHost: ollama-wake. ollama/interceptorroute.yamlmatches the hostollama-wakeand targetsollama-service:11434.
The same pattern works for any in-cluster caller that must wake a scaled-to-zero
backend: an ExternalName alias in the caller's namespace and an InterceptorRoute on
the alias name. No ReferenceGrant is needed, because no HTTPRoute is involved. The
network policy of the backend must admit the interceptor. ollama's
(ciliumnetworkpolicy.yaml)
allows port 11434 from the whole cluster.
Onboard an app¶
The app already has a Deployment, Service, HTTPRoute and CiliumNetworkPolicy under
k8s/talos/apps/<app>/. The templates below are in the StaticSite definition. Replace
each ${...} value with the value for the app. A new nginx site can use a StaticSite
instance instead (kro.md).
- Add
interceptorroute.yaml: copykro/rgd-static-site.yaml. Set the namespace,spec.target.serviceandportto the app Service. Setspec.rules[].hoststo the exact hostname in the HTTPRoute. - Add
scaledobject.yaml: copykro/rgd-static-site.yaml. SetscaleTargetRef.nameto the Deployment andinterceptorRouteto the name of the InterceptorRoute. Drop the cron trigger if the app may stay at zero all day. - In
httproute.yaml, replace the app Service inbackendRefswithkeda-add-ons-http-interceptor-proxy, namespacekeda, port8080(kro/rgd-static-site.yaml). Keep the hostnames and filters. - In
ciliumnetworkpolicy.yaml, add an ingress rule from the interceptor pods on the container port (kro/rgd-static-site.yaml). - Add both new files to the app's
kustomization.yaml. - Add the app namespace to the
fromlist inreferencegrant.yaml. Without it, the cross-namespace backendRef of the HTTPRoute is not permitted, and Traefik does not route the host.
Verify after Argo CD syncs:
kubectl -n <ns> get scaledobject,interceptorroute
kubectl -n <ns> get deploy <app> -w # 0 at night, 1 after a request
kubectl get scaledobject -A must list the app with READY True and its triggers.
ACTIVE is True while a trigger wants replicas, for example inside the cron window.
Gotchas¶
The InterceptorRoute host and the HTTPRoute hostname must match exactly. The
interceptor does not forward a request whose Host matches no InterceptorRoute, even
when Traefik routed it to the interceptor.
The network policy rule is easy to miss. Without it, the interceptor can wake the pod, but Cilium drops the request it forwards.
portfolio and blog prod keep minReplicaCount: 1 and bypass the interceptor, so the
public sites never cold-start.