<@ULVA73B9P> suppose i have ray running with the d...
# ask-marvin
c
@Marvin suppose i have ray running with the default image. a worker running with an optimized torch image. both have ray, prefect and prefect[ray] installed. when i try to use RayTaskRunner, i get an error saying torch is not installed (see below). torch is installed in the worker node but not the head node. will using pip install from runtime_env passed to RayTaskRunner fix this?
Copy code
Traceback (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\'"}"
>
m
thought for 117.1 seconds
Short answer: probably not. Ray’s
runtime_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:
Copy code
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.
c
@Mitch
@Marvin Inside a flow decorated with raytaskrunner:
Copy code
print(
        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:
Copy 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()}"
m
thought for 49.8 seconds
Thanks — that clarifies it. Because torch is imported elsewhere in your flow code, the Ray Client server on the head node still needs to import/deserialize that code path when it pickles/unpickles your task/flow objects. Since the head image doesn’t have torch, you get “No module named 'torch'.” Passing
runtime_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:
Copy code
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:
Copy code
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
Copy code
from prefect import task

  @task
  def gpu_infer(x: int):
      import torch
      return int(torch.tensor([x]).sum().item())
flow.py
Copy code
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).
m
wild
c
@Marvin I need to be able to maintain multiple versions of libraries which are installed at runtime. deferring imports is not really a solution because I need to use decorators which depend on these libraries. torch is one example, but there are local libraries which cannot be easily installed on the head node because they change often (and different users have different version requirements) and also cannot be deferred.
m
thought for 83.3 seconds
Got it — you need true per-run/per-user dependency isolation, including decorators that import at module scope. In that case, using
runtime_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 docs
c
@Marvin can you explain this better: Ensure the Prefect driver runs on a Ray node (VM/pod).
m
thought for 159.1 seconds
Great question. By “ensure the Prefect driver runs on a Ray node,” I mean: make the Python process that executes your @flow function (the flow run process) start on a machine/pod that is part of the Ray cluster, then connect via native Ray (not Ray Client). In that setup, your driver uses its own environment (your image/deps), and the Ray head does not need to import your flow modules or decorators. Why it matters - Ray Client (address like `ray://head:10001`) runs a server on the head node that deserializes your functions. Any module-scope imports or decorators must exist on the head image, causing your torch/local-lib import errors. - Native Ray (e.g.,
RayTaskRunner(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:
Copy code
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 guide
- Ray runtime envs (applied to workers and Ray Jobs; not to the Ray Client server): Runtime environments and dependencies - Prefect + Ray integration: prefect-ray docs
c
@Marvin using k8s, kuberay if i do this and raytaskrunner spins up 10,000 jobs, none of them will communicate with the head?
m
thought for 66.5 seconds
Short answer: they will still communicate with the head. Moving the Prefect driver onto a Ray node avoids the Ray Client server’s import/deserialize step on the head, but Ray’s control-plane traffic (scheduling, heartbeats, object refs, placement, autoscaling) always goes through the head. The difference is about where your Python code is imported/deserialized, not about bypassing the head entirely. How this plays out on KubeRay - Ray Client (ray://): the head runs a Ray Client server that must import/deserialize your functions. That’s why the head needs your torch/local libs. - Native Ray driver (driver runs on a Ray node): the driver process imports your modules and serializes functions itself; the head still orchestrates the cluster but does not need your Python dependencies. Two viable patterns on KubeRay to get per-run dependency isolation (including decorators at import time): 1) Ray Jobs (recommended for your use case) - Prefect triggers a Ray Job; the job’s driver runs inside the cluster and gets a runtime_env applied to the driver and workers. Your torch/local libs are installed per run; the head does not need them. - Sketch:
Copy code
from 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.
c
@Marvin how does the ray native solution not have to pickle things if you still need to go through the head node?
m
thought for 29.5 seconds
Great question — it still pickles, but the key difference is where unpickling (and thus importing your libraries) occurs. - Native Ray (driver on a Ray node) - Your driver process (the one running your @flow) imports your modules and creates the function objects. - When you submit a task, Ray cloudpickles the function/closure on the driver and stores the bytes in the Ray object store. - The head node coordinates scheduling and metadata, but it does not import or execute your user code. It forwards refs/metadata; the pickled bytes remain bytes. - The Ray workers that actually execute the task fetch those pickled bytes from the object store and unpickle them; that’s where your libraries must be installed (via the worker image or
runtime_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).
c
@Marvin why does this work though:
Copy code
print(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")
Copy code
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)
m
thought for 35.6 seconds
It works because, in that snippet, nothing you send to Ray Client requires torch during server-side deserialization. What’s different in your two cases - Your manual Ray code: - You call
ray.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 environments
c
@Marvin can you provide a kuberay config for this 2) Run the Prefect worker/driver inside a Ray pod (native mode)
m
thought for 169.2 seconds
Absolutely. Here’s a concrete KubeRay RayCluster config that runs a Prefect worker as a sidecar inside a Ray worker pod. This makes your Prefect flow driver run “on a Ray node” in native mode, so
RayTaskRunner(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.
Copy code
# 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..."
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}" volumeMounts: - name: ray-session mountPath: /tmp/ray volumes: - name: ray-session emptyDir: {} # 2) Your scalable compute workers - groupName: compute replicas: 3 minReplicas: 0 maxReplicas: 50 rayStartParams: {} template: spec: containers: - name: ray-worker image: rayproject/ray:2.9.3-py311 resources: limits: cpu: "4" memory: "8Gi" requests: cpu: "2" memory: "4Gi" volumeMounts: - name: ray-session mountPath: /tmp/ray volumes: - name: ray-session emptyDir: {}
Copy code
3) 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.).
c
@Marvin does this mean I can never scale PREFECT_WORK_POOL down to zero? what are my options here
m
thought for 83.3 seconds
Short answer: with the “Prefect worker as a sidecar in a Ray pod” pattern, if you scale that pod group to 0 there’s no Prefect worker running to poll your work pool—so runs will queue but won’t start until you scale the worker back up. Prefect always needs at least one worker process online somewhere to create infrastructure (pods/jobs) for flow runs. Your options - Keep 1 tiny always-on worker sidecar - Give it minimal CPU/mem; all heavy compute happens on Ray. Simple and reliable. - Autoscale the Prefect worker to/from zero with KEDA - Run the Prefect worker as its own Deployment (still colocated in a Ray worker pod if you want address="auto"). - Add a KEDA ScaledObject that scales the worker Deployment from 0→N when there are queued runs. - Where does the signal come from? - Easiest: expose a tiny “metrics adapter” service that queries Prefect’s API for your work pool’s queued runs and returns a number; point KEDA’s metrics-api/HTTP scaler at it. - Alternatively, push a custom metric (e.g., to Prometheus) and use KEDA’s Prometheus scaler. - Docs: KEDA scaling, metrics-api scaler, HTTP scaler Example sketch (metrics-api scaler):
Copy code
apiVersion: <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 docs
c
@Marvin can i sidecar prefect onto the head node instead?
m
thought for 26.3 seconds
Yes—you can sidecar a Prefect worker onto the Ray head pod. That still counts as “driver runs on a Ray node,” so your flows can use native Ray with
address="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)
Copy code
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:
Copy code
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.
- Sidecar on a worker pod: same benefits, with the option to scale that group independently (but not to zero without external autoscaling). - Ray Jobs: best per-run isolation for driver and workers; lets you autoscale to zero for the submitter (e.g., a small Prefect worker that only submits Ray Jobs). Docs - KubeRay on Kubernetes: KubeRay docs - Ray Client vs native: Ray Client guide - Runtime envs: Runtime environments and dependencies - Prefect + Ray: prefect-ray docs If you share your Ray/Python versions and any GPU requirements, I can tweak the YAML (resource requests, CUDA base images, tolerations, etc.).
m
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.
c
@Marvin suppose I have several GPU worker groups already registered with prefect. can i make these nodes a part of the ray cluster?
m
thought for 46.2 seconds
Short answer: yes, but not by “attaching” your existing Prefect worker pods. To make those machines part of a Ray cluster, you need Ray processes (raylets) running on them. With KubeRay, that means creating RayCluster worker groups that are scheduled onto the same GPU nodes as your Prefect GPU groups. You can co-locate Prefect with Ray (as a sidecar) on those nodes, but your current Prefect “worker groups” are an orchestration concept, not Ray nodes. What that looks like on Kubernetes (KubeRay) - Keep your GPU nodes as-is. Ensure they’re labeled (e.g.,
node-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
Copy code
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 docs
- Ray GPU scheduling: Ray GPU resources - Prefect + Ray: prefect-ray docs
c
@Marvin using the sidecar example, image i submit 10 jobs, each submitting 10,000. the prefect worker sidecar will have to do the preprocessing of 10 jobs?
m
thought for 106.6 seconds
Short answer: yes. In the sidecar pattern, the Prefect worker sidecar is the process that: - polls your work pool - accepts flow runs - starts a driver subprocess for each accepted run If you submit 10 concurrent flow runs and they’re all routed to that pool, that one sidecar will host up to 10 driver subprocesses (subject to limits you set). Each driver will submit its own 10,000 Ray tasks. The heavy compute runs on Ray workers, but the driver-side “preprocessing” (importing your flow, submitting tasks, handling futures/logging) happens inside the sidecar container. Good ways to control this and/or scale it - Set a work pool concurrency limit to cap simultaneous drivers:
Copy code
prefect 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.
m
@Marvin is there a way to have the prefect-ray integration work properly without having to have the head node have all the dependencies and not have to have some convoluted side car solution where you're required to have prefect and the worker running as a subprocess?
m
thought for 151.5 seconds
Yes—use Ray Jobs. It’s the cleanest way to run Prefect + Ray without installing your flow deps on the head and without keeping a Prefect sidecar “driver” online. Why Ray Jobs solves this - The Ray Job spins up a driver process inside the cluster with a per-run runtime_env. Your flow code (including decorators and module-scope imports) is imported in the job’s driver container, not on the head. - Workers also inherit the runtime_env, so torch/local libs are available where unpickling/execution happens. - No sidecars, no “driver subprocess” gymnastics. You can autoscale the Prefect submitter to zero with KEDA if you like. Blueprint 1) Keep a small Prefect worker anywhere (it just submits jobs; can be autoscaled to 0). 2) That worker runs a tiny “launcher” flow which submits a Ray Job to your KubeRay head. 3) The Ray Job’s entrypoint runs your real Prefect flow with RayTaskRunner in the job’s driver environment. Example - Prefect “launcher” (runs on your normal worker; no heavy deps needed):
Copy code
from 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)
Copy code
# 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.
- Sidecar driver: simplest wiring, but you keep at least one tiny worker online and manage driver capacity via replicas and work-pool concurrency. Links - Ray Jobs: Ray Job submission - Runtime envs: Runtime environments and dependencies - Ray Client vs native: Ray Client guide - Prefect + Ray: prefect-ray docs If you share your KubeRay head service name/namespace and any code packaging preferences (git working_dir vs shipping a wheel/py_module), I can tailor the launcher and entrypoint to your setup.
m
@Marvin does this solution allow the Ray task states to be observed in the prefect dashboard?
m
thought for 42.6 seconds
Yes—with Ray Jobs or the sidecar pattern, Prefect will still show flow and task states in the Prefect UI as long as: - you run a Prefect flow (i.e., call your @flow), - your units of work are Prefect @task calls running under RayTaskRunner, and - the driver environment has PREFECT_API_URL and PREFECT_API_KEY set so it can report to Prefect. What you will see - Each Prefect task invocation (including mapped tasks) will appear with full state transitions (Pending → Running → Completed/Failed) and logs in the Prefect UI. This is true whether the driver runs via a Ray Job or in a sidecar. What you will not see - Raw Ray tasks/actors launched outside of Prefect tasks (e.g., calling ray.remote inside your code without wrapping that work in a Prefect @task) will not show up as individual tasks in Prefect. You’ll see the parent Prefect task’s state, and you can use the Ray Dashboard for Ray-internal task/actor visibility. Rules of thumb - If you want 10,000 items to show as 10,000 task runs in Prefect, use Prefect task mapping under RayTaskRunner. - If you launch 10,000 Ray tasks inside one Prefect task, Prefect will show one task; the 10,000 Ray tasks are only visible in the Ray Dashboard. Links - Prefect + Ray integration: prefect-ray docs - Ray Jobs (for per-run driver env): Ray Job submission - Ray runtime envs: Runtime environments and dependencies
c
@Marvin essentially what I want is this: 1. prefect UI is used to submit a job from a deployment (it has its own image) 2. k8s spins up a pod to run the preprocessing. this may be heavy and need GPU 3. several thousand jobs are spun up - these are submitted to a RayTaskRunner each of of these jobs has its own image and set of requirements. we want to be able to edit these requirements dynamically and without going through infrastructure changes.
m
thought for 145.7 seconds
You can get very close to that design—without putting deps on the head and without a sidecar driver—by combining: - Prefect deployment to kick things off (your own image) - A Ray Job to run the “preprocessing + driver” inside the cluster with a per-run runtime_env - Per-task runtime_env via Prefect-Ray’s remote options to give different Python deps to different Ray tasks What this gives you - The Ray head never imports your code or needs your libs (driver runs in a Ray Job). - Your preprocessing can be heavy (even GPU) because the job’s driver runs in its own pod; you control its resources. - Each “inner job” can carry its own Python dependencies dynamically via Ray runtime_env—no cluster image rebuilds. Important constraints - On KubeRay, you cannot change the underlying container image per Ray task. All tasks run in the worker pod image KubeRay launched. You can change Python deps dynamically (via runtime_env pip/py_modules/working_dir), but not system libraries/CUDA base per task. If you truly need different base images/CUDA versions per inner job, see “If you truly need different images” below. Architecture that matches your steps 1) Prefect deployment (your own image) - A small launcher flow runs on any Prefect worker (can be autoscaled to 0 with KEDA). - It submits a Ray Job to the KubeRay head with a runtime_env that includes the exact driver deps (prefect, prefect-ray, ray, torch if preprocessing needs GPU, your local wheels, etc.). - The job’s entrypoint then runs your real Prefect flow with RayTaskRunner inside the Ray Job’s driver. 2) Preprocessing on k8s with GPU - The Ray Job driver pod can request GPU if your preprocessing needs it. Alternatively, keep the driver CPU-only and push GPU work to Ray tasks. 3) Thousands of Ray-backed Prefect tasks with per-job requirements - Use Prefect tasks under RayTaskRunner. - For per-job Python deps, wrap submissions with
remote_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:
Copy code
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():
# Heavy preprocessing here (GPU or CPU). If GPU, either: # - do it via Ray tasks with num_gpus=1, or # - ensure your job driver pod can see a GPU and do it locally. return [ # Example: each item declares its own pip deps {"item": 1, "pip": ["torch==2.2.1", "transformers==4.39"]}, {"item": 2, "pip": ["xgboost==2.0.3"]}, {"item": 3, "pip": ["opencv-python==4.9.0.80"]} ] @task def ray_unit_of_work(x: int) -> str: # This executes inside the worker with runtime_env applied for this task return f"processed {x}" def _ray_worker_env_for(pip_list): return {"pip": pip_list} if pip_list else {} @flow(task_runner=RayTaskRunner(address="ray://raycluster-head-svc.<ns>.svc.cluster.local:10001")) def main_flow(): items = preproc.submit().result() # Group by identical pip sets to amortize env creation and cache reuse 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(): runtime_env = _ray_worker_env_for(list(pip_key)) with remote_options( # Task-level resource and dependency isolation num_gpus=1, # if each item needs GPU; otherwise omit num_cpus=2, # tune as needed runtime_env=runtime_env # per-batch Python deps ): futures = [ray_unit_of_work.submit(x) for x in batch] results.extend([f.result() for f in futures]) return results if name == "__main__": # Optional: pass params from FLOW_PARAMS_JSON params = json.loads(os.environ.get("FLOW_PARAMS_JSON", "{}")) main_flow() ``` What you’ll see in Prefect - All Prefect tasks (preproc + each ray_unit_of_work call) with full state in the UI. - Ray-internal tasks only show up in the Ray dashboard if you create them directly. If you truly need different container images per “inner job” - Ray on Kubernetes does not change the worker container image per task. To vary base images/CUDA versions: - Pre-provision multiple KubeRay worker groups with different images and expose them via custom Ray resources; route tasks with
remote_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 design
- Do your “inner jobs” need different base images/CUDA/system libraries, or is “different Python packages” sufficient? If it’s the latter, the per-task runtime_env approach above will give you dynamic requirements with no infra changes. If it’s the former, we’ll need to plan worker groups (or sub-deployments) per image.
c
@Marvin the job submission flow spins up and exits - how do i then connect it to the main_flow flow run
m
thought for 161.0 seconds
Great question. The clean way to “connect” the short-lived launcher to the actual main_flow run is: - Create the main_flow run first (as a child of the launcher) using run_deployment(as_subflow=True). - Pass that flow_run_id into your Ray Job. - In the Ray Job entrypoint, execute that specific flow run by id with prefect flow-run execute <id>. This preserves the parent/child relationship in the Prefect UI while letting the real work happen inside the Ray Job driver environment. Wiring pattern 1) Launcher flow (runs on your normal Prefect worker) - Creates the child run (linked as a subflow to the launcher) - Submits a Ray Job to execute that run inside the cluster
Copy code
from 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)
Copy code
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 tips
- Ensure the job driver has PREFECT_API_URL and PREFECT_API_KEY in its runtime_env env_vars so it can report to Prefect. - If your deployment uses flow.from_source(...).deploy(...), Prefect will fetch your flow code in the Ray Job environment automatically based on the deployment’s source settings. No image rebuild required. Avoid deprecated 2.x patterns like Deployment.build_from_flow or prefect deployment build. - For very large fan-out, group items by identical pip specs so Ray reuses cached environments. Useful docs - Run a specific flow run by id: prefect flow-run execute --help (verified) - Run a deployment programmatically: run_deployment - Prefect + Ray integration: prefect-ray docs - Ray Jobs: Ray Job submission - Ray runtime envs: Runtime environments and dependencies If you share your deployment name and whether you’re using flow.from_source for code loading, I can tailor the launcher snippet precisely (including waiting for completion, error propagation, and tagging).
c
@Marvin lets go back to the sidecar solution. assume that I have: 1. 5 environments: prod, staging, etc. 2. 10 job types: ml_detection_1, ml_detection_2, ml_segmentation_1, ... each environment and each job type has its own image. in addition pull requests and branches need their own images. how would the sidecar pattern work with some many different environments. dont write any code, just explain
m
thought for 98.2 seconds
Here’s how the sidecar pattern scales when you have many environments and many job types, each with their own image (and even PR/branch-specific images). I’ll break it down by responsibilities and the practical options you have, plus their trade-offs. What the sidecar gives you - The Prefect worker (driver) runs inside a Ray pod so you use native Ray. The Ray head never imports your code. - But: anything imported at module scope (e.g., decorators) must exist in the driver’s environment (the sidecar container), not just on Ray workers. Key constraints to keep in mind - Ray workers’ container image is determined by KubeRay worker groups. You cannot change the container image per Ray task. You can dynamically install Python packages per task with Ray’s
runtime_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.
- If you choose multiple driver sidecars (replicas), they will share the pool and split runs. - Scale Ray worker groups independently based on job family load. 5) Handling PRs and branches - If PR/branch changes are Python-only, rely on per-task/runtime_env for workers and per-run venv bootstrap for the driver. No K8s changes needed. - If PRs require a new base image (e.g., new CUDA/system libs), you’ll need to: - Temporarily add a new worker group with that image to the target environment’s RayCluster (and optionally a matching driver sidecar image if you’re not using venv bootstrap), then route those runs to the new resource; or - Use a dedicated “preview” RayCluster per PR/feature (heavier but fully isolated). - In all cases, use a naming convention and labels (env, job type, branch) to keep routing explicit and auditable. Putting it together across your matrix - One RayCluster per environment (prod, staging, etc.). - Worker groups per job family (and GPU/CPU class) with their own images; route tasks by custom resource. - Driver pattern: - If you can tolerate per-run install time: one generic driver image per environment + per-run venv bootstrap → scales well with many job types and PRs. - If you need zero-install startup and strict immutability: a small set of driver sidecars per environment and job type → fast but more infra churn as versions change. - For dynamic Python deps inside the Ray workers: rely on runtime_env pip/py_modules/working_dir so you don’t change K8s for every version bump. - For truly different system stacks per inner job: plan additional worker groups (and possibly a preview RayCluster) for those images; there isn’t a per-task image swap in Ray. Trade-offs summary - Sidecar is great to avoid Ray Client and keep the head dependency-free. But driver dependencies must still be solved: either by many driver images (Option A) or by per-run venv bootstrap (Option B). - Per-task Python deps on workers are easy with runtime_env; per-task base images are not—pre-provision worker groups per base image you need. - If the matrix of images/branches becomes too dynamic for K8s CRD updates, consider moving the driver to Ray Jobs (per-run driver pod) for that workload class while keeping the rest on sidecars. Useful references - Prefect + Ray: prefect-ray docs - KubeRay on Kubernetes: KubeRay docs - Ray runtime envs (for Python-only variance): Runtime environments and dependencies - Ray GPU resources and placement: GPU scheduling in Ray If you confirm whether your job-type and PR differences are primarily Python-only or include different CUDA/system libs, I can recommend a concrete split (which worker groups you need and which parts can be handled by per-run venv and runtime_env) and sizing guidance for driver vs workers.
c
@Marvin how would: per-run bootstrapping work?
m
thought for 63.7 seconds
Here’s the per-run bootstrapping approach in the sidecar pattern, step by step, without code. High-level goal - Keep a single, lean “driver” image for the Prefect worker sidecar. - For each flow run, build/activate an isolated Python environment inside that container before any of your flow modules are imported. This satisfies decorator/module-scope imports without rebuilding images. - Then run the flow in that environment and use RayTaskRunner for the large fan-out, applying Ray runtime_env per task as needed. How a single run proceeds 1) Determine the dependency spec for this run - Decide where the run-specific requirements come from (e.g., deployment parameters, tags, a lockfile path in source, PR/branch name to look up a requirement set). - Resolve that to a concrete spec: exact pip requirements, optional local wheels/artifacts, and any constraint files. 2) Compute a cache key - Hash the dependency spec (e.g., contents of requirements + constraints + Python minor version). - This key identifies a reusable environment. If another run asks for the same spec, reuse it; if not, build a new one. 3) Build or reuse an environment - Check a local cache directory in the sidecar for an existing env by that key. - If missing, create a new isolated env (venv/uv/conda—pick one org-wide) and install the requested packages. - Use a shared wheel/cache dir to speed up cold starts. Consider a small persistent volume if you want cache to survive pod restarts. 4) Activate the env for the driver subprocess - The Prefect worker will spawn a subprocess per flow run; ensure that subprocess launches under the just-built env so all module-scope imports and decorators resolve there. - Pass through Prefect credentials (PREFECT_API_URL, PREFECT_API_KEY), the Ray address, and any run parameters as environment variables to that subprocess. - Set a reasonable timeout and resource limits for the bootstrap phase. 5) Import and execute the flow - Only after the env is active, import your flow module (so its decorators and module-scope imports see the run’s env). - The flow uses RayTaskRunner with the cluster address. The Ray head still orchestrates, but it never imports your code. 6) Delegate dynamic worker deps to Ray - For per-task Python-only differences (e.g., “this batch needs transformers, that batch needs xgboost”), set Ray per-task runtime_env via Prefect-Ray remote options so Ray installs those deps for the worker that executes that task. - Group items by identical runtime_env to maximize Ray’s environment cache reuse. 7) Cleanup and lifecycle - Keep a bounded LRU of envs (by last-used time or total disk size). GC old envs on a schedule or when thresholds are hit. - Emit metrics for bootstrap time, cache hit rate, and failure reasons to monitor cold-start behavior. Operational shape across many environments/job types - One RayCluster per environment (prod, staging, …) to isolate secrets, limits, and upgrades. - In each RayCluster: - Driver sidecar pattern: one or more pods running the Prefect worker sidecar + Ray node. These use per-run bootstrap to create driver envs on demand, avoiding driver image sprawl. - Worker groups per job family or hardware class (e.g., different CUDA/toolchains) with distinct base images. Route Ray tasks using custom resources; use Ray runtime_env for Python variance. - Routing: - Prefect pools per environment; queues per job type/branch if you want explicit control. - A convention (tags/parameters) maps a run to its dependency spec and, if needed, to a specific Ray resource (worker group). Performance and reliability considerations - Cold starts: first time a spec is seen, installs add latency; mitigate with caching, prewarming popular specs, and grouping runs by spec. - Concurrency: cap concurrent driver processes with a Prefect work pool concurrency limit and/or run multiple sidecars (replicas) to spread driver load.
- Security/compliance: restrict package indexes, validate wheels, and pin versions where required. Keep base images minimal but include system libs you don’t want to pip-install repeatedly. - GPU: if preprocessing needs GPU in the driver, the sidecar pod must be GPU-enabled. Prefer offloading GPU work to Ray tasks so the driver can remain CPU-only. When to reconsider - If the number of unique, frequently-changing specs is very high and cold starts dominate, Ray Jobs may be simpler: the job’s driver inherits a runtime_env per run, eliminating the need for your own bootstrap shim. - If you need different base images/CUDA per inner task, pre-provision multiple Ray worker groups per image and route tasks; there is no per-task image swap in Ray. References - Prefect + Ray integration: prefect-ray docs - Ray runtime environments (task/actor/job deps): Runtime environments and dependencies - KubeRay: Ray on Kubernetes
c
@Marvin can you show an example of this per-run bootstrapping?
m
thought for 96.9 seconds
Absolutely—here’s a concrete, end-to-end example of per-run bootstrapping in the sidecar pattern. The idea is: for each flow run, the sidecar creates (or reuses) an isolated Python environment based on a run-specific requirements spec, then executes the flow run inside that environment. This ensures your decorators and module-scope imports resolve in the correct deps without rebuilding images. What this example shows - A shell entrypoint that: - Waits for the local Ray node to be ready (so
address="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)
Copy code
#!/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.
# Since our command is a CLI ("prefect flow-run execute <id>"), we can call it via python -m. # But Prefect exposes it as an entrypoint; using the venv's bin/ preferrable: bin_dir = venv_dir / "bin" # If command starts with 'prefect', resolve to the venv's 'prefect' binary parts = cmd.strip().split() if parts and parts[0] == "prefect": parts[0] = str(bin_dir / "prefect") os.execvpe(parts[0], parts, env) else: # For arbitrary commands, run them via /bin/sh -c so PATH includes venv/bin env["PATH"] = f"{bin_dir}:{env.get('PATH','')}" os.execle("/bin/sh", "/bin/sh", "-c", cmd, env) def main(): ap = argparse.ArgumentParser() ap.add_argument("--run-spec", required=True, help="JSON spec, e.g. {\"pip\": [\"torch==2.2.1\"]}") ap.add_argument("--venv-root", required=True) ap.add_argument("--wheel-cache", required=True) ap.add_argument("--cmd", required=True, help="Command to exec inside the env, e.g. 'prefect flow-run execute <id>'") args = ap.parse_args() spec = json.loads(args.run_spec) pip = spec.get("pip", []) venv_root = Path(args.venv_root) wheel_cache = Path(args.wheel_cache) venv_root.mkdir(parents=True, exist_ok=True) wheel_cache.mkdir(parents=True, exist_ok=True) key = hash_spec({"pip": pip, "py": f"{sys.version_info.major}.{sys.version_info.minor}"}) venv_dir = venv_root / key # Build or reuse ensure_env(venv_dir, wheel_cache, pip) # Exec the provided command inside this env exec_in_env(venv_dir, args.cmd) if name == "__main__": main() ``` How you’d wire this in practice - The sidecar container image is lean (Python, Prefect, Ray) and includes this bootstrap.py. - The sidecar’s command uses the entrypoint above. You pass three key bits to the pod via env: - FLOW_RUN_ID: the id of the run to execute - RUN_SPEC_JSON: the per-run pip requirements for the driver (so your decorators/module-scope imports work) - PREFECT_API_URL and PREFECT_API_KEY: so the run can report state/logs to Prefect - The entrypoint waits for the local Ray node socket (because you’re sidecaring into a Ray pod) and then execs
prefect 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 Kubernetes
c
@Marvin RUN_SPEC_JSON is passed to the prefect job as a job_variable/environment variable?
m
thought for 27.9 seconds
Yes—there are two clean ways to pass RUN_SPEC_JSON into the sidecar so the bootstrapper can see it: Option A: Prefect job variables (preferred for deployments) - In Prefect 3.x deployments, you can set job_variables on the deployment. These are injected as environment variables into the process that executes the flow run. - Your launcher (or the sidecar entrypoint) reads RUN_SPEC_JSON from the environment and hands it to the bootstrapper. - If you’re using flow.from_source(...).deploy(...), you can set or override job_variables per deployment or at trigger time via the API/CLI. - This keeps the value in Prefect metadata and the audit trail. Option B: Kubernetes env (if you control the pod template) - If you run a dedicated sidecar process as the driver and you own the RayCluster YAML, you can set an env var on that container directly. This is more static; to vary per run, you generally prefer Option A. Typical pattern with Option A - At deploy time, set a default RUN_SPEC_JSON in job_variables (e.g., a base requirements set). - At trigger time (e.g., for PRs or branches), override RUN_SPEC_JSON by passing parameters that your flow/launcher converts into the desired spec and sets as a job variable for that run. - The sidecar entrypoint reads RUN_SPEC_JSON from env and feeds it to the bootstrapper. Notes - Keep RUN_SPEC_JSON small enough for env var limits; if it’s large, store the spec in S3/GCS and pass a URI in RUN_SPEC_JSON instead. - If you need different specs per task on the worker side, still use Ray’s per-task runtime_env via remote_options; RUN_SPEC_JSON is only for the driver’s environment (so that decorators/module-scope imports resolve). If you share how you’re creating your deployments (via code with flow.from_source(...).deploy(...) or CLI), I can show exactly where to set job_variables so RUN_SPEC_JSON appears in the flow run environment.
c
@Marvin What infra would be required to use the experimental @kubernetes decorator if I wanted to use that instead of ray? Remember that I already have GPU workpools set up
m
thought for 149.0 seconds
Here’s the infrastructure you need (and what to expect) to use the experimental @kubernetes decorator instead of Ray, given you already have GPU work pools. What @kubernetes does - It binds a flow to Kubernetes infrastructure: each flow run is executed as a single Kubernetes Job/Pod. - It’s flow-level only. Tasks do not get their own Pods; all tasks run inside that one Job container. - It’s experimental in the prefect-kubernetes collection and may change. Infra checklist - Kubernetes per environment - A K8s cluster (or clear namespace boundaries) for each of your environments (prod, staging, etc.). - NVIDIA device plugin installed on GPU nodes (you already have this for your GPU pools). - Prefect 3.x + Work pools - Prefect Cloud (or Server) reachable from the cluster. - One Kubernetes work pool per environment (you’ve got GPU pools already; you can reuse them). - The pool’s base job template should encode: - Namespace, serviceAccountName, imagePullSecrets - Node selectors/affinity/tolerations for GPU nodes - Resource limits/requests (e.g., limits.nvidia.com/gpu: "1", CPU/memory) - Default image (you can override per deployment or per run via job_variables) - Prefect Kubernetes Worker(s) in each environment - A Prefect Kubernetes worker running in the cluster/namespace to poll your Kubernetes work pool(s) and create Jobs for flow runs. - RBAC for the worker’s service account to create/watch/delete Jobs and Pods in the target namespace(s). - Network egress so Job Pods can reach the Prefect API. - Container registry - All images for your environments/job types/PRs must be available in a registry your cluster can pull from. - Use imagePullSecrets if the registry is private. - Code loading/storage - Your deployments should be created with a source-based configuration (e.g., flow.from_source(...).deploy(...)) or other supported storage so the Job can fetch and run your flow code in the cluster. - Ensure any external storage (Git, artifact storage) is reachable by the Job Pod. - Optional (based on needs) - PVCs/volumes for model caches, datasets, or scratch if your flows need them. - Pod security context, network policies, proxy/CA certs as required by your environment. How this works with your existing GPU work pools - Reuse each environment’s GPU work pool and point @kubernetes at it by name. - Put GPU scheduling knobs in the pool’s base job template (nodeSelector/tolerations/resources); this keeps the decorator simple. - For many images (job types, PRs, branches), manage image selection via deployments/job_variables: - Create separate deployments per job type (each with its own image) targeting the same GPU pool; or - Override the image per run by passing job_variables when starting the run (no cluster changes required). - Concurrency still comes from running multiple flow runs; since each flow is one Pod, you scale by runs, not tasks. Limitations to be aware of - No per-task Pods: you cannot fan out tasks across multiple Kubernetes Pods with this decorator. If you need thousands of parallel units across GPUs, Ray/Dask is a better fit. - One container image per flow run: choose the image (via pool default or job_variables) for that Job/Pod. - Experimental: API and behavior may evolve. Docs and references - Prefect Kubernetes integration: prefect-kubernetes - Experimental decorators overview: experimental decorators - Kubernetes worker how-to: Kubernetes worker guide - Prefect deployments (3.x): Deployments overview If you confirm how you want to select images (separate deployments per job type vs. override image per run), I can outline the cleanest workflow for your prod/staging/PR environments using your existing GPU pools.
c
@Marvin every flow which has been decorated with @kubernetes must be have a deployment?
m
thought for 15.4 seconds
Short answer: no, a deployment is not strictly required to use the experimental @kubernetes decorator. How it works - The decorator “binds” your flow to a Kubernetes work pool. When you call the flow, Prefect creates a flow run and provisions a single Kubernetes Job/Pod via that pool. This can work without creating a deployment, as long as: - You have a Kubernetes work pool configured - A Kubernetes worker is online to provision the Job - Your flow code is available to the running Pod (e.g., via source loading or image contents) When you still want a deployment - If you need to: - Start runs from the Prefect UI - Schedule runs - Capture versioned source and default job variables centrally - Promote across environments in a reproducible way …then create a deployment. The decorator’s infrastructure binding becomes the default for that deployment, and you can still override job variables per run. Important notes - @kubernetes is flow-level: it launches one Kubernetes Job per flow run; tasks do not get their own Pods. - You still need a Kubernetes work pool and a running Kubernetes worker with RBAC to create Jobs in your namespace. - It’s experimental (APIs may change). Docs - Prefect Kubernetes integration: prefect-kubernetes - Experimental decorators overview: experimental decorators
c
@Marvin suppose I have a deployment with image A. This deployment is an entry point which is called from the UI. It then calls flow_decorated which is decorated with kubernetes for a pool-GPU. Q1) which Image will flow_decorated run in? Q2) how do I specify an image B to flow_decorated?
m
thought for 29.4 seconds
Q1) Which image does flow_decorated run in? - The @kubernetes-decorated flow runs in the image resolved by the Kubernetes work pool/job variables for that flow run. It does not inherit the caller’s deployment image (A). - Resolution order in practice: 1) Image specified on the decorator for flow_decorated (via job variables/kwargs) 2) Image set on the target work pool’s base job template (pool-GPU) 3) The worker’s default image (if neither is set) - Net: flow_decorated will run in whatever image the pool-GPU job template (or the decorator) dictates, not image A. Q2) How do I make flow_decorated run in image B? - Set the image for that flow run via one of these: - On the decorator: configure the @kubernetes decorator for flow_decorated to include image B (as a job variable). This becomes the default image whenever that decorated flow is invoked. - In the work pool: set image B in the pool-GPU base job template (or create a pool/queue variant for that job type with image B). The decorator points at that pool. - With a dedicated deployment for flow_decorated: create a deployment that targets pool-GPU and sets its job_variables.image to B, then trigger that deployment (from the UI or programmatically). This is the most explicit way to guarantee a specific image per run. - If you need per-run dynamic images without changing infra state each time, the experimental decorator does not currently support “per-call” image overrides; use a deployment per image variant (or the Ray Jobs approach for per-run driver images). Notes and implications - @kubernetes is flow-level: the entire flow (and all its tasks) runs in a single Kubernetes Job/Pod using one image. - Your entry-point deployment with image A can call a @kubernetes flow that runs in image B on pool-GPU; they are isolated executions linked only by parent/child flow relationship. - Continue using your existing GPU work pools by pointing the decorator (or the deployment) at pool-GPU and setting image B via pool job template or deployment/job variables. Docs - Prefect Kubernetes integration: prefect-kubernetes - Experimental decorators: experimental decorators - Kubernetes worker (job variables and base job template): Kubernetes worker guide
c
@Marvin can you show a code example of this pattern
m
thought for 40.8 seconds
Here’s a minimal example of the pattern you described: a UI-triggered “entrypoint” flow (deployment uses image A) that calls another flow decorated with @kubernetes which runs on a GPU work pool in image B.
Copy code
# 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 guide
c
@Marvin Since I can call @kubernetes with a namespace, can I submit it to a ray-worker-only namespace? And then I can ensure that Ray native is used for raytaskrunner? @kubernetes( work_pool="my-kubernetes-pool", namespace="ray-worker-only" ) @flow def custom_namespace_flow(): pass
m
thought for 41.6 seconds
Short answer: no. Setting
namespace="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 docs
c
@Marvin Option 3 means basically doing this prior to ray init: ray start --address=head-node:10001?
m
thought for 35.0 seconds
Not quite. Starting a native Ray node means joining the cluster’s GCS address, not the Ray Client port. - Ray Client = port 10001 (ray://...). That’s the mode you’re trying to avoid. - Native Ray node join = GCS port (typically 6379) on the head service. So “Option 3” would look like: - In your @kubernetes Job Pod, start a local raylet that joins the Ray cluster via the head’s GCS address. - Then your flow process can use RayTaskRunner(address="auto") to attach to the local raylet. Conceptually:
Copy code
# 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 Kubernetes
c
@Marvin what do you think of this approach: Implementing this in Prefect 3 is remarkably clean because you can hijack the Kubernetes Pod's entrypoint directly from your Python deployment script using job_variables. You don't have to touch your overarching infrastructure repositories or mess with Helm charts. You simply tell the Prefect Work Pool to override the default command for this specific deployment. Here is the exact implementation, split into your flow code and your deployment script. 1. The Flow Code (src/flow.py) This file remains completely ignorant of Kubernetes. It just assumes it is running on a node that has a local Ray daemon running (which we will guarantee in step 2). import torch from prefect import flow, task from prefect_ray.task_runners import RayTaskRunner @task def gpu_inference_task(partition_id: int): # This executes natively on your heavy KubeRay worker pods device = torch.device("cuda" if torch.cuda.is_available() else "cpu") return f"Processed {partition_id} on {device}" # Address "auto" forces it to look for the local Ray daemon @flow(task_runner=RayTaskRunner(address="auto")) def heavy_execution_flow(num_partitions: int = 10000): # Fanning out 10,000 tasks directly to the cluster futures = [gpu_inference_task.submit(i) for i in range(num_partitions)] return [f.result() for f in futures] 2. The Prefect 3 Deployment Script (deploy.py) This is where the magic happens. In Prefect 3, you use the .deploy() method on your flow. We use the job_variables argument to overwrite the Kubernetes Work Pool's default command. from src.flow import heavy_execution_flow if name == "__main__": # Define the address of your permanent KubeRay Head Node service RAY_HEAD_ADDRESS = "ray-head-svc.ray-prod.svc.cluster.local:6379" # The bash script that decapitates the proxy driver limitation # 1. Starts the ray daemon, linking it to the cluster # 2. Requests 0 CPUs and 0 GPUs so the scheduler ignores this pod # 3. Sleeps for 5 seconds to ensure the daemon registers with the GCS # 4. Boots the standard Prefect engine to run your flow hijacked_entrypoint = [ "/bin/bash", "-c", f"ray start --address={RAY_HEAD_ADDRESS} --num-cpus=0 --num-gpus=0 --block & sleep 5 && python -m prefect.engine" ] heavy_execution_flow.deploy( name="ml-detect-a-native-driver", work_pool_name="kubernetes-prod-pool", # Your standard K8s work pool image="my-registry/ml_detect_a:latest", # The HEAVY image containing Torch job_variables={ # This directly overrides the pod container's command array "command": hijacked_entrypoint, # Optional: Ensure the pod has enough memory to handle the Prefect orchestrator # tracking 10,000 futures, even though it isn't doing the ML compute. "env": { "RAY_DEDUP_LOGS": "0" } } ) How the Execution Actually Flows * You run python deploy.py from your CI/CD pipeline or laptop. It registers the deployment with the Prefect API. * The Master DAG orchestrator (running somewhere else) triggers this deployment via run_deployment("heavy_execution_flow/ml-detect-a-native-driver"). * The Prefect Kubernetes Worker sees the trigger and spins up a new Pod using your heavy ml_detect_a:latest image. * Kubernetes executes the job_variables["command"]. * The local Ray daemon boots up, connects to your KubeRay cluster on port 6379, and announces itself: "I am a worker, but I have 0 capacity." * Five seconds later, prefect.engine boots up in the same container. * Prefect loads src/flow.py, parses @torch.no_grad() perfectly (because it's using the heavy image), hits RayTaskRunner(address="auto"), and wires the 10,000 tasks directly to the Ray Global Control Store. * The flow finishes, prefect.engine exits, the container dies, and the local Ray daemon is gracefully severed from the cluster. The Critical Infrastructure Warning Because you are no longer tunneling through the Ray Client's single 10001 port, you are participating in the cluster's internal network mesh. You must ensure that your Kubernetes NetworkPolicies allow this ephemeral Prefect Pod to communicate freely across all ports (specifically TCP 6379, plus the dynamic high ports used by the Ray Object Manager and Node Manager) with the KubeRay Head and Worker pods. If your cluster has strict namespace-to-namespace ingress/egress blocking, the ray start command will connect, but the actual task distribution will hang indefinitely.
m
Your message was too long, here's your message at the allowed limit: what do you think of this approach Implementing this in Prefect 3 is remarkably clean because you can hijack the Kubernetes Pod's entrypoint directly from your Python deployment script using job_variables You don't have to touch your overarching infrastructure repositories or mess with Helm charts You simply tell the Prefect Work Pool to override the default command for this specific deployment Here is the exact implementation split into your flow code and your deployment script 1 The Flow Code (src/flow py) This file remains completely ignorant of Kubernetes It just assumes it is running on a node that has a local Ray daemon running (which we will guarantee in step 2) import torch from prefect import flow task from prefect_ray task_runners import RayTaskRunner @task def gpu_inference_task(partition_id int) # This executes natively on your heavy KubeRay worker pods device = torch device( cuda if torch cuda is_available() else cpu ) return f Processed {partition_id} on {device} # Address auto forces it to look for the local Ray daemon @flow(task_runner=RayTaskRunner(address= auto )) def heavy_execution_flow(num_partitions int = 10000) # Fanning out 10 000 tasks directly to the cluster futures = [gpu_inference_task submit(i) for i in range(num_partitions)] return [f result() for f in futures] 2 The Prefect 3 Deployment Script (deploy py) This is where the magic happens In Prefect 3 you use the deploy() method on your flow We use the job_variables argument to overwrite the Kubernetes Work Pool's default command from src flow import heavy_execution_flow if name == main # Define the address of your permanent KubeRay Head Node service RAY_HEAD_ADDRESS = ray-head-svc ray-prod svc cluster local 6379 # The bash script that decapitates the proxy driver limitation # 1 Starts the ray daemon linking it to the cluster # 2 Requests 0 CPUs and 0 GPUs so the scheduler ignores this pod # 3 Sleeps for 5 seconds to ensure the daemon registers with the GCS # 4 Boots the standard Prefect engine to run your flow hijacked_entrypoint = [ /bin/bash -c f ray start --address={RAY_HEAD_ADDRESS} --num-cpus=0 --num-gpus=0 --block & sleep 5 && python -m prefect engine ] heavy_execution_flow deploy( name= ml-detect-a-native-driver work_pool_name= kubernetes-prod-pool # Your standard K8s work pool image= my-registry/ml_detect_a latest # The HEAVY image containing Torch job_variables={ # This directly overrides the pod container's command array command hijacked_entrypoint # Optional Ensure the pod has enough memory to handle the Prefect orchestrator # tracking 10 000 futures even though it isn't doing the ML compute env { RAY_DEDUP_LOGS 0 } } ) How the Execution Actually Flows * You run python deploy py from your CI/CD pipeline or laptop It registers the deployment with the Prefect API * The Master DAG orchestrator (running somewhere else) triggers this deployment via run_deployment( heavy_execution_flow/ml-detect-a-native-driver ) * The Prefect Kubernetes Worker sees the trigger and spins up a new Pod using your heavy ml_detect_a latest image * Kubernetes executes the job_variables[ command ] * The local Ray daemon boots up connects to your KubeRay cluster on port 6379 and announces itself I am
c
@Marvin what do you think of this approach: In Prefect 3, you can hijack the Pod's entrypoint directly in your Python deployment script using job_variables. This overrides the Kubernetes Work Pool's default command, ensuring the local daemon starts before Prefect runs. 1. The Flow Code (src/flow.py) This file remains completely ignorant of Kubernetes. 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}" # Address "auto" forces it to look for the local Ray daemon @flow(task_runner=RayTaskRunner(address="auto")) 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. The Deployment Script (deploy.py) Use the .deploy() method and inject your custom bash script into job_variables. from src.flow import heavy_execution_flow if name == "__main__": RAY_HEAD = "ray-head-svc.ray-prod.svc.cluster.local:6379" # 1. Starts ray daemon linked to the cluster with 0 capacity # 2. Sleeps to ensure GCS registration, then boots Prefect hijacked_entrypoint = [ "/bin/bash", "-c", f"ray start --address={RAY_HEAD} --num-cpus=0 --num-gpus=0 --block & sleep 5 && python -m prefect.engine" ] heavy_execution_flow.deploy( name="ml-detect-a-native-driver", work_pool_name="kubernetes-prod-pool", image="my-registry/ml_detect_a:latest", # Heavy image containing Torch job_variables={"command": hijacked_entrypoint} # Overrides pod command ) The Execution Flow * Trigger: The Master DAG triggers run_deployment("heavy_execution_flow/ml-detect-a-native-driver"). * Pod Boot: Kubernetes spins up a Pod using your heavy ml_detect_a image and executes the overridden command. * Ray Registration: The local Ray daemon connects to your KubeRay cluster (port 6379). By specifying 0 capacity, the Ray scheduler safely ignores this Pod. * Execution: Five seconds later, prefect.engine boots, successfully parses @torch dependencies, and address="auto" routes the 10,000 tasks directly to the Ray Global Control Store. * Teardown: Once complete, the container cleanly exits, severing the local daemon without orphaning any compute tasks. Critical Network Warning Because you are no longer tunneling through the Ray Client port (10001), this Ephemeral Pod must have unrestricted network access to all Ray cluster ports (TCP 6379, plus dynamic high ports for the Object and Node Managers). Strict cross-namespace firewalls will cause task distribution to hang indefinitely.
m
thought for 111.7 seconds
I like the intent, but a few critical adjustments will make this robust and “Prefect-native.” What’s good about your approach - You’re joining the cluster as a native Ray node (ray start …6379), then using RayTaskRunner(address="auto"). That avoids Ray Client and the head-server import trap. - You’re packaging torch and other heavy deps into the Job’s image so module-scope imports succeed. - You’re keeping the flow code Kubernetes-agnostic. What to change 1) Use the correct Prefect entrypoint - The Kubernetes worker expects to run the flow with: - Command: prefect flow-run execute - Flow run id is injected via PREFECT__FLOW_RUN_ID env var by the worker - Replace python -m prefect.engine with prefect flow-run execute. The engine module isn’t the supported entrypoint and may break. - Keep your hijacked “command” as a shell pipeline that starts Ray then execs prefect flow-run execute. 2) Don’t use the Ray Client port - You already did this, but to reiterate: join the GCS on 6379, not 10001. 10001 is Ray Client. 3) You don’t need --num-cpus/--num-gpus=0 just to “hide” the pod - A joined node with 0 resources is valid, but it’s easy to misconfigure. If your flow submits work with num_gpus or num_cpus>0, Ray will schedule on other worker pods anyway. If you want to avoid accidental placement on the driver pod, using 0 resources is fine; just be explicit about resource requests in your tasks. 4) Ensure network access for native Ray - The Job Pod must reach the head’s GCS (6379) and peer with other Ray nodes (object store/worker ports). With strict network policies, you’ll need allow rules across namespaces. 5) Use job_variables correctly - In Prefect 3.x, the Kubernetes worker populates container args from the job variable command via shlex splitting. Provide a string or a list; if a string, quoting matters. - Example shape as a list: - command: ["/bin/bash","-lc","ray start --address=ray-head-svc.ray-prod.svc.cluster.local:6379 --block & sleep 5; prefect flow-run execute"] - The worker injects PREFECT__FLOW_RUN_ID, PREFECT_API_URL, PREFECT_API_KEY automatically; your command should not try to pass the run id explicitly. 6) Prefer deployment job_variables or per-run overrides - Set the command override at deployment time (image B), or override per run via job_variables when calling run_deployment. Flow.from_source(...).deploy(...) supports this. Keep pool defaults clean. Risks and trade-offs - You’re introducing an out-of-band Ray node that KubeRay doesn’t manage. That’s okay for a short-lived driver, but be aware autoscaler metrics won’t include it. - If many runs start simultaneously, you’re spinning many ephemeral raylets. Size the head (GCS) and network accordingly. - If your flow/driver needs GPUs (for preprocessing), request them in the Job; otherwise, keep driver CPU-only and push GPU work to Ray workers. Alternative you may like better - Ray Jobs give you a per-run driver inside the cluster with runtime_env and no sidecar or command hijack. Your Prefect launcher submits: - entrypoint: prefect flow-run execute <id> - runtime_env: pip + env vars This avoids ad-hoc raylets and uses Ray’s native driver lifecycle. Bottom line - Your pattern will work if you: - Start a raylet against the head’s GCS (6379) - Wait for /tmp/ray/session_latest to appear - Then exec prefect flow-run execute (not python -m prefect.engine) - Ensure network policies allow native Ray - Use job_variables.command to inject the shell that does the above, and specify the heavy image on the deployment that targets your Kubernetes work pool. References - Kubernetes worker command handling and job_variables: Kubernetes worker guide - Prefect CLI entrypoint: prefect flow-run execute --help - Ray Client vs native: Ray Client guide - KubeRay on Kubernetes: KubeRay docs
c
@Marvin can you make the suggest code changes
m
thought for 35.0 seconds
Absolutely — here are the concrete changes I recommend to your pattern. 1) Keep your flow code the same (uses Ray native)
Copy code
# 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
Copy code
# 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(...)
Copy code
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. References
- Kubernetes worker command handling and job variables: Kubernetes worker guide - Ray Client vs native: Ray Client guide - Prefect + Ray integration: prefect-ray docs
c
@Marvin will this solution interfere with rays autoscaling?
m
thought for 45.6 seconds
Short answer: it can, but you can largely avoid interference with a few guardrails. What can interfere - If the Job pod joins the cluster as a Ray node with non-zero resources, Ray’s autoscaler will count those resources toward available capacity. That can suppress scale-ups of your managed worker groups and may even schedule tasks onto the driver pod. - The autoscaler cannot terminate out-of-band nodes it did not create. If those nodes report resources, they can skew scale-down logic as well. - A large number of ephemeral driver nodes increases GCS/scheduler overhead (heartbeats, object refs), which can impact autoscaler responsiveness under high concurrency. How to avoid interference - Join with zero resources: - Start the driver node with no schedulable capacity, e.g.
--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.