Carlos Alberto da Costa Filho
04/01/2026, 12:19 AMTraceback (most recent call last):
File "/home/ray/anaconda3/lib/python3.11/site-packages/prefect/flow_engine.py", line 1595, in run_context
yield self
File "/home/ray/anaconda3/lib/python3.11/site-packages/prefect/flow_engine.py", line 1657, in run_flow_async
await engine.call_flow_fn()
File "/home/ray/anaconda3/lib/python3.11/site-packages/prefect/flow_engine.py", line 1609, in call_flow_fn
result = await call_with_parameters(self.flow.fn, self.parameters)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/workflows/corrosion_detection/inspection_flows.py", line 104, in inspection_flow
shout_task.map(range(10)).wait()
^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/ray/anaconda3/lib/python3.11/site-packages/prefect/tasks.py", line 1620, in map
futures = task_runner.map(self, parameters, wait_for)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/ray/anaconda3/lib/python3.11/site-packages/prefect_ray/task_runners.py", line 314, in map
return super().map(task, parameters, wait_for)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/ray/anaconda3/lib/python3.11/site-packages/prefect/task_runners.py", line 216, in map
self.submit(
File "/home/ray/anaconda3/lib/python3.11/site-packages/prefect_ray/task_runners.py", line 278, in submit
ray_decorator(self._run_prefect_task)
File "/home/ray/anaconda3/lib/python3.11/site-packages/ray/remote_function.py", line 293, in remote
return func_cls._remote(
^^^^^^^^^^^^^^^^^
File "/home/ray/anaconda3/lib/python3.11/site-packages/ray/_private/auto_init_hook.py", line 22, in auto_init_wrapper
return fn(*args, **kwargs)
^^^^^^^^^^^^^^^^^^^
File "/home/ray/anaconda3/lib/python3.11/site-packages/ray/util/tracing/tracing_helper.py", line 310, in _invocation_remote_span
return method(self, args, kwargs, *_args, **_kwargs)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/ray/anaconda3/lib/python3.11/site-packages/ray/remote_function.py", line 326, in _remote
return client_mode_convert_function(self, args, kwargs, **task_options)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/ray/anaconda3/lib/python3.11/site-packages/ray/_private/client_mode_hook.py", line 165, in client_mode_convert_function
return client_func._remote(in_args, in_kwargs, **kwargs)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/ray/anaconda3/lib/python3.11/site-packages/ray/util/client/common.py", line 305, in _remote
return self.options(**option_args).remote(*args, **kwargs)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/ray/anaconda3/lib/python3.11/site-packages/ray/util/client/common.py", line 596, in remote
return return_refs(ray.call_remote(self, *args, **kwargs))
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/ray/anaconda3/lib/python3.11/site-packages/ray/util/client/api.py", line 100, in call_remote
return self.worker.call_remote(instance, *args, **kwargs)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/ray/anaconda3/lib/python3.11/site-packages/ray/util/client/worker.py", line 564, in call_remote
task = instance._prepare_client_task()
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/ray/anaconda3/lib/python3.11/site-packages/ray/util/client/common.py", line 602, in _prepare_client_task
task = self._remote_stub._prepare_client_task()
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/ray/anaconda3/lib/python3.11/site-packages/ray/util/client/common.py", line 331, in _prepare_client_task
self._ensure_ref()
File "/home/ray/anaconda3/lib/python3.11/site-packages/ray/util/client/common.py", line 326, in _ensure_ref
self._ref = ray.worker._put_pickled(
^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/ray/anaconda3/lib/python3.11/site-packages/ray/util/client/worker.py", line 515, in _put_pickled
resp = self.data_client.PutObject(req)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/ray/anaconda3/lib/python3.11/site-packages/ray/util/client/dataclient.py", line 568, in PutObject
resp = self._blocking_send(datareq)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/ray/anaconda3/lib/python3.11/site-packages/ray/util/client/dataclient.py", line 458, in _blocking_send
self._check_shutdown()
File "/home/ray/anaconda3/lib/python3.11/site-packages/ray/util/client/dataclient.py", line 511, in _check_shutdown
raise ConnectionError(msg)
ConnectionError: Request can't be sent because the Ray client has already been disconnected due to an error. Last exception: <_MultiThreadedRendezvous of RPC that terminated with:
status = StatusCode.FAILED_PRECONDITION
details = "No module named 'torch'"
debug_error_string = "UNKNOWN:Error received from peer ipv4:172.20.220.137:10001 {grpc_status:9, grpc_message:"No module named \'torch\'"}"
>Marvin
04/01/2026, 12:19 AMMarvin
04/01/2026, 12:21 AMruntime_env={"pip": [...]} will install packages for the worker processes that run your tasks/actors, but it will not fix imports needed by the driver or the Ray Client server on the head node.
What’s happening here
- Your stack trace shows Ray Client (ray.util.client.*). In client mode, the Ray Client server runs on the head node and must be able to deserialize your task function and its globals. If your task module imports torch at module scope (or uses torch in default args/annotations), the head node needs torch to unpickle/deserialize that function before it can schedule it. Since the head image doesn’t have torch, you get “No module named 'torch'”.
How to fix it
1) Easiest: install torch on the head node image as well (so the Ray Client server can import it).
2) Or: avoid importing torch on the driver/head by deferring imports to inside the task body and removing any torch usage in module-level code, default args, or type annotations.
- Example:
from prefect import task, flow
from prefect_ray.task_runners import RayTaskRunner
@task
def gpu_task(x):
import torch # defer import to worker
return int(torch.tensor([x]).sum().item())
@flow(task_runner=RayTaskRunner(address="auto", init_kwargs={
"runtime_env": {
# This ensures workers have torch; it won't fix driver/head imports.
"pip": [
"torch==2.2.1" # add extra index-url if you need CUDA wheels
]
}
}))
def my_flow():
futures = [gpu_task.submit(i) for i in range(5)]
return [f.result() for f in futures]
if __name__ == "__main__":
my_flow()
- Make sure there’s no import torch at the top of the module and no torch.* types in annotations like def gpu_task(x: torch.Tensor).
3) Alternative: run the driver inside a Ray Job with a runtime_env that includes torch. In that mode, the driver process itself is launched by Ray and can inherit the runtime_env. If you’re running the driver as a long-lived Prefect worker/flow process on the head node, this usually doesn’t apply.
So, to your question: will passing runtime_env with pip to RayTaskRunner fix this? It will ensure workers have torch, but it will not fix missing torch on the head/driver that’s needed during serialization/deserialization. You’ll still need to either:
- install torch on the head image, or
- avoid any top-level/module-scope references to torch so the head never needs to import it.
References
- Ray runtime environments: Runtime environments and dependencies
- Ray Client overview: Ray Client guide
- Prefect Ray integration: prefect-ray docs
If you can share a minimal snippet of your task/flow showing where torch is imported or used (especially at module scope), I can point out exactly what needs to move.Carlos Alberto da Costa Filho
04/01/2026, 12:22 AMCarlos Alberto da Costa Filho
04/01/2026, 12:26 AMprint(
f"using prefect-ray tasks: Shouting from {socket.gethostname()}: {os.getpid()}"
)
shout_task.map(range(10)).wait()
print("Shouting done")
this is the full file implementing the task - it doesnt need torch. torch is needed elsewhere in the flow code:
from __future__ import annotations
import os
import socket
import time
import ray
from prefect import task
@task
def shout_task(number: int) -> str:
time.sleep(1)
print(f"#{number} on {socket.gethostname()}: {os.getpid()}")
return f"#{number} on {socket.gethostname()}: {os.getpid()}"Marvin
04/01/2026, 12:26 AMMarvin
04/01/2026, 12:27 AMruntime_env={"pip": [...]} to RayTaskRunner only ensures workers have torch; it won’t fix imports needed by the driver or the Ray Client server on the head.
What will work
- Option A (simplest): install torch on the Ray head image too.
- Option B (avoid importing torch on the head):
- Ensure there are no top-level/module-scope imports of torch in any module that the driver/head imports to define the flow or tasks.
- Defer the import to inside the task(s) that actually run remotely:
from prefect import task
@task
def gpu_task(x: int):
import torch # defer import so only the Ray worker needs it
t = torch.tensor([x])
return int(t.sum().item())
- Also avoid torch in default argument values or type annotations that are evaluated at import time. Using from __future__ import annotations (as you did) helps; for static typing you can do:
from typing import TYPE_CHECKING
if TYPE_CHECKING:
import torch
- Option C: run the driver inside a Ray Job (not Ray Client) with a runtime_env that includes torch so the driver inherits it. Or start your Ray head with an image that includes torch.
Concrete pattern to keep the head torch-free
- Put all torch usage inside task bodies (no module-level torch imports).
- Import the module defining those tasks from your flow only if that module itself does not import torch at top level. Example:
tasks_torch.py
from prefect import task
@task
def gpu_infer(x: int):
import torch
return int(torch.tensor([x]).sum().item())
flow.py
from prefect import flow
from prefect_ray.task_runners import RayTaskRunner
@flow(task_runner=RayTaskRunner(address="ray://<head-ip>:10001", init_kwargs={
"runtime_env": {"pip": ["torch==2.2.1"]} # ensures workers have torch
}))
def my_flow():
# Safe: importing this module does not import torch at top-level
from .tasks_torch import gpu_infer
futures = [gpu_infer.submit(i) for i in range(10)]
return [f.result() for f in futures]
Key takeaway
- runtime_env={"pip": [...]} on RayTaskRunner is for the Ray workers. It won’t resolve imports that happen on the driver or Ray Client server. If any part of the flow definition/import graph touches torch at import time, you must either install torch on the head or refactor so torch is only imported inside tasks that execute on workers.
Refs
- Ray runtime environments: Runtime environments and dependencies
- Ray Client: Ray Client guide
- Prefect-Ray docs: prefect-ray
If you can point to where torch is imported in your flow module, I can suggest the minimal refactor (e.g., exactly which imports to move into task bodies).Mitch
04/01/2026, 12:27 AMCarlos Alberto da Costa Filho
04/01/2026, 12:31 AMMarvin
04/01/2026, 12:31 AMMarvin
04/01/2026, 12:33 AMruntime_env={"pip": [...]} on RayTaskRunner will not solve the error when you’re using Ray Client: the Ray Client server on the head must import/deserialize the flow/task definitions, so anything imported at import time (torch, your local libs, decorator code) must exist on the head too. Runtime env only affects task/actor workers (and Ray Jobs), not the Ray Client server.
Viable patterns for version isolation without installing everything on the head
- Run without Ray Client (preferred)
- Run the Prefect flow-run process (the “driver”) on a Ray node so it connects in native mode (not client), e.g. RayTaskRunner(address="auto").
- In native mode, the head does not need to import your flow module; the driver’s own environment handles your decorators/imports.
- Use Prefect deployments with per-deployment images to control versions for each user/flow. Your Prefect worker executes the flow in that image on a Ray node, giving you isolation.
- This is the simplest way to support multiple versions and avoid installing libraries on the head.
- Use Ray Jobs for the driver
- Submit the flow run as a Ray Job with a runtime_env that includes your exact dependencies (torch + your local wheels/py_modules). Ray Jobs apply the env to the driver process too.
- From Prefect, you can have a lightweight launcher that submits the job to Ray; the flow then runs inside that Ray Job environment and can still use RayTaskRunner.
- Docs: Ray Jobs, Runtime environments
- If you must stay on Ray Client
- Anything imported at module scope (decorators, torch, local libs) must be present on the head. To support multiple versions you’d need one of:
- Separate Ray head instances per dependency set (isolation by cluster)
- Multiple environments on the head with separate Ray Client servers per env (complex operationally)
- runtime_env will still not fix the server-side import.
Concrete next steps (pick one)
- Best fit for “many versions, fast-moving local libs”:
1) Ensure the Prefect driver runs on a Ray node (VM/pod). For example, place your Prefect worker on a Ray node.
2) In your flow, use RayTaskRunner(address="auto") (no ray://). This uses native Ray, avoiding the client-side deserialization on the head.
3) Package user-specific deps per deployment via a Docker/K8s work pool image, or install them at container start.
- Or submit the flow as a Ray Job with:
- runtime_env={"pip": ["torch==...", "yourlib @ https://.../yourlib.whl"], "py_modules": ["./your_local_lib_dir"]}
- This applies to the driver and workers, keeping the head clean.
If you can share:
- How you’re connecting to Ray now (is the address ray://...?),
- Where your Prefect worker/flow run process executes (on a Ray node or outside the cluster),
- Whether you can run the worker on a Ray node or use Ray Jobs,
…I can give you an exact wiring plan and example config (Prefect deployment + RayTaskRunner settings) for your setup.
Refs
- Ray runtime envs: Runtime environments and dependencies
- Ray Client: Ray Client guide
- Ray Jobs: Ray Job submission
- Prefect + Ray: prefect-ray docsCarlos Alberto da Costa Filho
04/01/2026, 12:35 AMMarvin
04/01/2026, 12:35 AMMarvin
04/01/2026, 12:38 AMRayTaskRunner(address="auto") from a process running on a Ray node) does not use the Ray Client server for deserialization. The driver’s own environment is used to import your flow module and decorators. Your per-run/per-user deps can live entirely with the driver and workers.
What “driver runs on a Ray node” looks like in practice
- The flow run process is started on a machine/pod that is already part of the Ray cluster (it has a local raylet).
- From that process, RayTaskRunner(address="auto") connects to the local Ray runtime in native mode.
- Your flow module (and any decorators/libraries at import time) are imported in the driver’s environment, not on the head.
Common ways to achieve this
- VMs/bare metal (fastest path):
- Run a Prefect worker on a VM that is also a Ray node (head or worker).
- Use a Process work pool so each flow run is a subprocess on that same VM.
- Build per-deployment Docker images (or manage per-run envs) with the exact libs you need. The driver process then has those libs, and RayTaskRunner(address="auto") works without Ray Client.
- Kubernetes:
- If you’re using KubeRay, run your Prefect worker inside a Ray pod (e.g., sidecar on a Ray worker pod) and use a Process work pool. Then the flow run process (driver) starts in the same pod (a Ray node), and can use address="auto".
- Alternatively, submit the flow as a Ray Job so the driver pod is created by Ray and inherits a runtime_env (true per-run isolation). Your Prefect trigger/launcher can submit the Ray Job and the flow still uses RayTaskRunner inside that job.
- Single host with containers:
- If you need per-run images, place the Prefect worker on a Ray node and use a Docker work pool only if those Docker containers are themselves part of the Ray cluster (i.e., they run with a local raylet). If they’re just “random” containers on the host, they’re not Ray nodes; you’d fall back to Ray Client if you connect to ray://, which reintroduces the head import issue.
Minimal example (driver on a Ray node)
- Run Prefect worker on a Ray node (head or worker) and point your deployment at it. In your flow:
from prefect import flow
from prefect_ray.task_runners import RayTaskRunner
@flow(task_runner=RayTaskRunner(address="auto", init_kwargs={
"runtime_env": {
"pip": [
"torch==2.2.1",
"yourlib @ https://.../yourlib.whl"
],
# optional: "py_modules": ["./your_local_lib_dir"]
}
}))
def my_flow():
# Your decorators and imports at module scope are fine;
# they are resolved in the driver image/env, not by the head.
...
- Because the driver runs on a Ray node, this avoids the Ray Client server and the head does not need torch or your local libs.
If you share a bit more about your setup:
- Are you on VMs or Kubernetes?
- Which Prefect work pool type are you using (Process, Docker, Kubernetes)?
- Where is your Prefect worker running today (on a Ray node or outside the cluster)?
I can give you an exact wiring plan for that topology (including example deployment config and any nodeSelector/affinity notes if you’re on K8s).
Refs
- Ray Client vs native: Ray Client guideMarvin
04/01/2026, 12:38 AMCarlos Alberto da Costa Filho
04/01/2026, 12:45 AMMarvin
04/01/2026, 12:45 AMMarvin
04/01/2026, 12:46 AMfrom ray.job_submission import JobSubmissionClient
client = JobSubmissionClient("http://<kuberay-head-svc>:8265")
client.submit_job(
# This script/module runs your Prefect flow; inside it, use RayTaskRunner(address="auto")
entrypoint="python -m mypkg.run_prefect_flow",
runtime_env={
"pip": [
"prefect==3.*",
"prefect-ray==<compatible>",
"torch==2.2.1",
"yourlib @ https://.../yourlib.whl",
],
# Optional: ship your code
# "working_dir": "git+<https://github.com/org/repo@ref#subdirectory=flows>",
# "py_modules": ["./local_pkg_dir"],
"env_vars": {
"PREFECT_API_URL": "...",
"PREFECT_API_KEY": "...",
# any other auth/config needed by your flow
},
},
)
- Inside mypkg.run_prefect_flow run your flow normally with RayTaskRunner(address="auto"). The job driver’s env will already have your deps. Each Ray task/actor still talks to the head for scheduling, but the head does not import your libs.
- Docs: Ray Job submission, Runtime environments
2) Run the Prefect worker/driver inside a Ray pod (native mode)
- Put your Prefect worker in a Ray pod (e.g., via a sidecar in the RayCluster workerGroupSpec). Your flow runs as a subprocess in that pod and can use RayTaskRunner(address="auto").
- This avoids Ray Client, but per-run dependency isolation becomes your responsibility (different Prefect deployment images or on-start installs). If you truly need many per-user versions, Ray Jobs are usually a better fit.
Key takeaways
- Yes, your 10,000 Ray tasks will still communicate with the head (they always do).
- The fix for “head needs torch” is not to remove head communication, but to stop using Ray Client so the head does not have to import your code. Use a native driver (best via Ray Jobs) so per-run deps live with the driver and workers via runtime_env.
If you can share whether you prefer Ray Jobs or embedding the Prefect worker in a Ray pod, I can sketch the exact KubeRay/RayCluster manifest changes and a minimal Prefect deployment layout for either path.Carlos Alberto da Costa Filho
04/01/2026, 12:50 AMMarvin
04/01/2026, 12:50 AMMarvin
04/01/2026, 12:51 AMruntime_env).
- Result: the head never needs your deps.
- Ray Client (ray://)
- Your local process is not the “driver” to the cluster; the Ray Client server on the head acts as the driver.
- That server must reconstruct/deserialze functions/closures, which triggers imports of your decorators and any module-scope libs.
- Result: the head must have your deps, hence “No module named 'torch'”.
So, with native Ray you still pickle, but the unpickling that requires torch (or your local libs) happens on the workers — not on the head. The head continues to orchestrate everything (heartbeats, scheduling, autoscaling), but it doesn’t import your code.
Good references
- Ray Client vs native: Ray Client guide
- Runtime envs (affect workers and Ray Jobs; not the Ray Client server): Runtime environments and dependencies
- Prefect + Ray: prefect-ray docs
If you want, I can sketch the exact KubeRay wiring for either:
- running the Prefect worker in a Ray pod and using RayTaskRunner(address="auto"), or
- submitting the flow as a Ray Job so the driver also inherits runtime_env (best for per-run version isolation).Carlos Alberto da Costa Filho
04/01/2026, 12:54 AMprint(f"Inspection flow started (suffix: {SUFFIX}, ray address: {RAY_ADDRESS})")
runtime_env = _ray_runtime_env()
print(f"Ray worker runtime_env (flow run only): {runtime_env}")
if not ray.is_initialized():
ray_init_kwargs: dict[str, object] = {
"log_to_driver": True,
"runtime_env": runtime_env,
}
if RAY_ADDRESS:
ray_init_kwargs["address"] = RAY_ADDRESS
ray.init(**ray_init_kwargs)
print(f"Using ray remote: Shouting from {socket.gethostname()}: {os.getpid()}")
remotes = [shout.remote(i) for i in range(10)]
for ref in ray.util.as_completed(remotes, yield_obj_refs=True):
print(ray.get(ref))
print("Shouting done")
Shouting done
04:39:14 PM
prefect.flow_runs
#9 on raycluster-worker-default-worker-76j7f: 7939
04:39:14 PM
prefect.flow_runs
#8 on raycluster-worker-default-worker-76j7f: 7943
04:39:14 PM
prefect.flow_runs
#7 on raycluster-worker-default-worker-76j7f: 7940
04:39:14 PM
prefect.flow_runs
#6 on raycluster-worker-default-worker-76j7f: 7938
04:39:14 PM
prefect.flow_runs
#5 on raycluster-worker-default-worker-76j7f: 7942
04:39:14 PM
prefect.flow_runs
#4 on raycluster-worker-default-worker-76j7f: 7939
04:39:14 PM
prefect.flow_runs
#3 on raycluster-worker-default-worker-76j7f: 7943
04:39:14 PM
prefect.flow_runs
#2 on raycluster-worker-default-worker-76j7f: 7941
04:39:14 PM
prefect.flow_runs
#1 on raycluster-worker-default-worker-76j7f: 7937
04:39:14 PM
prefect.flow_runs
#0 on raycluster-worker-default-worker-76j7f: 7940
04:39:14 PM
prefect.flow_runs
Using ray remote: Shouting from rho573-chamaeleontis-r-qn2vj-kztfx: 75
04:39:10 PM
prefect.flow_runs
Ray worker runtime_env (flow run only): {'working_dir': '/workflows/corrosion_detection'}
04:39:10 PM
prefect.flow_runs
Inspection flow started (suffix: dev-prefect3, ray address: ray://....local:10001)Marvin
04/01/2026, 12:54 AMMarvin
04/01/2026, 12:55 AMray.init(address=ray://..., runtime_env=...) from the flow and submit a simple @ray.remote func shout that only uses stdlib.
- In Ray Client mode, the client pickles your function and sends it to the head’s Ray Client server. The server does minimal work with it and stores the bytes; the actual unpickling/import of dependencies happens on the workers when they execute the task.
- Since shout doesn’t touch torch or decorators that import torch, the head never needs torch and everything works.
- Prefect with RayTaskRunner:
- RayTaskRunner wraps tasks in an internal callable and submits via Ray Client. During this path, Ray’s client/server may unpickle parts of the task wrapper/closure on the server to build metadata/descriptors.
- If your task/flow module has module-scope imports (or decorators) that bring in torch (or your local libs), the server-side unpickle triggers those imports on the head — and torch isn’t there — so you get “No module named 'torch'”.
Said another way: both paths pickle. The key is what the server has to import while handling your submission. Your simple @ray.remote function requires no non-stdlib imports on the server, so it sails through; the Prefect task submission path pulls in your flow/task module where torch is imported at module scope, so the server tries to import torch and fails.
This is why the “native driver” fix works
- If the Prefect driver runs inside the Ray cluster (native mode, e.g., address="auto" from a Ray pod), your driver process imports your modules/decorators locally (in your image/env). The head still coordinates scheduling, but it does not import your code; workers unpickle and import your libs at execution time.
Given your need for per-run/per-user dependency isolation (including decorators at import time), the two robust options on KubeRay are:
- Use Ray Jobs so the driver gets a runtime_env too (best isolation): Ray Job submission
- Or run the Prefect worker inside a Ray pod and avoid Ray Client (RayTaskRunner(address="auto")), managing per-run deps via per-deployment images.
Prefect + Ray docs for reference: prefect-ray, Ray Client vs native: Ray Client guide, runtime envs: Runtime environmentsCarlos Alberto da Costa Filho
04/01/2026, 12:59 AMMarvin
04/01/2026, 1:00 AMMarvin
04/01/2026, 1:02 AMRayTaskRunner(address="auto") works without Ray Client and the head does not need your flow’s dependencies.
Key points
- We add a dedicated worker group with replicas: 1 and a Prefect sidecar. Only that pod runs the Prefect worker; your other worker pods are just compute.
- The Prefect sidecar shares /tmp/ray with the Ray container via an emptyDir so address="auto" can discover the local Ray session.
- Use a Process work pool; each flow run is a subprocess inside the Prefect sidecar container and imports your decorators/libs locally.
1) Prefect sidecar image (example)
Make sure this image contains prefect, prefect-ray, and ray plus any base libs you always need. Per-run/per-user libs can still be provided via Ray runtime_env.
# your-registry/prefect-driver:latest
FROM python:3.11-slim
# System deps as needed
RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/*
# Base Python deps
RUN pip install --no-cache-dir "prefect==3.*" "prefect-ray>=0.4.0" "ray>=2.9"
# Optional: add your org-wide packages or CA certs, etc.
# COPY . /app # if you ship your code in the image
WORKDIR /app
2) RayCluster with a Prefect sidecar in a single dedicated worker pod
- One workerGroupSpec (“prefect-driver”) runs the Prefect worker sidecar (replicas: 1)
- Another group (“compute”) scales your Ray workers
- We mount the same emptyDir volume at /tmp/ray in both containers in the prefect-driver pod
```
apiVersion: ray.io/v1
kind: RayCluster
metadata:
name: raycluster
spec:
rayVersion: "2.9.3"
headGroupSpec:
serviceType: ClusterIP
rayStartParams:
dashboard-host: "0.0.0.0"
template:
spec:
containers:
- name: ray-head
image: rayproject/ray:2.9.3-py311
ports:
- containerPort: 8265 # dashboard
- containerPort: 10001 # ray client (not used in native)
# Optional: mount /tmp/ray too (not required for sidecar on head)
volumeMounts:
- name: ray-session
mountPath: /tmp/ray
volumes:
- name: ray-session
emptyDir: {}
workerGroupSpecs:
# 1) Dedicated worker pod that also runs the Prefect worker sidecar
- groupName: prefect-driver
replicas: 1
minReplicas: 1
maxReplicas: 1
rayStartParams: {}
template:
spec:
containers:
- name: ray-worker
image: rayproject/ray:2.9.3-py311
# Your Ray resources here
resources:
limits:
cpu: "2"
memory: "4Gi"
requests:
cpu: "1"
memory: "2Gi"
volumeMounts:
- name: ray-session
mountPath: /tmp/ray
- name: prefect-sidecar
image: your-registry/prefect-driver:latest
env:
# Point Prefect at your API (Cloud or Server)
- name: PREFECT_API_URL
valueFrom:
secretKeyRef:
name: prefect-creds
key: PREFECT_API_URL
- name: PREFECT_API_KEY
valueFrom:
secretKeyRef:
name: prefect-creds
key: PREFECT_API_KEY
# Name of your Process work pool
- name: PREFECT_WORK_POOL
value: "ray-process-pool"
# Optional: tweak logging; add other env vars as needed
command: ["/bin/sh", "-c"]
args:
- |
echo "Waiting for local Ray to be ready..."Marvin
04/01/2026, 1:02 AM3) Prefect flow: use native Ray with address="auto"
- The driver (your flow run subprocess) runs inside the `prefect-sidecar` container, which is in the same pod as a Ray worker. Because we share `/tmp/ray`, `address="auto"` finds the local Ray session. The head still orchestrates but does not import your code.
from prefect import flow
from prefect_ray.task_runners import RayTaskRunner
def _ray_runtime_env():
return {
# Per-run deps for workers (and can include your own artifacts)
"pip": [
"torch==2.2.1",
"yourlib @ https://.../yourlib.whl"
],
# optionally:
# "py_modules": ["./local_pkg_dir"],
# "working_dir": "git+https://github.com/org/repo@ref#subdirectory=flows",
# "env_vars": {"EXTRA_FLAG": "1"},
}
@flow(task_runner=RayTaskRunner(address="auto", init_kwargs={"runtime_env": _ray_runtime_env()}))
def my_flow():
# Your decorators can import torch/local libs at module scope; they live in the driver image.
...
```
4) Prefect work pool and worker
- Create a Process work pool (once), then the sidecar’s prefect worker start --pool ... will pick up runs and execute the flow in-place.
- You can have multiple deployments with different images by updating the sidecar image in the “prefect-driver” group, or (recommended) keep the sidecar image slim and push per-run deps to Ray workers via runtime_env as shown.
Notes and gotchas
- Sidecar discovery: sharing /tmp/ray is the trick that allows address="auto" from the sidecar to attach to the local raylet. The socket path may differ slightly by Ray version; the “wait loop” path above is the common one. Adjust if needed.
- Per-run dependency isolation: heavy libs that differ run-to-run should be specified in runtime_env (pip/py_modules/working_dir). These are applied to Ray workers/actors.
- Head still orchestrates: your tasks will still schedule and heartbeat through the head; the difference is the head does not need to import your code anymore.
- If you prefer the driver to inherit runtime_env as well, use Ray Jobs instead of a persistent sidecar. That gives the highest isolation but changes how you launch runs.
Docs
- KubeRay: RayCluster config and pod templates KubeRay on Kubernetes
- Ray Client vs native Ray Client guide
- Ray runtime envs Runtime environments and dependencies
- Prefect + Ray integration prefect-ray docs
If you share your current Ray version and any specific resource/affinity constraints, I can tailor the YAML (e.g., node selectors, tolerations, GPUs, CUDA base images, etc.).Carlos Alberto da Costa Filho
04/01/2026, 1:23 AMMarvin
04/01/2026, 1:23 AMMarvin
04/01/2026, 1:25 AMapiVersion: <http://keda.sh/v1alpha1|keda.sh/v1alpha1>
kind: ScaledObject
metadata:
name: prefect-worker-autoscaler
spec:
scaleTargetRef:
name: prefect-worker-deployment
minReplicaCount: 0
maxReplicaCount: 3
triggers:
- type: metrics-api
metadata:
targetValue: "1"
url: <http://prefect-queue-metrics.default.svc.cluster.local/queued_runs?pool=ray-process-pool>
Your metrics adapter just returns an integer queued run count for the specified pool.
- Use Ray Jobs (best for per-run dependency isolation)
- Keep a lightweight Prefect worker outside the Ray cluster (can be autoscaled with KEDA from 0).
- On each scheduled run, that worker submits a Ray Job to the KubeRay head using a per-run runtime_env. The Ray Job creates the driver and applies the env to driver and workers.
- No always-on sidecar needed; the driver pod is created on demand by Ray.
- Docs: Ray Job submission, runtime envs: Runtime environments
- Event-driven scale-up via Prefect Automation + webhook
- Create an Automation that triggers when a deployment is scheduled or a run enters a queued state, and call a webhook that scales your “prefect-driver” workerGroup/Deployment to 1 replica (then let KEDA/HPA scale further if needed).
- Useful if you already have a control plane that can patch your K8s Deployment/CRD on webhooks.
Trade-offs
- Sidecar pattern: native Ray, head doesn’t import your code; simplest runtime path, but you need something to be online (or auto-startable) to poll the work pool.
- Ray Jobs: removes the need for an always-on sidecar and gives you true per-run dependency isolation on the driver. Still requires a minimal Prefect worker (or service) to submit the job; you can autoscale that to 0 with KEDA.
If you tell me which direction you prefer (stay with sidecar or move to Ray Jobs), I can provide:
- A KEDA ScaledObject + minimal “queue-length” metrics adapter example for your work pool, or
- A Prefect deployment + Ray Job submission skeleton that cleanly hands off execution to Ray.
References
- KubeRay: Ray on Kubernetes
- Ray Jobs: Ray Job submission
- Prefect + Ray integration: prefect-ray docsCarlos Alberto da Costa Filho
04/01/2026, 1:31 AMMarvin
04/01/2026, 1:32 AMMarvin
04/01/2026, 1:32 AMaddress="auto". The Prefect sidecar is its own container/image, so the head does not need your flow’s libraries; the sidecar imports them locally. The head will still coordinate scheduling, but it won’t import your code.
Caveats
- You can’t scale the head to zero, so the Prefect worker sidecar will always be “on” while the cluster is up.
- Don’t run very heavy driver logic on the head; it’s a single point of failure and handles cluster control-plane. Keep driver CPU/mem small; push compute to workers.
- Share /tmp/ray between the head and the sidecar so address="auto" discovers the local ray session.
RayCluster example (head with Prefect sidecar)
apiVersion: <http://ray.io/v1|ray.io/v1>
kind: RayCluster
metadata:
name: raycluster
spec:
rayVersion: "2.9.3"
headGroupSpec:
serviceType: ClusterIP
rayStartParams:
dashboard-host: "0.0.0.0"
template:
spec:
containers:
- name: ray-head
image: rayproject/ray:2.9.3-py311
ports:
- containerPort: 8265 # dashboard
- containerPort: 10001 # ray client (unused in native mode)
volumeMounts:
- name: ray-session
mountPath: /tmp/ray
- name: prefect-sidecar
image: your-registry/prefect-driver:latest
env:
- name: PREFECT_API_URL
valueFrom:
secretKeyRef:
name: prefect-creds
key: PREFECT_API_URL
- name: PREFECT_API_KEY
valueFrom:
secretKeyRef:
name: prefect-creds
key: PREFECT_API_KEY
- name: PREFECT_WORK_POOL
value: "ray-head-pool"
resources:
requests:
cpu: "100m"
memory: "256Mi"
limits:
cpu: "500m"
memory: "512Mi"
volumeMounts:
- name: ray-session
mountPath: /tmp/ray
command: ["/bin/sh", "-c"]
args:
- |
echo "Waiting for local Ray to be ready..."
while [ ! -S /tmp/ray/session_latest/sockets/raylet ]; do sleep 1; done
echo "Starting Prefect worker for pool: ${PREFECT_WORK_POOL}"
prefect worker start --pool "${PREFECT_WORK_POOL}"
volumes:
- name: ray-session
emptyDir: {}
workerGroupSpecs:
- groupName: compute
replicas: 3
minReplicas: 0
maxReplicas: 50
rayStartParams: {}
template:
spec:
containers:
- name: ray-worker
image: rayproject/ray:2.9.3-py311
resources:
requests:
cpu: "2"
memory: "4Gi"
limits:
cpu: "4"
memory: "8Gi"
volumeMounts:
- name: ray-session
mountPath: /tmp/ray
volumes:
- name: ray-session
emptyDir: {}
Prefect flow runner
- In your flow, use:
from prefect import flow
from prefect_ray.task_runners import RayTaskRunner
@flow(task_runner=RayTaskRunner(address="auto", init_kwargs={
"runtime_env": {
"pip": ["torch==2.2.1", "yourlib @ https://.../yourlib.whl"],
# optional: "py_modules": ["./local_pkg_dir"], "working_dir": "git+https://..."
}
}))
def my_flow():
...
- Your decorators and module-scope imports live in the Prefect sidecar image, not the head image. Workers get per-run deps via runtime_env.
When to prefer this vs other patterns
- Sidecar on head: simplest wiring, always-on worker; fine if you accept a tiny always-on cost and keep drivers light.Marvin
04/01/2026, 1:32 AMMitch
04/01/2026, 1:34 AMprefect worker start --pool ... will pick up runs and execute the flow in-place.
- You can have multiple deployments with different images by updating the sidecar image in the “prefect-driver” group, or (recommended) keep the sidecar image slim and push per-run deps to Ray workers via runtime_env as shown.Carlos Alberto da Costa Filho
04/01/2026, 1:35 AMMarvin
04/01/2026, 1:35 AMMarvin
04/01/2026, 1:36 AMnode-pool=gpu-a100) and have the NVIDIA device plugin.
- Define RayCluster workerGroupSpecs that:
- request GPUs (`resources.limits.nvidia.com/gpu`)
- use a CUDA/torch-capable image
- use nodeSelectors/affinity/tolerations to land on your GPU nodes
Example: add a GPU Ray worker group that lands on your existing GPU node pool
apiVersion: <http://ray.io/v1|ray.io/v1>
kind: RayCluster
metadata:
name: raycluster
spec:
rayVersion: "2.9.3"
headGroupSpec:
serviceType: ClusterIP
template:
spec:
containers:
- name: ray-head
image: rayproject/ray:2.9.3-py311
ports:
- containerPort: 8265
- containerPort: 10001
volumeMounts:
- name: ray-session
mountPath: /tmp/ray
volumes:
- name: ray-session
emptyDir: {}
workerGroupSpecs:
- groupName: gpu-a100
replicas: 2
minReplicas: 0
maxReplicas: 20
rayStartParams: {}
template:
spec:
nodeSelector:
node-pool: gpu-a100 # <- match your GPU nodes
tolerations:
- key: "<http://nvidia.com/gpu|nvidia.com/gpu>"
operator: "Exists"
effect: "NoSchedule"
containers:
- name: ray-worker
# Use a GPU-capable base with CUDA + torch, or your own image
image: <http://nvcr.io/nvidia/pytorch:24.02-py3|nvcr.io/nvidia/pytorch:24.02-py3>
resources:
requests:
cpu: "4"
memory: "32Gi"
<http://nvidia.com/gpu|nvidia.com/gpu>: "1"
limits:
cpu: "8"
memory: "64Gi"
<http://nvidia.com/gpu|nvidia.com/gpu>: "1"
volumeMounts:
- name: ray-session
mountPath: /tmp/ray
volumes:
- name: ray-session
emptyDir: {}
Then choose how to run your Prefect “driver”:
- Sidecar the Prefect worker onto the Ray head or onto a dedicated Ray worker pod (as shown earlier). This gives you native Ray (address="auto") and keeps the head from importing your libs.
- Or submit each run as a Ray Job so the driver gets a runtime_env too (best for per-run dependency isolation).
What you cannot do
- You can’t “convert” existing Prefect Kubernetes worker pods (the ephemeral flow-run pods) into Ray workers on the fly; they aren’t running raylets and are managed by Prefect, not KubeRay.
- You generally shouldn’t mix KubeRay-managed workers with ad-hoc Ray workers you launch yourself; the autoscaler won’t manage out-of-band nodes reliably.
If your “GPU worker groups” are VMs (Process work pools), not Kubernetes:
- Yes, you can run a Ray worker (raylet) on those machines and join them to the Ray head (ray start --address ...). Your Prefect worker and Ray worker can co-exist on the same VM. Just be aware KubeRay won’t manage those external nodes; you’d manage their lifecycle yourself.
Next steps I can help with
- Share whether your GPU groups are Kubernetes worker pools or VM-based Process workers, and the node labels you use. I’ll tailor the RayCluster YAML (nodeSelector, tolerations, GPU image) and optionally include a Prefect sidecar on head/worker for the native-driver pattern.
Refs
- KubeRay on Kubernetes: KubeRay docsMarvin
04/01/2026, 1:36 AMCarlos Alberto da Costa Filho
04/01/2026, 1:43 AMMarvin
04/01/2026, 1:43 AMMarvin
04/01/2026, 1:44 AMprefect work-pool set-concurrency-limit "your-pool" 2
This ensures at most N flow runs execute concurrently across all workers in that pool; the rest queue.
- Run multiple Prefect worker sidecars for the same pool to spread drivers across pods:
- Don’t put the sidecar only on the head if you expect many concurrent flows; you can’t scale the head.
- Prefer a dedicated Ray workerGroup with the Prefect sidecar and set replicas: N so you can scale driver capacity independently.
- Each sidecar is an independent Prefect worker; they’ll share the pool and split incoming runs.
- Consider Ray Jobs for per-run driver pods
- Prefect worker (which can be autoscaled to 0 with KEDA) just submits a Ray Job per flow run.
- Ray creates a driver pod per run (with your runtime_env), so driver “preprocessing” scales naturally with the number of runs.
- Best fit if you expect lots of concurrent flows and want strict per-run dependency isolation.
- Reduce driver/Prefect overhead for 10k inner tasks
- If you’re mapping 10k Prefect tasks per flow, that’s also 10k Prefect task runs and API chatter. For very large inner fan-out, consider a pattern where a small number of Prefect tasks submit the 10k Ray tasks internally (i.e., let Ray handle the fine-grained parallelism) to keep Prefect orchestration surface smaller.
Rules of thumb
- Size the sidecar CPU/memory for the number of concurrent drivers you allow (drivers use CPU for submitting tasks and memory for object refs/logs).
- Use a work pool concurrency limit to protect the sidecar(s).
- For high concurrency, run multiple sidecars (replicas) in a dedicated Ray workerGroup, not on the head.
If you want, I can:
- Show a quick edit of the earlier RayCluster YAML to run, say, 3 Prefect sidecars (replicas: 3) in a “prefect-driver” workerGroup, and
- Sketch a Ray Job submission wrapper if you’d rather offload each driver to its own Ray-managed pod.Mitch
04/01/2026, 1:52 AMMarvin
04/01/2026, 1:52 AMMarvin
04/01/2026, 1:54 AMfrom ray.job_submission import JobSubmissionClient
import os
def submit_ray_job(
head_dashboard_url: str,
entrypoint: str = "python -m myproj.run_flow_entrypoint",
pip_packages: list[str] = None,
working_dir: str | None = None,
py_modules: list[str] | None = None,
env: dict[str, str] = None,
):
client = JobSubmissionClient(head_dashboard_url)
runtime_env = {}
if pip_packages:
runtime_env["pip"] = pip_packages
if working_dir:
runtime_env["working_dir"] = working_dir
if py_modules:
runtime_env["py_modules"] = py_modules
if env:
runtime_env["env_vars"] = env
job_id = client.submit_job(
entrypoint=entrypoint,
runtime_env=runtime_env,
)
return job_id
# Example use inside a Prefect task/flow:
# job_id = submit_ray_job(
# head_dashboard_url="<http://raycluster-head-svc>.<ns>.svc.cluster.local:8265",
# pip_packages=[
# "prefect==3.*", "prefect-ray>=0.4.0", "torch==2.2.1", "yourlib @ https://.../yourlib.whl"
# ],
# working_dir="git+<https://github.com/org/repo@ref#subdirectory=flows>",
# env={
# "PREFECT_API_URL": os.environ["PREFECT_API_URL"],
# "PREFECT_API_KEY": os.environ["PREFECT_API_KEY"],
# # any other per-run config vars
# },
# )
- Ray Job entrypoint (runs inside the Ray Job driver; this is where your flow actually executes)
# myproj/run_flow_entrypoint.py
import os
from prefect import flow
from prefect_ray.task_runners import RayTaskRunner
def _ray_runtime_env():
# Optional: additional worker-only deps (if different from driver)
return {
# "pip": ["..."],
# "py_modules": ["./local_pkg_dir"],
}
@flow(task_runner=RayTaskRunner(
# In a Ray Job, the driver runs inside the cluster. Use the cluster’s address.
# If your job image includes ray, ray.init() will be called by RayTaskRunner.
address="<ray://raycluster-head-svc>.<ns>.svc.cluster.local:10001",
init_kwargs={"runtime_env": _ray_runtime_env()},
))
def my_flow():
# Safe to import heavy libs at module scope: they’re installed in the job’s driver env.
# Submit your Ray/Prefect work here.
...
if __name__ == "__main__":
my_flow()
Notes
- With Ray Jobs, the driver has your runtime_env. The head does not need your torch/local libs and does not import your code.
- You can keep the Prefect “launcher” image very slim; all per-run deps go into the job’s runtime_env.
- If you prefer, your entrypoint can call ray.init(address="auto") if the job driver runs on a Ray node; most KubeRay Job drivers connect via ray://<head-svc>:10001 and it still does not force the head to import your code because deserialization happens in the driver process.
When to use which pattern
- Ray Jobs: best for per-run version isolation for both driver and workers; no sidecars; easy autoscaling to zero for the submitter.Marvin
04/01/2026, 1:54 AMMitch
04/01/2026, 1:56 AMMarvin
04/01/2026, 1:56 AMMarvin
04/01/2026, 1:56 AMCarlos Alberto da Costa Filho
04/01/2026, 2:01 AMMarvin
04/01/2026, 2:01 AMMarvin
04/01/2026, 2:04 AMremote_options(runtime_env=...). These options are passed through to @ray.remote(...) for that task, so Ray will resolve/install those pip requirements for the worker process executing that task.
- Ray caches environments keyed by runtime_env; repeated uses of the same spec are fast. Thousands of unique envs will be slow; batch/group by env where possible.
Concrete sketch
- Prefect “launcher” that submits the Ray Job:
from prefect import flow, get_run_logger
from ray.job_submission import JobSubmissionClient
import os, json
@flow
def submit_preproc_ray_job(params: dict):
logger = get_run_logger()
client = JobSubmissionClient("<http://raycluster-head-svc>.<ns>.svc.cluster.local:8265")
# Driver deps for preprocessing + running the flow
runtime_env = {
"pip": [
"prefect==3.*",
"prefect-ray>=0.4.0",
"ray>=2.9",
# add driver-only deps here (e.g., torch) if preprocessing needs GPU locally
# "torch==2.2.1",
# yourlocal pkg as a wheel
# "yourlib @ https://.../yourlib.whl",
],
# Choose one of these to ship your code
# "working_dir": "git+<https://github.com/org/repo@ref#subdirectory=flows>",
# or package a module
# "py_modules": ["./your_project_dir"],
"env_vars": {
"PREFECT_API_URL": os.environ["PREFECT_API_URL"],
"PREFECT_API_KEY": os.environ["PREFECT_API_KEY"],
# pass flow params if you like
"FLOW_PARAMS_JSON": json.dumps(params),
},
}
job_id = client.submit_job(
entrypoint="python -m myproj.run_flow_entrypoint",
runtime_env=runtime_env,
)
<http://logger.info|logger.info>(f"Submitted Ray Job id={job_id}")
- Ray Job entrypoint that runs your Prefect flow with RayTaskRunner:
```
# myproj/run_flow_entrypoint.py
import json, os
from prefect import flow, task
from prefect_ray.task_runners import RayTaskRunner
from prefect_ray.context import remote_options
@task
def preproc():Marvin
04/01/2026, 2:04 AMremote_options(resources={"res_name": 0.01}). This is an infra change whenever you add a new image.
- Or launch each “inner job” as its own Ray Job and customize the driver image via KubeRay RayJob CRD (YAML). From Python JobSubmissionClient you can’t easily switch the driver image per submission; you’d use the RayJob CRD/programmatic apply. Still an infra pathway.
- Or model each “inner job” as a Prefect sub-deployment targeting a K8s work pool where you can pass the image tag at runtime. This gives per-job images, but you’ll create many K8s pods and shift fan-out to Prefect/K8s instead of Ray.
Trade-offs and tips
- Per-task runtime_env with pip is your most dynamic path without infra changes, but creating thousands of unique envs will be slow. Group items by identical deps to leverage Ray’s env caching.
- Keep worker images “fat” enough for system-level libs (CUDA drivers, common native deps) so pip-only installation suffices for most jobs.
- Control driver concurrency via Prefect work pool limits; control Ray-side placement via remote_options (num_cpus/num_gpus/resources).
References
- Prefect + Ray integration: prefect-ray docs
- Ray runtime environments (per job/actor/task): Runtime environments and dependencies
- Ray Job submission: Ray Job submission
- KubeRay on Kubernetes: KubeRay docs
Open question to tailor the final designMarvin
04/01/2026, 2:04 AMCarlos Alberto da Costa Filho
04/01/2026, 2:10 AMMarvin
04/01/2026, 2:10 AMMarvin
04/01/2026, 2:13 AMfrom prefect import flow, get_run_logger
from prefect.deployments import run_deployment
from ray.job_submission import JobSubmissionClient
import os
@flow
def launcher(params: dict):
logger = get_run_logger()
# 1) Create the child run and mark it as a subflow of this launcher
fr = run_deployment(
name="main-flow/main-deployment",
parameters=params,
as_subflow=True, # critical for linking in the UI
)
<http://logger.info|logger.info>(f"Created child flow run: {fr.id}")
# 2) Submit a Ray Job that will EXECUTE this run id
client = JobSubmissionClient("<http://raycluster-head-svc>.<ns>.svc.cluster.local:8265")
job_id = client.submit_job(
entrypoint=f"prefect flow-run execute {fr.id}",
runtime_env={
"pip": [
"prefect==3.*",
"prefect-ray>=0.4.0",
"ray>=2.9",
# add any driver-time deps if needed for preprocessing logs, etc.
],
# If your deployment uses flow.from_source(...), Prefect will load code from source
# per the deployment config; you do NOT need to ship code here.
"env_vars": {
"PREFECT_API_URL": os.environ["PREFECT_API_URL"],
"PREFECT_API_KEY": os.environ["PREFECT_API_KEY"],
},
},
)
<http://logger.info|logger.info>(f"Submitted Ray Job: {job_id}")
# Optional: poll flow run state here if you want the launcher to wait
return {"flow_run_id": str(fr.id), "ray_job_id": job_id}
2) Inside the Ray Job driver
- The entrypoint prefect flow-run execute <id> runs your deployment’s flow run in that job’s environment.
- main_flow should use RayTaskRunner and can set per-task runtime_env via remote_options as needed.
Example main_flow skeleton (in your deployment)
from prefect import flow, task
from prefect_ray.task_runners import RayTaskRunner
from prefect_ray.context import remote_options
@task
def preproc():
# heavy prep, optionally GPU via Ray tasks (set num_gpus=1 in remote_options)
return [{"item": 1, "pip": ["torch==2.2.1"]}, {"item": 2, "pip": ["xgboost==2.0.3"]}]
@task
def unit_of_work(x: int) -> str:
return f"processed {x}"
@flow(task_runner=RayTaskRunner(address="<ray://raycluster-head-svc>.<ns>.svc.cluster.local:10001"))
def main_flow():
items = preproc.submit().result()
# group by identical deps so Ray reuses envs
from collections import defaultdict
by_env = defaultdict(list)
for spec in items:
key = tuple(sorted(spec.get("pip", [])))
by_env[key].append(spec["item"])
results = []
for pip_key, batch in by_env.items():
with remote_options(runtime_env={"pip": list(pip_key)}, num_cpus=2):
futures = [unit_of_work.submit(x) for x in batch]
results.extend([f.result() for f in futures])
return results
Why this works
- The launcher’s run_deployment(as_subflow=True) creates and links the child flow run to the launcher in the Prefect UI.
- The Ray Job only executes that existing run id. All state, logs, and tasks from main_flow show up under that child.
- The Ray head never needs your libs; the driver is the Ray Job pod. Per-task Python deps are set dynamically with remote_options(runtime_env=...).
Notes and tipsMarvin
04/01/2026, 2:13 AMCarlos Alberto da Costa Filho
04/01/2026, 2:23 AMMarvin
04/01/2026, 2:23 AMMarvin
04/01/2026, 2:25 AMruntime_env (pip/py_modules/working_dir), but you cannot swap CUDA/system libs per task.
- If your decorators import job-type-specific libs at module scope, the driver must have those libs available before your flow imports. That means either multiple driver images or a way to create a per-run Python environment inside the sidecar before imports.
Recommended layering for many environments and job types
1) Environment boundaries (prod, staging, etc.)
- Create a RayCluster per environment. This isolates blast radius, secrets, and scaling policies.
- Create a Prefect work pool per environment. Route productions runs to the prod pool, staging to staging, etc.
2) Worker images for execution (per job family)
- In each RayCluster, define multiple workerGroupSpecs that map to your job families (e.g., ml_detection_1, ml_segmentation_1), each with its own base image (CUDA/toolchains/etc.).
- Expose each group via a distinct Ray custom resource (e.g., “res:ml_detection_1”), then route tasks from Prefect to the correct group with remote options (so the right group executes each task).
- This lets you scale each job family independently and keeps system-level differences out of “install at runtime.”
3) Driver (sidecar) strategy options
You have two viable approaches; pick based on how often/deeply your job-type dependencies change and whether decorators require them at import time.
- Option A: Driver image per environment and job type
- Run a small “driver” workerGroup in each environment with a Prefect sidecar built for each job type’s dependencies. Each job type gets its own work queue in that environment’s pool and targets its matching sidecar image.
- Pros: strict isolation; fastest startup; no per-run installs.
- Cons: image explosion as job types and PRs multiply; operational overhead to update RayCluster CRDs whenever a new image is introduced.
- Option B: One “generic” driver image per environment + per-run venv bootstrap
- Keep a lean driver image (Prefect + Ray) and, before importing any flow code, create a per-run Python environment (venv/uv) and install the requested requirements, then execute the flow in that env. Because installs happen before the import, decorators that import at module scope are satisfied without rebuilding the driver image.
- Pros: dramatically reduces driver image sprawl; supports PR/branch images/requirements without K8s changes; compatible with your “can’t defer imports” constraint.
- Cons: per-run bootstrap time; you must manage caching (e.g., wheel cache) to keep cold starts reasonable.
4) Routing and scaling
- Prefect routing:
- Pool per environment.
- One or more queues per job type (and optionally per branch/PR). Your deployments target queues that map to the right driver behavior (image-per-job-type or venv-bootstrap parameters).
- Ray routing:
- Use custom resources to send each task batch to the correct worker group/image (e.g., ml_detection_1 tasks → “res:ml_detection_1”).
- Use runtime_env per task to handle Python-only variance (version bumps, local wheels) without changing the worker image.
- Concurrency controls:
- Set a concurrency limit on each environment pool to cap simultaneous drivers.Marvin
04/01/2026, 2:25 AMCarlos Alberto da Costa Filho
04/01/2026, 2:32 AMMarvin
04/01/2026, 2:32 AMMarvin
04/01/2026, 2:33 AMMarvin
04/01/2026, 2:33 AMCarlos Alberto da Costa Filho
04/01/2026, 2:34 AMMarvin
04/01/2026, 2:34 AMMarvin
04/01/2026, 2:36 AMaddress="auto" works)
- Calls a Python bootstrapper with a JSON “run spec” describing the deps for this run
- Execs the flow run in the bootstrapped env via prefect flow-run execute <id>
- A Python bootstrapper that:
- Hashes the run spec to compute a cache key
- Reuses or creates a virtualenv
- Installs pip deps (with a shared wheel cache to speed up cold starts)
- Execs the target command inside that env
1) Sidecar entrypoint (run this as the container’s command/args)
#!/bin/sh
set -eu
# Required env:
# - FLOW_RUN_ID: the Prefect flow-run id to execute
# - RUN_SPEC_JSON: JSON with per-run Python deps (e.g., {"pip":["torch==2.2.1","yourlib @ https://.../yourlib.whl"]})
# - PREFECT_API_URL / PREFECT_API_KEY: so the run can report to Prefect
# Optional:
# - RAY_ADDRESS: if you prefer explicit address; otherwise we’ll use address="auto"
# - VENV_ROOT: path where we cache envs (default /opt/prefect-venvs)
# - WHEEL_CACHE: path for pip cache (default /opt/pip-cache)
: "${FLOW_RUN_ID:?FLOW_RUN_ID is required}"
: "${RUN_SPEC_JSON:?RUN_SPEC_JSON is required}"
: "${PREFECT_API_URL:?PREFECT_API_URL is required}"
: "${PREFECT_API_KEY:?PREFECT_API_KEY is required}"
VENV_ROOT="${VENV_ROOT:-/opt/prefect-venvs}"
WHEEL_CACHE="${WHEEL_CACHE:-/opt/pip-cache}"
echo "Waiting for local Ray to be ready..."
# KubeRay commonly exposes the raylet socket here; adjust if your Ray version differs
while [ ! -S /tmp/ray/session_latest/sockets/raylet ]; do sleep 1; done
echo "Ray local session is ready."
# Create cache directories if they don’t exist
mkdir -p "${VENV_ROOT}" "${WHEEL_CACHE}"
# Bootstrap an env and exec the run by id
exec python /app/bootstrap.py \
--run-spec "${RUN_SPEC_JSON}" \
--venv-root "${VENV_ROOT}" \
--wheel-cache "${WHEEL_CACHE}" \
--cmd "prefect flow-run execute ${FLOW_RUN_ID}"
2) The bootstrapper (Python)
```
import argparse
import hashlib
import json
import os
import shutil
import subprocess
import sys
from pathlib import Path
def hash_spec(spec: dict) -> str:
# Normalize the spec (pip list order can vary)
norm = {}
for k, v in spec.items():
if isinstance(v, list):
norm[k] = sorted(v)
else:
norm[k] = v
data = json.dumps(norm, sort_keys=True).encode()
return hashlib.sha256(data).hexdigest()[:16]
def ensure_env(venv_dir: Path, wheel_cache: Path, pip: list[str]):
if not (venv_dir / "bin" / "python").exists():
# Create venv
subprocess.check_call([sys.executable, "-m", "venv", str(venv_dir)])
# Upgrade pip/setuptools/wheel in that venv
py = str(venv_dir / "bin" / "python")
subprocess.check_call([py, "-m", "pip", "install", "--upgrade", "pip", "setuptools", "wheel"])
# Install requested deps with cache
if pip:
install_cmd = [
py, "-m", "pip", "install",
"--cache-dir", str(wheel_cache),
*pip,
]
subprocess.check_call(install_cmd)
def exec_in_env(venv_dir: Path, cmd: str, extra_env: dict[str, str] | None = None):
py = str(venv_dir / "bin" / "python")
env = os.environ.copy()
env.update(extra_env or {})
# Ensure the venv’s interpreter is used for module resolution
# Use execvp-like spawn by calling python -c 'import os,sys; os.execvpe(...)' or just run the CLI directly.
# Here we just run the shell command through the venv’s python -m to ensure the env is active for imports.Marvin
04/01/2026, 2:36 AMprefect flow-run execute <id> inside the freshly bootstrapped env.
- In your flow code, use RayTaskRunner(address="auto") and set per-task runtime_env via Prefect-Ray remote options for worker-side deps that vary by task. The head never imports your code; the driver and workers each get the deps they need.
Operational notes
- Caching: mount a small PersistentVolume for /opt/prefect-venvs and /opt/pip-cache if you want cache to survive pod restarts; otherwise, use emptyDir for node-local caching.
- Throughput: if you expect many concurrent runs, scale the number of sidecars (replicas) and cap pool concurrency so each sidecar hosts only as many drivers as it can handle comfortably.
- Security: restrict indexes (e.g., use an internal PyPI), require hash-checking if needed, and pin Python minor versions per environment to keep env keys stable.
- GPU: if driver-time preprocessing needs GPU, request GPU in the sidecar pod; otherwise, offload GPU work to Ray tasks (set num_gpus in remote_options).
When to pick Ray Jobs instead
- If you don’t want to maintain this bootstrap shim, Ray Jobs gives you per-run driver runtime_env “for free” and is generally simpler to operate at scale. Your Prefect launcher submits a Ray Job that then executes `prefect flow-run execute <id>`—same linking pattern, less plumbing.
References
- Prefect + Ray: prefect-ray docs
- Ray runtime envs (for worker-side Python deps): Runtime environments and dependencies
- KubeRay: Ray on KubernetesCarlos Alberto da Costa Filho
04/01/2026, 2:40 AMMarvin
04/01/2026, 2:40 AMMarvin
04/01/2026, 2:40 AMCarlos Alberto da Costa Filho
04/01/2026, 6:15 AMMarvin
04/01/2026, 6:15 AMMarvin
04/01/2026, 6:17 AMCarlos Alberto da Costa Filho
04/01/2026, 6:19 AMMarvin
04/01/2026, 6:19 AMMarvin
04/01/2026, 6:19 AMCarlos Alberto da Costa Filho
04/01/2026, 6:23 AMMarvin
04/01/2026, 6:23 AMMarvin
04/01/2026, 6:24 AMCarlos Alberto da Costa Filho
04/01/2026, 6:37 AMMarvin
04/01/2026, 6:37 AMMarvin
04/01/2026, 6:38 AM# flows.py
from prefect import flow, task
from prefect_kubernetes.experimental import kubernetes
@task
def preprocess(x: int) -> int:
# trivial; replace with your real preprocessing
return x + 1
# This entire flow runs as a single Kubernetes Job/Pod on pool-GPU,
# using image B (it does NOT inherit the entrypoint's image A).
@kubernetes(
work_pool="pool-GPU",
image="<http://ghcr.io/acme/ml_detection_image:B|ghcr.io/acme/ml_detection_image:B>", # <- image B for the decorated flow
# You can add other job variables here (e.g., cpu/memory requests, SA name, include_files)
)
@flow
def flow_decorated(data: list[int]) -> list[int]:
# All tasks inside flow_decorated execute within the same Job/Pod (no per-task Pods)
futures = [preprocess.submit(i) for i in data]
return [f.result() for f in futures]
# This flow is your UI entrypoint; create a deployment for THIS flow with image A.
@flow
def entrypoint_flow(n: int = 10) -> list[int]:
# Runs in image A (the deployment image), then delegates to flow_decorated.
data = list(range(n))
return flow_decorated(data)
How this behaves
- entrypoint_flow runs in image A (because its deployment uses image A).
- flow_decorated is bound to your GPU work pool via @kubernetes and runs in image B as a single Kubernetes Job/Pod.
- Tasks within flow_decorated do not get separate Pods; they share the same container of that Job.
- Make sure a Kubernetes worker is online for the target work pool so Prefect can create the Job.
If you prefer to set image B via deployment instead of hardcoding it on the decorator, you can create a deployment for flow_decorated that targets pool-GPU and sets job_variables.image="...B..."; then call that deployment from entrypoint_flow. The end result is the same: flow_decorated runs in image B on pool-GPU.
Docs
- Experimental decorators (prefect-kubernetes): Experimental Kubernetes decorators
- Kubernetes worker and work pools: Kubernetes worker guideCarlos Alberto da Costa Filho
04/01/2026, 6:42 AMMarvin
04/01/2026, 6:42 AMMarvin
04/01/2026, 6:43 AMnamespace="ray-worker-only" on the @kubernetes decorator just tells Prefect’s Kubernetes worker where to create the Job/Pod for that flow run. It does not make that Pod part of your Ray cluster, and it does not enable Ray “native” mode by itself.
Why
- Ray “native” requires the driver process to attach to a local raylet (e.g., running inside a Ray pod with the /tmp/ray session). A plain Kubernetes Job Pod created by @kubernetes does not have a raylet.
- In that Pod, address="auto" will not work because there’s no local Ray session/socket. You can still connect with ray://<head-svc>:10001 (Ray Client), but that reintroduces the head-server import/deserialize issue you’re trying to avoid.
Ways to actually get Ray native
- Sidecar pattern: run the driver (Prefect worker or driver subprocess) as a sidecar container in a Ray head/worker pod so it shares /tmp/ray — then RayTaskRunner(address="auto") works natively.
- Ray Jobs: submit the run as a Ray Job; the driver runs inside the Ray cluster and does not require the head to import your code. This is the cleanest way to avoid head deps without sidecars.
If you insist on @kubernetes for that flow
- You can:
- Use Ray Client (ray://…) from inside the Job Pod, but then the Ray Client server on the head must be able to import/deserialize your flow/task definitions (i.e., it needs those libs or you must avoid module-scope imports).
- Or bootstrap a raylet inside that Job Pod and have it join the cluster first, then run your flow with address="auto" — but that’s effectively reimplementing KubeRay’s node lifecycle and is not recommended for production.
Key takeaways
- Namespace targeting ≠ joining the Ray cluster.
- To guarantee native mode, the driver must run inside a Ray pod (sidecar) or as a Ray Job driver.
- @kubernetes is great to run a whole flow as a single K8s Job/Pod, but it won’t turn that Pod into a Ray node on its own.
Refs
- Prefect Kubernetes decorators (experimental): Experimental Kubernetes decorators
- Prefect Kubernetes worker: Kubernetes worker guide
- Ray Client vs native: Ray Client guide
- KubeRay on Kubernetes: KubeRay docsCarlos Alberto da Costa Filho
04/01/2026, 6:45 AMMarvin
04/01/2026, 6:45 AMMarvin
04/01/2026, 6:46 AM# Inside the Job pod before your flow starts
ray start --address=raycluster-head-svc.<ns>.svc.cluster.local:6379 --block &
# wait for /tmp/ray/session_latest/sockets/raylet to exist
# then run your flow which does RayTaskRunner(address="auto")
Key caveats
- This pod is not managed by KubeRay’s autoscaler. You’re effectively adding an out-of-band Ray node; lifecycle and capacity aren’t tracked by KubeRay.
- You must include ray (and NVIDIA/CUDA libs if you want GPU) in the Job’s image. Ray will usually auto-detect GPUs via the device plugin; if not, you can pass explicit resource flags (e.g., --num-gpus).
- Use the head service’s GCS port (usually 6379). Do not point ray start at 10001; that’s only for Ray Client.
- Operationally this is brittle compared to:
- Sidecar in a Ray pod (shares /tmp/ray; fully native without DIY join), or
- Ray Jobs (driver runs inside the cluster with per-run runtime_env; no sidecars).
If your goal is “native” without putting deps on the head, I’d strongly recommend Ray Jobs or the sidecar-in-a-Ray-pod pattern. Spinning up ad-hoc raylets in @kubernetes Jobs works in a pinch, but it bypasses KubeRay’s node management and can surprise you under load.
Refs
- Ray Client vs native: Ray Client guide
- KubeRay (ports and node joining): KubeRay on KubernetesCarlos Alberto da Costa Filho
04/01/2026, 7:04 AMMarvin
04/01/2026, 7:04 AMCarlos Alberto da Costa Filho
04/01/2026, 7:06 AMMarvin
04/01/2026, 7:06 AMMarvin
04/01/2026, 7:08 AMCarlos Alberto da Costa Filho
04/01/2026, 7:12 AMMarvin
04/01/2026, 7:12 AMMarvin
04/01/2026, 7:12 AM# src/flow.py
import torch
from prefect import flow, task
from prefect_ray.task_runners import RayTaskRunner
@task
def gpu_inference_task(partition_id: int):
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
return f"Processed {partition_id} on {device}"
@flow(task_runner=RayTaskRunner(address="auto")) # native: attach to local raylet
def heavy_execution_flow(num_partitions: int = 10000):
futures = [gpu_inference_task.submit(i) for i in range(num_partitions)]
return [f.result() for f in futures]
2) Change the deployment’s hijacked entrypoint to:
- Join the Ray cluster via GCS (6379), not Ray Client (10001)
- Wait for the local raylet socket instead of a fixed sleep
- Exec Prefect’s supported entrypoint: prefect flow-run execute (the worker injects PREFECT__FLOW_RUN_ID)
If your flow code is baked into the image
# deploy.py
from src.flow import heavy_execution_flow
if __name__ == "__main__":
RAY_HEAD_GCS = "ray-head-svc.ray-prod.svc.cluster.local:6379"
# Provide command as a list to avoid shell-quoting surprises
hijacked_entrypoint = [
"/bin/bash", "-lc",
# 1) Start a local raylet that joins the cluster’s GCS
# 2) Wait for the local ray session socket
# 3) Exec the supported Prefect entrypoint
f"ray start --address={RAY_HEAD_GCS} --block & "
"until [ -S /tmp/ray/session_latest/sockets/raylet ]; do sleep 1; done; "
"exec prefect flow-run execute"
]
heavy_execution_flow.deploy(
name="ml-detect-a-native-driver",
work_pool_name="kubernetes-prod-pool",
image="my-registry/ml_detect_a:latest", # Torch + any driver-time deps
job_variables={
"command": hijacked_entrypoint
# if needed, also set cpu/memory/gpu here or in the pool’s base template
},
)
If you load code from source (recommended for CI/CD), use from_source(...).deploy(...)
from prefect import flow
from src.flow import heavy_execution_flow
if __name__ == "__main__":
RAY_HEAD_GCS = "ray-head-svc.ray-prod.svc.cluster.local:6379"
hijacked_entrypoint = [
"/bin/bash", "-lc",
f"ray start --address={RAY_HEAD_GCS} --block & "
"until [ -S /tmp/ray/session_latest/sockets/raylet ]; do sleep 1; done; "
"exec prefect flow-run execute"
]
heavy_execution_flow.from_source(
source="git+<https://github.com/org/repo.git@main#subdirectory=>.",
entrypoint="src/flow.py:heavy_execution_flow",
).deploy(
name="ml-detect-a-native-driver",
work_pool_name="kubernetes-prod-pool",
image="my-registry/ml_detect_a:latest",
job_variables={"command": hijacked_entrypoint},
)
Why these changes
- prefect flow-run execute is the supported entrypoint in Prefect 3.x; the worker injects PREFECT__FLOW_RUN_ID so you don’t pass an id on the command line.
- Waiting on /tmp/ray/session_latest/sockets/raylet is more reliable than a fixed sleep.
- Joining 6379 (GCS) makes your Pod a native Ray node; address="auto" then works as intended. 10001 is Ray Client and reintroduces head-server import issues.
Operational notes
- Networking: ensure this Pod can reach the head’s GCS (6379) and the other Ray nodes (object store/worker ports). Strict cross-namespace policies will break native Ray.
- Resources: you can keep the driver CPU-only and push GPU work to Ray workers via task resource requests; if your preprocessing needs GPU in the driver, request it in the Job (pool/job_variables).
- KubeRay awareness: this Pod becomes an out-of-band node; KubeRay won’t autoscale it. That’s fine for short-lived drivers, but size your head accordingly.
ReferencesMarvin
04/01/2026, 7:12 AMCarlos Alberto da Costa Filho
04/01/2026, 7:42 AMMarvin
04/01/2026, 7:42 AMMarvin
04/01/2026, 7:43 AM--num-cpus=0 --num-gpus=0 (or --resources='{"CPU":0,"GPU":0}').
- This prevents your driver pod from contributing capacity that would suppress autoscaling or attract tasks.
- Always specify task resources:
- In Prefect with RayTaskRunner, use remote_options(num_cpus=..., num_gpus=..., resources=...) so tasks target the managed worker groups that actually have capacity.
- Optionally use a custom resource (e.g., {"worker_group:ml_detection": 0.01}) that only your KubeRay worker pods advertise; your driver pod won’t have it.
- Cap driver concurrency:
- Use a Prefect work pool concurrency limit and/or multiple driver replicas to avoid spawning too many ephemeral raylets at once. This reduces load on the head and keeps autoscaler decisions stable.
- Keep networking open for native Ray:
- Ensure the Job pod can reach the head’s GCS (6379) and worker ports; partial connectivity can cause tasks to appear “pending,” which may trigger unnecessary (or stalled) scale-ups.
Net effect
- With zero resources on the driver nodes and explicit resource requests on tasks, your approach will not materially interfere with Ray’s autoscaling decisions for your managed worker groups.
- If you register non-zero resources on the driver nodes, you risk under-scaling (autoscaler thinks capacity exists) and accidental task placement on the driver pods.
If you want to avoid all of this operationally, Ray Jobs are still the cleanest path: the driver runs inside the cluster with per-run runtime_env and you don’t add unmanaged nodes to the cluster at all.