Skip to content

Commit 6993059

Browse files
committed
✨ add run launcher and executor for KubeRay
1 parent 5d0c98d commit 6993059

5 files changed

Lines changed: 668 additions & 5 deletions

File tree

src/dagster_ray/core/executor.py

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
from __future__ import annotations
2-
31
from collections.abc import Iterator
42
from typing import TYPE_CHECKING, Any, cast
53

@@ -23,14 +21,14 @@
2321
except ImportError:
2422
# for new versions of dagster > 1.11.6
2523
from dagster._core.remote_origin import RemoteJobOrigin # pyright: ignore[reportMissingImports]
24+
2625
from dagster._utils.merger import merge_dicts
2726
from packaging.version import Version
2827
from pydantic import Field
2928

3029
from dagster_ray.configs import RayExecutionConfig, RayJobSubmissionClientConfig
3130
from dagster_ray.core.run_launcher import RayRunLauncher
32-
from dagster_ray.kuberay.utils import get_k8s_object_name
33-
from dagster_ray.utils import resolve_env_vars_list
31+
from dagster_ray.utils import get_k8s_object_name, resolve_env_vars_list
3432

3533
if TYPE_CHECKING:
3634
from ray.job_submission import JobSubmissionClient
@@ -129,7 +127,7 @@ def name(self):
129127

130128
def __init__(
131129
self,
132-
client: JobSubmissionClient,
130+
client: "JobSubmissionClient",
133131
env_vars: list[str] | None,
134132
runtime_env: dict[str, Any] | None,
135133
num_cpus: float | None,

src/dagster_ray/kuberay/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from dagster_ray.kuberay.configs import RayClusterConfig, RayClusterSpec, RayJobConfig, RayJobSpec
2+
from dagster_ray.kuberay.executor import kuberay_executor
23
from dagster_ray.kuberay.jobs import cleanup_kuberay_clusters, delete_kuberay_clusters
34
from dagster_ray.kuberay.ops import cleanup_kuberay_clusters_op, delete_kuberay_clusters_op
45
from dagster_ray.kuberay.pipes import PipesKubeRayJobClient
@@ -8,6 +9,7 @@
89
KubeRayInteractiveJob,
910
KubeRayJobClientResource,
1011
)
12+
from dagster_ray.kuberay.run_launcher import KubeRayRunLauncher
1113
from dagster_ray.kuberay.schedules import cleanup_kuberay_clusters_daily
1214

1315
__all__ = [
@@ -25,4 +27,6 @@
2527
"RayJobSpec",
2628
"KubeRayInteractiveJob",
2729
"KubeRayJobClientResource",
30+
"kuberay_executor",
31+
"KubeRayRunLauncher",
2832
]
Lines changed: 326 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,326 @@
1+
from collections.abc import Iterator
2+
from typing import Any, cast
3+
4+
import dagster as dg
5+
from dagster._core.definitions.executor_definition import multiple_process_executor_requirements
6+
from dagster._core.definitions.metadata import MetadataValue
7+
from dagster._core.events import DagsterEvent, EngineEventData
8+
from dagster._core.execution.retries import RetryMode, get_retries_config
9+
from dagster._core.execution.tags import get_tag_concurrency_limits_config
10+
from dagster._core.executor.base import Executor
11+
from dagster._core.executor.init import InitExecutorContext
12+
from dagster._core.executor.step_delegating import (
13+
CheckStepHealthResult,
14+
StepDelegatingExecutor,
15+
StepHandler,
16+
StepHandlerContext,
17+
)
18+
19+
try:
20+
from dagster._core.remote_representation.origin import RemoteJobOrigin
21+
except ImportError:
22+
# for new versions of dagster > 1.11.6
23+
from dagster._core.remote_origin import RemoteJobOrigin # pyright: ignore[reportMissingImports]
24+
from dagster._utils.merger import merge_dicts
25+
from packaging.version import Version
26+
from pydantic import Field
27+
28+
from dagster_ray.configs import Lifecycle, RayExecutionConfig
29+
from dagster_ray.kuberay.client import RayJobClient
30+
from dagster_ray.kuberay.client.base import load_kubeconfig
31+
from dagster_ray.kuberay.resources.base import BaseKubeRayResourceConfig
32+
from dagster_ray.kuberay.resources.rayjob import InteractiveRayJobConfig
33+
from dagster_ray.kuberay.utils import normalize_k8s_label_values
34+
from dagster_ray.utils import get_k8s_object_name
35+
36+
37+
class KubeRayExecutorConfig(BaseKubeRayResourceConfig, RayExecutionConfig):
38+
"""Configuration for the KubeRay executor."""
39+
40+
lifecycle: Lifecycle = Field(
41+
default_factory=lambda: Lifecycle(cleanup="on_exception"),
42+
description="Actions to perform during resource setup.",
43+
)
44+
45+
ray_job: InteractiveRayJobConfig = Field(
46+
default_factory=InteractiveRayJobConfig,
47+
description="Configuration for the Kubernetes `RayJob` CR",
48+
)
49+
50+
kube_context: str | None = Field(
51+
default=None,
52+
description="Kubernetes context to use. If not specified, uses the current context.",
53+
)
54+
55+
kube_config: str | None = Field(
56+
default=None,
57+
description="Path to the Kubernetes config file. If not specified, uses the default config.",
58+
)
59+
60+
log_cluster_conditions: bool = Field(
61+
default=True,
62+
description="Whether to log `RayCluster` conditions while waiting for the RayCluster to become ready. Learn more: [KubeRay docs](https://docs.ray.io/en/latest/cluster/kubernetes/user-guides/observability.html#raycluster-status-conditions).",
63+
)
64+
65+
timeout: float = Field(
66+
default=600.0,
67+
description="Timeout for various Kubernetes operations in seconds.",
68+
)
69+
70+
71+
_KUBERAY_CONFIG_SCHEMA = KubeRayExecutorConfig.to_config_schema().as_field()
72+
73+
_KUBERAY_EXECUTOR_CONFIG_SCHEMA = merge_dicts(
74+
{"kuberay": _KUBERAY_CONFIG_SCHEMA}, # type: ignore
75+
{"retries": get_retries_config(), "tag_concurrency_limits": get_tag_concurrency_limits_config()},
76+
)
77+
78+
79+
@dg.executor(
80+
name="kuberay",
81+
config_schema=_KUBERAY_EXECUTOR_CONFIG_SCHEMA,
82+
requirements=multiple_process_executor_requirements(),
83+
)
84+
def kuberay_executor(init_context: InitExecutorContext) -> Executor:
85+
"""Executes steps by submitting them as KubeRay jobs.
86+
87+
Each step is executed as a separate Ray Job on a Kubernetes cluster using the KubeRay operator.
88+
This executor provides automatic cluster management and cleanup through Kubernetes native resources.
89+
90+
Example:
91+
Use `kuberay_executor` for the entire code location
92+
```python
93+
import dagster as dg
94+
from dagster_ray import kuberay_executor
95+
96+
kuberay_executor = kuberay_executor.configured(
97+
{
98+
"kuberay": {
99+
"ray_job": {
100+
"spec": {
101+
"rayClusterSpec": {
102+
"headGroupSpec": {
103+
"template": {
104+
"spec": {
105+
"containers": [
106+
{
107+
"name": "ray-head",
108+
"image": "rayproject/ray:2.0.0",
109+
}
110+
]
111+
}
112+
}
113+
}
114+
}
115+
}
116+
}
117+
}
118+
}
119+
)
120+
121+
defs = dg.Definitions(..., executor=kuberay_executor)
122+
```
123+
124+
Example:
125+
Override configuration for a specific asset
126+
```python
127+
import dagster as dg
128+
129+
@dg.asset(
130+
op_tags={"dagster-ray/config": {"num_cpus": 2}}
131+
)
132+
def my_asset(): ...
133+
```
134+
"""
135+
exc_cfg = init_context.executor_config
136+
kuberay_cfg = KubeRayExecutorConfig(**exc_cfg["kuberay"]) # type: ignore
137+
138+
load_kubeconfig(context=kuberay_cfg.kube_context, config_file=kuberay_cfg.kube_config)
139+
client = RayJobClient(kube_context=kuberay_cfg.kube_context, kube_config=kuberay_cfg.kube_config)
140+
141+
return StepDelegatingExecutor(
142+
KubeRayStepHandler(
143+
client=client,
144+
config=kuberay_cfg,
145+
),
146+
retries=RetryMode.from_config(exc_cfg["retries"]), # type: ignore
147+
max_concurrent=dg._check.opt_int_elem(exc_cfg, "max_concurrent"),
148+
tag_concurrency_limits=dg._check.opt_list_elem(exc_cfg, "tag_concurrency_limits"),
149+
should_verify_step=True,
150+
)
151+
152+
153+
class KubeRayStepHandler(StepHandler):
154+
@property
155+
def name(self):
156+
return "KubeRayStepHandler"
157+
158+
def __init__(
159+
self,
160+
client: RayJobClient,
161+
config: KubeRayExecutorConfig,
162+
):
163+
super().__init__()
164+
self.client = client
165+
self.config = config
166+
167+
def _get_step_key(self, step_handler_context: StepHandlerContext) -> str:
168+
step_keys_to_execute = cast(list[str], step_handler_context.execute_step_args.step_keys_to_execute)
169+
assert len(step_keys_to_execute) == 1, "Launching multiple steps is not currently supported"
170+
return step_keys_to_execute[0]
171+
172+
def _get_ray_job_name(self, step_handler_context: StepHandlerContext) -> str:
173+
step_key = self._get_step_key(step_handler_context)
174+
175+
name_key = get_k8s_object_name(
176+
step_handler_context.execute_step_args.run_id,
177+
step_key,
178+
)
179+
180+
if step_handler_context.execute_step_args.known_state:
181+
retry_state = step_handler_context.execute_step_args.known_state.get_retry_state()
182+
if retry_state.get_attempt_count(step_key):
183+
return f"dagster-step-{name_key}-{retry_state.get_attempt_count(step_key)}"
184+
185+
return f"dagster-step-{name_key}"
186+
187+
def _get_dagster_tags(self, step_handler_context: StepHandlerContext) -> dict[str, str]:
188+
"""Get standardized Dagster tags for labeling Kubernetes resources."""
189+
run = step_handler_context.dagster_run
190+
step_key = self._get_step_key(step_handler_context)
191+
192+
tags = {
193+
"dagster/job": run.job_name,
194+
"dagster/op": step_key,
195+
"dagster/run-id": step_handler_context.execute_step_args.run_id,
196+
"dagster/deployment": self.config.deployment_name,
197+
}
198+
199+
if Version(dg.__version__) >= Version("1.8.12"):
200+
remote_job_origin = run.remote_job_origin # type: ignore
201+
else:
202+
remote_job_origin = run.external_job_origin # type: ignore
203+
204+
remote_job_origin = cast(RemoteJobOrigin | None, remote_job_origin)
205+
206+
if remote_job_origin:
207+
tags["dagster/code-location"] = remote_job_origin.repository_origin.code_location_origin.location_name
208+
209+
return tags
210+
211+
def _create_ray_job_spec(self, step_handler_context: StepHandlerContext) -> dict[str, Any]:
212+
"""Create the RayJob specification for the step."""
213+
run = step_handler_context.dagster_run
214+
step_key = self._get_step_key(step_handler_context)
215+
216+
# Get user-provided step configuration
217+
user_provided_config = RayExecutionConfig.from_tags({**step_handler_context.step_tags[step_key]})
218+
219+
# Create the base RayJob spec
220+
ray_job_name = self._get_ray_job_name(step_handler_context)
221+
222+
ray_job_spec = self.config.ray_job.to_k8s(
223+
context=step_handler_context, # type: ignore
224+
image=(self.config.image or run.tags.get("dagster/image")),
225+
labels=normalize_k8s_label_values(self._get_dagster_tags(step_handler_context)),
226+
env_vars=self._get_env_vars_to_inject(step_handler_context),
227+
)
228+
229+
ray_job_spec["metadata"]["name"] = ray_job_name
230+
ray_job_spec["metadata"]["namespace"] = self.config.ray_job.namespace
231+
232+
# Add the Dagster command to the entrypoint
233+
command_args = step_handler_context.execute_step_args.get_command_args(skip_serialized_namedtuple=True)
234+
ray_job_spec["spec"]["entrypoint"] = " ".join(command_args)
235+
236+
# Override resource requirements if specified in step tags
237+
if user_provided_config.num_cpus:
238+
ray_job_spec["spec"]["entrypointNumCpus"] = user_provided_config.num_cpus
239+
if user_provided_config.num_gpus:
240+
ray_job_spec["spec"]["entrypointNumGpus"] = user_provided_config.num_gpus
241+
if user_provided_config.memory:
242+
ray_job_spec["spec"]["entrypointMemory"] = user_provided_config.memory
243+
if user_provided_config.resources:
244+
ray_job_spec["spec"]["entrypointResources"] = user_provided_config.resources
245+
246+
return ray_job_spec
247+
248+
def _get_env_vars_to_inject(self, step_handler_context: StepHandlerContext) -> dict[str, str]:
249+
"""Get environment variables to inject into the Ray job."""
250+
run = step_handler_context.dagster_run
251+
step_key = self._get_step_key(step_handler_context)
252+
253+
env_vars = {
254+
"DAGSTER_RUN_JOB_NAME": run.job_name,
255+
"DAGSTER_RUN_STEP_KEY": step_key,
256+
**{env["name"]: env["value"] for env in step_handler_context.execute_step_args.get_command_env()},
257+
}
258+
259+
return env_vars
260+
261+
def launch_step(self, step_handler_context: StepHandlerContext) -> Iterator[DagsterEvent]:
262+
step_key = self._get_step_key(step_handler_context)
263+
ray_job_name = self._get_ray_job_name(step_handler_context)
264+
namespace = self.config.ray_job.namespace
265+
266+
yield DagsterEvent.step_worker_starting(
267+
step_handler_context.get_step_context(step_key),
268+
message=f'Executing step "{step_key}" in KubeRay job {namespace}/{ray_job_name}.',
269+
metadata={
270+
"RayJob Name": MetadataValue.text(ray_job_name),
271+
"Namespace": MetadataValue.text(namespace),
272+
},
273+
)
274+
275+
ray_job_spec = self._create_ray_job_spec(step_handler_context)
276+
277+
self.client.create(
278+
body=ray_job_spec,
279+
namespace=namespace,
280+
)
281+
282+
# Wait for the job to start running
283+
self.client.wait_until_running(
284+
name=ray_job_name,
285+
namespace=namespace,
286+
timeout=self.config.timeout,
287+
poll_interval=self.config.poll_interval,
288+
terminate_on_timeout=True,
289+
port_forward=False,
290+
log_cluster_conditions=self.config.log_cluster_conditions,
291+
)
292+
293+
def check_step_health(self, step_handler_context: StepHandlerContext) -> CheckStepHealthResult:
294+
step_key = self._get_step_key(step_handler_context)
295+
ray_job_name = self._get_ray_job_name(step_handler_context)
296+
namespace = self.config.ray_job.namespace
297+
298+
try:
299+
status = self.client.get_status(name=ray_job_name, namespace=namespace)
300+
except Exception as e:
301+
return CheckStepHealthResult.unhealthy(
302+
reason=f"KubeRay job {namespace}/{ray_job_name} for step {step_key} could not be found: {e}"
303+
)
304+
305+
job_status = status.get("jobStatus")
306+
307+
if job_status in ["FAILED", "STOPPED"]:
308+
message = status.get("message", "No message provided")
309+
return CheckStepHealthResult.unhealthy(
310+
reason=f"Discovered failed KubeRay job {namespace}/{ray_job_name} for step {step_key}. Status: {job_status}. Message: {message}"
311+
)
312+
313+
return CheckStepHealthResult.healthy()
314+
315+
def terminate_step(self, step_handler_context: StepHandlerContext) -> Iterator[DagsterEvent]:
316+
step_key = self._get_step_key(step_handler_context)
317+
ray_job_name = self._get_ray_job_name(step_handler_context)
318+
namespace = self.config.ray_job.namespace
319+
320+
yield DagsterEvent.engine_event(
321+
step_handler_context.get_step_context(step_key),
322+
message=f"Stopping KubeRay job {namespace}/{ray_job_name} for step",
323+
event_specific_data=EngineEventData(),
324+
)
325+
326+
self.client.terminate(name=ray_job_name, namespace=namespace, port_forward=False)

0 commit comments

Comments
 (0)