runpod: optionally keep pod dependencies on the volume, not in the image

A capability image is ~7 GB and nearly all of it is torch, pulled again by
every cold pod. With a network volume those libraries can live there
instead: `venv_on_volume` boots a slim image — OS, Python and coderai
only — and runs coderai from a venv on the volume. The first pod builds
it; every pod after skips both the pull and the install.

Opt-in per pool and per model, never a default, because the trade is real
and unmeasured: the pull gets much smaller, but importing torch off
network storage is slower than off local disk, so some of the saving
comes back at import time. Whether that is a net win depends on how often
pods cold-start, which is the user's workload, not mine to assume.

Two hazards are handled rather than left to chance. The ready marker is
written AFTER the install, so a pod that dies mid-build leaves nothing a
later pod would import from. And a lock directory — mkdir is atomic —
makes a second pod wait for the first instead of two installs
interleaving into the same directory.

`venv_on_volume` without a volume is refused with the reason, rather than
booting a pod that installs 7 GB onto container disk and discards it.
Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0182DhR1eFNPQyGmDrpHbedY
parent 4efe6331
...@@ -2795,7 +2795,8 @@ async def api_model_configure(request: Request, username: str = Depends(require_ ...@@ -2795,7 +2795,8 @@ async def api_model_configure(request: Request, username: str = Depends(require_
"registry_auth_id", "api_key", "pool", "registry_auth_id", "api_key", "pool",
# where the POD gets the weights # where the POD gets the weights
"source", "hf_repo", "model_url", "quantization", "source", "hf_repo", "model_url", "quantization",
"network_volume_id", "volume_mount_path"): "network_volume_id", "volume_mount_path",
"venv_name", "slim_image"):
v = src.get(k) v = src.get(k)
if isinstance(v, str) and v.strip(): if isinstance(v, str) and v.strip():
rpo[k] = v.strip() rpo[k] = v.strip()
...@@ -2811,7 +2812,7 @@ async def api_model_configure(request: Request, username: str = Depends(require_ ...@@ -2811,7 +2812,7 @@ async def api_model_configure(request: Request, username: str = Depends(require_
rpo["cloud_types"] = ct rpo["cloud_types"] = ct
# bools # bools
for k in ("allow_spot", "allow_open_pod", "sticky_sessions", "keep_warm", for k in ("allow_spot", "allow_open_pod", "sticky_sessions", "keep_warm",
"model_url_is_tar"): "model_url_is_tar", "venv_on_volume"):
if k in src and src.get(k) is not None and src.get(k) != "": if k in src and src.get(k) is not None and src.get(k) != "":
sv = src.get(k) sv = src.get(k)
rpo[k] = (sv.lower() in ("1", "true", "on", "yes") rpo[k] = (sv.lower() in ("1", "true", "on", "yes")
......
...@@ -800,6 +800,17 @@ window.__DEFAULT_WHISPER_SERVER_PATH__ = {{ default_whisper_server_path|tojson } ...@@ -800,6 +800,17 @@ window.__DEFAULT_WHISPER_SERVER_PATH__ = {{ default_whisper_server_path|tojson }
</label> </label>
</div> </div>
</div> </div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:.5rem;margin-top:.5rem">
<div class="form-row" style="margin:0">
<label class="form-label">Network volume id <span class="muted">(this model)</span></label>
<input type="text" id="cfg-rp-volume" class="form-input" placeholder="blank = the account default, or none">
<span class="form-hint" style="font-size:11px">Weights, uploads and adapters land here and outlive the pod. Secure Cloud only; pods are pinned to the volume's region.</span>
</div>
<div class="form-row" style="margin:0;align-self:start">
<label style="display:flex;align-items:center;gap:.3rem;font-size:12px;margin-top:1.4rem"><input type="checkbox" id="cfg-rp-venv-volume" style="width:auto"> Keep dependencies on the volume</label>
<span class="form-hint" style="font-size:11px">Boots a small image and runs from a venv on the volume — the first pod builds it, later pods skip the 7 GB pull. Needs a volume; imports are slower off network storage.</span>
</div>
</div>
<div class="form-row" style="margin:.5rem 0 0"> <div class="form-row" style="margin:.5rem 0 0">
<label class="form-label">Share pod pool <span class="muted">(optional name)</span></label> <label class="form-label">Share pod pool <span class="muted">(optional name)</span></label>
<input type="text" id="cfg-rp-pool" class="form-input" placeholder="blank = this model rents its own pods"> <input type="text" id="cfg-rp-pool" class="form-input" placeholder="blank = this model rents its own pods">
...@@ -4207,6 +4218,8 @@ function _populateRunpod(s){ ...@@ -4207,6 +4218,8 @@ function _populateRunpod(s){
set('cfg-rp-engine', rp.engine || 'auto'); set('cfg-rp-engine', rp.engine || 'auto');
set('cfg-rp-hf-gguf', rp.hf_gguf || ''); set('cfg-rp-hf-gguf', rp.hf_gguf || '');
set('cfg-rp-pool', rp.pool || ''); set('cfg-rp-pool', rp.pool || '');
set('cfg-rp-volume', rp.network_volume_id || '');
chk('cfg-rp-venv-volume', rp.venv_on_volume);
set('cfg-rp-source', rp.source || 'auto'); set('cfg-rp-source', rp.source || 'auto');
set('cfg-rp-hf-repo', rp.hf_repo || ''); set('cfg-rp-hf-repo', rp.hf_repo || '');
set('cfg-rp-model-url', rp.model_url || ''); set('cfg-rp-model-url', rp.model_url || '');
...@@ -4375,6 +4388,8 @@ function _collectRunpod(){ ...@@ -4375,6 +4388,8 @@ function _collectRunpod(){
engine: val('cfg-rp-engine') || 'auto', engine: val('cfg-rp-engine') || 'auto',
hf_gguf: (val('cfg-rp-hf-gguf') || '').trim(), hf_gguf: (val('cfg-rp-hf-gguf') || '').trim(),
pool: (val('cfg-rp-pool') || '').trim(), pool: (val('cfg-rp-pool') || '').trim(),
network_volume_id: (val('cfg-rp-volume') || '').trim(),
venv_on_volume: document.getElementById('cfg-rp-venv-volume').checked,
source: val('cfg-rp-source') || 'auto', source: val('cfg-rp-source') || 'auto',
hf_repo: (val('cfg-rp-hf-repo') || '').trim(), hf_repo: (val('cfg-rp-hf-repo') || '').trim(),
model_url: (val('cfg-rp-model-url') || '').trim(), model_url: (val('cfg-rp-model-url') || '').trim(),
......
...@@ -64,6 +64,14 @@ class RunpodModelConfig: ...@@ -64,6 +64,14 @@ class RunpodModelConfig:
# account-wide one. Weights, uploads and adapters on it outlive the pod. # account-wide one. Weights, uploads and adapters on it outlive the pod.
network_volume_id: str = "" network_volume_id: str = ""
volume_mount_path: str = "" # blank = the account default volume_mount_path: str = "" # blank = the account default
# Keep this pod's Python dependencies on the VOLUME instead of in the image,
# and boot a small image that uses them. The first pod builds the venv (a few
# minutes); every pod after skips both the 7 GB image pull and the install.
# Needs a network volume. Opt-in per pool — never a default, because it
# trades a fast pull for slower imports off network storage.
venv_on_volume: bool = False
venv_name: str = "" # blank = the profile/capability name
slim_image: str = "" # blank = the published slim image
# Share one set of pods with every other model naming the same pool, instead # Share one set of pods with every other model naming the same pool, instead
# of renting a card each. Only possible when the pod's server can serve more # of renting a card each. Only possible when the pod's server can serve more
# than one model: a coderai pod is a whole coderai and picks the model from # than one model: a coderai pod is a whole coderai and picks the model from
...@@ -366,9 +374,9 @@ def pod_plan(mcfg: "RunpodModelConfig", served: str, model_key: str = "", ...@@ -366,9 +374,9 @@ def pod_plan(mcfg: "RunpodModelConfig", served: str, model_key: str = "",
# them meant a pod with an explicit image got no models and refused # them meant a pod with an explicit image got no models and refused
# everything with "not available". # everything with "not available".
image = mcfg.image image = mcfg.image
capability_hint = model_capability(entry or {}, include_text=True)
if not image: if not image:
cap = model_capability(entry or {}, include_text=True) image = default_capability_image(capability_hint) if capability_hint else ""
image = default_capability_image(cap) if cap else ""
if not image: if not image:
raise RuntimeError( raise RuntimeError(
f"RunPod {engine} pod: set `image` on the runpod block to the image " f"RunPod {engine} pod: set `image` on the runpod block to the image "
...@@ -376,7 +384,7 @@ def pod_plan(mcfg: "RunpodModelConfig", served: str, model_key: str = "", ...@@ -376,7 +384,7 @@ def pod_plan(mcfg: "RunpodModelConfig", served: str, model_key: str = "",
f"{CAPABILITY_IMAGE_REPO}-<capability> image; this one has none, so " f"{CAPABILITY_IMAGE_REPO}-<capability> image; this one has none, so "
"name it explicitly.") "name it explicitly.")
return _plan(engine, image, mcfg.docker_args, mcfg, api_key, entry, served, return _plan(engine, image, mcfg.docker_args, mcfg, api_key, entry, served,
seed_entries) seed_entries, capability=capability_hint)
else: else:
image = mcfg.image or DEFAULT_POD_IMAGE image = mcfg.image or DEFAULT_POD_IMAGE
args = mcfg.docker_args or _vllm_docker_args(mcfg, served, entry) args = mcfg.docker_args or _vllm_docker_args(mcfg, served, entry)
...@@ -464,6 +472,55 @@ def volume_for(mcfg: "RunpodModelConfig", account) -> tuple: ...@@ -464,6 +472,55 @@ def volume_for(mcfg: "RunpodModelConfig", account) -> tuple:
return vol, mount.rstrip("/") or "/workspace" return vol, mount.rstrip("/") or "/workspace"
#: The dependency-free image that runs coderai from a venv on a volume.
SLIM_POD_IMAGE = os.environ.get("CODERAI_SLIM_POD_IMAGE",
f"{CAPABILITY_IMAGE_REPO}-slim:{CAPABILITY_IMAGE_TAG}")
def venv_bootstrap_script(mount: str, profile: str, venv_name: str = "",
port: int = 8000) -> str:
"""Start command for a slim pod: build the venv on the volume if needed, run.
The first pod to use a given venv pays for the install; every pod after finds
it and starts in seconds. A completion marker is written LAST and checked
first, so a pod that dies mid-install cannot leave a half-built venv that
later pods would import from and fail on in confusing ways.
Concurrent builders are the other hazard — two pods starting together would
install into the same directory. A lock directory (mkdir is atomic) makes the
second one wait for the first rather than interleave with it.
"""
name = (venv_name or profile or "default").strip()
venv = f"{mount}/venvs/{name}"
reqs = "/opt/coderai/app/packaging/runpod/profiles"
return "\n".join([
"set -eu",
f'VENV={_sh_quote(venv)}',
f'LOCK={_sh_quote(venv + ".lock")}',
f'MARK={_sh_quote(venv + "/.ready")}',
# Wait for another pod that is already building this venv.
'for i in $(seq 1 180); do',
' [ -f "$MARK" ] && break',
' if mkdir "$LOCK" 2>/dev/null; then',
' echo "[venv] building $VENV (first pod pays for this)"',
' python -m venv "$VENV"',
f' "$VENV/bin/python" -m pip install --upgrade pip',
f' "$VENV/bin/python" -m pip install -r {reqs}/core.txt',
f' "$VENV/bin/python" -m pip install -r {reqs}/{profile}.txt',
' touch "$MARK"', # last: a crash leaves no usable marker
' rmdir "$LOCK" || true',
' break',
' fi',
' echo "[venv] another pod is building $VENV — waiting"',
' sleep 10',
'done',
'[ -f "$MARK" ] || { echo "[venv] $VENV never became ready"; exit 1; }',
'echo "[venv] using $VENV"',
f'exec "$VENV/bin/python" -m uvicorn codai.api.app:app --host 0.0.0.0 '
f'--port {int(port)}',
])
def volume_env(mount: str) -> dict: def volume_env(mount: str) -> dict:
"""Point everything that writes big files at the volume. """Point everything that writes big files at the volume.
...@@ -487,7 +544,7 @@ def volume_env(mount: str) -> dict: ...@@ -487,7 +544,7 @@ def volume_env(mount: str) -> dict:
def _plan(engine, image, args, mcfg, api_key, entry, served, def _plan(engine, image, args, mcfg, api_key, entry, served,
seed_entries: list = None) -> dict: # noqa: D401 seed_entries: list = None, capability: str = "") -> dict: # noqa: D401
"""Finish a plan for a coderai pod: auth plus the models it must serve.""" """Finish a plan for a coderai pod: auth plus the models it must serve."""
import json as _json import json as _json
env = {} env = {}
...@@ -514,9 +571,38 @@ def _plan(engine, image, args, mcfg, api_key, entry, served, ...@@ -514,9 +571,38 @@ def _plan(engine, image, args, mcfg, api_key, entry, served,
unique.append(s) unique.append(s)
env["CODERAI_SEED_MODELS"] = _json.dumps(unique) env["CODERAI_SEED_MODELS"] = _json.dumps(unique)
print(f"[runpod] pod will be told about {len(unique)} model(s)", flush=True) print(f"[runpod] pod will be told about {len(unique)} model(s)", flush=True)
return {"engine": engine, "image": image, "args": args, "env": env, plan = {"engine": engine, "image": image, "args": args, "env": env,
"health_path": mcfg.health_path or _HEALTH_PATHS.get(engine, "/healthz")} "health_path": mcfg.health_path or _HEALTH_PATHS.get(engine, "/healthz")}
# Dependencies on the volume instead of in the image: a small image, and a
# venv the first pod builds and every later pod reuses.
if getattr(mcfg, "venv_on_volume", False):
vol_id, mount = volume_for(mcfg, _account_hint())
if not vol_id:
raise RuntimeError(
"RunPod: `venv_on_volume` needs a network volume — the venv has "
"nowhere to live otherwise. Set network_volume_id (account-wide "
"or on this pod block), or turn venv_on_volume off.")
profile = (capability or model_capability(entry or {}, include_text=True)
or "text")
plan["image"] = mcfg.slim_image or SLIM_POD_IMAGE
plan["entrypoint"] = ["/bin/sh", "-c"]
plan["start_cmd"] = [venv_bootstrap_script(
mount, profile, mcfg.venv_name, mcfg.port or 8000)]
print(f"[runpod] pod will run from a venv on the volume "
f"({mount}/venvs/{mcfg.venv_name or profile}) with image "
f"{plan['image']}", flush=True)
return plan
def _account_hint():
"""The active RunPod account config, for defaults the plan needs."""
try:
from codai.models.manager import get_active_runpod_config
return get_active_runpod_config()
except Exception:
return None
def seed_model_env(entry: dict, served: str = "", source: str = "") -> str: def seed_model_env(entry: dict, served: str = "", source: str = "") -> str:
"""The model entry to register on a fresh pod, as JSON for CODERAI_SEED_MODELS. """The model entry to register on a fresh pod, as JSON for CODERAI_SEED_MODELS.
...@@ -677,6 +763,9 @@ def parse_model_runpod(block: Optional[dict]) -> RunpodModelConfig: ...@@ -677,6 +763,9 @@ def parse_model_runpod(block: Optional[dict]) -> RunpodModelConfig:
# in the list just produces candidates the attach would reject. # in the list just produces candidates the attach would reject.
cfg.cloud_types = ["SECURE"] cfg.cloud_types = ["SECURE"]
cfg.volume_mount_path = (b.get("volume_mount_path") or "").strip() cfg.volume_mount_path = (b.get("volume_mount_path") or "").strip()
cfg.venv_on_volume = _as_bool(b.get("venv_on_volume"), cfg.venv_on_volume)
cfg.venv_name = (b.get("venv_name") or "").strip()
cfg.slim_image = (b.get("slim_image") or "").strip()
cfg.pool = (b.get("pool") or "").strip().lower() cfg.pool = (b.get("pool") or "").strip().lower()
cfg.quantization = (b.get("quantization") or "").strip() cfg.quantization = (b.get("quantization") or "").strip()
cfg.source = (b.get("source") or cfg.source).strip().lower() or "auto" cfg.source = (b.get("source") or cfg.source).strip().lower() or "auto"
......
...@@ -537,6 +537,39 @@ volume at once can corrupt it — coderai's writes are write-once (content-addre ...@@ -537,6 +537,39 @@ volume at once can corrupt it — coderai's writes are write-once (content-addre
adapter blobs, named model files), but keep that in mind before pointing many adapter blobs, named model files), but keep that in mind before pointing many
pods at one volume for anything else. pods at one volume for anything else.
#### Dependencies on the volume, small image in the registry
A capability image is ~7 GB and nearly all of it is torch. With a volume those
libraries can live there instead, and the pod boots a **slim** image — OS,
Python and coderai only — that runs from a venv on the volume:
```json
{"runpod": {"network_volume_id": "abc123", "venv_on_volume": true}}
```
The first pod to use a given venv builds it (a few minutes of pip); every pod
after finds it ready and skips both the big pull and the install. The venv is
named after the capability by default, so `embeddings` and `video` pods get their
own; `venv_name` overrides that to share or separate them deliberately.
Build and push the slim image once:
```bash
PUSH=1 ./packaging/runpod/build_capability_image.sh slim ghcr.io/<you>/coderai-slim:latest
```
**The trade-off is real and unmeasured on your workload.** The pull gets much
smaller, but importing torch from network-backed storage is slower than from
local disk, so some of the saving comes back at import time. Whether it is a net
win depends on how often your pods cold-start. It is opt-in per pool, never a
default.
Two failure modes are handled rather than left to chance: the ready marker is
written **after** the install, so a pod that dies mid-build cannot leave a
half-built venv that later pods import from; and a lock directory (mkdir is
atomic) makes a second pod **wait** for the first rather than installing into the
same directory at the same time.
**Container images cannot come from a volume.** A pod pulls its image from a **Container images cannot come from a volume.** A pod pulls its image from a
registry before any volume exists; the volume holds model weights and adapters, registry before any volume exists; the volume holds model weights and adapters,
not the image. not the image.
......
# A coderai pod image with NO machine-learning dependencies.
#
# The full capability images are ~7 GB, nearly all of it torch and friends, and
# every cold pod pulls that before it can answer anything. When a network volume
# is attached, those libraries can live on the volume instead: this image carries
# only the OS packages, Python and coderai itself, and the pod's start command
# points it at a venv on the volume — built once by the first pod, reused by
# every pod after.
#
# The trade is real but not free, and worth measuring for your own workload:
# * the image is small (hundreds of MB, not 7 GB), so the pull is quick;
# * but importing torch from network-backed storage is slower than from local
# disk, so some of what the pull saved comes back at import time.
#
# It is opt-in per pool (`venv_on_volume`), never a default.
#
# docker build -f packaging/runpod/Dockerfile.capability-slim \
# -t ghcr.io/<you>/coderai-slim:latest .
ARG BASE=python:3.12-slim
FROM ${BASE}
ENV PYTHONUNBUFFERED=1 \
CODERAI_HOME=/config
# The same runtime libraries the full images carry: ffmpeg for audio/video,
# libgl/libglib for opencv. These are OS packages, not Python ones, so they stay
# in the image — a venv on a volume cannot provide them.
RUN apt-get update && apt-get install -y --no-install-recommends \
ffmpeg git curl ca-certificates libgl1 libglib2.0-0 \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /opt/coderai/app
COPY . /opt/coderai/app
# Nothing is imported at build time: the dependencies are not here yet. The pod's
# start command builds (or reuses) the venv on the volume and runs coderai from
# it — see venv_bootstrap_script() in codai/api/runpod_worker.py.
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=5s --start-period=600s \
CMD curl -fsS http://127.0.0.1:8000/healthz || exit 1
...@@ -16,6 +16,20 @@ PROFILE="${1:-}" ...@@ -16,6 +16,20 @@ PROFILE="${1:-}"
TAG="${2:-coderai-${PROFILE}:latest}" TAG="${2:-coderai-${PROFILE}:latest}"
PROFILES_DIR="packaging/runpod/profiles" PROFILES_DIR="packaging/runpod/profiles"
# The slim image carries no ML dependencies: it runs coderai from a venv on a
# network volume (see venv_on_volume). Built from its own Dockerfile, with no
# profile and no import smoke test — there is nothing to import yet.
if [[ "$PROFILE" == "slim" ]]; then
TAG="${2:-coderai-slim:latest}"
echo "==> building $TAG (no ML dependencies; runs from a venv on a volume)"
DOCKER_BUILDKIT=1 docker build -f packaging/runpod/Dockerfile.capability-slim \
-t "$TAG" . || exit 1
size=$(docker image inspect "$TAG" --format '{{.Size}}')
echo "==> $TAG built ($(( size / 1000000 )) MB)"
if [[ "${PUSH:-0}" == "1" ]]; then docker push "$TAG"; fi
exit 0
fi
if [[ -z "$PROFILE" ]]; then if [[ -z "$PROFILE" ]]; then
echo "usage: $0 <profile> [tag]" >&2 echo "usage: $0 <profile> [tag]" >&2
echo "profiles: $(ls "$PROFILES_DIR" | grep -v '^core.txt$' | sed 's/\.txt$//' | tr '\n' ' ')" >&2 echo "profiles: $(ls "$PROFILES_DIR" | grep -v '^core.txt$' | sed 's/\.txt$//' | tr '\n' ' ')" >&2
......
...@@ -432,3 +432,56 @@ def test_a_volume_forces_secure_cloud(): ...@@ -432,3 +432,56 @@ def test_a_volume_forces_secure_cloud():
assert cfg.cloud_types == ["SECURE"] assert cfg.cloud_types == ["SECURE"]
# Without a volume the choice is left alone. # Without a volume the choice is left alone.
assert parse_model_runpod({"cloud_types": ["COMMUNITY"]}).cloud_types == ["COMMUNITY"] assert parse_model_runpod({"cloud_types": ["COMMUNITY"]}).cloud_types == ["COMMUNITY"]
def test_venv_on_volume_uses_a_small_image_and_the_volumes_venv(monkeypatch):
"""The 7 GB image is nearly all torch. With a volume, those libraries can
live there and the pod pulls a small image instead."""
import codai.api.runpod_worker as rw
class _Acct:
network_volume_id = "vol-abc"
volume_mount_path = "/workspace"
enabled = True
monkeypatch.setattr(rw, "_account_hint", lambda: _Acct())
cfg = rw.parse_model_runpod({"engine": "coderai", "venv_on_volume": True})
plan = rw.pod_plan(cfg, "embeddings", "capability:embeddings",
api_key="tok", entry={"path": "org/e",
"model_type": "embedding_models"})
assert plan["image"].endswith("coderai-slim:latest")
assert plan["entrypoint"] == ["/bin/sh", "-c"]
script = plan["start_cmd"][0]
assert "/workspace/venvs/embeddings" in script
assert "profiles/embeddings.txt" in script # the right profile installed
assert script.rstrip().endswith("--port 8000")
def test_venv_on_volume_without_a_volume_is_refused(monkeypatch):
"""The venv would have nowhere to live; say so rather than boot a pod that
installs 7 GB onto container disk and throws it away."""
import codai.api.runpod_worker as rw
class _NoVol:
network_volume_id = ""
volume_mount_path = "/workspace"
monkeypatch.setattr(rw, "_account_hint", lambda: _NoVol())
cfg = rw.parse_model_runpod({"engine": "coderai", "image": "img",
"venv_on_volume": True})
with pytest.raises(RuntimeError, match="needs a network volume"):
rw.pod_plan(cfg, "x", "capability:embeddings", entry={})
def test_the_venv_marker_is_written_last_and_builders_are_serialised():
"""Two hazards: a pod that dies mid-install leaving a venv later pods import
from, and two pods installing into the same directory at once."""
from codai.api.runpod_worker import venv_bootstrap_script
s = venv_bootstrap_script("/workspace", "tts")
# The marker is created after the installs, not before.
assert s.index("pip install -r") < s.index('touch "$MARK"')
# And the ready check gates the exec.
assert s.index('touch "$MARK"') < s.index('[ -f "$MARK" ] ||')
# mkdir is the atomic lock; a loser waits instead of installing too.
assert 'mkdir "$LOCK"' in s and "another pod is building" in s
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment