runpod: make the capability images actually share their core

The Dockerfile split core into its own RUN so the nine images would share
it. Measuring the built result showed they did not: only 4 of 11 layers
were shared, and each image carried its own ~7 GB copy. An identical
`pip install` does not write identical bytes (dist-info metadata, RECORD
files), so the layer digests differ even when the command and inputs
match. Sharing by coincidence does not work.

Dockerfile.capability-base builds the core (OS + apt + torch + the
import-time deps) ONCE as its own image, and each profile is FROM it, so
the sharing is structural. build_capability_image.sh builds it on first
use and reuses it after (REBUILD_CORE=1 to force, needed when core.txt
changes).

Measured after rebuilding all nine: every image now carries all 8 core
layers and adds 2 of its own (audio 5, from its build-deps apt churn).
29 distinct layers instead of 67, local disk 40.9 GB instead of 94, and a
push uploads the 6.79 GB core once rather than nine near-identical
copies. A pod pulling a second capability image already has most of it.

Also corrected the publish script's claim that `gh` could make the
packages public: GitHub's Packages REST API is list/get/delete/restore
only, there is no visibility endpoint, and new packages are always
private. It now prints the per-package settings URLs and says plainly
that this is a click each.
Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0182DhR1eFNPQyGmDrpHbedY
parent 687c14c8
......@@ -14,33 +14,18 @@
# Or use packaging/runpod/build_capability_image.sh, which also smoke-tests the
# result before you push it.
ARG BASE=python:3.12-slim
FROM ${BASE}
# The shared core (OS + apt + torch + import-time deps). Built once by
# build_capability_image.sh; every profile is FROM it, so all nine images share
# those layers by construction rather than by coincidence.
ARG CORE_BASE=coderai-capability-base:latest
FROM ${CORE_BASE}
ARG PROFILE=images
ARG TORCH_INDEX=https://download.pytorch.org/whl/cu124
ENV PROFILE=${PROFILE} \
PYTHONUNBUFFERED=1 \
HF_HOME=/cache/huggingface \
CODERAI_HOME=/config
# ffmpeg for every audio/video path; git because a few ML packages still want it
# at install time; libgl/libglib for opencv.
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/*
ENV PROFILE=${PROFILE}
WORKDIR /opt/coderai/app
# Dependencies first so a code change doesn't re-resolve the ML stack.
COPY packaging/runpod/profiles/ /tmp/profiles/
# A BuildKit cache mount, so building all nine profiles downloads torch ONCE
# instead of nine times. The cache lives on the builder, never in the image.
# Core in its OWN layer: identical for every profile, so all nine images share
# it and it is pushed and pulled once.
RUN --mount=type=cache,target=/root/.cache/pip \
python -m pip install --upgrade pip \
&& python -m pip install --extra-index-url ${TORCH_INDEX} -r /tmp/profiles/core.txt
# Then only what this capability adds. A profile may ship a `<name>.build-deps`
# listing apt packages its wheels need to COMPILE (deepfilterlib wants a Rust
......
# Shared core for every coderai capability image.
#
# This exists for one reason: layer sharing. Installing the same core.txt in each
# profile image ought to produce the same layer, but it does not — an identical
# `pip install` writes non-identical bytes (dist-info metadata, RECORD files), so
# the layer digests differ and every image carries its own ~7 GB copy. Measured
# on the first build of all nine: only 4 of 11 layers were shared.
#
# Building the core ONCE as its own image makes the sharing structural rather
# than hopeful: the profile images are FROM this, so they share its layers by
# construction. The registry stores and transfers it once, and a cold pod pulling
# a second capability image already has most of it.
#
# docker build -f packaging/runpod/Dockerfile.capability-base \
# -t coderai-capability-base:0.2.2 .
ARG BASE=python:3.12-slim
FROM ${BASE}
ARG TORCH_INDEX=https://download.pytorch.org/whl/cu124
ENV PYTHONUNBUFFERED=1 \
HF_HOME=/cache/huggingface \
CODERAI_HOME=/config
# ffmpeg for every audio/video path; git because a few ML packages still want it
# at install time; libgl/libglib for opencv.
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
# torch and the import-time requirements every capability needs. A BuildKit cache
# mount keeps the wheels on the builder across rebuilds; they never enter the image.
COPY packaging/runpod/profiles/core.txt /tmp/core.txt
RUN --mount=type=cache,target=/root/.cache/pip \
python -m pip install --upgrade pip \
&& python -m pip install --extra-index-url ${TORCH_INDEX} -r /tmp/core.txt \
&& rm -f /tmp/core.txt
......@@ -26,10 +26,24 @@ if [[ ! -f "$PROFILES_DIR/$PROFILE.txt" ]]; then
exit 2
fi
# The shared core image. Built once and reused by every profile, so the nine
# images share those layers by construction — the registry stores and transfers
# the core once instead of nine near-identical ~7 GB copies. Rebuild it with
# REBUILD_CORE=1 (needed when core.txt changes).
CORE_BASE="${CORE_BASE:-coderai-capability-base:latest}"
if [[ "${REBUILD_CORE:-0}" == "1" ]] || ! docker image inspect "$CORE_BASE" >/dev/null 2>&1; then
echo "==> building the shared core $CORE_BASE"
DOCKER_BUILDKIT=1 docker build -f packaging/runpod/Dockerfile.capability-base \
-t "$CORE_BASE" . || { echo "core build failed" >&2; exit 1; }
else
echo "==> reusing the shared core $CORE_BASE"
fi
echo "==> building $TAG from profile '$PROFILE'"
# BuildKit for the pip cache mount: nine profiles, one torch download.
DOCKER_BUILDKIT=1 docker build -f packaging/runpod/Dockerfile.capability \
--build-arg "PROFILE=$PROFILE" \
--build-arg "CORE_BASE=$CORE_BASE" \
-t "$TAG" .
# The Dockerfile already proves the app imports. This proves it SERVES: a pod is
......
......@@ -148,21 +148,17 @@ fi
if [[ "$REGISTRY" == "ghcr.io" ]]; then
owner="${NS#*/}"
say
say "${c_warn}GHCR publishes new packages as PRIVATE.${c_off} A RunPod pod cannot pull a"
say "private image without a registry-credential id, so make each one public:"
say "${c_warn}GHCR publishes new packages as PRIVATE, and there is no API to change${c_off}"
say "${c_warn}that${c_off} — the Packages REST API is list/get/delete/restore only, so this is a"
say "click per package. A RunPod pod cannot pull a private image without a"
say "registry-credential id, so open each of these and set Visibility → Public:"
say
for t in "${built[@]}"; do
name="${t#*/}"; name="${name%%:*}"
say " https://github.com/users/${owner}/packages/container/${name}/settings"
done
if command -v gh >/dev/null 2>&1 && ask "Try to flip them with the gh CLI?"; then
for t in "${built[@]}"; do
name="${t#*/}"; name="${name%%:*}"
gh api --method PATCH "/user/packages/container/${name}" \
-f visibility=public >/dev/null 2>&1 \
&& say " ${c_ok}public${c_off} ${name}" \
|| say " ${c_warn}could not set ${name} — use the URL above${c_off}"
done
fi
say
say "(Scroll to 'Danger Zone' → 'Change visibility' → Public.)"
fi
# ---- what to do with them ----------------------------------------------- #
......
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