KEDA Autoscaling
Scale flexiq worker replicas on Kubernetes by queue depth, using the bundled scaler server.
Scale flexiq worker replicas on Kubernetes by queue depth, using the bundled scaler server.
KEDA (Kubernetes Event-driven Autoscaling) scales a worker
Deployment up and down based on queue depth. flexiq ships a dedicated
scaler server that KEDA's metrics-api trigger polls directly — no separate
metrics pipeline required.
Requires a Kubernetes cluster with the KEDA operator installed.
Running on bare metal, Docker, or systemd without Kubernetes? Use the
bare-metal autoscaler
instead — it spawns and drains flexiq worker subprocesses with the same
HPA-style formula.
The scaler's /api/scaler endpoint is unauthenticated by design — keep it
reachable only from inside the cluster (ClusterIP, never a public
LoadBalancer or Ingress).
flexiq scaler and serve_scaler() bind to 127.0.0.1 by default — see
Security: Scaler
bind address. Widen to 0.0.0.0 only behind a trusted network
boundary (e.g. the ClusterIP Service below).
serveScaler binds 0.0.0.0 by default, which is what lets a Kubernetes
Service reach it out of the box — bind to 127.0.0.1 explicitly if you ever
run it outside a container on a shared host.
ScalerOptions.defaults() binds 0.0.0.0 by default, which is what lets a
Kubernetes Service reach it out of the box — pass an explicit host if you
ever run it outside a container on a shared host.
flexiq scaler --app myapp:queue --port 9091Or programmatically:
from flexiq.scaler import serve_scaler
serve_scaler(queue, host="0.0.0.0", port=9091, target_queue_depth=10)import { Queue, serveScaler } from "@byteveda/flexiq";
const queue = new Queue({ backend: "postgres", dsn: process.env.DATABASE_URL });
serveScaler(queue, { port: 9091, targetQueueDepth: 10 });Or from the CLI, over a backend:
flexiq --backend postgres --dsn "$DATABASE_URL" scaler --port 9091try (FlexiQ flexiq = FlexiQ.builder().postgres(System.getenv("DATABASE_URL")).open();
Scaler scaler = Scaler.start(flexiq, ScalerOptions.onPort(9090))) {
Thread.currentThread().join(); // serve until the process is killed
}(main needs throws InterruptedException for the join().)
There's no bundled CLI for the scaler — embed Scaler.start(...) in a small
entrypoint alongside (or instead of) your worker process.
| Flag | Default | Description |
|---|---|---|
--app | — (required) | Python path to the Queue instance, e.g. myapp.tasks:queue |
--host | 127.0.0.1 | Bind address |
--port | 9091 | Bind port |
--target-queue-depth | 10 | Scaling target hint returned to KEDA |
serve_scaler(queue, host=, port=, target_queue_depth=) takes the same
options as keyword arguments.
| Option | Default | Description |
|---|---|---|
port | 9091 | Listen port |
host | "0.0.0.0" | Bind address |
targetQueueDepth | 10 | Target depth per replica, returned to KEDA |
queue | all queues | Restrict the metric to one queue; overridable per request via ?queue= |
The CLI mirrors these as --port, --host, --target-queue-depth, and
--queue, plus the global --backend/--dsn connection flags.
ScalerOptions field | Default | Description |
|---|---|---|
port | 9090 | Bind port. 0 picks an ephemeral port — scaler.port() reports it. |
host | "0.0.0.0" | Bind address |
targetQueueDepth | 10 | Target depth per replica; must be > 0 |
queue | null (all queues) | Restrict the metric to one queue |
ScalerOptions.defaults() sets all four; ScalerOptions.onPort(port) keeps
the rest at their defaults.
| Endpoint | Returns |
|---|---|
GET /api/scaler[?queue=<name>] | Current queue depth and scaling target |
GET /health | Liveness check — always {"status": "ok"} |
Also serves GET /metrics in Prometheus text format when prometheus-client
is installed (501 otherwise).
KEDA only reads the field named by valueLocation in the trigger config
(metricValue below) — everything else in the response is informational.
{
"metricName": "flexiq_queue_depth",
"metricValue": 42,
"isActive": true,
"liveWorkers": 3,
"totalCapacity": 12,
"targetQueueDepth": 10,
"workerUtilization": 0.583,
"perQueue": {
"default": { "pending": 42, "running": 7 }
}
}Filtering with ?queue=emails narrows metricValue, isActive, and
metricName (suffixed flexiq_queue_depth_emails) to that queue —
perQueue still reports every queue. There's no separate queueName field;
the filter shows up in metricName instead.
{ "metricValue": 42, "targetValue": 10, "queueName": "*" }metricValue is the pending count for queueName ("*" for all queues, or
the name passed via ?queue=).
{ "metricValue": 42, "targetValue": 10, "queueName": "all" }metricValue is pending + running (outstanding work), not pending-only —
account for that when tuning targetValue if you're porting a threshold from
Python or Node.
Deploy the scaler as its own Deployment, exposed as a ClusterIP Service:
apiVersion: apps/v1
kind: Deployment
metadata:
name: flexiq-scaler
spec:
replicas: 1
selector:
matchLabels:
app: flexiq-scaler
template:
metadata:
labels:
app: flexiq-scaler
spec:
containers:
- name: scaler
image: your-image:latest
command: [ ] # see "Container command" below
ports:
- containerPort: 9091
---
apiVersion: v1
kind: Service
metadata:
name: flexiq-scaler
spec:
selector:
app: flexiq-scaler
ports:
- port: 9091
targetPort: 9091Run the scaler as its own small Deployment + Service (not in the worker pod) so scaling to zero workers doesn't take the metric source down with it.
Container command:
command: ["flexiq", "scaler", "--app", "myapp:queue", "--port", "9091", "--host", "0.0.0.0"]The explicit --host 0.0.0.0 matters — flexiq scaler binds to
127.0.0.1 by default, which the Service above can't reach.
command: ["flexiq", "--backend", "postgres", "--dsn", "$(DATABASE_URL)", "scaler", "--port", "9091"]
env:
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: flexiq-db
key: dsncommand: ["java", "-cp", "app.jar", "com.example.ScalerMain"]
env:
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: flexiq-db
key: dsnwith a small entrypoint packaged into app.jar — bound to 9091 here to
match the Service above (Java's own default is 9090, see Options):
public final class ScalerMain {
public static void main(String[] args) throws InterruptedException {
try (FlexiQ flexiq = FlexiQ.builder().postgres(System.getenv("DATABASE_URL")).open();
Scaler scaler = Scaler.start(flexiq, new ScalerOptions(9091, "0.0.0.0", 10, null))) {
Thread.currentThread().join(); // serve until the container is killed
}
}
}Scale a long-running worker Deployment based on pending job count. This
config is identical regardless of which SDK built the scaler — it only talks
to the HTTP endpoint above:
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: flexiq-worker
namespace: default
spec:
scaleTargetRef:
name: flexiq-worker # your worker Deployment name
pollingInterval: 15 # seconds between KEDA polls
cooldownPeriod: 60 # seconds before scaling to zero
minReplicaCount: 0 # scale to zero when idle
maxReplicaCount: 10
triggers:
- type: metrics-api
metadata:
url: "http://flexiq-scaler:9091/api/scaler"
valueLocation: "metricValue"
targetValue: "10"Filter to a specific queue name:
url: "http://flexiq-scaler:9091/api/scaler?queue=emails"Adjust the port to 9090 (the default for ScalerOptions) unless you
overrode it when starting the scaler.
For batch/ETL workloads, use ScaledJob to create short-lived Kubernetes
Jobs — one pod per N pending tasks — instead of scaling a long-running
Deployment:
apiVersion: keda.sh/v1alpha1
kind: ScaledJob
metadata:
name: flexiq-batch-worker
namespace: default
spec:
jobTargetRef:
template:
spec:
containers:
- name: flexiq-worker
image: your-image:latest
command: [ ] # see "Worker container command" below
restartPolicy: Never
pollingInterval: 15
successfulJobsHistoryLimit: 5
failedJobsHistoryLimit: 5
maxReplicaCount: 20
scalingStrategy:
strategy: default # or "accurate" for 1:1 job-to-pod mapping
triggers:
- type: metrics-api
metadata:
url: "http://flexiq-scaler:9091/api/scaler"
valueLocation: "metricValue"
targetValue: "5" # one pod per 5 pending jobsWorker container command:
command: ["flexiq", "worker", "--app", "myapp:queue"]command: ["flexiq", "run", "./worker.js"]command: ["java", "-jar", "worker.jar"]Package a main() that opens a FlexiQ, registers handlers with
flexiq.worker(), and calls worker.awaitShutdown() — see
Deployment.
If you already have Prometheus scraping your workers, skip the scaler server
and point KEDA's prometheus trigger at it directly:
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: flexiq-worker-prometheus
namespace: default
spec:
scaleTargetRef:
name: flexiq-worker
pollingInterval: 15
cooldownPeriod: 60
minReplicaCount: 0
maxReplicaCount: 10
triggers:
- type: prometheus
metadata:
serverAddress: "http://prometheus:9090"
metricName: flexiq_queue_depth
query: sum(flexiq_queue_depth{queue="default"})
threshold: "10"
- type: prometheus
metadata:
serverAddress: "http://prometheus:9090"
metricName: flexiq_worker_utilization
query: flexiq_worker_utilization{queue="default"}
threshold: "0.8"Both metrics come from PrometheusStatsCollector — see the
Prometheus integration.
If you already have Prometheus scraping your workers, skip the scaler server
and point KEDA's prometheus trigger at the queue-depth gauge directly:
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: flexiq-worker-prometheus
namespace: default
spec:
scaleTargetRef:
name: flexiq-worker
pollingInterval: 15
cooldownPeriod: 60
minReplicaCount: 0
maxReplicaCount: 10
triggers:
- type: prometheus
metadata:
serverAddress: "http://prometheus:9090"
metricName: flexiq_queue_depth
query: sum(flexiq_queue_depth{queue="default"})
threshold: "10"This metric comes from PrometheusStatsCollector — see the
Prometheus integration.
There's no flexiq_worker_utilization gauge yet, so a second trigger on
utilization isn't available — queue depth alone is the supported signal.
There's no Prometheus contrib yet — use the ScaledObject or ScaledJob triggers above, which talk to the scaler endpoint directly and need no separate metrics pipeline.
Ready-to-use YAML templates are included in the repository under
deploy/keda/:
| File | Purpose |
|---|---|
scaled-object.yaml | ScaledObject using the HTTP scaler endpoint |
scaled-object-prometheus.yaml | ScaledObject using Prometheus metrics |
scaled-job.yaml | ScaledJob for ephemeral batch workers |
When using SQLite, all worker replicas must share the same database volume. For multi-replica Kubernetes deployments, use the Postgres backend — workers connect over the network and there's no shared-file constraint.
When using SQLite, all worker replicas must share the same database volume. For multi-replica Kubernetes deployments, use a networked Postgres or Redis backend instead — workers connect over the network and there's no shared-file constraint.
When using SQLite, all worker replicas must share the same database volume. For multi-replica Kubernetes deployments, use a networked Postgres or Redis backend instead — workers connect over the network and there's no shared-file constraint.
See also mesh scheduling for within-process dispatch locality — it composes with KEDA rather than replacing it, since KEDA scales the number of worker processes and mesh only changes how quickly a claimed job reaches an idle one.