Autoscaling¶
Both services scale out, and they scale on different things — because they are limited by different things.
| Signal | Needs | |
|---|---|---|
| tap-api | CPU utilisation | metrics-server, which every cluster runs |
| tap-executor | tap_jobs{phase="QUEUED"} |
KEDA, or an external-metrics provider |
Off by default. A single-replica deployment is a valid one, and handing the replica count to an autoscaler is a decision an operator should make rather than inherit.
The API scales on CPU¶
About half of a request's CPU holds the GIL (ADQL translation and the result
writers), so one worker cannot use more than one core however large the pod's
CPU limit is — which makes CPU the signal, and it needs nothing beyond
metrics-server. Measured (run 20260825T180219Z-f8b21fc4): one worker
saturates at 99 requests/s consuming 1.04–1.08 cores; a second worker on a
2-core pod takes the same pod to 187.6 (1.89x); single-worker replicas
multiply the 99 at 0.96 efficiency at two, 0.87 at four and 0.73 at eight on
the benchmark's single host. Set tapApi.workers to the pod's CPU limit first
(Deployment has the worker
table), and let the autoscaler move replicas:
horizontalAutoscaling:
tapApi:
enabled: true
minReplicas: 2
maxReplicas: 6
targetCPUUtilizationPercentage: 70
The target is a percentage of the pod's CPU request, so
tapApi.resources.requests.cpu has to be set — the chart refuses to render
an HPA without it, because a utilisation target with no request is a metric
Kubernetes reports as unavailable, and an autoscaler that never acts is worse
than none.
Do not scale the API on database pool waits. It is tempting —
tap_db_pool_wait_seconds is right there, and it rises under load. But a
pool wait means the database is the limit, and every new API pod opens its
own pool: scaling on it adds connections to a server that already has none to
give. That is the one signal on the dashboard that must not become a scaling
rule.
Alongside a VPA: one controller per resource¶
Package 5 ships optional VerticalPodAutoscalers, and a VPA in Auto mode
that sizes CPU resizes the pod while the HPA counts pods against a percentage
of the size the VPA just changed. Kubernetes documents that as unsupported,
so the chart refuses it — but only that: two controllers driving the same
resource is the problem, not the pair. Either keep the VPA in
recommendation mode, which stays useful (kubectl describe vpa) while the
HPA scales:
verticalAutoscaling: {enabled: true, updateMode: "Off"}
or split the resources, which is supported and often what you want — the VPA sizes memory, where a right-sized limit avoids OOM kills, and the HPA counts pods on CPU:
verticalAutoscaling:
enabled: true
updateMode: Auto
controlledResources: ["memory"]
The executor scales on the queue's depth¶
An executor spends its time waiting on PostgreSQL, so its CPU says nothing
about whether it is keeping up. The queue is the signal — and specifically
its depth, tap_jobs{phase="QUEUED"}, the number of jobs waiting.
Not the oldest job's age, which is what this chart originally scaled on.
The age of the head of the queue measures how long one job has waited,
and as soon as the queue is being drained at all it tops out near a job's
service time — however much work is piling up behind it. The benchmark
suite caught it plainly: 1,713 jobs queued, the oldest 54 s old, and the
age-based trigger asked for one pod. No threshold fixes that — the signal
saturates, so a lower threshold moves where it saturates, not whether.
Depth grows with the work outstanding, which is what a scaling signal is
for. (tap_oldest_queued_job_seconds is still exported: it is a fine
latency figure for dashboards and alerts — just not a scaling signal.)
The knob is the number of queued jobs one replica should absorb, and the autoscaler divides:
replicas = ceil(queued_jobs / queuedJobsPerReplica)
So 50 queued jobs against the default 10 asks for 5 executors. Lower it to
react sooner and run more; raise it to tolerate a queue. The old sizing
aid — one executor works through roughly 2 jobs/s, so 10 is about 5 s of
queue — was re-measured and confirmed on the image that still re-parsed
each job's query (run 20260826T013544Z-88d8c5e9: 2.3–2.5 jobs/s at ~0.9
cores per pod). With that parse removed (package 20) an executor's fixed
cost is ~5 ms of CPU per job and the benchmark's autoscaling family can no
longer saturate one: run 20260826T050451Z-0f62a0fa completed every
offered job in-window at every load with the queue never past 52 jobs and
pods at 2–3% CPU. The default of 10 still behaves — it now buys well under
a second of queue per replica, and the async path's practical ceiling is
the API pod, not the executors (the 2026-08-26 finding on the
Roadmap has the before/after).
Renamed values
backlogSecondsPerReplica and activationBacklogSeconds are refused by
the chart with a message naming their replacements
(queuedJobsPerReplica, activationQueuedJobs): the unit changed from
seconds to jobs, and a value silently read in the wrong unit would scale
wrongly without a word.
With KEDA (default)¶
horizontalAutoscaling:
tapExecutor:
enabled: true
maxReplicas: 8
queuedJobsPerReplica: 10
prometheusAddress: http://prometheus-operated.monitoring:9090
KEDA is Apache-2.0 and a graduated CNCF project; install the operator, and
the chart's ScaledObject carries the PromQL with it. That is the reason
this is the default: the query is reviewed here, next to the metric it reads,
rather than in another release's values.
The query it ships is
max(tap_jobs{phase="QUEUED",namespace="<release namespace>"})
max(), not sum(). Every replica reports the same figures for one shared
queue, so sum() would scale on the replica count — more executors, bigger
sum, more executors, which is an autoscaler feeding on its own output. The
namespace selector keeps a Prometheus that watches several namespaces from
scaling this release on another one's queue. Override query if your
scraper adds no namespace label, or if two releases share one namespace.
(query reaches KEDA only — the external scaler below has its own knob for
the same job.)
Without KEDA¶
If the cluster already serves external metrics — prometheus-adapter is the
usual one, also Apache-2.0, from kubernetes-sigs — a plain HPA can read the
same gauge:
horizontalAutoscaling:
tapExecutor:
enabled: true
scaler: external
The chart then renders a HorizontalPodAutoscaler with an External metric
and records the query it expects in an annotation, because the query itself
now lives in the adapter's configuration, not here. The adapter rule has to
aggregate the same way:
# prometheus-adapter values
rules:
external:
- seriesQuery: 'tap_jobs{phase="QUEUED"}'
resources:
overrides:
namespace: {resource: namespace}
name:
as: tap_jobs_queued
metricsQuery: max(<<.Series>>{<<.LabelMatchers>>,phase="QUEUED"})
max(...) there for the same reason as above. Getting it wrong in that file
produces an autoscaler that looks healthy and scales on nonsense, which is
the trade this option makes: one less component to install, one more place
where the query can be wrong.
The metric is scoped by a label selector, not by the query value — that one
only reaches KEDA. By default the chart selects namespace: <release
namespace>, which is what the rule above publishes. A provider that exposes
different labels, or none, needs the selector changed to match, because a
selector matching no series does not make the autoscaler stricter — it
leaves it at <unknown> for good:
horizontalAutoscaling:
tapExecutor:
externalMetricSelector: {} # no labels published
# externalMetricSelector: {job: tap-executor} # or different ones
Scaling to zero, and why the default query cannot¶
The default query reads a gauge the executor itself exports. With no
executors running nothing reports the queue, the series goes stale a few
minutes later, and no amount of queued work can bring a pod back — a
deadlock, not an optimisation. So the chart refuses minReplicas: 0 while
the query is the default one.
It is not refused in general, because the deadlock is a property of that
series rather than of scaling to zero. Point query at something that
survives having no executors — a recording rule, or an exporter reading
uws.jobs directly — and zero is sound:
horizontalAutoscaling:
tapExecutor:
minReplicas: 0
query: max(tap_queue_depth_from_db) # not exported by the executor
activationQueuedJobs: 1 # queue worth starting a pod for
activationQueuedJobs governs the 0→1 transition only, so the chart
renders it only when the minimum is 0; at a minimum of 1 it would be a
setting that looks live and does nothing. A plain HPA (scaler: external)
cannot reach zero at all without the HPAScaleToZero feature gate, and is
refused with a message saying so.
The connection ceiling moves with the replica count¶
Every API worker and every executor holds its own pool, so the peak number of database connections is
tapApi.maxReplicas x tapApi.workers x config.dbPoolMax
+ tapExecutor.maxReplicas x config.dbPoolMax
Autoscaling makes that number reachable without anyone deciding to reach it.
When postgresql.tuning.max_connections says what the limit is, the chart
does the arithmetic and refuses a configuration that would scale into a
connection wall — PostgreSQL holds 3 connections back for superusers, so
that is subtracted:
$ helm template … --set horizontalAutoscaling.tapApi.enabled=true \
--set tapApi.workers=4 --set postgresql.tuning.max_connections=100
Error: … fully scaled out, this release would open 200 database connections
(API: 6 pods x 4 workers x dbPoolMax 8; executor: 1 x 8), but
postgresql.tuning.max_connections is 100 …
Raise max_connections, lower config.dbPoolMax, or lower the maximums. For
a deployment that genuinely needs many pods against a small server, put
pgbouncer in front — the pool ceiling is then the pooler's, not the sum of
every pod's.
Do not read a quiet run as permission to skip the arithmetic. The worker
sweep (run 20260825T180219Z-f8b21fc4) deliberately measured a shape whose
ceiling exceeds the server's limit — 8 pods x 4 workers x 8 is 256 against
max_connections: 200 — and it served 1,005 requests/s without an error,
because a CPU-bound workload holds few connections at once. The ceiling is
what the fleet may open, and the first slow, I/O-heavy query mix is what
turns may into does.
Read replicas for the query path¶
With externalDatabase.queryUrl set, query execution goes to the standbys
(see Deployment) and the
sum above splits between servers. Every process still holds up to
config.dbPoolMax connections to the primary — the UWS job table, ingest,
upload-bearing queries — and up to config.dbPoolMax more spread over the
standbys, each of which has its own max_connections. So the primary's
ceiling is unchanged, a standby's is roughly the query total divided by how
many standbys there are, and the fleet's total connection count is up to
twice what the arithmetic above gives.
The chart's refusal does not know about the split: it keeps checking the
whole sum against postgresql.tuning.max_connections, which is now
conservative — it can refuse a configuration whose primary would in fact
have been fine. Raising max_connections on a server that has room is the
answer; the arithmetic is deliberately not made cleverer, because a check
that trusts a DSN nobody in the cluster has validated would be worse than
one that over-refuses.
Damping¶
Both paths accept an autoscaling/v2 behavior: block, passed through
verbatim, for sites that want scale-down slower than the 5-minute default:
horizontalAutoscaling:
tapExecutor:
behavior:
scaleDown:
stabilizationWindowSeconds: 600
That is where hysteresis belongs for the scaling range.
activationQueuedJobs is a different thing and covers only the 0→1
transition, which is why it is rendered only when the minimum is 0.
Checking it works¶
$ kubectl get hpa
NAME REFERENCE TARGETS MINPODS MAXPODS REPLICAS
egernia-tap-api Deployment/egernia-tap-api 12%/70% 2 6 2
$ kubectl get scaledobject # with KEDA
$ kubectl describe scaledobject tap-executor # trigger errors show here
An HPA reporting <unknown> for its target is the usual symptom: for the API
that means no CPU request or no metrics-server; for the executor, that the
metrics provider is not serving the metric — check the Prometheus named in
prometheusAddress actually has tap_jobs{phase="QUEUED"}, which needs
the executor pods being scraped in the first place (see
Observability).
A fresh install starts at one pod per autoscaled service, because the chart
no longer sends a replica count and a Deployment defaults to one; the
autoscaler raises it to minReplicas on its first reconcile, within about
fifteen seconds. helm install --wait returns before that, so a pod count
read immediately after an install is not the steady state.
While an autoscaler owns a Deployment, the chart stops rendering replicas
for it. A helm upgrade that set it would reset the count the autoscaler had
chosen, and the two would then take turns overwriting each other.
podDisruptionBudget follows minReplicas instead, since that — not the
static value the autoscaler ignores — is the floor a node drain has to
respect.