docker: ship Jupyter, Studio and prebuilt llama.cpp out of the box
Base image (docker/Dockerfile): - Install JupyterLab + notebook + ipywidgets in a separate pure-Python uv pass so the cu128 pin set cannot move; EXPOSE 8888. - Bake the prebuilt llama.cpp bundle into /opt/unsloth/llama.cpp at the runtime stage using studio/install_llama_prebuilt.py from the same UNSLOTH_REF (sha256-verified, portable CUDA bundle since the build host has no GPU; arm64 resolves the linux-arm64-cuda13 bundle). Export UNSLOTH_LLAMA_CPP_PATH so unsloth_zoo's save_pretrained_gguf finds it and never reaches the interactive install prompt or a source build. - Optional github_token BuildKit secret for the resolver's API calls on shared CI runner IPs. Entrypoint: UNSLOTH_ALLOW_CPU=1 degrades a missing GPU to a warning so Docker Desktop on macOS / Windows-without-WSL2-GPU and plain CPU hosts can run Jupyter, GGUF tooling and Studio chat; with a GPU visible the normal pre-flight still runs. Full image (docker/Dockerfile.studio): now mirrors the production service set under supervisord - Studio on 8000, JupyterLab on 8888, key-only sshd on 22 (enabled only when PUBLIC_KEY/SSH_KEY is set). Points Studio's llama.cpp dir at the baked bundle to skip a duplicate download, accepts any git ref via fetch+checkout (CI passes commit SHAs), and FROMs a digest-pinned BASE_IMAGE. Publish workflow: base image moves to the base-* tag namespace; new build-studio/merge-studio jobs publish the full image as :latest (hub parity with the previous production image, which shipped Studio + Jupyter + SSH). Studio builds FROM the exact base manifest digest published by the same run. GPU smoke job now also boots the full image and probes Studio /api/health and Jupyter /api. run.sh: UNSLOTH_GPUS=none, UNSLOTH_ALLOW_CPU forwarding, UNSLOTH_PORTS publish flags, CPU-mode and Jupyter usage examples.
This commit is contained in:
parent
6448587483
commit
f1a63db6fa
7 changed files with 481 additions and 51 deletions
221
.github/workflows/docker-publish.yml
vendored
221
.github/workflows/docker-publish.yml
vendored
|
|
@ -124,6 +124,11 @@ jobs:
|
|||
cache-from: type=gha,scope=build-${{ matrix.platform }}
|
||||
cache-to: type=gha,scope=build-${{ matrix.platform }},mode=max
|
||||
outputs: type=image,name=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
|
||||
# The llama.cpp prebuilt bake reads GITHUB_TOKEN (BuildKit secret,
|
||||
# never a layer) so the resolver's GitHub API calls are not subject
|
||||
# to the anonymous per-IP rate limit shared across Actions runners.
|
||||
secrets: |
|
||||
github_token=${{ github.token }}
|
||||
build-args: |
|
||||
CUDA_VERSION=12.8.1
|
||||
UBUNTU_VERSION=24.04
|
||||
|
|
@ -153,7 +158,7 @@ jobs:
|
|||
- name: Upload digest
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: digests-${{ matrix.platform == 'linux/amd64' && 'amd64' || 'arm64' }}
|
||||
name: digests-base-${{ matrix.platform == 'linux/amd64' && 'amd64' || 'arm64' }}
|
||||
path: /tmp/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
|
@ -170,11 +175,17 @@ jobs:
|
|||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
outputs:
|
||||
# Multi-arch manifest digest of the just-published base image. The
|
||||
# build-studio job FROMs this exact digest so the Studio image always
|
||||
# layers on the bits published by THIS run, not whatever `base`
|
||||
# happens to point at when the job is scheduled.
|
||||
digest: ${{ steps.manifest_digest.outputs.digest }}
|
||||
steps:
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: /tmp/digests
|
||||
pattern: digests-*
|
||||
pattern: digests-base-*
|
||||
merge-multiple: true
|
||||
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
|
@ -191,10 +202,148 @@ jobs:
|
|||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
# Only tag :latest when the workflow ran on the default branch
|
||||
# The lean training image publishes under the base- prefix; the
|
||||
# full Studio image (build-studio/merge-studio below) owns
|
||||
# :latest, matching what the previous production image shipped.
|
||||
# Only tag :base when the workflow ran on the default branch
|
||||
# AND the operator did NOT override unsloth_ref on dispatch.
|
||||
# Without the second condition a maintainer testing a feature
|
||||
# SHA from main could overwrite :latest with non-main source.
|
||||
# SHA from main could overwrite :base with non-main source.
|
||||
type=raw,value=base,enable=${{ github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && github.event.inputs.unsloth_ref == '' }}
|
||||
type=ref,event=tag,prefix=base-
|
||||
type=schedule,pattern=base-nightly
|
||||
type=sha,prefix=base-sha-,format=short
|
||||
|
||||
- name: Create multi-arch manifest
|
||||
working-directory: /tmp/digests
|
||||
run: |
|
||||
docker buildx imagetools create \
|
||||
$(jq -cr '.tags | map("-t " + .) | join(" ")' <<<"$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
$(printf '${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@sha256:%s ' *)
|
||||
|
||||
- name: Inspect the result
|
||||
run: |
|
||||
for tag in $(jq -r '.tags[]' <<<"$DOCKER_METADATA_OUTPUT_JSON"); do
|
||||
echo "=== $tag ==="
|
||||
docker buildx imagetools inspect "$tag"
|
||||
done
|
||||
|
||||
- name: Export manifest digest
|
||||
id: manifest_digest
|
||||
run: |
|
||||
TAG="$(jq -r '.tags[0]' <<<"$DOCKER_METADATA_OUTPUT_JSON")"
|
||||
DIGEST="$(docker buildx imagetools inspect "$TAG" --format '{{json .Manifest.Digest}}' | tr -d '"')"
|
||||
test -n "$DIGEST"
|
||||
echo "digest=${DIGEST}" >> "$GITHUB_OUTPUT"
|
||||
echo "base manifest: ${TAG} @ ${DIGEST}"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Full image: base + Unsloth Studio + JupyterLab + sshd (Dockerfile.studio).
|
||||
# This is what :latest points at, matching the service set of the previous
|
||||
# production image. Same by-digest build + manifest-merge pattern as the
|
||||
# base. FROMs the exact base manifest digest published by the merge job.
|
||||
# The arm64 leg builds Studio's vite frontend natively on the arm runner;
|
||||
# that is the long pole, hence the larger timeout.
|
||||
# ---------------------------------------------------------------------------
|
||||
build-studio:
|
||||
needs: merge
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux/amd64
|
||||
runner: ubuntu-latest
|
||||
- platform: linux/arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
runs-on: ${{ matrix.runner }}
|
||||
timeout-minutes: 150
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Reclaim disk
|
||||
run: |
|
||||
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc \
|
||||
/opt/hostedtoolcache/CodeQL "$AGENT_TOOLSDIRECTORY" || true
|
||||
df -h /
|
||||
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Resolve labels
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
|
||||
- name: Build and push (per-arch by digest)
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ./docker
|
||||
file: ./docker/Dockerfile.studio
|
||||
platforms: ${{ matrix.platform }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha,scope=studio-${{ matrix.platform }}
|
||||
cache-to: type=gha,scope=studio-${{ matrix.platform }},mode=max
|
||||
outputs: type=image,name=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
|
||||
build-args: |
|
||||
BASE_IMAGE=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ needs.merge.outputs.digest }}
|
||||
# Mirror of the base job's UNSLOTH_REF resolution so the Studio
|
||||
# tree matches the unsloth baked into the base venv.
|
||||
UNSLOTH_STUDIO_REF=${{ github.event.inputs.unsloth_ref || (startsWith(github.ref, 'refs/tags/') && github.ref_name) || github.sha || 'main' }}
|
||||
|
||||
- name: Export digest
|
||||
run: |
|
||||
mkdir -p /tmp/digests
|
||||
digest='${{ steps.build.outputs.digest }}'
|
||||
touch "/tmp/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: digests-studio-${{ matrix.platform == 'linux/amd64' && 'amd64' || 'arm64' }}
|
||||
path: /tmp/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
merge-studio:
|
||||
runs-on: ubuntu-latest
|
||||
needs: build-studio
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: /tmp/digests
|
||||
pattern: digests-studio-*
|
||||
merge-multiple: true
|
||||
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Resolve tags
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
# The full Studio image owns the unprefixed namespace, headed by
|
||||
# :latest. Same :latest gating rationale as the base job.
|
||||
type=raw,value=latest,enable=${{ github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && github.event.inputs.unsloth_ref == '' }}
|
||||
type=ref,event=tag
|
||||
type=schedule,pattern=nightly
|
||||
|
|
@ -220,25 +369,51 @@ jobs:
|
|||
# registered. Architecture matches whatever the runner is.
|
||||
# ---------------------------------------------------------------------------
|
||||
smoke-test:
|
||||
needs: merge
|
||||
needs: [merge, merge-studio]
|
||||
if: ${{ vars.HAS_GPU_RUNNER == 'true' }}
|
||||
runs-on: [self-hosted, gpu]
|
||||
timeout-minutes: 20
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# Re-compute the tag list deterministically from the same metadata-action
|
||||
# config the merge job used, so tag/schedule/SHA runs pull the image
|
||||
# they just published instead of an unrelated `:latest` from a prior run.
|
||||
# IMPORTANT: keep this `enable=` expression byte-identical to the merge
|
||||
# job's :latest gate above. The two used to differ
|
||||
# they just published instead of an unrelated tag from a prior run.
|
||||
# IMPORTANT: keep the `enable=` expressions byte-identical to the
|
||||
# corresponding merge jobs' gates above. The two used to differ
|
||||
# (merge: ref + unsloth_ref guard; smoke: is_default_branch only),
|
||||
# which meant workflow_dispatch with unsloth_ref defaulting to "main"
|
||||
# would skip :latest on merge but still emit :latest as tags[0] on
|
||||
# smoke -- so docker pull would fetch a previously-published :latest
|
||||
# from Docker Hub, not the image just merged.
|
||||
- name: Resolve published tag
|
||||
id: meta
|
||||
- name: Resolve published base tag
|
||||
id: meta_base
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=raw,value=base,enable=${{ github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && github.event.inputs.unsloth_ref == '' }}
|
||||
type=ref,event=tag,prefix=base-
|
||||
type=schedule,pattern=base-nightly
|
||||
type=sha,prefix=base-sha-,format=short
|
||||
|
||||
- name: Pull and smoke-test the base image
|
||||
run: |
|
||||
# Use the first tag from the metadata output -- that is the image we
|
||||
# just published. Falls back to :base only when the metadata is
|
||||
# empty (defensive; should not happen on default-branch runs).
|
||||
TAG="$(jq -r '.tags[0] // ""' <<<"$STEPS_META_BASE_JSON")"
|
||||
if [ -z "$TAG" ]; then
|
||||
TAG="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:base"
|
||||
fi
|
||||
echo "smoke-testing $TAG"
|
||||
docker pull "$TAG"
|
||||
docker run --rm --gpus all "$TAG" python /workspace/smoke_test.py
|
||||
env:
|
||||
STEPS_META_BASE_JSON: ${{ steps.meta_base.outputs.json }}
|
||||
|
||||
- name: Resolve published studio tag
|
||||
id: meta_studio
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
|
|
@ -248,15 +423,25 @@ jobs:
|
|||
type=schedule,pattern=nightly
|
||||
type=sha,prefix=sha-,format=short
|
||||
|
||||
- name: Pull and smoke-test
|
||||
- name: Boot the full image and probe Studio + Jupyter
|
||||
run: |
|
||||
# Use the first tag from the metadata output -- that is the image we
|
||||
# just published. Falls back to :latest only when the metadata is
|
||||
# empty (defensive; should not happen on default-branch runs).
|
||||
TAG="$(jq -r '.tags[0] // ""' <<<"$DOCKER_METADATA_OUTPUT_JSON")"
|
||||
TAG="$(jq -r '.tags[0] // ""' <<<"$STEPS_META_STUDIO_JSON")"
|
||||
if [ -z "$TAG" ]; then
|
||||
TAG="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest"
|
||||
fi
|
||||
echo "smoke-testing $TAG"
|
||||
echo "booting $TAG"
|
||||
docker pull "$TAG"
|
||||
docker run --rm --gpus all "$TAG" python /workspace/smoke_test.py
|
||||
CID="$(docker run -d --gpus all -p 18000:8000 -p 18888:8888 "$TAG")"
|
||||
trap 'docker logs --tail 100 "$CID"; docker rm -f "$CID"' EXIT
|
||||
ok_studio=0; ok_jupyter=0
|
||||
for i in $(seq 1 60); do
|
||||
if curl -fsS http://localhost:18000/api/health >/dev/null 2>&1; then ok_studio=1; fi
|
||||
if curl -fsS http://localhost:18888/api >/dev/null 2>&1; then ok_jupyter=1; fi
|
||||
[ "$ok_studio" = 1 ] && [ "$ok_jupyter" = 1 ] && break
|
||||
sleep 5
|
||||
done
|
||||
[ "$ok_studio" = 1 ] || { echo "Studio /api/health never went healthy"; exit 1; }
|
||||
[ "$ok_jupyter" = 1 ] || { echo "Jupyter /api never responded"; exit 1; }
|
||||
echo "Studio + Jupyter healthy"
|
||||
env:
|
||||
STEPS_META_STUDIO_JSON: ${{ steps.meta_studio.outputs.json }}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue