From 264e114cc6881c3c350f1d2d85c20c929c8c27ef Mon Sep 17 00:00:00 2001 From: Nathan Weinberg Date: Mon, 3 Aug 2026 14:53:39 -0400 Subject: [PATCH] feat(vllm): add GPU container image build pipeline Add Containerfile, GitHub Actions workflow, and Dependabot config for building CUDA-based vLLM GPU inference images with models baked in at build time Signed-off-by: Nathan Weinberg --- .github/dependabot.yaml | 10 ++ .github/workflows/vllm-gpu-container.yaml | 151 ++++++++++++++++++++++ vllm/Containerfile | 35 +++++ vllm/README.md | 85 ++++++++++++ 4 files changed, 281 insertions(+) create mode 100644 .github/workflows/vllm-gpu-container.yaml create mode 100644 vllm/Containerfile create mode 100644 vllm/README.md diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml index 16c90cceb3..d79b26526c 100644 --- a/.github/dependabot.yaml +++ b/.github/dependabot.yaml @@ -14,6 +14,16 @@ updates: schedule: interval: "weekly" + - package-ecosystem: "docker" + directory: "/vllm" + schedule: + interval: "weekly" + labels: + - docker + - dependencies + commit-message: + prefix: "chore(vllm)" + - package-ecosystem: "github-actions" directory: "/" schedule: diff --git a/.github/workflows/vllm-gpu-container.yaml b/.github/workflows/vllm-gpu-container.yaml new file mode 100644 index 0000000000..be1cd06375 --- /dev/null +++ b/.github/workflows/vllm-gpu-container.yaml @@ -0,0 +1,151 @@ +name: vLLM GPU Container + +on: + push: + branches: [main] + paths: + - "vllm/Containerfile" + - ".github/workflows/vllm-gpu-container.yaml" + pull_request: + branches: [main] + types: [opened, synchronize, reopened] + paths: + - "vllm/Containerfile" + - ".github/workflows/vllm-gpu-container.yaml" + merge_group: + branches: [main] + workflow_dispatch: + inputs: + inference_model: + description: "Inference model (HuggingFace ID) — default is Qwen/Qwen3-0.6B" + type: string + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + +permissions: {} + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ghcr.io/praxis-proxy/vllm-gpu + INFERENCE_MODEL: ${{ inputs.inference_model || 'Qwen/Qwen3-0.6B' }} + +jobs: + changes: + if: github.event_name == 'merge_group' + runs-on: ubuntu-24.04 + permissions: + contents: read + outputs: + relevant: ${{ steps.filter.outputs.relevant }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + + - name: Detect relevant path changes + id: filter + run: | + CHANGED=$(git diff --name-only "${{ github.event.merge_group.base_sha }}" \ + "${{ github.event.merge_group.head_sha }}" -- \ + 'vllm/Containerfile' \ + '.github/workflows/vllm-gpu-container.yaml') + if [ -n "$CHANGED" ]; then + echo "relevant=true" >> "$GITHUB_OUTPUT" + else + echo "relevant=false" >> "$GITHUB_OUTPUT" + fi + + build-test: + needs: [changes] + if: | + always() && + (needs.changes.result == 'skipped' || needs.changes.outputs.relevant == 'true') + runs-on: TODO-GPU-RUNNER-LABEL + timeout-minutes: 45 + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set image tag + run: echo "IMAGE_TAG=${INFERENCE_MODEL#*/}" >> "$GITHUB_ENV" + + - name: Free disk space + run: | + sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /opt/hostedtoolcache/CodeQL 2>/dev/null || true + docker system prune -af + + - name: Build image + env: + DOCKER_BUILDKIT: "1" + HF_TOKEN: ${{ secrets.HF_TOKEN }} + run: | + BUILD_ARGS=( + --build-arg "INFERENCE_MODEL=${INFERENCE_MODEL}" + --tag "${IMAGE_NAME}:${IMAGE_TAG}" + --file vllm/Containerfile + ) + if [ -n "${HF_TOKEN}" ]; then + BUILD_ARGS+=(--secret "id=hf_token,env=HF_TOKEN") + fi + docker build "${BUILD_ARGS[@]}" . + + - name: Start vLLM + run: | + docker run -d --name vllm-inference \ + --gpus all \ + -p 8000:8000 \ + "${IMAGE_NAME}:${IMAGE_TAG}" \ + --model "/opt/vllm/models/${INFERENCE_MODEL}" \ + --max-model-len 4096 \ + --served-model-name "${INFERENCE_MODEL#*/}" \ + --enable-auto-tool-choice \ + --tool-call-parser hermes + + - name: Wait for vLLM readiness + run: | + echo "Waiting for vLLM health endpoint..." + timeout 300 bash -c 'until curl -sf http://127.0.0.1:8000/health > /dev/null 2>&1; do + sleep 5 + done' + echo "vLLM is ready" + + - name: Smoke test inference + run: | + response=$(curl -sf http://127.0.0.1:8000/v1/chat/completions \ + -H "Content-Type: application/json" \ + -d '{ + "model": "'"${INFERENCE_MODEL#*/}"'", + "messages": [{"role": "user", "content": "Say hello in one word."}], + "max_tokens": 16 + }') + echo "$response" | jq . + echo "$response" | jq -e '.choices[0].message.content' > /dev/null + + - name: vLLM logs + if: failure() + run: docker logs vllm-inference + + - name: Stop vLLM + if: always() + run: docker rm -f vllm-inference || true + + - name: Log in to GHCR + if: contains(fromJSON('["push", "workflow_dispatch"]'), github.event_name) + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Push image + if: contains(fromJSON('["push", "workflow_dispatch"]'), github.event_name) + run: | + docker push "${IMAGE_NAME}:${IMAGE_TAG}" + if [ "${{ github.event_name }}" = "push" ]; then + docker tag "${IMAGE_NAME}:${IMAGE_TAG}" "${IMAGE_NAME}:latest" + docker push "${IMAGE_NAME}:latest" + fi diff --git a/vllm/Containerfile b/vllm/Containerfile new file mode 100644 index 0000000000..4972d6735b --- /dev/null +++ b/vllm/Containerfile @@ -0,0 +1,35 @@ +# syntax=docker/dockerfile:1 + +# GPU-accelerated vLLM image with a model baked in at build time. +# +# Reference: https://hub.docker.com/r/vllm/vllm-openai + +FROM vllm/vllm-openai:v0.26.0 + +RUN pip install "huggingface-hub[cli]" + +ARG INFERENCE_MODEL="" + +ENV INFERENCE_MODEL="${INFERENCE_MODEL}" +ENV MODEL_CACHE_DIR="/opt/vllm/models" + +RUN if [ -z "${INFERENCE_MODEL}" ]; then \ + echo "ERROR: INFERENCE_MODEL build argument is required" >&2 && exit 1; \ + fi + +RUN --mount=type=secret,id=hf_token \ + set -e && \ + model_path="${MODEL_CACHE_DIR}/${INFERENCE_MODEL}" && \ + mkdir -p "${model_path}" && \ + if [ -f /run/secrets/hf_token ]; then \ + HF_TOKEN=$(cat /run/secrets/hf_token) && \ + hf download "${INFERENCE_MODEL}" --local-dir "${model_path}" --token "${HF_TOKEN}"; \ + else \ + hf download "${INFERENCE_MODEL}" --local-dir "${model_path}"; \ + fi && \ + rm -rf "${MODEL_CACHE_DIR}/.huggingface" "${model_path}/original" + +RUN useradd -r -u 1001 -m vllm +USER 1001 + +ENTRYPOINT ["vllm", "serve"] diff --git a/vllm/README.md b/vllm/README.md new file mode 100644 index 0000000000..3c76e02f38 --- /dev/null +++ b/vllm/README.md @@ -0,0 +1,85 @@ +# vLLM GPU Container + +Pre-built [vLLM](https://docs.vllm.ai/) GPU container image with an +inference model baked in at build time, eliminating runtime download +latency. + +Published to `ghcr.io/praxis-proxy/vllm-gpu`. + +## Requirements + +- Docker with [BuildKit](https://docs.docker.com/build/buildkit/) enabled + (default in Docker 23+); required for the `# syntax=` directive and + `--secret` mounts used below. +- [NVIDIA Container Toolkit](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html) + on the host, so `docker run --gpus all` can expose the GPU to the + container. + +## Building + +```console +docker build \ + --build-arg INFERENCE_MODEL=Qwen/Qwen3-0.6B \ + --tag vllm-gpu:Qwen3-0.6B \ + --file vllm/Containerfile \ + . +``` + +For gated models that require a HuggingFace token: + +```console +docker build \ + --build-arg INFERENCE_MODEL=meta-llama/Llama-3.1-8B \ + --secret id=hf_token,env=HF_TOKEN \ + --tag vllm-gpu:Llama-3.1-8B \ + --file vllm/Containerfile \ + . +``` + +## Running + +```console +docker run --gpus all -p 8000:8000 vllm-gpu:Qwen3-0.6B \ + --model /opt/vllm/models/Qwen/Qwen3-0.6B \ + --max-model-len 4096 \ + --served-model-name Qwen3-0.6B \ + --enable-auto-tool-choice \ + --tool-call-parser hermes +``` + +Additional flags for larger GPUs: + +```console +--gpu-memory-utilization 0.92 +--enable-chunked-prefill +--enable-prefix-caching +``` + +## Health check + +```console +curl http://localhost:8000/health +``` + +## CI + +The `.github/workflows/vllm-gpu-container.yaml` workflow builds, smoke +tests, and publishes the image on a GPU runner: + +- **Push to `main`** (when `Containerfile` or the workflow changes) — + builds, tests, and pushes both `:` (e.g. `:Qwen3-0.6B`) and + `:latest`. +- **Pull request** / **merge queue** — builds and tests only; nothing is + pushed. +- **Manual (`workflow_dispatch`)** — accepts an `inference_model` input to + build an arbitrary HuggingFace model; pushes `:` but not + `:latest`. Defaults to `Qwen/Qwen3-0.6B`. + +The image tag is derived from the model ID with the org prefix stripped +(`Qwen/Qwen3-0.6B` → `Qwen3-0.6B`). Gated models are supported via the +`HF_TOKEN` repository secret. + +## Dependabot + +The `FROM` directive in `Containerfile` is monitored by Dependabot for +weekly base image updates (see `.github/dependabot.yaml`).