Jordan Jones
12/19/2025, 9:55 PMMarvin
12/19/2025, 9:55 PMMarvin
12/19/2025, 10:01 PMprefect runner CLI; you typically run runners via prefect flow serve ... or programmatically with flow.serve() / Runner().
- On SIGTERM, the runner stops taking new work and cancels active runs. Child processes are given a short grace before being killed. There isn’t a built‑in “drain until all runs finish” mode.
- The Runner can expose a tiny HTTP server with useful endpoints (health and run_count). You can enable it with PREFECT_RUNNER_SERVER_ENABLE=true or Runner(webserver=True).
Given that, you have two practical patterns depending on your tolerance for canceling vs draining:
Option A: Allow cancellation on scale-in (simplest)
- Accept that in‑flight runs will be cancelled during scale‑in.
- Ensure your container’s termination grace window is long enough for graceful cancellation to complete.
- In Azure Container Apps, set a termination grace period that’s comfortably above 30 seconds to give the runner time to stop cleanly.
- Keep your runner concurrency (limit / PREFECT_RUNNER_PROCESS_LIMIT) at a level you’re comfortable cancelling.
Option B: True draining (finish current work, then exit)
- Wrap the runner with a tiny entrypoint that:
1) Enables the runner’s webserver so you can see active in‑flight runs (run count).
2) On SIGTERM, stops the runner from accepting new work and waits until run_count drops to 0, then lets the process exit.
- Sample entrypoint you can adapt (the runner webserver listens on localhost:8080 by default):
#!/usr/bin/env bash
set -euo pipefail
# 1) Enable the runner webserver so we can see in-flight runs
export PREFECT_RUNNER_SERVER_ENABLE=true
export PREFECT_RUNNER_SERVER_PORT=8080
# Start your runner — using flow serve as an example
prefect flow serve ./path/to/flows.py:my_flow -n my-deployment &
RUNNER_PID=$!
drain_and_exit() {
echo "Scale-in signal received: initiating drain"
# Optional: stop taking new work. If you’re starting via Python, prefer programmatic control.
# If using the runner webserver, you can choose to pause new intake in your own code path.
# Otherwise, just wait for run_count to drop.
# 2) Wait for in-flight runs to finish (bounded wait)
end=$((SECONDS+900)) # wait up to 15 minutes; adjust
while [ $SECONDS -lt $end ]; do
rc=$(curl -fsS <http://127.0.0.1:8080/run_count> || echo "0")
if [ "$rc" = "0" ]; then
break
fi
sleep 2
done
echo "Forwarding SIGTERM to runner"
kill -TERM "$RUNNER_PID" || true
wait "$RUNNER_PID" || true
}
trap drain_and_exit TERM INT
wait "$RUNNER_PID"
- In Azure Container Apps:
- Set a termination grace period that’s long enough for your worst‑case drain time.
- Prefer scale rules that don’t pick instances with active work during scale‑in. If you can’t express “in‑flight runs” as a metric, use conservative scale‑down stabilization windows so instances don’t get terminated immediately after picking up work.
- If you can influence scale-in selection, try to bias toward idle instances (e.g., via custom metrics). The runner’s run_count endpoint can serve as a signal if you can surface it to the scaler.
Notes and knobs you can use
- Enable the runner webserver:
- Env vars: PREFECT_RUNNER_SERVER_ENABLE=true, PREFECT_RUNNER_SERVER_HOST, PREFECT_RUNNER_SERVER_PORT
- Programmatic: Runner(webserver=True)
- Limit concurrency to reduce “drain time”: limit in flow.serve(...) or Runner(limit=...), or PREFECT_RUNNER_PROCESS_LIMIT.
- prefect flow serve --help is available if you’re invoking via CLI to confirm flags.
A couple of clarifying questions to tailor this:Marvin
12/19/2025, 10:01 PMprefect flow serve ... in the container entrypoint, or a custom Python script using Runner() / flow.serve()?
- Is it acceptable if in‑flight runs are cancelled during scale‑in, or do they need to finish?
- What scale rules are you using in Azure Container Apps (CPU/memory, queue length, custom)? If you can consume a custom/HTTP metric, we can surface the runner’s active run count to the scaler.Jordan Jones
12/19/2025, 10:21 PMprefect worker start to start the worker.Marvin
12/19/2025, 10:21 PMMarvin
12/19/2025, 10:26 PMprefect worker start (Process worker), you’re on Prefect 2.x. In 2.x, workers handle shutdown gracefully:
- When the container gets SIGTERM, the worker stops polling for new flow runs and waits for in‑flight runs to finish.
- For Process work pools, each running flow is a subprocess; the worker waits for those subprocesses to exit cleanly before the worker process exits.
What you should do for autoscaling in Azure Container Apps (ACA):
1) Give enough termination time
- Ensure the container has a generous termination grace period so in‑flight runs can finish. Pick something ≥ your worst‑case flow duration.
- If ACA scaling config has a cooldown or scale‑down stabilization window, increase it so instances don’t get terminated immediately after they pick up work.
2) Cap concurrency to bound drain time
- Use a limit so you know the maximum amount of work that may be draining on a single instance.
prefect worker start --pool <your-pool> --limit <max-concurrent-flows>
3) Bias scale‑in away from busy instances
- If you scale on a queue/trigger (e.g., KEDA), tune rules so scale‑in only happens when there’s been sustained low demand.
- If possible, prefer selecting idle instances for termination. If that’s not available, rely on the termination grace period + concurrency limit above.
4) Plan for the “not enough time” case
- If a container is still terminated before runs finish, those flow runs will be interrupted. Use flow‑level retries where appropriate so they’re rescheduled.
If you later move to Prefect 3.x (runners), behavior changes: runners cancel in‑flight runs on shutdown by default, so you’d need a custom drain wrapper. But on Prefect 2.x workers, you already get the “finish current runs, then exit” behavior — just make sure ACA gives the container enough time and you cap concurrency.
Quick questions to tailor this:
- About how long do your longest flows run?
- What ACA scale trigger are you using (CPU, queue length, custom) and what are your current cooldown/stabilization settings?Jordan Jones
12/19/2025, 10:54 PMMarvin
12/19/2025, 10:54 PMMarvin
12/19/2025, 10:57 PMprefect worker start command is from Prefect 2.x. If you’re truly on 3.x, you’ll want to run a Runner via prefect flow serve ... (or programmatically with flow.serve() / Runner(...)). Could you share the output of prefect version? That will confirm which behavior you get on shutdown.
Assuming Prefect 3.x and Azure Container Apps:
- Default behavior: when the container receives SIGTERM, the Runner stops accepting new work and cancels all active flow runs. It gives each child process a short grace before forcing termination. There is no built‑in “drain until current runs finish” mode.
- To ensure “done before scale-in” you have two practical patterns:
Pattern 1: Only terminate idle runners (recommended)
- Enable the runner’s tiny webserver to expose /run_count (active flow runs).
- Configure your scaler/scale-in logic to only pick instances with /run_count == 0.
- Set a termination grace period long enough for clean shutdown.
How to enable the webserver and serve your deployment:
# In your container env:
PREFECT_RUNNER_SERVER_ENABLE=true
PREFECT_RUNNER_SERVER_HOST=0.0.0.0
PREFECT_RUNNER_SERVER_PORT=8080
# Start the runner serving your flow
prefect flow serve ./path/to/file.py:my_flow -n my-deployment
- Your scaler (or a pre-termination step) can poll:
- GET http//<container ip>8080/run_count → only scale in when it returns 0.
Pattern 2: Add a drain wrapper (stop intake, let current runs finish, then exit)
- Trap SIGTERM yourself, stop intake, wait for active runs to finish, then exit. Here’s a simple entrypoint script that waits for /run_count to reach 0 before forwarding SIGTERM to the runner:
#!/usr/bin/env bash
set -euo pipefail
export PREFECT_RUNNER_SERVER_ENABLE=true
export PREFECT_RUNNER_SERVER_HOST=0.0.0.0
export PREFECT_RUNNER_SERVER_PORT=8080
prefect flow serve ./path/to/file.py:my_flow -n my-deployment &
RUNNER_PID=$!
drain_and_exit() {
echo "Scale-in signal received: draining..."
# Wait up to 15 minutes for active runs to complete; adjust for your flows
end=$((SECONDS+900))
while [ $SECONDS -lt $end ]; do
rc=$(curl -fsS <http://127.0.0.1:8080/run_count> || echo "0")
if [ "$rc" = "0" ]; then
break
fi
sleep 2
done
echo "Forwarding SIGTERM to runner"
kill -TERM "$RUNNER_PID" || true
wait "$RUNNER_PID" || true
}
trap drain_and_exit TERM INT
wait "$RUNNER_PID"
Additional recommendations for ACA
- Termination grace time: give the container enough time to drain (e.g., worst‑case flow duration or your chosen drain timeout).
- Concurrency: cap per-runner concurrency so your drain time is bounded (use limit in flow.serve(...) or the corresponding environment if you set it programmatically).
- Retries: if a run does get cancelled due to scale-in, configure retries on the flow/run steps so it will be rescheduled.
Open questions so I can tailor this precisely:
- Can you share prefect version and how you’re starting the process today (CLI prefect flow serve vs a Python script)?
- Do you need strict draining (finish in‑flight runs) or is cancel-and-retry acceptable?
- What ACA scale-in trigger are you using, and can it check an HTTP endpoint on the instance (so we can use /run_count)?Jordan Jones
12/22/2025, 5:55 PMMarvin
12/22/2025, 5:55 PMMarvin
12/22/2025, 5:59 PMprefect flow serve ... in the background
- Enables the runner webserver so we can read /run_count
- On SIGTERM, polls /run_count until it reaches 0 (or timeout), then forwards SIGTERM to the runner
- Exits after the runner shuts down
Drop-in entrypoint for ACA
#!/usr/bin/env bash
set -euo pipefail
# Configure the runner webserver so we can observe active runs
: "${PREFECT_RUNNER_SERVER_ENABLE:=true}"
: "${PREFECT_RUNNER_SERVER_HOST:=0.0.0.0}"
: "${PREFECT_RUNNER_SERVER_PORT:=8080}"
# Optional: bound concurrency so drain time is predictable
# export PREFECT_RUNNER_PROCESS_LIMIT=4
# REQUIRED: update your flow entrypoint and deployment name
ENTRYPOINT="${ENTRYPOINT:-./path/to/file.py:my_flow}"
DEPLOYMENT_NAME="${DEPLOYMENT_NAME:-my-deployment}"
# Start the runner
prefect flow serve "$ENTRYPOINT" -n "$DEPLOYMENT_NAME" &
RUNNER_PID=$!
# Function to read active run count
get_run_count() {
curl -fsS "<http://127.0.0.1:${PREFECT_RUNNER_SERVER_PORT}/run_count>" || echo "0"
}
drain_and_exit() {
echo "[drain] Termination signal received: beginning drain"
# Wait up to DRAIN_TIMEOUT seconds for active runs to finish
DRAIN_TIMEOUT="${DRAIN_TIMEOUT:-900}" # default 15 minutes
end=$((SECONDS + DRAIN_TIMEOUT))
# Poll until run_count == 0 or timeout
while [ $SECONDS -lt $end ]; do
rc="$(get_run_count)"
# rc should be an integer; if curl failed, we default to "0" above
if [ "$rc" = "0" ]; then
echo "[drain] No active runs; safe to stop"
break
fi
echo "[drain] Active runs: $rc; waiting..."
sleep 2
done
echo "[drain] Forwarding SIGTERM to runner (PID $RUNNER_PID)"
kill -TERM "$RUNNER_PID" || true
# Give the runner time to exit cleanly
wait "$RUNNER_PID" || true
echo "[drain] Runner exited; shutting down entrypoint"
}
trap drain_and_exit TERM INT
# Block until runner exits or a signal is trapped
wait "$RUNNER_PID"
How to use it in your container
- Set these env vars (or bake into the image):
- ENTRYPOINT: ./path/to/file.py:my_flow
- DEPLOYMENT_NAME: my-deployment
- Optional: PREFECT_RUNNER_PROCESS_LIMIT to cap concurrency
- Optional: DRAIN_TIMEOUT to cap how long you’ll wait before forcing termination
- Ensure your ACA termination grace period is greater than your DRAIN_TIMEOUT so the drain completes before the platform kills the container.
- Scale-in selection: if you can, configure ACA to avoid terminating instances that still have active runs. If ACA can’t read a per-instance metric, the drain script still protects in-flight runs; the platform just needs to allow enough time.
Notes
- This pattern stops intake implicitly by not killing the runner immediately; while draining, the runner may still pick up new work unless you prevent it. To prevent new intake:
- Run with fewer or zero eligible schedules during drain windows, or
- Use tags/filters to isolate which runner instances pick up which deployments, or
- If you control the scale-in signal timing, briefly disable schedules (CLI/API) before signaling with SIGTERM.
- If you start multiple deployments in the same runner, /run_count reflects all active runs across that runner.
If you’d prefer a Python-based wrapper that flips the runner into a true “no new work” state during drain, I can share a small script that sets runner.stopping = True and waits on runner._flow_run_process_map before shutting down.Jordan Jones
12/23/2025, 11:36 PMMarvin
12/23/2025, 11:36 PMMarvin
12/23/2025, 11:40 PMMarvin
12/23/2025, 11:40 PMJordan Jones
12/23/2025, 11:41 PMprefect worker start with prefect v3 so I can run any flow/deployment on a single worker. Is that the wrong thing to do? I think I will just stick to a shell script wrapper.Marvin
12/23/2025, 11:41 PMMarvin
12/23/2025, 11:43 PMprefect worker start that polls a work pool (and optionally multiple queues) is a common, supported pattern. It can execute any deployment that targets the same work pool/queues. If you prefer a shell script wrapper around the CLI, that’s also a great, simple approach.
A few tips and examples:
- Start one worker that can handle “any” deployment in a pool (optionally multiple queues)
prefect worker start -p my-pool -q default -q high-priority -n single-worker
Notes:
- Point your deployments at the same work pool (and queue(s)) so this worker will pick them up.
- A single worker can run multiple flow runs depending on your infra type and concurrency settings; scale by running more workers if needed.
- For process workers, you usually run one per machine. For Docker/Kubernetes pools, a single worker can orchestrate many ephemeral jobs.
- Trigger a deployment and watch it (nice for a shell wrapper)
prefect deployment run "my-flow/production" --param key=value --watch
You can also trigger by id with --id <deployment-id>, pass multiple --param flags, or add --job-variable flags to set job variables.
- Minimal shell script pattern
#!/usr/bin/env bash
set -euo pipefail
POOL="my-pool"
QUEUE="default"
DEPLOYMENT="my-flow/production"
# Start the worker under a supervisor in practice (systemd, tmux, Docker, K8s, etc.)
prefect worker start -p "$POOL" -q "$QUEUE" -n "single-worker" &
# Trigger a run and watch logs/state until terminal
prefect deployment run "$DEPLOYMENT" --param run_date="$(date +%F)" --watch
When a single worker is not ideal:
- You want strict workload isolation by team or environment (use separate pools/workers).
- You need differing infrastructure (e.g., some jobs on Docker, others on K8s).
- You need higher parallelism than one worker can comfortably handle.
If you want, I can help you tailor a tiny shell wrapper for your pool/queues and common parameters. For background reading, see work pools and workers: Work pools and workers.