oleander-whirlwind
04/28/2026, 8:55 AMMarvin
04/28/2026, 8:55 AMMarvin
04/28/2026, 8:58 AMserve-based apps, the usual pattern is:
1. Deploy your app via Helm as a normal long-running Deployment
2. Scale replicas with Kubernetes (replicas or HPA)
3. Use Prefect concurrency settings to cap how much work can run at once
Prefect does not do Kubernetes horizontal autoscaling for serve apps. Kubernetes does that.
---
What Prefect concurrency does vs what Helm scaling does
Prefect UI / deployment concurrency
- Limits concurrent flow runs
- Protects databases, APIs, downstream services
- Coordinates execution at the orchestration layer
- Good for “only 10 runs total at once”
Helm / Kubernetes scaling
- Adds/removes pods
- Gives you more polling/execution capacity
- Handles availability and resource scaling
- Good for “run more copies of this service when CPU/memory rises”
So if you want more capacity, increase pod replicas.
If you want more allowed simultaneous work, increase Prefect concurrency.
Often you want both.
---
Important caveat for serve and multiple replicas
If you run multiple replicas of the same serve process, all replicas may poll Prefect for work. Prefect’s orchestration is what prevents duplicate execution by coordinating state transitions and concurrency centrally.
That means:
- Yes, horizontal scaling can work
- But you should think of Prefect as coordinating who gets the run, not as driving the Kubernetes scaling event itself
In practice, to make this sane:
- set a deployment/global concurrency limit in Prefect
- optionally set a per-process limit in the runner/serve config
- scale the Deployment with Kubernetes based on CPU/memory or custom metrics
---
Recommended mental model
Use:
- Kubernetes/Helm for: “How many app instances exist?”
- Prefect for: “How many runs may execute concurrently?”
- Your app/resources for: “How much parallelism can one pod safely handle?”
A simple formula:
- total possible throughput ~= replicas × per-pod concurrency
- but actual execution should also be bounded by Prefect deployment concurrency if you set one
So for example:
- 4 replicas in Kubernetes
- each pod can safely process 2 flow runs
- Prefect deployment concurrency = 6
Then max actual concurrent runs should be 6, even though theoretical pod capacity is 8.
---
What I would usually recommend
For non-ephemeral apps on Kubernetes:
Option A: one flow run at a time per pod
- scale pod count with HPA
- keep per-pod concurrency low
- use Prefect deployment concurrency as the global cap
This is the safest option if runs are heavy or not perfectly isolated.
Option B: multiple flow runs per pod
- only if your app is clearly designed for it
- set a per-pod/process limit
- still keep a Prefect global/deployment concurrency limit
This is more efficient but easier to overload.
---
Community perspective / common pattern
The most common community pattern is generally:
- use workers + work pools when you want Prefect to launch ephemeral infrastructure
- use serve when you already have a long-lived service/process and just want it to poll for work
For your setup, since you already have long-running Helm-managed apps, the community perspective is usually:
- let Kubernetes own horizontal scaling
- let Prefect own orchestration and concurrency
- don’t expect Prefect UI concurrency settings to create or remove pods
So if the question is “should I scale through Prefect UI or Helm chart?” the answer is:
- Helm/Kubernetes for horizontal scaling
- Prefect for concurrency control
---
How to make sure horizontal scaling is “done properly”
A good setup usually includes:
1. Deployment concurrency limit in PrefectMarvin
04/28/2026, 8:58 AMserve
---
One subtle point
If you want true infrastructure autoscaling driven by queued Prefect work, serve is usually not the strongest model. That’s where work pools/workers and ephemeral execution are often a better fit, because Prefect is then much closer to the infrastructure lifecycle.
With serve, your app is already running, so Kubernetes is the natural scaler.
---
I didn’t find a specific GitHub discussion that neatly codifies a single “official community answer” for this exact pattern, but the practical guidance is strongly in this direction:
- Kubernetes scales the service
- Prefect limits/coordinates the work
If you want, I can help you design a concrete setup for your case, for example:
- 1 deployment, N replicas, 1 run per pod
- HPA + Prefect concurrency
- or whether you should switch from serve to workers/work pools for better scaling behavioroleander-whirlwind
04/28/2026, 10:26 AMoleander-whirlwind
04/28/2026, 10:26 AMMarvin
04/28/2026, 10:26 AMMarvin
04/28/2026, 10:30 AMserve apps on Kubernetes, I’d implement it like this:
Target behavior
- each pod handles at most 1 flow run at a time
- Kubernetes HPA scales the number of pods
- Prefect enforces the global max concurrent runs
- pods expose health checks so Kubernetes only keeps healthy pollers
---
Best-practice architecture
Think in 3 layers:
1. Per pod capacity → set Prefect limit=1
2. Global system capacity → set Prefect global_limit=N
3. Horizontal scaling → set Helm/Kubernetes replicas + HPA
So:
- limit=1 means one pod won’t run two flow runs concurrently
- global_limit=10 means all pods together won’t exceed 10 concurrent runs
- HPA can scale from e.g. 1 → 10 replicas
That gives you a very predictable model:
- 1 pod = 1 active run max
- 5 pods = 5 active runs max
- if HPA scales to 12 pods but global_limit=10, only 10 runs should execute at once
---
Recommended Prefect code
Use flow.serve(...) like this:
python
from prefect import flow
@flow(log_prints=True)
def my_flow(customer_id: str):
print(f"Processing {customer_id}")
# your work here
if __name__ == "__main__":
my_flow.serve(
name="my-flow",
limit=1,
global_limit=10,
pause_on_shutdown=False,
webserver=True,
cron="*/5 * * * *",
)
Why these settings?
- limit=1 → one run at a time per pod
- global_limit=10 → max 10 concurrent runs across all replicas
- pause_on_shutdown=False → better for Kubernetes restarts/rollouts
- webserver=True → gives you /health for readiness/liveness probes
---
If you want the more explicit global concurrency config
You can also use the richer config form:
python
from prefect import flow
from prefect.client.schemas.objects import ConcurrencyLimitConfig, ConcurrencyLimitStrategy
@flow
def my_flow():
pass
if __name__ == "__main__":
my_flow.serve(
name="my-flow",
limit=1,
global_limit=ConcurrencyLimitConfig(
limit=10,
collision_strategy=ConcurrencyLimitStrategy.ENQUEUE,
grace_period_seconds=300,
),
pause_on_shutdown=False,
webserver=True,
)
That’s useful if you want queued runs to wait instead of being canceled when the concurrency limit is full.
---
Best-practice Kubernetes deployment
Your Helm chart / Deployment should manage replicas and health checks.
A good baseline Deployment looks like:
yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: my-flow
spec:
replicas: 2
selector:
matchLabels:
app: my-flow
template:
metadata:
labels:
app: my-flow
spec:
containers:
- name: my-flow
image: my-registry/my-flow:latest
env:
- name: PREFECT_API_URL
value: "<https://api.prefect.cloud/api>"
- name: PREFECT_API_KEY
valueFrom:
secretKeyRef:
name: prefect-api-key
key: token
- name: PREFECT_RUNNER_PROCESS_LIMIT
value: "1"
ports:
- name: http
containerPort: 8080
readinessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 10
periodSeconds: 5
livenessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 60
periodSeconds: 30
resources:
requests:
cpu: "500m"
memory: "1Gi"
limits:
cpu: "1"
memory: "2Gi"
A few notes:
- PREFECT_RUNNER_PROCESS_LIMIT=1 is equivalent to `limit=1`; I’d usually set it in code or env, not both unless you want env-driven overrides
- readiness/liveness probes matter a lot for long-lived pollers
- set resource requests realistically, because HPA depends on them
---
Best-practice HPA
Then let Kubernetes scale the number of pods.Marvin
04/28/2026, 10:30 AMyaml
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: my-flow
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: my-flow
minReplicas: 1
maxReplicas: 10
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
- type: Resource
resource:
name: memory
target:
type: Utilization
averageUtilization: 80
Best practice here:
- start with CPU/memory-based HPA
- don’t overcomplicate with custom metrics first
- tune based on real flow runtime behavior
---
How to choose the numbers
A simple approach:
Step 1: determine safe per-pod concurrency
- if flows are heavy or stateful → use 1
- that’s your case, so limit=1
Step 2: determine safe global concurrency
Ask:
- how many runs can your downstream systems handle?
- how many DB connections / API calls / memory-heavy jobs can the cluster safely support?
Example:
- each run needs ~1.5 CPU and ~2 GB RAM
- cluster safely supports 8 of them
- downstream DB safely supports 6
- then set global_limit=6
Use the smallest real bottleneck, not the optimistic one.
Step 3: set HPA bounds
- `minReplicas`: enough to avoid cold starts / increase availability
- `maxReplicas`: enough to match global_limit or slightly exceed it
If limit=1, a clean rule is:
- maxReplicas ~= global_limit
Example:
- limit=1
- global_limit=8
- minReplicas=2
- maxReplicas=8 or 10
---
My recommended default pattern
For heavy flows:
- limit=1
- global_limit=number your systems can really tolerate
- minReplicas=1 or 2
- maxReplicas=global_limit
- CPU/memory requests set accurately
- HPA on CPU + memory
- health probes enabled
- flows designed to be idempotent if possible
---
What happens operationally
Example:
- limit=1
- global_limit=5
- HPA can scale to 5 pods
Then:
- Pod A can run 1 flow
- Pod B can run 1 flow
- ...
- total max = 5
If a 6th run is scheduled:
- Prefect will not let it run immediately
- it should wait for a concurrency slot, depending on your concurrency strategy
If HPA scales up faster than work arrives:
- extra pods just poll idle
- that’s fine, though maybe slightly wasteful
If HPA is too slow:
- scheduled runs may queue in Prefect waiting for pods/slots
---
Important best-practice caveat
serve is okay for this pattern, but it is still a long-lived poller model.
That means:
- Kubernetes scales the app
- Prefect coordinates work
- but Prefect does not directly ask Kubernetes for “one new pod per run”
So if your real goal is:
- “I want one Kubernetes pod per flow run”
- “I want queued Prefect work to naturally map to new compute”
- “I want stronger isolation between runs”
then workers + Kubernetes work pools are usually the cleaner architecture.
For your current setup though, if you want to stay with serve, the limit=1 + global_limit=N + HPA pattern is the safest way.
---
What I would do in your shoes
If you want to keep your current non-ephemeral Helm-deployed services:
1. Keep the app as a Deployment
2. Add webserver=True
3. Set limit=1
4. Set global_limit in the deployment
5. Add readiness/liveness probes on /health
6. Add HPA on CPU/memory
7. Set maxReplicas roughly equal to your desired global concurrency
8. Load test and tune from there
---
Concrete example recommendation
If you’re unsure where to start, start with this:
- limit=1
- global_limit=3
- minReplicas=2
- maxReplicas=3
- HPA target CPU 70%
- HPA target memory 80%
Then observe:
- are runs queueing too much? increase global_limit
- are pods underutilized? lower minReplicas
- are pods OOMing or thrashing? increase memory or reduce workload size
---
Community-style recommendation in one sentence
For long-running serve apps on Kubernetes, the safest community-style pattern is:Marvin
04/28/2026, 10:30 AMprefect.flow.serve
- prefect global_concurrency_limit
- prefect concurrency_limit
If you want, I can next give you one of these:
1. a complete Helm values.yaml example for this pattern
2. a full production-ready Python serve script
3. a decision guide: stay on serve vs move to Kubernetes workers/work poolsoleander-whirlwind
04/28/2026, 10:40 AM- name: PREFECT_RUNNER_PROCESS_LIMIT
value: "1"
or with the deployment options
name="my-flow",
limit=1,
global_limit=10,
pause_on_shutdown=False,
webserver=True,
cron="*/5 * * * *",
also i do not get why a cron is inserted here?Marvin
04/28/2026, 10:41 AMMarvin
04/28/2026, 10:41 AMPREFECT_RUNNER_PROCESS_LIMIT=1 and limit=1 do not make apps start immediately.
They only mean:
- this pod may run at most 1 flow run at a time
So they control concurrency inside the pod, not startup speed.
---
What controls “starts immediately”?
That depends on what you mean:
If you mean “the app pod should already be running and ready”
- that is controlled by Kubernetes replicas / HPA / startup time
- keep at least minReplicas: 1 or more
- use readiness probes
- keep the app warm
If you mean “a scheduled/manual flow run should begin execution immediately when submitted”
- you need:
- at least one ready pod already running
- available Prefect concurrency slot
- no backlog of older runs
- polling interval not too slow
So immediate start is mostly affected by:
- pod availability
- pod readiness
- Prefect polling cadence
- concurrency availability
---
About these two settings
These are basically two ways to set the same per-runner limit:
In environment variables
yaml
- name: PREFECT_RUNNER_PROCESS_LIMIT
value: "1"
In Python
python
my_flow.serve(
name="my-flow",
limit=1,
...
)
I would usually recommend:
- set it in code if you want the behavior tied to the app definition
- set it in env/Helm values if you want ops to tune it without rebuilding code
But conceptually they are about max concurrent flow runs per pod, not immediate startup.
---
Why I would not use both unless you want overrides
If you define both, it can become confusing for your team:
- “is concurrency controlled in code?”
- “or in Helm values?”
- “which one are we changing in production?”
Best practice:
- choose one source of truth for per-pod concurrency
For example:
- application-owned: use limit=1 in code
- platform-owned: use PREFECT_RUNNER_PROCESS_LIMIT=1 in Helm
---
Why was cron in the example?
Only because I showed a scheduled deployment example.
You do not need cron unless you want Prefect to schedule runs automatically.
cron="*/5 * * * *" means:
- Prefect should create a run every 5 minutes
If your flows are started another way, for example:
- manually from UI/API
- via automations
- event triggers
- external systems calling Prefect
then remove the cron.
So your actual serve may look more like:
python
from prefect import flow
@flow
def my_flow(customer_id: str):
pass
if __name__ == "__main__":
my_flow.serve(
name="my-flow",
limit=1,
global_limit=10,
pause_on_shutdown=False,
webserver=True,
)
That registers the deployment and keeps the runner alive, without creating scheduled runs on a cron.
---
So how do you get “immediate” starts in practice?
For serve, the practical recipe is:
1. Keep at least one pod always running
- minReplicas: 1 or 2
2. Make pods become ready quickly
- lightweight image
- fast startup
- good readiness probes
3. Keep polling interval reasonable
- don’t make polling too infrequent
4. Keep limit=1
- so one pod handles one run at a time
5. Use HPA for scale-out
- extra pods help absorb load, but they do not eliminate the need for one warm pod
---
Important subtlety
If you need true immediate scale-from-zero per run, serve is not ideal.
Because with `serve`:
- the app must already be running to poll and pick up work
So if there are zero pods, no run starts immediately.
For that kind of model, workers + Kubernetes work pools are usually better, because Prefect can submit infrastructure for runs instead of waiting for a long-running app to poll.
---
Best-practice answer to your exact question
- PREFECT_RUNNER_PROCESS_LIMIT=1 → not for immediate start, only per-pod concurrency
- limit=1 → same purpose, also not for immediate start
- global_limit=10 → global concurrency cap, also not for immediate start
- cron=... → only if you want scheduled runs; otherwise remove itMarvin
04/28/2026, 10:41 AMminReplicas > 0
- fast pod startup
- readiness probes
- a warm app already running
---
If you want, I can give you:
- a minimal serve config without cron
- or a Helm Deployment + HPA example optimized for fast pickupoleander-whirlwind
05/04/2026, 11:18 AMyaml
- name: PREFECT_RUNNER_PROCESS_LIMIT
value: "1"
In Python
python
my_flow.serve(
name="my-flow",
limit=1,
...
)
@Marvin
with a value like limit 1, is then each horizontal pod truly limited to one? and how does the server know that there are different serve pods to the same deployment name?Marvin
05/04/2026, 11:18 AMMarvin
05/04/2026, 11:22 AMPREFECT_RUNNER_PROCESS_LIMIT=1 means one concurrent run per serve process / per pod, not one for the whole deployment across all pods.*
So if you have 3 replicas, and each pod is running the same serve(...) with limit=1, then you can get up to 3 concurrent flow runs total.
Example:
python
my_flow.serve(
name="my-flow",
limit=1,
)
or
yaml
- name: PREFECT_RUNNER_PROCESS_LIMIT
value: "1"
If that same setup is running in 4 pods, then the effective capacity is:
text
1 run per pod × 4 pods = up to 4 concurrent runs
How Prefect handles multiple serve pods for the same deployment
The server does not really think in terms of “this is pod A” and “this is pod B” as durable registered serve instances.
Instead, each serve process independently:
1. polls for scheduled runs for that deployment
2. sees runs that are ready
3. tries to claim one by proposing a state change
4. the server accepts that claim for only one runner
So multiple pods may all see the same scheduled run, but only one of them successfully transitions it into the runnable state. The others lose the race and move on.
So the coordination is basically:
text
many serve pods poll
-> all may notice the same scheduled run
-> only one can successfully claim it
-> that pod executes it
Answering your two specific questions
1. “Is each horizontal pod truly limited to one?”
Yes, if each pod has limit=1 (or PREFECT_RUNNER_PROCESS_LIMIT=1), then that pod will only execute one flow run at a time.
But that is not a deployment-wide limit. It is only local to that runner process.
2. “How does the server know there are different serve pods for the same deployment name?”
It doesn’t need to track them as named pods in order to schedule correctly.
Each pod just polls for the same deployment, and Prefect uses the state transition / claim step to ensure only one pod actually takes a given run.
So the server doesn’t need a permanent “runner registry” of your serve pods to avoid duplicate execution.
If you want a true cluster-wide limit
Use a deployment-level concurrency limit, not just runner limit.
In Prefect 3.x, limit= is local runner concurrency. If you want “only 1 run total across all pods for this deployment”, you want the deployment global limit instead.
Conceptually:
python
my_flow.serve(
name="my-flow",
limit=1, # max 1 per pod
global_limit=1, # max 1 across all pods total
)
That gives you:
- `limit=1`: each pod can only work on one run at a time
- `global_limit=1`: across all pods combined, only one run may be active
So:
- only limit=1 → one per pod
- only global_limit=1 → one total across all pods
- both → one per pod, but still one total overall
If you want, I can draw the exact behavior for a setup like “3 Kubernetes replicas serving the same deployment” with a timeline example.oleander-whirlwind
05/04/2026, 11:26 AMMarvin
05/04/2026, 11:26 AMMarvin
05/04/2026, 11:27 AMserve(deployment), the deployment-level concurrency arg is concurrency_limit, not global_limit.
So it should look more like this:
python
deployment = myflow.to_deployment(
name=args.deployment_name,
concurrency_limit=5,
)
serve(
deployment,
limit=1,
pause_on_shutdown=False,
webserver=True,
)
Important distinction:
- limit=1 on serve(...) = runner/pod-local limit
- concurrency_limit=5 on the deployment = deployment-wide limit across all serve pods
So with the above:
- each pod can run at most 1 flow run at a time
- across all pods serving that same deployment, Prefect allows at most 5 active runs total
Why your example is slightly off
This version:
python
deployment = myflow.to_deployment(
name=args.deployment_name,
limit=1,
global_limit=5,
pause_on_shutdown=False,
webserver=True
)
serve(deployment)
is off because:
- to_deployment(...) does not take limit
- to_deployment(...) does not take global_limit
- pause_on_shutdown and webserver belong on serve(...), not on to_deployment(...)
Correct patterns
If you want the short single-flow style, this is valid:
python
myflow.serve(
name=args.deployment_name,
limit=1,
global_limit=5,
pause_on_shutdown=False,
webserver=True,
)
If you want the explicit deployment-object style, use:
python
deployment = myflow.to_deployment(
name=args.deployment_name,
concurrency_limit=5,
)
serve(
deployment,
limit=1,
pause_on_shutdown=False,
webserver=True,
)
So the mapping is basically:
- flow.serve(..., global_limit=5) ↔️ deployment created with concurrency_limit=5
- flow.serve(..., limit=1) ↔️ serve(deployment, limit=1)
If you want, I can also show the Kubernetes replica math for this exact config, e.g. “3 pods, limit=1, concurrency_limit=5 -> what actually happens.”oleander-whirlwind
05/04/2026, 11:35 AMMarvin
05/04/2026, 11:35 AMMarvin
05/04/2026, 11:36 AMwebserver=True starts a small HTTP server inside the serve process.
It is not required to run flows. It’s mainly for health checks and monitoring.
What it gives you
It exposes a few endpoints like:
- GET /health — lets you check if the runner is healthy
- GET /run_count — shows how many flow runs are currently executing
- POST /shutdown — tells the runner to shut down gracefully
So it’s useful for things like:
- Kubernetes readiness/liveness probes
- container health checks
- simple operational monitoring
What it does not do
- it does not serve the Prefect UI
- it does not replace Prefect Cloud / Prefect Server
- it is not needed for scheduling or execution
Default behavior
By default, it’s off unless you set webserver=True.
The runner webserver defaults to:
- host: localhost
- port: 8080
You can change that with runner server settings/env vars if needed.
Practical takeaway
If you’re running serve(...) in Kubernetes, webserver=True can be handy so you can point probes at /health.
If you don’t need local health endpoints, you can leave it off.
Relevant source-backed details were from Prefect 3.x runner/webserver implementation. If helpful, I can show a Kubernetes probe example using webserver=True.oleander-whirlwind
05/04/2026, 11:42 AMMarvin
05/04/2026, 11:42 AMMarvin
05/04/2026, 11:43 AMtext
serve(..., limit=1)
then each pod is basically either:
- busy = running 1 flow run
- idle = running 0 flow runs
So the thing you really care about is likely:
text
idle pods = total pods - busy pods
And your desired policy is something like:
text
idle pods >= 1
That’s much more direct than “memory under 80%”.
Why the memory rule feels strange
You’re right to question it.
Memory-based autoscaling works better when:
- load correlates strongly with memory
- a pod can process many units of work at once
- memory pressure is the bottleneck
But in your setup, if a pod runs at most one flow at a time, memory may not reflect scheduling capacity very well:
- one pod could be busy but only using 35% memory
- another flow could spike memory to 90%
- memory says little about whether that pod is available for another run if limit=1
So if the point is leave one runner free, memory is not the best primary signal.
Better rule for your goal
If each pod can run one flow at a time, then you want enough replicas so that:
text
replicas >= active_runs + 1
That exactly means “one extra unused serve pod”.
Examples:
- active_runs = 1 -> want 2 replicas
- active_runs = 4 -> want 5 replicas
- active_runs = 10 -> want 11 replicas
That is a much better rule for your stated behavior.
How to think about it operationally
Suppose:
- every pod runs serve(..., limit=1)
- all pods serve the same deployment
- deployment concurrency allows enough total runs
Then:
- 3 replicas = capacity for 3 concurrent runs
- if 2 runs are active, 1 pod remains idle
- if 3 runs are active, 0 pods remain idle
So if you must keep one idle, scale before you hit full occupancy.
That means your trigger should be based on busy pods / active flow runs, not memory.
Even simpler heuristic
If you can observe active runs, the desired replica count is:
text
desired_replicas = active_runs + 1
with min/max caps:
text
desired_replicas = min(max_replicas, max(min_replicas, active_runs + 1))
What if you cannot scale from active Prefect runs directly?
Then better proxies than memory would be:
- runner /run_count from webserver=True
- number of scheduled / pending / late runs
- queue depth / backlog
- custom metric: busy runners vs total runners
If each serve pod exposes /run_count, then each pod returns either 0 or 1 in your setup. Summing that across pods gives actual busy capacity.
Then you can scale on:
text
desired replicas = sum(run_count) + 1
That’s almost exactly your desired behavior.
Important caveat
If startup time for a new pod is slow, “+1 idle pod” may still not be enough during bursts. In that case you might want:
text
replicas >= active_runs + buffer
where buffer is 1, 2, or more depending on burstiness and pod startup latency.
For example:
- slow image pull / cold start -> keep buffer 2
- fast startup -> buffer 1 may be enough
Bottom line
Yes — for your goal, a rule based on 70–80% memory utilization is probably not the best rule.
A better rule is:
text
keep replicas = busy_pods + 1
or more generally:
text
keep at least 1 idle pod
That directly matches how limit=1 serve pods behave.
If you want, I can help translate that into a concrete Kubernetes autoscaling strategy, for example using:
- HPA with a custom metric
- KEDA
- Prometheus metrics from /run_count
- or a simpler approximation if you only have CPU/memory availableoleander-whirlwind
05/04/2026, 11:44 AMMarvin
05/04/2026, 11:44 AMMarvin
05/04/2026, 11:46 AMserve pods plus memory-based autoscaling.
In the community, the more common pattern for Kubernetes is:
use a Kubernetes work pool + worker, and let each flow run launch as its own Kubernetes job/pod.
That tends to fit the “keep spare capacity / scale on actual work” goal much better than horizontally scaling serve(...) replicas.
What people usually do instead
1. Prefer workers + work pools for Kubernetes
This is the standard scalable Kubernetes pattern in Prefect 3.x:
- a small number of worker pods poll Prefect
- each flow run becomes a new Kubernetes job/pod
- Kubernetes handles placement/scaling
- cluster/node autoscaling can react to actual pods needing resources
That means you don’t need to keep a bunch of long-running serve replicas around just in case.
2. If they use serve, it’s usually for simpler/static setups
serve(...) is more of a long-running runner process model:
- process stays up
- it polls Prefect
- it executes runs itself, usually in subprocesses
- concurrency is local to that runner
That can be fine for:
- VM-based deployments
- small fixed-capacity services
- simple always-on infra
- a few regularly running flows
But it’s usually not the nicest pattern for “Kubernetes autoscaling with one spare slot”.
Why the community tends toward workers in Kubernetes
Because your desired behavior is really:
text
when new work appears -> create execution capacity
when work is gone -> remove it
That is exactly what Kubernetes jobs + workers are good at.
By contrast, with serve replicas you’re trying to infer capacity using:
- memory
- replica count
- local runner limit
- idle/busy state
That’s workable, but kind of awkward.
What people commonly optimize for
In Kubernetes, community setups often optimize for one of these:
- one flow run = one pod/job
- scale cluster nodes when jobs require more resources
- set per-deployment resource requests/limits
- use deployment/work-pool concurrency limits to cap total active runs
That is usually cleaner than:
- “3 identical serve pods”
- each with limit=1
- HPA based on memory
- trying to preserve one idle pod
So for your exact question — “what does the community do?”
Most commonly:
- for Kubernetes: use workers + Kubernetes work pools
- for simple static infra: use serve(...)
- for concurrency control: use deployment-level concurrency limits
- for scaling execution: let Kubernetes scale jobs/nodes, not serve replicas based on memory
If someone insists on serve replicas
Then yes, a better community-style rule would be based on busy vs idle capacity, not memory.
Something like:
text
desired replicas = active_runs + 1
is much closer to the behavior you want.
But that usually requires custom metrics, and once you’re doing that, many teams decide it’s simpler to move to work pools/workers instead.
My practical recommendation
If your real requirement is:
- one run per execution pod
- keep some spare capacity
- autoscale sensibly in Kubernetes
- avoid weird memory heuristics
then I would strongly consider switching from serve replicas to a Kubernetes work pool + worker model.
That is the more natural and common Prefect-on-Kubernetes pattern.
If you want, I can show you a side-by-side of:
- your current serve replica model
vs
- the equivalent Kubernetes work pool setup
and explain which one better matches “always have +1 spare”.