Krystal Zeng
05/20/2026, 10:52 AMMarvin
05/20/2026, 10:52 AMMarvin
05/20/2026, 10:55 AMkill_infrastructure()
- The underlying platform is unavailable (Kubernetes API, Docker daemon, cloud batch service, etc.)
- The run is in Pending rather than Running/Scheduled, so there is effectively nothing concrete to terminate
How to avoid it
1. Make sure workers stay healthy and connected
- If the worker is down when you cancel, cleanup may never happen.
- Check worker health/heartbeat and logs around the time the run entered Pending or Cancelling.
2. Investigate why jobs fail to schedule in the first place
This is the most important fix. If scheduling succeeds reliably, cancellation is usually straightforward.
Look for worker/infrastructure errors such as:
- bad image / image pull errors
- missing Kubernetes service account / RBAC / quota
- invalid job variables
- missing Docker daemon access
- cloud batch/API permissions
- namespace / resource constraints
3. Use work pools/workers that support infrastructure killing properly
If your worker type cannot terminate infrastructure cleanly, runs may remain in Cancelling.
4. Add an operational cleanup path for stuck Cancelling runs
If a run has been in Cancelling for a long time and the job never actually started, force it to Cancelled.
Example using the Prefect client:
python
import asyncio
from uuid import UUID
from prefect import get_client
from prefect.states import Cancelled
FLOW_RUN_ID = UUID("YOUR-FLOW-RUN-ID")
async def force_cancel():
async with get_client() as client:
await client.set_flow_run_state(
FLOW_RUN_ID,
Cancelled(message="Force cancelled after stuck in Cancelling"),
force=True,
)
asyncio.run(force_cancel())
That does not terminate external infrastructure by itself — it just fixes the Prefect state if there is no real job left to kill.
5. Monitor for stale Pending/Cancelling runs
A practical pattern is to periodically:
- find runs stuck in Pending or Cancelling beyond a threshold
- inspect whether infrastructure was ever created
- if not, force them to Cancelled
What I’d check next
- Worker logs for the specific run
- Whether the flow run has an infrastructure_pid
- Whether the underlying job actually exists in your execution platform
- Whether the worker was online when you pressed cancel
Important nuance
If the job never scheduled successfully, there may be nothing to terminate. In that case, the fix is not really “kill harder” — it’s:
- resolve the infrastructure submission failure
- and optionally force-close orphaned Prefect states
Relevant references
- Prefect deployments/workers docs: Deployments and workers
- Work pools docs: Work pools
- How workers execute flow runs: Run flows with workersMarvin
05/20/2026, 10:55 AMKrystal Zeng
05/20/2026, 11:00 AMMarvin
05/20/2026, 11:00 AMMarvin
05/20/2026, 11:03 AMJob exists
- Pod is stuck Pending or never created correctly
- You click Cancel
- Prefect moves to Cancelling
- worker cannot cleanly delete the K8s job/pod or never gets a clean response
- run remains stuck in Cancelling
Most common Kubernetes-specific causes
1. Pod unschedulable
- insufficient CPU/memory
- node selector / affinity mismatch
- missing tolerations for tainted nodes
2. Image pull problems
- bad image tag
- private registry auth missing
- ImagePullBackOff / ErrImagePull
3. Namespace / RBAC issues
- worker can create/read jobs but cannot delete them
- worker service account lacks permission in the target namespace
4. Resource quotas / limits
- namespace quota exceeded
- LimitRange/defaults conflict with requested resources
5. Job exists but no runnable pod
- job deletion may stall or fail from the worker side
- Prefect state gets stuck waiting for cleanup
What to check first
For a stuck run, inspect all three layers:
1. Prefect run details
Check whether the run has an infrastructure_pid. For Kubernetes workers this is typically effectively namespace:job_name.
If there is no infrastructure_pid, then Prefect has nothing concrete to kill, and cancellation often needs manual state repair.
2. Kubernetes Job
Check whether the job exists:
bash
kubectl get jobs -n <namespace>
kubectl describe job <job-name> -n <namespace>
3. Kubernetes Pod/events
If the job exists, inspect pod scheduling:
bash
kubectl get pods -n <namespace> -l job-name=<job-name>
kubectl describe pod <pod-name> -n <namespace>
Look at the Events section for:
- FailedScheduling
- ImagePullBackOff
- ErrImagePull
- Forbidden
- Exceeded quota
How to avoid this going forward
1. Reduce unschedulable pods
Make sure your work pool job template does not request unrealistic resources.
Typical things to review:
- CPU/memory requests and limits
- node selectors / affinities
- tolerations
- namespace quotas
2. Verify worker RBAC includes delete permissions
Your worker needs to be able to delete jobs as well as create them.
At minimum, test:
bash
kubectl auth can-i create jobs --as=system:serviceaccount:<ns>:<sa> -n <namespace>
kubectl auth can-i delete jobs --as=system:serviceaccount:<ns>:<sa> -n <namespace>
kubectl auth can-i get pods --as=system:serviceaccount:<ns>:<sa> -n <namespace>
kubectl auth can-i list pods --as=system:serviceaccount:<ns>:<sa> -n <namespace>
If delete is missing, cancellation can absolutely get stuck.
3. Validate image pullability
Before using an image in the deployment:
- confirm the tag exists
- confirm nodes can access the registry
- confirm imagePullSecrets if needed
4. Enable better worker logging
Run the worker with debug logging so you can see whether:
- job creation succeeded
- infrastructure_pid was recorded
- delete job call failed
- Kubernetes API returned 403/404/timeout
5. Treat “Pending too long” as an operational alert
If a run is Pending for more than a few minutes, that’s usually a cluster/config problem, not a Prefect scheduling delay.
Operational recovery when one is already stuck
If the job exists, delete it manually:
bash
kubectl delete job <job-name> -n <namespace>
If that hangs or the pod is wedged, force it:
bash
kubectl delete job <job-name> -n <namespace> --grace-period=0 --force
kubectl delete pods -n <namespace> -l job-name=<job-name> --grace-period=0 --force
Then force the Prefect flow run state to Cancelled.
Using Python client:
```python
import asyncio
from uuid import UUID
from prefect import get_client
from prefect.states import Cancelled
FLOW_RUN_ID = UUID("YOUR-FLOW-RUN-ID")
async def fix_state():Marvin
05/20/2026, 11:03 AMcreate/get/delete/list jobs and inspect pods
- image pull secrets configured
- monitor FailedScheduling and ImagePullBackOff
- alert on runs stuck in Pending or Cancelling
Useful docs
- Kubernetes worker docs: Run flows on Kubernetes
- Work pools: Work pools
- Deployments/workers overview: Deployments and workers
If you want, I can help you with a concrete debug checklist for your cluster, or help you inspect your Kubernetes work pool job template for the exact fields that commonly cause this.Krystal Zeng
05/20/2026, 12:39 PMMarvin
05/20/2026, 12:39 PMMarvin
05/20/2026, 12:42 PMmy-pool
- Cluster B worker polls my-pool
then without queue separation:
- any scheduled run in that pool can be picked up by either worker
- it’s basically first-come-first-served
If instead you do this:
- Cluster A worker polls queue cluster-a
- Cluster B worker polls queue cluster-b
then deployments targeted at cluster-a will only be picked up by the worker in cluster A.
Best practice setup
1. Create one Kubernetes work pool
2. Create a queue for each cluster
- cluster-a
- cluster-b
3. Run one worker in each cluster, each polling only its own queue
4. Point deployments to the correct queue
This is the cleanest multi-cluster setup.
Worker commands
I wasn’t able to verify the CLI help live because the check timed out, so I’ll avoid overcommitting to flags beyond the standard pattern.
Typical startup pattern is:
bash
prefect worker start --pool my-k8s-pool --type kubernetes --name worker-cluster-a --work-queue cluster-a
and in the other cluster:
bash
prefect worker start --pool my-k8s-pool --type kubernetes --name worker-cluster-b --work-queue cluster-b
If your installed CLI prefers -q / --work-queue, use that form from prefect worker start --help in your environment.
Deployment routing example
In prefect.yaml, target the queue:
yaml
deployments:
- name: my-flow-cluster-a
entrypoint: flows/my_flow.py:my_flow
work_pool:
name: my-k8s-pool
queue_name: cluster-a
And for cluster B:
yaml
deployments:
- name: my-flow-cluster-b
entrypoint: flows/my_flow.py:my_flow
work_pool:
name: my-k8s-pool
queue_name: cluster-b
Important caveat for Kubernetes workers
Each worker launches jobs in its own cluster context unless you explicitly override Kubernetes cluster configuration.
So if both workers share the same pool and same queue:
- a run intended for cluster A might get launched in cluster B
That’s why queues are the important boundary.
Cancellation implications
This matters for your earlier issue too:
- the worker that picked up the run is the one expected to cancel its Kubernetes job
- if work is not cluster-separated, debugging and cancellation become much harder
- queue-per-cluster makes it much easier to know which worker/cluster owns the job
Extra recommendations
- Give workers distinct names like worker-eks-prod and worker-gke-prod
- Use separate queues per cluster
- Optionally pass env vars/job labels so runs can log which cluster they are on
- Keep namespaces and RBAC aligned per cluster
Suggested architecture
- Work pool: my-k8s-pool
- Queues:
- cluster-a
- cluster-b
- Workers:
- cluster A worker polls cluster-a
- cluster B worker polls cluster-b
This is usually better than making separate work pools unless the clusters need very different base job templates or governance boundaries.
Docs
- Work pools: Work pools
- Kubernetes deployments/worker usage: Run flows on Kubernetes
- Deployment concepts: Deployments and workersMarvin
05/20/2026, 12:42 PMprefect.yaml that routes deployments to cluster A vs cluster B.Krystal Zeng
05/20/2026, 12:43 PMMarvin
05/20/2026, 12:44 PMMarvin
05/20/2026, 12:44 PMmulti-cluster-k8s
- Two queues: cluster-a, cluster-b
- One Helm-installed worker per cluster
- Each worker polls only its own queue
This avoids ambiguous routing.
---
1. Create the work pool
In Prefect 3.x, create a Kubernetes work pool:
bash
prefect work-pool create --type kubernetes multi-cluster-k8s
You can confirm it in the UI or CLI.
Docs: Work pools
---
2. Create one queue per cluster
Create queues in that pool:
bash
prefect work-pool queue create cluster-a --pool multi-cluster-k8s
prefect work-pool queue create cluster-b --pool multi-cluster-k8s
If your installed CLI differs slightly, check:
bash
prefect work-pool queue --help
The intent is:
- queue cluster-a for worker in cluster A
- queue cluster-b for worker in cluster B
---
3. Helm values for worker in cluster A
Below is a representative values-cluster-a.yaml.
You will need to adapt:
- API URL / Cloud config
- namespace
- image
- auth secret references
- chart keys if your chart version differs
yaml
worker:
enabled: true
type: kubernetes
config:
workPool: multi-cluster-k8s
name: worker-cluster-a
extraEnvVars:
PREFECT_LOGGING_LEVEL: INFO
# If supported by your chart/version, restrict worker to a queue
# Some chart versions expose this as args instead of config.
args:
- prefect
- worker
- start
- --pool
- multi-cluster-k8s
- --type
- kubernetes
- --name
- worker-cluster-a
- --work-queue
- cluster-a
prefect:
createApiKeySecret: false
apiKeySecret:
name: prefect-api-key
serverApiConfig:
accountId: "<ACCOUNT_ID>"
workspaceId: "<WORKSPACE_ID>"
serviceAccount:
create: true
name: prefect-worker
rbac:
create: true
Install in cluster A:
bash
helm upgrade --install prefect-worker prefect/prefect-worker \
-n prefect \
--create-namespace \
-f values-cluster-a.yaml
---
4. Helm values for worker in cluster B
`values-cluster-b.yaml`:
yaml
worker:
enabled: true
type: kubernetes
config:
workPool: multi-cluster-k8s
name: worker-cluster-b
extraEnvVars:
PREFECT_LOGGING_LEVEL: INFO
args:
- prefect
- worker
- start
- --pool
- multi-cluster-k8s
- --type
- kubernetes
- --name
- worker-cluster-b
- --work-queue
- cluster-b
prefect:
createApiKeySecret: false
apiKeySecret:
name: prefect-api-key
serverApiConfig:
accountId: "<ACCOUNT_ID>"
workspaceId: "<WORKSPACE_ID>"
serviceAccount:
create: true
name: prefect-worker
rbac:
create: true
Install in cluster B:
bash
helm upgrade --install prefect-worker prefect/prefect-worker \
-n prefect \
--create-namespace \
-f values-cluster-b.yaml
---
5. Kubernetes secret for Prefect API key
In each cluster, create the secret referenced above:
bash
kubectl create secret generic prefect-api-key \
-n prefect \
--from-literal=key='<PREFECT_API_KEY>'
That key should belong to the same Prefect Cloud workspace.
Cloud app: Prefect Cloud
---
6. Minimal RBAC example
Your worker service account needs to create and delete jobs and inspect pods.
Example:
```yaml
apiVersion: v1
kind: ServiceAccount
metadata:
name: prefect-worker
namespace: prefect
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: prefect-worker
namespace: prefect
rules:
- apiGroups: ["batch"]
resources: ["jobs"]
verbs: ["create", "get", "list", "watch", "delete", "patch"]
- apiGroups: [""]
resources: ["pods", "pods/log"]
verbs: ["get", "list", "watch"]
- apiGroups: [""]
resources: ["events"]
verbs: ["get", "list", "watch"]Marvin
05/20/2026, 12:44 PMIf your jobs run in a different namespace, scope RBAC there too.
---
*7. Example `prefect.yaml` with cluster-specific queue routing*
Here’s a concrete deployment config:
```yaml
name: multi-cluster-demo
deployments:
- name: flow-on-cluster-a
entrypoint: flows/hello.py:hello_flow
work_pool:
name: multi-cluster-k8s
queue_name: cluster-a
job_variables:
namespace: prefect
image: my-registry/my-flow-image:latest
image_pull_policy: IfNotPresent
finished_job_ttl: 3600
env:
EXECUTION_CLUSTER: cluster-a
- name: flow-on-cluster-b
entrypoint: flows/hello.py:hello_flow
work_pool:
name: multi-cluster-k8s
queue_name: cluster-b
job_variables:
namespace: prefect
image: my-registry/my-flow-image:latest
image_pull_policy: IfNotPresent
finished_job_ttl: 3600
env:
EXECUTION_CLUSTER: cluster-b
Then deploy with:
bash
prefect deploy
This way:
- deployment flow-on-cluster-a only goes to cluster A worker
- deployment flow-on-cluster-b only goes to cluster B worker
---
8. Example flow to confirm which cluster executed it
`flows/hello.py`:
python
import os
from prefect import flow
@flow
def hello_flow():
cluster = os.getenv("EXECUTION_CLUSTER", "unknown")
print(f"Hello from {cluster}")
This makes it obvious from logs which cluster ran the flow.
---
9. Operational checks
After installation, verify workers are registered:
- in Prefect UI under work pool workers
- or via CLI if you use it locally
You should see distinct worker names:
- worker-cluster-a
- worker-cluster-b
And confirm in Kubernetes:
bash
kubectl get pods -n prefect
kubectl logs -n prefect deploy/prefect-worker
Do this in both clusters.
---
10. What if you want “either cluster can run it”?
You can point both workers at the same queue, but I would not recommend that for your setup given the cancellation issues you described earlier.
If you do:
- routing is non-deterministic
- either cluster may pick the run
- cancellation ownership is less obvious
Much better:
- use one queue per cluster for deterministic placement
- optionally create a third shared queue only for workloads that truly can run anywhere
Example:
- cluster-a
- cluster-b
- shared
Then both workers can poll shared, but keep critical jobs pinned to the cluster-specific queues.
---
11. Common gotchas
- Both workers polling the default queue by accident
- Missing queue_name in deployment, so runs go to default queue
- Worker service account can create jobs but cannot delete them
- Different namespaces between worker and launched jobs
- Image pull secret missing in one cluster
- Cluster resource differences causing one cluster to fail scheduling
---
12. My recommendation for your case
Given your earlier stuck Pending -> Cancelling issue, I’d strongly recommend:
- one pool
- separate queues per cluster
- unique worker names
- explicit namespace in job variables
- TTL for finished jobs
- good worker RBAC including delete permissions
---
If you want, next I can give you either:
1. a full Helm values file tailored for Prefect Cloud, or
2. a full prefect.yaml + Docker image example for multi-cluster deployments.Krystal Zeng
05/21/2026, 12:17 PMMarvin
05/21/2026, 12:17 PMMarvin
05/21/2026, 12:19 PMconnection-string.
Recommended approach
If you already have a K8s secret populated from AWS Secrets Manager, make it look like this:
yaml
apiVersion: v1
kind: Secret
metadata:
name: prefect-db-connection
namespace: prefect
type: Opaque
stringData:
connection-string: <postgresql+asyncpg://USERNAME:PASSWORD@HOST:5432/DBNAME>
Then in your values.yaml for the Prefect server chart:
yaml
postgresql:
enabled: false
secret:
create: false
name: prefect-db-connection
That tells the chart:
- do not deploy internal PostgreSQL
- do not create a new DB secret
- use the existing secret named prefect-db-connection
---
If your secret currently stores username/password/host separately
That will not usually be enough by itself for the Prefect server chart.
The chart expects a secret containing the full connection URL under connection-string.
So if your AWS secret currently has JSON like:
json
{
"username": "prefect",
"password": "secret",
"host": "<http://mydb.xxxxx.us-east-1.rds.amazonaws.com|mydb.xxxxx.us-east-1.rds.amazonaws.com>",
"port": "5432",
"database": "prefect"
}
then you should either:
1. transform it into a Kubernetes secret with connection-string, or
2. use External Secrets Operator templating to create that connection-string key
Example ExternalSecret template pattern:
yaml
apiVersion: <http://external-secrets.io/v1beta1|external-secrets.io/v1beta1>
kind: ExternalSecret
metadata:
name: prefect-db-connection
namespace: prefect
spec:
refreshInterval: 1h
secretStoreRef:
name: aws-secrets-manager
kind: SecretStore
target:
name: prefect-db-connection
template:
engineVersion: v2
data:
connection-string: "postgresql+asyncpg://{{ .username }}:{{ .password }}@{{ .host }}:{{ .port }}/{{ .database }}"
data:
- secretKey: username
remoteRef:
key: my/prefect/postgres
property: username
- secretKey: password
remoteRef:
key: my/prefect/postgres
property: password
- secretKey: host
remoteRef:
key: my/prefect/postgres
property: host
- secretKey: port
remoteRef:
key: my/prefect/postgres
property: port
- secretKey: database
remoteRef:
key: my/prefect/postgres
property: database
Then your Prefect Helm values remain:
yaml
postgresql:
enabled: false
secret:
create: false
name: prefect-db-connection
---
Concrete full example values.yaml
yaml
postgresql:
enabled: false
secret:
create: false
name: prefect-db-connection
server:
replicaCount: 1
backgroundServices:
runAsSeparateDeployment: false
If you are running HA / separate background services, you can still use the same DB secret.
---
Important caveats
1. Secret key name matters
The chart expects the secret to contain:
- connection-string
2. Use asyncpg driver format
The URL should look like:
text
<postgresql+asyncpg://user:password@host:5432/dbname>
3. Disable built-in PostgreSQL
Otherwise the chart may still try to provision its own DB:
yaml
postgresql:
enabled: false
4. Special characters in password
If the password contains special characters like @, :, /, %, they must be URL-encoded in the connection string.
5. SSL parameters
If your external Postgres requires SSL, append query params to the URL, for example:
text
<postgresql+asyncpg://user:password@host:5432/dbname?ssl=require>
---
How to verify after deploy
Check the secret:
bash
kubectl get secret prefect-db-connection -n prefect -o jsonpath='{.data.connection-string}' | base64 -d
Check the server pod env wiring:
```bashMarvin
05/21/2026, 12:19 PMThen inspect logs:
```bash
kubectl logs -n prefect deploy/<prefect-server-deployment-name>
If the DB connection is wrong, Prefect server usually fails on startup/migrations with a DB error.
---
Docs / repo references
- Prefect Helm charts: prefect-helm
- Prefect deployment docs: Deployments and workers
If you want, paste your current:
- Kubernetes secret shape, and
- values.yaml
and I can rewrite it into the exact working format for the Prefect server chart.Krystal Zeng
05/21/2026, 5:27 PMMarvin
05/21/2026, 5:27 PMMarvin
05/21/2026, 5:27 PMv2_enabled?
For example:
- in the Helm values
- in browser/network config
- in a doc
- or in an env var name
If you want, I can also look up the exact supported setting for the current self-hosted dashboard once you share that snippet.Krystal Zeng
05/21/2026, 5:34 PMMarvin
05/21/2026, 5:34 PMMarvin
05/21/2026, 5:36 PMjavascript
localStorage.removeItem('prefect-v2-promo-dismissed');
window.location.reload();
That should allow the prompt to appear again.
---
If you just want to switch to the new UI directly
Prefect also tracks UI selection in the browser, so if the v2 UI is available on your server, clearing the promo dismissal is usually enough. If not, also try clearing site storage for your Prefect domain:
- browser settings
- site data / storage
- clear local storage + cookies for that host
---
Server-side settings for self-hosted Prefect Server
There are also server settings that affect this behavior:
- PREFECT_SERVER_UI_SHOW_PROMOTIONAL_CONTENT=true
- PREFECT_SERVER_UI_V2_ENABLED=true
If you’re using Helm, that would typically look like:
yaml
server:
config:
PREFECT_SERVER_UI_SHOW_PROMOTIONAL_CONTENT: "true"
PREFECT_SERVER_UI_V2_ENABLED: "true"
Then upgrade your release:
bash
helm upgrade --install prefect-server prefect/prefect-server \
-n prefect \
-f values.yaml
What these do
- `PREFECT_SERVER_UI_SHOW_PROMOTIONAL_CONTENT`: allows the “try the updated UI” banner/promo to show
- `PREFECT_SERVER_UI_V2_ENABLED`: enables v2 as the default available UI for users without a saved preference
---
What I’d try in your case
1. First, in browser console:
javascript
localStorage.removeItem('prefect-v2-promo-dismissed');
window.location.reload();
2. If that doesn’t work, confirm your self-hosted server has:
yaml
server:
config:
PREFECT_SERVER_UI_SHOW_PROMOTIONAL_CONTENT: "true"
PREFECT_SERVER_UI_V2_ENABLED: "true"
3. Then clear site storage/cookies for your Prefect UI hostname and reload.
If you want, I can help you patch your current Helm values.yaml with the exact settings in the right place.Krystal Zeng
06/08/2026, 2:31 PMMarvin
06/08/2026, 2:31 PMMarvin
06/08/2026, 2:34 PMflow_run
- work_pool
- work_queue
Conceptually:
- deployments create flow runs
- flow runs are assigned to a work pool / queue
- workers poll Prefect Server
- Prefect Server reads from the DB and returns eligible scheduled runs
So the queueing model is:
- logical queue in the DB, not a standalone message broker
How workers get work
Workers do not consume from Redis/SQS/RabbitMQ/etc.
They typically:
1. poll the Prefect API
2. API queries the DB for scheduled flow runs in the relevant work pool / queue
3. worker submits infrastructure for those runs
So this is a polling architecture, not a broker-push architecture.
Can Prefect Server submit jobs to an external messaging queue instead?
Not as a supported pattern for work-pool job submission.
There is not a supported configuration like:
- “store scheduled runs in SQS”
- “publish job submissions to Kafka”
- “have workers pull from RabbitMQ instead of Prefect DB”
That is not how work pools/workers are implemented today.
Important nuance about Redis
You may see Redis mentioned in self-hosted Prefect Server setups, but that is not the main worker job queue.
Redis is used for other internal messaging-related purposes in some server configurations, especially HA/background-service patterns and cleanup-related workflows. It is not a drop-in external broker for work-pool dispatch.
So:
- Postgres/DB = authoritative store for scheduled work
- workers = poll Prefect API
- Redis ≠ external replacement for work submission queue
What this means for your architecture
If your goal is:
- durable job submission
- decoupled worker pickup
- broker-based execution pipeline
then Prefect’s standard self-hosted worker model does not let you swap in an external broker.
Supported/realistic options
1. Use PostgreSQL as the durable backing store
- this is the normal production approach
- scale Prefect Server + workers around it
2. Run multiple workers against the same work pool/queues
- horizontal scaling
- still DB/API-polled
3. Use queue separation inside Prefect
- work pools + work queues
- not external broker queues, but usually enough for routing/priorities
4. Build a custom bridge outside supported Prefect behavior
- e.g. your own service listens for events / scans runs / publishes to your broker / later calls Prefect APIs
- possible architecturally, but this is custom glue, not native Prefect worker behavior
For your earlier Kubernetes use case
If your concern is reliability when jobs are stuck pending, an external queue won’t really solve the core Kubernetes scheduling issue. The more effective fixes are:
- stable Postgres
- clear work queue routing
- better worker observability
- robust K8s RBAC/resources
- cleanup/recovery for stuck cancelling runs
Useful docs
- Work pools: Work pools
- Deployments and workers: Deployments and workers
If you want, I can sketch the actual self-hosted Prefect Server architecture for:
- Postgres
- Redis
- API server
- background services
- workers
and explain exactly where each component fits in the submission path.Krystal Zeng
06/08/2026, 3:14 PMMarvin
06/08/2026, 3:14 PMMarvin
06/08/2026, 3:15 PMtext
Users / CI / Apps
|
v
One Prefect API URL
|
v
Prefect Server (self-hosted)
|
v
Shared PostgreSQL
|
v
Work pool / queue state
/ \
/ \
EKS Cluster A EKS Cluster B
worker(s) worker(s)
The server is the control plane.
The workers in both clusters are the execution plane.
---
How to achieve “whichever cluster is free”
Use:
- one Kubernetes work pool
- one shared work queue (for flows that can run in either cluster)
- workers in both clusters polling that queue
Then:
- users always hit the same Prefect API URL
- flow runs land in the same work pool/queue
- workers in either cluster can pick them up
- the first available worker polls and claims the run
That is effectively active-active at the worker layer.
---
Important caveat
This is not cluster-aware load balancing in the sense of:
- checking current cluster CPU utilization centrally
- then explicitly routing to the least-loaded cluster
Instead, it is worker-poll-based competition:
- both worker groups poll
- eligible runs are claimed by an available worker
- capacity naturally distributes based on worker availability and polling timing
In practice, that often works well enough for active-active execution.
---
Recommended self-hosted setup
1. One shared Prefect Server endpoint
- self-hosted Prefect server accessible from both EKS clusters and your users
- behind one stable URL / ingress / load balancer
2. One shared external PostgreSQL
- required for production
- both server and background services use the same DB
3. Optional Redis for HA/background messaging
- useful in HA self-hosted server setups
- but not the worker job queue
4. One work pool
Example:
- eks-active-active
5. One shared queue
Example:
- shared
6. Workers in both clusters poll the same queue
Example:
- worker deployment in cluster A polls shared
- worker deployment in cluster B polls shared
---
How the routing works
If all workers poll:
- pool: eks-active-active
- queue: shared
then either cluster can pick up the run.
If cluster A is busy and cluster B has idle workers, cluster B will tend to pick up more runs.
If both are free, either may get the run.
---
Helm pattern for both clusters
In both EKS clusters, deploy a worker pointed at the same work pool and same queue, but with different worker names.
Example cluster A values:
yaml
worker:
enabled: true
type: kubernetes
config:
workPool: eks-active-active
name: worker-eks-a
args:
- prefect
- worker
- start
- --pool
- eks-active-active
- --type
- kubernetes
- --name
- worker-eks-a
- --work-queue
- shared
Example cluster B values:
yaml
worker:
enabled: true
type: kubernetes
config:
workPool: eks-active-active
name: worker-eks-b
args:
- prefect
- worker
- start
- --pool
- eks-active-active
- --type
- kubernetes
- --name
- worker-eks-b
- --work-queue
- shared
Then install the worker chart in both clusters.
---
Flow deployment example
All deployments that should run on either cluster go to the same pool/queue:
```yaml
deployments:
- name: shared-runner
entrypoint: flows/my_flow.py:my_flow
work_pool:
name: eks-active-activeMarvin
06/08/2026, 3:15 PMNow any worker in either cluster can pick up the run.
---
*Critical Kubernetes caveat*
Because you are using *Kubernetes workers*, the infrastructure gets created in the cluster where the worker is running.
So:
- cluster A worker picks up the run -> K8s Job created in cluster A
- cluster B worker picks up the run -> K8s Job created in cluster B
That’s exactly what you want for active-active.
---
*How to know which cluster ran the job*
It helps to inject an env var per worker/cluster or use separate images/labels.
For example, cluster A work pool template or deployment variables could include:
```yaml
env:
EXECUTION_CLUSTER: eks-a
And cluster B:
yaml
env:
EXECUTION_CLUSTER: eks-b
Then your flow can log it.
If both clusters share the same queue, you won’t know in advance where it will land, so this is useful for observability.
---
What about failover?
This setup gives you active-active worker failover:
- if cluster A workers are down, cluster B workers still poll and execute
- if cluster B is down, cluster A still works
But note: that only works if the Prefect control plane is also highly available.
For true production active-active behavior, make these HA too:
- Prefect API server replicas
- background services deployment
- PostgreSQL HA / managed Postgres
- ingress / load balancer
---
What this is not
This is not:
- a cross-cluster Kubernetes scheduler
- a centralized least-loaded-cluster router
- a broker-based execution queue
It is:
- a shared Prefect control plane
- multiple workers in multiple clusters competing for runnable work
That is the supported pattern.
---
Best practice for your use case
I’d suggest two queues, not just one:
- shared
- cluster-a-only
- cluster-b-only
Why:
- most flows can go to shared
- special flows that require a specific cluster can still be pinned when needed
Then:
- both clusters poll shared
- cluster A worker can also poll cluster-a-only
- cluster B worker can also poll cluster-b-only
This gives flexibility without losing active-active.
---
Concerns from your earlier cancellation issue
If you run active-active with one shared queue:
- cancellation is still handled by the worker/cluster that picked up the run
- observability becomes very important
- give workers clear names
- ensure both clusters have proper RBAC/delete permissions
- monitor stuck Pending / Cancelling jobs in both clusters
---
What you need for self-hosted Prefect
Minimum production components:
- Prefect Server
- external PostgreSQL
- optionally Redis depending on your HA/server setup
- one shared work pool
- workers in both EKS clusters
- one ingress/API URL reachable by users and workers
---
Recommended deployment strategy
Control plane cluster or shared environment
- run Prefect Server in one stable place
- use external Postgres
- expose one API/UI URL
Execution clusters
- EKS A runs worker Helm release
- EKS B runs worker Helm release
- both use same API key / workspace / server URL
- both poll the same shared queue
---
Docs
- Deployments and workers: Deployments and workers
- Work pools: Work pools
- Kubernetes worker usage: Run flows on Kubernetes
If you want, I can give you a full reference architecture next with:
- server Helm values
- worker Helm values for both clusters
- work pool / queue layout
- and a prefect.yaml for shared active-active routing.Krystal Zeng
06/08/2026, 3:17 PMMarvin
06/08/2026, 3:17 PMMarvin
06/08/2026, 3:18 PMtext
Users / clients
|
+-----------+-----------+
| |
v v
Prefect Server A Prefect Server B
reachable from reachable from
Cluster A Cluster B
\ /
\ /
+------ Postgres ----+
(shared)
Then:
- Worker in cluster A talks only to Server A
- Worker in cluster B talks only to Server B
- both server instances use the same Postgres
- both see the same flow runs, work pools, queues, deployments, etc.
So yes, this is a valid way to avoid direct inter-cluster communication.
---
Important caveat: shared Redis / messaging may matter
For modern self-hosted Prefect HA patterns, shared Postgres is required, and depending on your exact setup, shared Redis may also be needed for some background-service/messaging behavior.
So the production-safe pattern is usually:
- shared Postgres
- shared Redis if your self-hosted HA topology uses it
- multiple server/API replicas or separate server deployments
- workers in both clusters pointing to their nearest/reachable server URL
If you deploy two servers with the same Postgres but inconsistent supporting components/configuration, you may get odd behavior.
---
What you must not do
Do not do this:
- Server A with its own Postgres
- Server B with its own Postgres
That would create two separate Prefect installations, not one shared active-active system.
Workers would not share work in that model.
---
Can workers in both clusters still pull the same work?
Yes, if both Prefect server instances share the same Postgres state.
Since work-pool/work-queue scheduling lives in the DB, both server instances can return eligible work to workers that poll them.
So:
- worker in cluster A asks Server A for runnable work
- worker in cluster B asks Server B for runnable work
- both servers query the same DB
- whichever worker claims the run first gets it
That gives you the “whichever cluster is free” behavior without cross-cluster worker communication.
---
Is this active-active?
Yes, that is effectively active-active on the control plane and worker plane, provided:
- both server instances are live
- both are backed by the same DB
- workers in both clusters are polling
- your ingress/DNS/client model supports submission to either or both API endpoints as desired
---
Key design question: one URL or two URLs?
You said users submit to the same URL.
There are two ways to do that:
Option 1: one global/load-balanced URL in front of both servers
- <https://prefect.example.com>
- routes to Server A or Server B
- both servers use same Postgres
This is ideal if clients can reach both server endpoints through a common LB/DNS layer.
Option 2: separate URLs per cluster/server
- <https://prefect-a.example.com>
- <https://prefect-b.example.com>
Workers use their local/reachable one, but users would not have a single submission URL unless you place something in front.Marvin
06/08/2026, 3:18 PMprefect-server-a
- Cluster B hosts prefect-server-b
- both connect outbound to shared Postgres
- optionally both connect outbound to shared Redis
- workers in each cluster connect only to the local server endpoint
- users hit a shared public URL or any reachable server URL
No cluster needs to call the other cluster internally.
---
Potential issues / caveats
1. Database migrations
Only one migration process should manage schema upgrades at a time.
2. Background services duplication
You need to be careful if both server deployments run all background services independently.
Depending on your topology, this may be okay if Prefect supports coordinated multi-instance behavior through shared state, but you should avoid unmanaged duplication of migration/initialization hooks.
3. Redis/shared messaging
If your topology uses Redis-backed internal messaging, it should also be shared, not per-cluster.
4. Version parity
Both server deployments should run the same Prefect version.
5. Sticky debugging
A worker may pick up a flow via server A while another run is queried via server B. That’s okay with shared DB, but logs/ops should assume a distributed control plane.
---
Recommended architecture for your case
Given your network constraint, I’d recommend:
- external managed Postgres accessible from both clusters
- external managed Redis accessible from both clusters if using HA/background messaging features
- Prefect Server deployment in cluster A
- Prefect Server deployment in cluster B
- both configured identically against the same DB/Redis
- one shared work pool and shared queue for “run anywhere”
- workers in each cluster point only to their local Prefect server URL
- optionally one public DNS name in front of both API servers
---
How work claiming behaves
Because claiming happens against shared DB-backed scheduling state, you don’t need worker-to-worker communication or server-to-server communication for the basic shared-queue model.
The shared DB is the coordination point.
That’s the key architectural insight for your use case.
---
Practical answer
So to your exact question:
Can I deploy two Prefect servers?
Yes — as long as they are two instances of the same Prefect control plane backed by the same external Postgres (and shared supporting services as needed), not two separate independent installations.
That is the approach I would use to avoid opening direct communication between the clusters.
---
What I would not promise without checking your exact chart/setup
I’d want to verify your exact Helm topology for:
- API server
- background services
- Redis
- migrations job
before telling you exactly which components to duplicate in both clusters vs run only once.
That part matters.
If you want, I can help you design the exact multi-cluster self-hosted topology next, including:
- which Prefect Helm components should run in both clusters
- which should run once only
- how to wire shared Postgres/Redis
- and how workers should point to the nearest server endpoint.Krystal Zeng
06/11/2026, 8:24 AMMarvin
06/11/2026, 8:24 AMMarvin
06/11/2026, 8:25 AMbash
kubectl get jobs -n <namespace>
kubectl describe job <job-name> -n <namespace>
kubectl get pods -n <namespace> -l job-name=<job-name>
Then delete it:
bash
kubectl delete job <job-name> -n <namespace>
If needed, force it:
bash
kubectl delete job <job-name> -n <namespace> --grace-period=0 --force
kubectl delete pods -n <namespace> -l job-name=<job-name> --grace-period=0 --force
2. Force the Prefect flow run state to Cancelled
If the infrastructure is already gone or there was never anything real to kill, force-close the Prefect state:
python
import asyncio
from uuid import UUID
from prefect import get_client
from prefect.states import Cancelled
FLOW_RUN_ID = UUID("YOUR-FLOW-RUN-ID")
async def force_cancel():
async with get_client() as client:
await client.set_flow_run_state(
FLOW_RUN_ID,
Cancelled(message="Manually cancelled after stuck Cancelling"),
force=True,
)
asyncio.run(force_cancel())
This fixes the Prefect state even if cleanup logic got stuck.
---
How to prevent it
The real fix is to address why the pod is Pending.
For Kubernetes, the usual causes are:
- insufficient CPU / memory
- node selector or affinity mismatch
- missing tolerations
- namespace quota exceeded
- image pull failures
- RBAC or namespace issues
Check:
bash
kubectl describe pod <pod-name> -n <namespace>
Look at Events for:
- FailedScheduling
- ImagePullBackOff
- ErrImagePull
- Forbidden
- quota errors
---
Important practical point
If the pod never scheduled, there may be nothing running to terminate. In that case, cancelling from Prefect is mostly a metadata/state transition problem, not an actual process-kill problem.
So the recovery pattern is usually:
- clean up K8s objects if they exist
- force the Prefect run state to Cancelled
---
Operational workaround
A lot of teams add a cleanup job that periodically:
- finds runs stuck in Cancelling for > N minutes
- checks if the Kubernetes job/pod still exists
- if not, force-sets the state to Cancelled
That’s often the safest short-term mitigation.
---
Bottom line
Yes, there is a way to resolve it: manually remove the K8s job/pod if present, then force the Prefect flow run to Cancelled.
If you want, I can also give you a small automation script that:
- takes a flow run ID
- finds the related K8s job
- deletes it
- and force-cancels the Prefect run.Krystal Zeng
06/11/2026, 8:34 AMMarvin
06/11/2026, 8:34 AMMarvin
06/11/2026, 8:36 AMbash
PREFECT_SERVER_SERVICES_DB_VACUUM_ENABLED=events,flow_runs
PREFECT_SERVER_SERVICES_DB_VACUUM_LOOP_SECONDS=3600
PREFECT_SERVER_SERVICES_DB_VACUUM_RETENTION_PERIOD=2592000
PREFECT_EVENTS_RETENTION_PERIOD=7d
What those mean:
- DB_VACUUM_ENABLED=events,flow_runs
enables cleanup of:
- old flow runs
- orphaned logs
- orphaned artifacts
- old events
- DB_VACUUM_LOOP_SECONDS=3600
run cleanup every hour
- DB_VACUUM_RETENTION_PERIOD=2592000
retain flow runs for 30 days
- PREFECT_EVENTS_RETENTION_PERIOD=7d
retain events for 7 days
If you run a lot of flows, you may want to be more aggressive:
- flow runs: 7–14 days
- events: 1–3 days
---
For Helm values
If you are using the Prefect server Helm chart, add these under the server config/env area.
A representative example:
yaml
server:
config:
PREFECT_SERVER_SERVICES_DB_VACUUM_ENABLED: "events,flow_runs"
PREFECT_SERVER_SERVICES_DB_VACUUM_LOOP_SECONDS: "3600"
PREFECT_SERVER_SERVICES_DB_VACUUM_RETENTION_PERIOD: "2592000"
PREFECT_EVENTS_RETENTION_PERIOD: "7d"
If your chart version structures env differently, the equivalent is to inject them as container env vars.
---
Important caveat for your topology
You said you have one pod for my server.
That means the API server and background services may be running together in that same process/pod depending on how you started it.
The cleanup service only works if server services are actually running.
If you started Prefect with normal server startup, that’s usually fine.
If you started with --no-services, then cleanup will not run unless you have a separate background-services process.
So the first thing to confirm is:
- are server background services enabled?
---
How to verify cleanup is active
Look at server logs for database vacuum activity:
bash
kubectl logs -n <namespace> deployment/<prefect-server-deployment>
You want to see messages related to:
- database vacuum
- deleting old flow runs
- deleting old events
Also check current settings inside the container if needed.
---
What gets cleaned up
With DB vacuum enabled, Prefect cleans up things like:
- old terminal flow runs
- orphaned logs
- orphaned artifacts
- stale event data
This is the main built-in answer to “how do I keep the DB from filling up too quickly?”
---
What usually grows fastest
In many self-hosted setups, the fastest-growing data is:
1. events
2. logs
3. flow/task run history
So the biggest wins are usually:
- lower PREFECT_EVENTS_RETENTION_PERIOD
- enable flow_runs vacuum
- reduce unnecessary logging volume in flows
---
Recommended retention starting point
For moderate usage:
bash
PREFECT_SERVER_SERVICES_DB_VACUUM_ENABLED=events,flow_runs
PREFECT_SERVER_SERVICES_DB_VACUUM_LOOP_SECONDS=3600
PREFECT_SERVER_SERVICES_DB_VACUUM_RETENTION_PERIOD=1209600
PREFECT_EVENTS_RETENTION_PERIOD=3d
That means:
- clean hourly
- keep flow runs for 14 days
- keep events for 3 days
For high-volume environments, this is often much better than 30–90 day retention.
---
Postgres-side recommendation
Even with Prefect cleanup enabled, Postgres still needs its own vacuum/autovacuum to reclaim space efficiently.
So also make sure on RDS:
- autovacuum is enabled
- monitor table growth and dead tuples
- alert on storage growth
Prefect deletes rows, but Postgres storage reclamation behavior still matters.
---
If you want more aggressive cleanup than built-in retentionMarvin
06/11/2026, 8:36 AMyaml
server:
config:
PREFECT_SERVER_SERVICES_DB_VACUUM_ENABLED: "events,flow_runs"
PREFECT_SERVER_SERVICES_DB_VACUUM_LOOP_SECONDS: "3600"
PREFECT_SERVER_SERVICES_DB_VACUUM_RETENTION_PERIOD: "1209600"
PREFECT_EVENTS_RETENTION_PERIOD: "3d"
2. redeploy Helm
3. verify server logs show vacuum activity
4. monitor RDS table growth for a few days
---
References
- Prefect self-hosted/server docs: Self-host Prefect
- Helm charts: prefect-helm
If you want, I can help you patch these exactly into your current Helm values file based on how you installed Prefect server.Krystal Zeng
06/11/2026, 8:42 AMMarvin
06/11/2026, 8:42 AMMarvin
06/11/2026, 8:42 AMyaml
backgroundServices:
runAsSeparateDeployment: true
replicaCount: 1
server:
config:
PREFECT_SERVER_SERVICES_DB_VACUUM_ENABLED: "events,flow_runs"
PREFECT_SERVER_SERVICES_DB_VACUUM_LOOP_SECONDS: "3600"
PREFECT_SERVER_SERVICES_DB_VACUUM_RETENTION_PERIOD: "1209600"
PREFECT_EVENTS_RETENTION_PERIOD: "3d"
Then upgrade the release:
bash
helm upgrade --install prefect-server prefect/prefect-server \
-n prefect \
-f values.yaml
What this does
- backgroundServices.runAsSeparateDeployment: true
creates a dedicated background-services pod
- that pod runs the internal services like:
- database vacuum
- cancellation cleanup
- lease cleanup / service loops
- the DB cleanup env vars tell it to retain less data
---
How to verify it worked
After upgrade:
bash
kubectl get pods -n prefect
You should see something like:
- server pod
- background services pod
Then inspect logs for the background deployment/pod, not just the server pod:
bash
kubectl logs -n prefect deployment/<background-services-deployment-name>
or:
bash
kubectl get pods -n prefect
kubectl logs -n prefect <background-services-pod-name>
That’s where you’d expect to see vacuum-related activity.
---
Why you may not see it in the server pod
In Helm deployments, background services are often separated so the API process doesn’t also own all service loops. That’s normal and usually preferable.
---
One important caveat
If you enable separate background services in a more HA-style setup, some Prefect topologies also expect Redis/shared messaging support for full coordination. If you’re still on a simple single-server deployment, you may still be okay, but if you scale beyond that, I’d strongly recommend reviewing Redis/shared service coordination too.
---
What I’d do in your case
Since you currently have:
- EKS
- Helm-installed Prefect server
- external RDS
- concern about DB growth
I would:
1. enable backgroundServices.runAsSeparateDeployment: true
2. set the DB vacuum retention env vars
3. redeploy
4. inspect logs of the background services pod
---
If you want, send me your current Helm values.yaml and I can mark up the exact section to add so you don’t have to guess the chart structure.Krystal Zeng
06/11/2026, 8:47 AMMarvin
06/11/2026, 8:47 AMMarvin
06/11/2026, 8:48 AMKrystal Zeng
06/11/2026, 8:50 AMMarvin
06/11/2026, 8:50 AMMarvin
06/11/2026, 8:50 AMMarvin
06/11/2026, 8:50 AMKrystal Zeng
06/11/2026, 1:36 PMMarvin
06/11/2026, 1:36 PMMarvin
06/11/2026, 1:39 PMprint() output → yes, if enabled
- third-party Python package logs → yes, if you explicitly configure those loggers
- arbitrary container stdout/stderr from non-Python processes → not always automatically
- crash logs from failed K8s pods → often yes, via the Kubernetes observer feature
---
What to enable
### 1. Enable log sending to the Prefect API
Usually this is already on, but you want:
bash
PREFECT_LOGGING_TO_API_ENABLED=true
### 2. Capture print() statements
At the flow level:
python
from prefect import flow
@flow(log_prints=True)
def my_flow():
print("hello from print")
Or globally with env:
bash
PREFECT_LOGGING_LOG_PRINTS=true
---
### 3. Capture third-party Python logger output
This is the key piece for packages like:
- requests
- urllib3
- sqlalchemy
- etc.
Set:
bash
PREFECT_LOGGING_EXTRA_LOGGERS=requests,urllib3,sqlalchemy
That tells Prefect to attach its API log handler to those named loggers.
---
Example Kubernetes job env / work-pool job variables
If you configure this in your deployment/job variables:
yaml
job_variables:
env:
PREFECT_LOGGING_TO_API_ENABLED: "true"
PREFECT_LOGGING_LOG_PRINTS: "true"
PREFECT_LOGGING_LEVEL: "INFO"
PREFECT_LOGGING_EXTRA_LOGGERS: "requests,urllib3,sqlalchemy"
Then those logs should appear in the Prefect UI.
---
Example Python flow
python
import logging
import requests
from prefect import flow, get_run_logger
@flow(log_prints=True)
def my_flow():
logger = get_run_logger()
<http://logger.info|logger.info>("Starting flow")
print("This print will appear in Prefect UI")
third_party_logger = logging.getLogger("requests")
third_party_logger.setLevel(<http://logging.INFO|logging.INFO>)
requests.get("<https://example.com>")
<http://logger.info|logger.info>("Done")
---
Important limitation
Prefect does not magically ingest every log emitted inside the pod.
It primarily captures logs that go through:
- Prefect loggers
- configured Python loggers
- captured print()
- some crash-log forwarding from the Kubernetes observer
If a process inside the container writes directly to stdout/stderr and is not integrated with Python logging / Prefect logging, those logs may remain only in Kubernetes logs unless you explicitly bridge them.
---
For crashed runs / startup failures
Kubernetes workers also support forwarding logs from crashed pods, which is especially useful when the flow never starts cleanly.
This helps with cases like:
- import errors
- bad image/entrypoint
- missing dependencies
- crash on startup
Relevant settings include enabling the Kubernetes observer crash-log forwarding. In practice, you want this enabled if you're debugging failed jobs.
---
Best practice for “show everything possible in UI”
Use all three:
1. @flow(log_prints=True)
2. PREFECT_LOGGING_EXTRA_LOGGERS=...
3. set log levels appropriately for those libraries
For example:
bash
PREFECT_LOGGING_TO_API_ENABLED=true
PREFECT_LOGGING_LOG_PRINTS=true
PREFECT_LOGGING_LEVEL=INFO
PREFECT_LOGGING_EXTRA_LOGGERS=requests,urllib3,sqlalchemy,boto3,botocore
And in code, if needed:
python
import logging
logging.getLogger("urllib3").setLevel(<http://logging.INFO|logging.INFO>)
logging.getLogger("sqlalchemy").setLevel(<http://logging.INFO|logging.INFO>)
---
If you want all pod logs regardless of source
If your goal is truly:
- everything written in the pod
- including shell commands, subprocess output, non-Python processes
then Prefect UI is not really a full log aggregation platform.
In that case, the usual pattern is:
- send pod/container logs to CloudWatch / Datadog / ELK / Loki
- use Prefect UI for orchestration logs
- use your cluster log platform for full raw container logsMarvin
06/11/2026, 1:39 PMprint()
- requests
- sqlalchemy
- boto3
- and sets sensible logging levels.