Skip to content

Commit 9bc2ca1

Browse files
nilsmechtelclaude
andauthored
feat(worker): opt-in container-image runtime for GPU apps (single-machine) (#156)
* feat(worker): opt-in container-image runtime for GPU apps (single-machine) Add an opt-in path to run a Ray Serve replica inside a prebuilt container image instead of the default pip/py_modules runtime. Pip stays the recommended default; this is strictly gated behind the worker flag --enable-container-runtime and the @bioengine.app(container_image=…) param. - decorators: container_image= param drops a {"container": {"image": …}} marker into runtime_env; raises if combined with pip (Ray forbids mixing a container runtime_env with pip/py_modules). - bootstrap: a container branch in build_and_run_application builds a minimal runtime_env ({container, env_vars} only — the sole keys Ray permits alongside container) and returns before the normal source/hook injection. The image is the sole source of code (bioengine + app source baked on PYTHONPATH). GPU wiring rides in podman run_options: --device nvidia.com/gpu=all + LD_LIBRARY_PATH to the driver libs. - ray_cluster: when enabled, generate a podman-4.9.3-compatible CDI spec at startup (nvidia-ctk, --disable-hook update-ldcache, down-version to cdiVersion 0.6.0, strip additionalGids + its orphaned bare-int items) and export the driver-lib dir for the replica LD_LIBRARY_PATH. - worker/__main__: --enable-container-runtime flag (Ray Cluster group, flows straight into RayCluster via ray_cluster_config). - docker/worker-container-runtime.Dockerfile: dedicated rootful worker image (ubuntu:24.04 for podman 4.9.3, nvidia-container-toolkit, Ray keep-id patch). Kept a separate image from the lean python:3.11-slim prod worker on purpose — the podman pin forces the heavier base and its CVE surface should stay out of the production worker's scan report. Single-machine mode only; KubeRay path unaffected. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * build(worker): add manual build script for container-runtime image Manual, on-demand builder for the container-as-runtime worker image (docker/worker-container-runtime.Dockerfile). Deliberately not wired into docker-publish-worker.yml — the niche, heavier ubuntu:24.04 variant is built only when needed and published as a separate GHCR package sharing the worker version. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore(release): bump version to 0.14.0 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent a7ea741 commit 9bc2ca1

8 files changed

Lines changed: 317 additions & 3 deletions

File tree

bioengine/_app/bootstrap.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -532,6 +532,42 @@ def _with_pkg(cls: Any, spec_ray_opts: Dict[str, Any]) -> Any:
532532
if _rk in (spec_ray_opts or {}):
533533
opts[_rk] = spec_ray_opts[_rk]
534534
runtime_env = dict(opts.get("runtime_env") or {})
535+
536+
container = runtime_env.get("container")
537+
if container:
538+
# Container-as-runtime (opt-in via @bioengine.app(container_image=…)).
539+
# The image is the sole source of code: it bakes bioengine + the
540+
# app source (on PYTHONPATH) + deps, so ``import bioengine`` and
541+
# ``import <entry>`` resolve natively at cloudpickle.loads and the
542+
# replica finder never needs to fetch source. Ray REJECTS any
543+
# runtime_env that pairs ``container`` with py_modules/pip/
544+
# worker_process_setup_hook (only env_vars/config are allowed),
545+
# so this branch deliberately builds a minimal runtime_env and
546+
# returns before the normal injection below.
547+
#
548+
# GPU + host wiring rides in podman ``run_options`` because Ray's
549+
# plugin hardcodes its base flags and only appends run_options;
550+
# ``--device nvidia.com/gpu=all`` is the CDI GPU injector and
551+
# ``LD_LIBRARY_PATH`` points at the driver libs the disabled
552+
# ``update-ldcache`` CDI hook would otherwise have wired up.
553+
run_options = list(container.get("run_options") or [])
554+
if opts.get("num_gpus"):
555+
run_options += ["--device", "nvidia.com/gpu=all"]
556+
ld_library_path = os.environ.get(
557+
"BIOENGINE_CONTAINER_LD_LIBRARY_PATH", "/usr/lib64"
558+
)
559+
run_options += ["-e", f"LD_LIBRARY_PATH={ld_library_path}"]
560+
opts["runtime_env"] = {
561+
"container": {
562+
"image": container["image"],
563+
"run_options": run_options,
564+
},
565+
"env_vars": {
566+
**replica_env_vars,
567+
**(runtime_env.get("env_vars") or {}),
568+
},
569+
}
570+
return cls.options(ray_actor_options=opts)
535571
# Ray Serve replicas do NOT inherit job-level py_modules (observed
536572
# empirically on KTH). The bioengine package has to ride in the
537573
# deployment's runtime_env to be on sys.path at

bioengine/_app/decorators.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,7 @@ def app(
199199
num_gpus: float = 0,
200200
memory_mb: Optional[int] = None,
201201
pip: Optional[List[str]] = None,
202+
container_image: Optional[str] = None,
202203
env_vars: Optional[Dict[str, str]] = None,
203204
max_ongoing_requests: int = 10,
204205
ray_actor_options: Optional[Dict[str, Any]] = None,
@@ -214,7 +215,15 @@ def app(
214215
convention internally.
215216
pip: Additional pip requirements for the replica's runtime_env on
216217
top of BioEngine's baseline (``hypha-rpc``, ``pydantic``,
217-
``httpx``, and the ``bioengine[worker]`` package).
218+
``httpx``, and the ``bioengine[worker]`` package). Mutually
219+
exclusive with ``container_image``.
220+
container_image: Opt-in alternative to pip: run the replica inside
221+
this prebuilt container image instead of installing deps
222+
per-actor. The image must be self-contained — Ray forbids
223+
combining ``container`` with ``py_modules``/``pip``, so the
224+
image has to bake ``bioengine`` plus the app source on
225+
``PYTHONPATH``. Requires the worker to run with
226+
``--enable-container-runtime`` (single-machine mode only).
218227
env_vars: Static env vars baked into the replica's runtime_env.
219228
Secrets passed at deploy time are layered on top.
220229
max_ongoing_requests: Concurrent request cap per replica.
@@ -234,6 +243,13 @@ def decorator(cls: type) -> Any:
234243
f"{type(cls).__name__}"
235244
)
236245

246+
if container_image and pip:
247+
raise ValueError(
248+
"@bioengine.app cannot combine 'container_image' with 'pip': "
249+
"Ray forbids mixing a container runtime_env with pip. Bake "
250+
"your dependencies into the image instead."
251+
)
252+
237253
_reject_reserved_names(cls)
238254
lifecycle, method_schemas = _scan_class(cls)
239255
composition_params = _extract_composition_params(cls)
@@ -253,6 +269,7 @@ def decorator(cls: type) -> Any:
253269
num_gpus=num_gpus,
254270
memory_mb=memory_mb,
255271
pip=pip,
272+
container_image=container_image,
256273
env_vars=env_vars,
257274
extra=ray_actor_options,
258275
)
@@ -381,6 +398,7 @@ def _build_ray_actor_options(
381398
num_gpus: float,
382399
memory_mb: Optional[int],
383400
pip: Optional[List[str]],
401+
container_image: Optional[str],
384402
env_vars: Optional[Dict[str, str]],
385403
extra: Optional[Dict[str, Any]],
386404
) -> Dict[str, Any]:
@@ -390,6 +408,13 @@ def _build_ray_actor_options(
390408
opts["memory"] = int(memory_mb) * 1024 * 1024
391409

392410
runtime_env: Dict[str, Any] = {}
411+
if container_image:
412+
# Marker only; the worker's bootstrap fills in the GPU/host
413+
# ``run_options`` at deploy time (it knows whether the container
414+
# runtime is enabled). Ray forbids ``container`` alongside pip/
415+
# py_modules, so the container branch in bootstrap._with_pkg
416+
# deliberately skips those.
417+
runtime_env["container"] = {"image": container_image}
393418
if pip:
394419
runtime_env["pip"] = list(pip)
395420
if env_vars:

bioengine/_version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,4 @@
1313
Must stay in lock-step with ``pyproject.toml``'s ``version`` field.
1414
The ``version-check.yml`` CI workflow enforces the match.
1515
"""
16-
__version__ = "0.13.0"
16+
__version__ = "0.14.0"

bioengine/cluster/ray_cluster.py

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import logging
33
import os
44
import re
5+
import shutil
56
import subprocess
67
import time
78
import uuid
@@ -93,6 +94,7 @@ def __init__(
9394
head_memory_in_gb: Optional[int] = None,
9495
runtime_env_pip_cache_size_gb: int = 30, # Ray default is 10 GB
9596
force_clean_up: bool = True,
97+
enable_container_runtime: bool = False,
9698
# SLURM Worker Configuration parameters
9799
image: str = f"ghcr.io/aicell-lab/bioengine-worker:{bioengine.__version__}",
98100
worker_workspace_dir: Optional[str] = None,
@@ -135,6 +137,10 @@ def __init__(
135137
head_memory_in_gb: Memory limit for head node in GB. If not set, Ray will auto-detect available memory.
136138
runtime_env_pip_cache_size_gb: Size of pip cache for runtime environments in GB. Default 30.
137139
force_clean_up: Force cleanup of previous Ray cluster on start. Default True.
140+
enable_container_runtime: Opt-in container-as-runtime for apps
141+
using ``@bioengine.app(container_image=…)`` (single-machine
142+
mode). Generates a podman-compatible CDI spec at startup for
143+
nested-GPU passthrough. Default False.
138144
image: Container image for workers (SLURM mode). Default bioengine-worker.
139145
worker_workspace_dir: Workspace directory mounted to worker containers (SLURM mode).
140146
default_num_gpus: Default GPU count per worker. Default 1.
@@ -174,6 +180,7 @@ def __init__(
174180
"Supported modes are 'slurm', 'single-machine' and 'external-cluster'."
175181
)
176182
self.mode = mode
183+
self.enable_container_runtime = bool(enable_container_runtime)
177184

178185
# Initialize cluster state and monitoring attributes
179186
self.address = None
@@ -582,6 +589,105 @@ def _update_symlink(self, ray_temp_dir: Path) -> None:
582589
self.logger.error(f"Symlink '{symlink_path}' does not exist")
583590
raise FileNotFoundError(f"Symlink '{symlink_path}' does not exist")
584591

592+
async def _generate_cdi_spec(self) -> None:
593+
"""Emit a podman-4.9.3-compatible CDI spec for nested-GPU passthrough.
594+
595+
Container-as-runtime replicas run in a nested podman container; the
596+
only GPU injector that works there is native podman CDI
597+
(``--device nvidia.com/gpu=all``). Two host-specific fixups are baked
598+
in because the recipe was validated on a double-nested cgroup-v1 host:
599+
600+
* ``--disable-hook update-ldcache`` — the ldcache hook runs ``ldconfig``
601+
via a mount op that fails in the nested container; the driver libs
602+
are reached via ``LD_LIBRARY_PATH`` instead (exported below and read
603+
by ``bootstrap._with_pkg`` as a podman ``-e`` run option).
604+
* down-version to ``cdiVersion: 0.6.0`` and strip the 0.7.0-only
605+
``additionalGids`` field — podman 4.9.3's CDI parser predates 0.7.0.
606+
607+
No-op with a warning if ``nvidia-ctk`` is absent or the process is not
608+
root (a rootless worker can't write ``/etc/cdi`` nor create a usable
609+
CUDA context anyway).
610+
"""
611+
if os.geteuid() != 0:
612+
self.logger.warning(
613+
"enable_container_runtime is set but the worker is not running "
614+
"as root; skipping CDI generation. GPU container apps will fail."
615+
)
616+
return
617+
618+
nvidia_ctk = shutil.which("nvidia-ctk")
619+
if not nvidia_ctk:
620+
self.logger.warning(
621+
"enable_container_runtime is set but 'nvidia-ctk' was not found; "
622+
"skipping CDI generation. GPU container apps will fail."
623+
)
624+
return
625+
626+
cdi_path = "/etc/cdi/nvidia.yaml"
627+
await asyncio.to_thread(
628+
lambda: Path(cdi_path).parent.mkdir(parents=True, exist_ok=True)
629+
)
630+
631+
proc = await asyncio.create_subprocess_exec(
632+
nvidia_ctk,
633+
"cdi",
634+
"generate",
635+
f"--output={cdi_path}",
636+
"--disable-hook",
637+
"update-ldcache",
638+
stdout=asyncio.subprocess.PIPE,
639+
stderr=asyncio.subprocess.PIPE,
640+
)
641+
_, stderr = await proc.communicate()
642+
if proc.returncode != 0:
643+
raise subprocess.CalledProcessError(
644+
proc.returncode,
645+
"nvidia-ctk cdi generate",
646+
stderr=stderr.decode() if stderr else "Unknown error",
647+
)
648+
649+
def _downversion_spec() -> None:
650+
lines = Path(cdi_path).read_text().splitlines()
651+
out = []
652+
for line in lines:
653+
if "additionalgids" in line.lower():
654+
continue
655+
# additionalGids' numeric list items (e.g. `- 44`) would otherwise
656+
# orphan into the preceding deviceNodes list once the key is gone,
657+
# producing a bare number where podman expects a DeviceNode. No
658+
# other list entry in the spec is a bare integer, so drop them.
659+
if re.fullmatch(r"\s*-\s*\d+\s*", line):
660+
continue
661+
if line.startswith("cdiVersion:"):
662+
line = "cdiVersion: 0.6.0"
663+
out.append(line)
664+
Path(cdi_path).write_text("\n".join(out) + "\n")
665+
666+
await asyncio.to_thread(_downversion_spec)
667+
668+
# Point replicas' LD_LIBRARY_PATH at the host dir holding libcuda.so.1
669+
# (CDI mounts driver libs at their host paths inside the container).
670+
ld_dir = "/usr/lib64"
671+
ldconfig = shutil.which("ldconfig")
672+
if ldconfig:
673+
proc = await asyncio.create_subprocess_exec(
674+
ldconfig,
675+
"-p",
676+
stdout=asyncio.subprocess.PIPE,
677+
stderr=asyncio.subprocess.DEVNULL,
678+
)
679+
stdout, _ = await proc.communicate()
680+
for line in stdout.decode().splitlines():
681+
if "libcuda.so.1" in line and "=>" in line:
682+
ld_dir = str(Path(line.split("=>")[-1].strip()).parent)
683+
break
684+
os.environ["BIOENGINE_CONTAINER_LD_LIBRARY_PATH"] = ld_dir
685+
686+
self.logger.info(
687+
f"Generated CDI spec at {cdi_path} (cdiVersion 0.6.0); "
688+
f"container GPU LD_LIBRARY_PATH={ld_dir}"
689+
)
690+
585691
async def _start_cluster(self) -> None:
586692
"""Start Ray cluster head node with configured ports and resources.
587693
@@ -614,6 +720,11 @@ async def _start_cluster(self) -> None:
614720
# Check and set cluster ports
615721
await asyncio.to_thread(self._set_cluster_ports)
616722

723+
# Opt-in container-as-runtime: emit a podman-compatible CDI spec so
724+
# GPU apps can run inside their image (single-machine mode only).
725+
if self.enable_container_runtime:
726+
await self._generate_cdi_spec()
727+
617728
# Start ray as the head node with the specified parameters
618729
args = [
619730
"start",

bioengine/worker/__main__.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -319,6 +319,15 @@ def create_parser() -> argparse.ArgumentParser:
319319
help="Skip cleanup of previous Ray cluster processes and data. "
320320
"Use with caution as it may cause port conflicts or resource issues.",
321321
)
322+
ray_cluster_group.add_argument(
323+
"--enable-container-runtime",
324+
action="store_true",
325+
help="Opt-in: allow apps declared with @bioengine.app(container_image=…) "
326+
"to run inside a prebuilt container image as their Ray Serve runtime "
327+
"(single-machine mode only). Requires a rootful worker with podman + "
328+
"nvidia-container-toolkit; the worker generates a podman-compatible CDI "
329+
"spec at startup for GPU passthrough. Pip remains the default runtime.",
330+
)
322331

323332
# SLURM job configuration options (for HPC environments)
324333
slurm_job_group = parser.add_argument_group(
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
# PoC-only worker image for container-as-runtime GPU apps (single-machine,
2+
# ROOTFUL). Differs from worker.Dockerfile in three ways:
3+
# 1. Ubuntu 24.04 base — ships podman 4.9.3 via apt (podman 5.x rejects
4+
# --pid=host on cgroup-v1 hosts, which Ray's container plugin requires).
5+
# 2. Bundles nvidia-container-toolkit (nvidia-ctk + CDI hooks) so the worker
6+
# can emit a CDI spec at startup for nested-GPU passthrough.
7+
# 3. Patches the installed Ray to drop the hardcoded `--userns=keep-id`
8+
# podman flag, which breaks /proc remount in a nested rootful container.
9+
#
10+
# This image is NOT a drop-in replacement for the production worker; it is the
11+
# rootful PoC target for @bioengine.app(container_image=…). Pip runtime stays
12+
# the default and is unaffected.
13+
FROM ubuntu:24.04
14+
15+
ENV DEBIAN_FRONTEND=noninteractive \
16+
PYTHONUNBUFFERED=1 \
17+
PYTHONDONTWRITEBYTECODE=1 \
18+
PIP_NO_CACHE_DIR=1 \
19+
SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt
20+
21+
# System deps: build toolchain + git (framework), podman 4.9.3 + fuse-overlayfs
22+
# + uidmap (nested container runtime). Python 3.11 via deadsnakes to match the
23+
# app image's interpreter — the deployment class is cloudpickled from the worker
24+
# (head) into the app-image replica, so the two Python versions must agree.
25+
RUN apt-get update && apt-get install -y --no-install-recommends \
26+
git build-essential curl gnupg ca-certificates software-properties-common \
27+
podman fuse-overlayfs uidmap \
28+
&& add-apt-repository -y ppa:deadsnakes/ppa \
29+
&& apt-get update && apt-get install -y --no-install-recommends \
30+
python3.11 python3.11-venv python3.11-dev \
31+
&& rm -rf /var/lib/apt/lists/*
32+
33+
# nvidia-container-toolkit: provides nvidia-ctk (CDI spec generation) and the
34+
# nvidia-cdi-hook binaries (create-symlinks etc.).
35+
RUN curl -fsSL https://nvidia.github.io/libnvidia-container/gpgkey \
36+
| gpg --dearmor -o /usr/share/keyrings/nvidia-container-toolkit-keyring.gpg \
37+
&& curl -fsSL https://nvidia.github.io/libnvidia-container/stable/deb/nvidia-container-toolkit.list \
38+
| sed 's#deb https://#deb [signed-by=/usr/share/keyrings/nvidia-container-toolkit-keyring.gpg] https://#g' \
39+
> /etc/apt/sources.list.d/nvidia-container-toolkit.list \
40+
&& apt-get update && apt-get install -y --no-install-recommends nvidia-container-toolkit \
41+
&& rm -rf /var/lib/apt/lists/*
42+
43+
# Isolated venv (Ubuntu 24.04 marks the system Python externally-managed).
44+
RUN python3.11 -m venv /opt/venv
45+
ENV PATH=/opt/venv/bin:$PATH
46+
47+
WORKDIR /app
48+
49+
# See worker.Dockerfile: requirements first (does NOT pin Ray), so the
50+
# RAY_VERSION build arg can change without invalidating this layer.
51+
COPY requirements-worker.txt /app/
52+
RUN pip install -U pip && \
53+
pip install -r requirements-worker.txt
54+
55+
COPY bioengine/ /app/bioengine/
56+
COPY pyproject.toml README.md LICENSE /app/
57+
RUN pip install --no-deps .
58+
59+
ARG RAY_VERSION=2.55.1
60+
RUN pip install "ray[client,serve]==${RAY_VERSION}"
61+
ENV BIOENGINE_RAY_VERSION=${RAY_VERSION}
62+
63+
# Drop Ray's hardcoded `--userns=keep-id` podman flag. keep-id is a rootless
64+
# volume-permission remap; combined with --pid=host in a nested ROOTFUL
65+
# container it fails to remount /proc ("mount proc to proc: Operation not
66+
# permitted"). The grep guard fails the build loudly if a Ray bump renames or
67+
# removes the anchor line, so we never silently ship an unpatched image.
68+
# The bare string `userns=keep-id` also appears in a doc comment (the example
69+
# command), so the post-patch guard must check the QUOTED code anchor is gone,
70+
# not the bare string.
71+
RUN f="$(python -c 'import ray._private.runtime_env.image_uri as m; print(m.__file__)')" \
72+
&& grep -q '"--userns=keep-id",' "$f" \
73+
&& sed -i '/"--userns=keep-id",/d' "$f" \
74+
&& ! grep -q '"--userns=keep-id",' "$f" \
75+
&& echo "Patched out --userns=keep-id in $f"
76+
77+
CMD [ "/bin/bash" ]

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "bioengine"
7-
version = "0.13.0"
7+
version = "0.14.0"
88
description = "BioEngine — CLI and SDK for deploying and calling AI model services on BioEngine workers"
99
requires-python = ">=3.11"
1010
authors = [

0 commit comments

Comments
 (0)