@tduncan / Nvidia-Optimization-Stack-VLLM Mirror · read-only / commits / 3df1c7c43e
Fork 0

A high-throughput and memory-efficient inference and serving engine for LLMs

Mirrored from https://github.com/tduncan-lum/Nvidia-Optimization-Stack---VLLM.git

Synced Nov 2, 22:00 UTC

[Docker] Non-root support for vllm-openai; add opt-in vllm-openai-nonroot target (#40275)

Signed-off-by: TheDuyIT <nduy250299@gmail.com> Signed-off-by: dtnguyen <dtnguyen@nvidia.com> Co-authored-by: Claude <noreply@anthropic.com>

Nguyễn Thế Duy committed May 25, 2026 at 12:45 UTC 3df1c7c43e73c6a06f70390136deecfe856c5969
7 files changed +577 -30
.buildkite/image_build/image_build.yaml
+42
@@ -6,6 +6,48 @@ steps:
6 timeout_in_minutes: 600
7 commands:
8 - if [[ "$BUILDKITE_BRANCH" == "main" ]]; then .buildkite/image_build/image_build.sh $REGISTRY $REPO $BUILDKITE_COMMIT $BRANCH $IMAGE_TAG $IMAGE_TAG_LATEST; else .buildkite/image_build/image_build.sh $REGISTRY $REPO $BUILDKITE_COMMIT $BRANCH $IMAGE_TAG; fi
9 + # Non-root smoke 1: the default (root) image must still be importable
10 + # under a non-root UID via `--user 2000:0`. Validates the `vllm` passwd
11 + # entry + group-0-writable /home/vllm + uv path cleanup from #31959.
12 + # Uses `import vllm` rather than `vllm serve --help` because the latter
13 + # instantiates `VllmConfig` which requires a GPU attached to the
14 + # container.
15 + - docker run --rm --user 2000:0 --entrypoint python3 "$IMAGE_TAG" -c "import vllm; print(vllm.__version__)"
16 + # Non-root smoke 2: assert the non-root enabling invariants are baked
17 + # into the image. Runs as UID 2000:0 via a shell so we can verify
18 + # filesystem perms + passwd/group file state + wrapper presence without
19 + # triggering vLLM's GPU-requiring config-init path. The opt-in
20 + # `vllm-openai-nonroot` target adds only `USER vllm`, `WORKDIR
21 + # /home/vllm`, and an `ENTRYPOINT` override on top of these invariants;
22 + # its build correctness is reviewed at the Dockerfile level. Wrapper
23 + # logic is covered separately by the pre-commit hook
24 + # `test-nonroot-entrypoint` (see .pre-commit-config.yaml).
25 + - |
26 + docker run --rm --user 2000:0 --entrypoint /bin/sh "$IMAGE_TAG" -ec '
27 + if ! getent passwd 2000 | grep -q ^vllm:; then
28 + echo FAIL: UID 2000 != vllm
29 + exit 1
30 + fi
31 + if ! id -gn 2>/dev/null | grep -qx root; then
32 + echo FAIL: GID 0 not root group
33 + exit 1
34 + fi
35 + touch /home/vllm/.smoke && rm /home/vllm/.smoke
36 + touch /opt/uv/cache/.smoke && rm /opt/uv/cache/.smoke
37 + if ! test -x /usr/local/bin/vllm-nonroot-entrypoint.sh; then
38 + echo FAIL: wrapper missing
39 + exit 1
40 + fi
41 + if ! test -w /etc/passwd; then
42 + echo FAIL: /etc/passwd not group-writable
43 + exit 1
44 + fi
45 + if ! test -w /etc/group; then
46 + echo FAIL: /etc/group not group-writable
47 + exit 1
48 + fi
49 + echo non-root invariants OK
50 + '
51 retry:
52 automatic:
53 - exit_status: -1 # Agent was lost
.pre-commit-config.yaml
+6
@@ -222,6 +222,12 @@ repos:
222 name: Update Dockerfile dependency graph
223 entry: tools/pre_commit/update-dockerfile-graph.sh
224 language: script
225 + - id: test-nonroot-entrypoint
226 + name: Test non-root entrypoint wrapper
227 + entry: bash docker/entrypoints/test_vllm_nonroot_entrypoint.sh
228 + language: system
229 + pass_filenames: false
230 + files: ^docker/entrypoints/(vllm-nonroot-entrypoint|test_vllm_nonroot_entrypoint)\.sh$
231 - id: check-forbidden-imports
232 name: Check for forbidden imports
233 entry: python tools/pre_commit/check_forbidden_imports.py
docker/Dockerfile
+118 -30
@@ -105,6 +105,23 @@ ARG BUILD_OS
105
106 ENV DEBIAN_FRONTEND=noninteractive
107
108 +# Environment for uv
109 +# Declared BEFORE the installer + `uv venv` invocations below so the uv
110 +# binary, managed Python, download cache, and /opt/venv all land under
111 +# /opt/uv instead of /root/.local/. Without this, the venv created at
112 +# build time hardlinks back to /root/.local/share/uv/python and
113 +# descendants of this stage (`build`, `dev`, `csrc-build`,
114 +# `extensions-build`) inherit a root-owned, non-root-unreadable layout.
115 +# See #15174, #15359, #31959. Child stages inherit these via Dockerfile
116 +# `ENV` unless they override them explicitly.
117 +ENV UV_HTTP_TIMEOUT=500
118 +ENV UV_INDEX_STRATEGY="unsafe-best-match"
119 +ENV UV_PYTHON_INSTALL_DIR=/opt/uv/python
120 +ENV UV_CACHE_DIR=/opt/uv/cache
121 +ENV UV_INSTALL_DIR=/opt/uv/bin
122 +ENV PATH="/opt/venv/bin:/opt/uv/bin:$PATH"
123 +ENV VIRTUAL_ENV="/opt/venv"
124 +
125 # Install system dependencies including build tools.
126 # The Ubuntu path uses apt + deadsnakes-via-uv for Python; the manylinux path
127 # (AlmaLinux 8, e.g. pytorch/manylinux2_28-builder) uses dnf and the Python
@@ -145,15 +162,21 @@ RUN if [ "${BUILD_OS}" = "manylinux" ]; then \
162
163 # Install uv and bootstrap /opt/venv. Both paths converge on /opt/venv so all
164 # downstream stages stay distro-agnostic.
148 -RUN curl -LsSf https://astral.sh/uv/install.sh | sh \
165 +RUN mkdir -p "${UV_PYTHON_INSTALL_DIR}" "${UV_CACHE_DIR}" "${UV_INSTALL_DIR}" \
166 + && chmod -R a+rX /opt/uv \
167 + && curl -LsSf https://astral.sh/uv/install.sh | sh \
168 + # `--seed` installs pip/setuptools/wheel into the venv so `python3 -m
169 + # pip` works regardless of how uv happens to link the venv back to the
170 + # managed Python install (which, at a non-default UV_PYTHON_INSTALL_DIR,
171 + # doesn't always expose ensurepip via the default venv layout).
172 && if [ "${BUILD_OS}" = "manylinux" ]; then \
173 # manylinux images ship Python at /opt/python/cpXY-cpXY/; point uv
174 # at the matching interpreter rather than letting it fetch one.
175 PYV_NODOT=$(echo ${PYTHON_VERSION} | tr -d '.') \
176 && MANYLINUX_PY=/opt/python/cp${PYV_NODOT}-cp${PYV_NODOT}/bin/python${PYTHON_VERSION} \
154 - && $HOME/.local/bin/uv venv /opt/venv --python "$MANYLINUX_PY"; \
177 + && uv venv --seed /opt/venv --python "$MANYLINUX_PY"; \
178 else \
156 - $HOME/.local/bin/uv venv /opt/venv --python ${PYTHON_VERSION}; \
179 + uv venv --seed /opt/venv --python ${PYTHON_VERSION}; \
180 fi \
181 && rm -f /usr/bin/python3 /usr/bin/python3-config /usr/bin/pip \
182 && ln -sf /opt/venv/bin/python3 /usr/bin/python3 \
@@ -161,13 +184,10 @@ RUN curl -LsSf https://astral.sh/uv/install.sh | sh \
184 && ln -sf /opt/venv/bin/pip /usr/bin/pip \
185 && python3 --version && python3 -m pip --version
186
164 -# Activate virtual environment and add uv to PATH
165 -ENV PATH="/opt/venv/bin:/root/.local/bin:$PATH"
166 -ENV VIRTUAL_ENV="/opt/venv"
167 -
168 -# Environment for uv
169 -ENV UV_HTTP_TIMEOUT=500
170 -ENV UV_INDEX_STRATEGY="unsafe-best-match"
187 +# UV_LINK_MODE=copy applies to subsequent `uv pip install` RUNs (avoids
188 +# hardlink failures with BuildKit cache mounts); it must not be set during
189 +# `uv venv` above, which relies on hardlinking /opt/venv back to the
190 +# managed Python source so ensurepip / `python3 -m pip` still resolve.
191 ENV UV_LINK_MODE=copy
192
193 # Verify GCC version
@@ -198,7 +218,7 @@ COPY requirements/common.txt requirements/common.txt
218 COPY requirements/cuda.txt requirements/cuda.txt
219 COPY use_existing_torch.py use_existing_torch.py
220 COPY pyproject.toml pyproject.toml
201 -RUN --mount=type=cache,target=/root/.cache/uv \
221 +RUN --mount=type=cache,target=/opt/uv/cache \
222 if [ "$(echo $CUDA_VERSION | cut -d. -f1)" = "12" ]; then \
223 sed -i 's/^nvidia-cutlass-dsl\[cu13\]>=/nvidia-cutlass-dsl>=/' requirements/cuda.txt; \
224 fi \
@@ -218,7 +238,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
238 # Track PyTorch lib versions used during build and match in downstream instances.
239 # We do this for both nightly and release so we can strip dependencies/*.txt as needed.
240 # Otherwise library dependencies can upgrade/downgrade torch incorrectly.
221 -RUN --mount=type=cache,target=/root/.cache/uv \
241 +RUN --mount=type=cache,target=/opt/uv/cache \
242 uv pip freeze | grep -i "^torch=\|^torchvision=\|^torchaudio=" > torch_lib_versions.txt \
243 && TORCH_LIB_VERSIONS=$(cat torch_lib_versions.txt | xargs) \
244 && echo "Installed torch libs: ${TORCH_LIB_VERSIONS}"
@@ -304,7 +324,7 @@ ENV UV_INDEX_STRATEGY="unsafe-best-match"
324 # Use copy mode to avoid hardlink failures with Docker cache mounts
325 ENV UV_LINK_MODE=copy
326
307 -RUN --mount=type=cache,target=/root/.cache/uv \
327 +RUN --mount=type=cache,target=/opt/uv/cache \
328 if [ "${PYTORCH_NIGHTLY}" = "1" ]; then \
329 echo "Installing build requirements without torch..." \
330 && python3 use_existing_torch.py --prefix \
@@ -349,7 +369,7 @@ ARG VLLM_MAIN_CUDA_VERSION=""
369 ENV SETUPTOOLS_SCM_PRETEND_VERSION="0.0.0+csrc.build"
370
371 # Use existing torch for nightly builds
352 -RUN --mount=type=cache,target=/root/.cache/uv \
372 +RUN --mount=type=cache,target=/opt/uv/cache \
373 if [ "${PYTORCH_NIGHTLY}" = "1" ]; then \
374 python3 use_existing_torch.py --prefix; \
375 fi
@@ -365,7 +385,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
385 # Build the vLLM wheel
386 # if USE_SCCACHE is set, use sccache to speed up compilation
387 # AWS credentials mounted at ~/.aws/credentials for sccache S3 auth (optional)
368 -RUN --mount=type=cache,target=/root/.cache/uv \
388 +RUN --mount=type=cache,target=/opt/uv/cache \
389 --mount=type=secret,id=aws-credentials,target=/root/.aws/credentials,required=false \
390 if [ "$USE_SCCACHE" = "1" ]; then \
391 echo "Installing sccache..." \
@@ -399,7 +419,7 @@ ARG vllm_target_device="cuda"
419 ENV VLLM_TARGET_DEVICE=${vllm_target_device}
420 ENV CCACHE_DIR=/root/.cache/ccache
421 RUN --mount=type=cache,target=/root/.cache/ccache \
402 - --mount=type=cache,target=/root/.cache/uv \
422 + --mount=type=cache,target=/opt/uv/cache \
423 if [ "$USE_SCCACHE" != "1" ]; then \
424 # Clean any existing CMake artifacts
425 rm -rf .deps && \
@@ -431,7 +451,7 @@ COPY tools/ep_kernels/install_python_libraries.sh /tmp/install_python_libraries.
451 # Defaults moved here from tools/ep_kernels/install_python_libraries.sh for centralized version management
452 ARG DEEPEP_COMMIT_HASH=73b6ea4
453 ARG NVSHMEM_VER
434 -RUN --mount=type=cache,target=/root/.cache/uv \
454 +RUN --mount=type=cache,target=/opt/uv/cache \
455 mkdir -p /tmp/ep_kernels_workspace/dist && \
456 export TORCH_CUDA_ARCH_LIST='9.0a 10.0a' && \
457 /tmp/install_python_libraries.sh \
@@ -465,7 +485,7 @@ ENV UV_INDEX_STRATEGY="unsafe-best-match"
485 # Use copy mode to avoid hardlink failures with Docker cache mounts
486 ENV UV_LINK_MODE=copy
487
468 -RUN --mount=type=cache,target=/root/.cache/uv \
488 +RUN --mount=type=cache,target=/opt/uv/cache \
489 if [ "${PYTORCH_NIGHTLY}" = "1" ]; then \
490 echo "Installing build requirements without torch..." \
491 && python3 use_existing_torch.py --prefix \
@@ -500,13 +520,13 @@ ENV VLLM_TARGET_DEVICE=${vllm_target_device}
520 ENV VLLM_SKIP_PRECOMPILED_VERSION_SUFFIX=1
521
522 # Use existing torch for nightly builds
503 -RUN --mount=type=cache,target=/root/.cache/uv \
523 +RUN --mount=type=cache,target=/opt/uv/cache \
524 if [ "${PYTORCH_NIGHTLY}" = "1" ]; then \
525 python3 use_existing_torch.py --prefix; \
526 fi
527
528 # Build the vLLM wheel
509 -RUN --mount=type=cache,target=/root/.cache/uv \
529 +RUN --mount=type=cache,target=/opt/uv/cache \
530 --mount=type=bind,source=.git,target=.git \
531 if [ "${vllm_target_device}" = "cuda" ]; then \
532 export VLLM_USE_PRECOMPILED=1; \
@@ -564,7 +584,7 @@ COPY requirements/test/cuda.txt requirements/test/cuda.txt
584 COPY requirements/dev.txt requirements/dev.txt
585 COPY use_existing_torch.py use_existing_torch.py
586 COPY --from=base /workspace/torch_lib_versions.txt torch_lib_versions.txt
567 -RUN --mount=type=cache,target=/root/.cache/uv \
587 +RUN --mount=type=cache,target=/opt/uv/cache \
588 if [ "${PYTORCH_NIGHTLY}" = "1" ]; then \
589 echo "Installing dev requirements plus torch nightly..." \
590 && python3 use_existing_torch.py --prefix \
@@ -664,9 +684,50 @@ RUN CUDA_VERSION_DASH=$(echo $CUDA_VERSION | cut -d. -f1,2 | tr '.' '-') && \
684 RUN python3 -m pip install uv
685
686 # Environment for uv
687 +# Redirect uv's managed Python and download cache out of /root/ so downstream
688 +# images (`FROM vllm/vllm-openai` + `USER <uid>`) and direct non-root runs
689 +# (`docker run --user <uid>:<gid>`) can read and execute them. See #15174,
690 +# #15359, #31959.
691 ENV UV_HTTP_TIMEOUT=500
692 ENV UV_INDEX_STRATEGY="unsafe-best-match"
693 ENV UV_LINK_MODE=copy
694 +ENV UV_PYTHON_INSTALL_DIR=/opt/uv/python
695 +ENV UV_CACHE_DIR=/opt/uv/cache
696 +RUN mkdir -p "${UV_PYTHON_INSTALL_DIR}" "${UV_CACHE_DIR}" \
697 + && chgrp -R 0 /opt/uv \
698 + && chmod -R g+rwX,a+rX /opt/uv
699 +
700 +# ----------------------------------------------------------------------
701 +# Non-root support (opt-in)
702 +# ----------------------------------------------------------------------
703 +# Create a conventional `vllm` user (UID 2000, GID 0) so the image can be
704 +# run under `--user 2000:0` or the opt-in `vllm-openai-nonroot` target.
705 +#
706 +# Design notes:
707 +# * GID 0 + group-writable cache dirs follow the OpenShift arbitrary-UID
708 +# pattern, so any UID that is a member of group 0 at runtime can write
709 +# to /home/vllm and /opt/uv without additional chown work.
710 +# * The default `vllm-openai` image keeps `USER root`, so every existing
711 +# `docker run vllm/vllm-openai ...` / K8s manifest / `FROM vllm/vllm-openai`
712 +# + `RUN uv pip install --system ...` flow is unchanged.
713 +# * The entrypoint wrapper below is only used by `vllm-openai-nonroot`; it
714 +# handles the OpenShift arbitrary-UID case (UID not in /etc/passwd).
715 +# See #31959 and docs/deployment/docker.md.
716 +RUN useradd --uid 2000 --gid 0 --create-home --home-dir /home/vllm \
717 + --shell /bin/bash vllm \
718 + && mkdir -p /home/vllm/.cache /home/vllm/.config \
719 + && chown -R 2000:0 /home/vllm \
720 + && chmod -R g+rwX /home/vllm \
721 + # Allow the entrypoint wrapper to append a /etc/passwd entry for an
722 + # arbitrary runtime UID that shares GID 0. Without this, `whoami`, bash's
723 + # `\u` prompt, `id -un`, and anything else that calls `getpwuid()`
724 + # directly return "I have no name!" for OpenShift-style arbitrary UIDs.
725 + # This matches the convention used by Red Hat UBI base images.
726 + && chgrp 0 /etc/passwd /etc/group \
727 + && chmod g=u /etc/passwd /etc/group
728 +COPY docker/entrypoints/vllm-nonroot-entrypoint.sh \
729 + /usr/local/bin/vllm-nonroot-entrypoint.sh
730 +RUN chmod 0755 /usr/local/bin/vllm-nonroot-entrypoint.sh
731
732 # Enable CUDA forward compatibility by setting '-e VLLM_ENABLE_CUDA_COMPATIBILITY=1'
733 # Only needed for datacenter/professional GPUs with older drivers.
@@ -683,7 +744,7 @@ ENV VLLM_ENABLE_CUDA_COMPATIBILITY=0
744 ARG PYTORCH_CUDA_INDEX_BASE_URL
745 COPY requirements/common.txt /tmp/common.txt
746 COPY requirements/cuda.txt /tmp/requirements-cuda.txt
686 -RUN --mount=type=cache,target=/root/.cache/uv \
747 +RUN --mount=type=cache,target=/opt/uv/cache \
748 if [ "$(echo $CUDA_VERSION | cut -d. -f1)" = "12" ]; then \
749 sed -i 's/^nvidia-cutlass-dsl\[cu13\]>=/nvidia-cutlass-dsl>=/' /tmp/requirements-cuda.txt; \
750 fi && \
@@ -695,7 +756,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
756 # https://docs.flashinfer.ai/installation.html
757 # From versions.json: .flashinfer.version
758 ARG FLASHINFER_VERSION=0.6.11.post2
698 -RUN --mount=type=cache,target=/root/.cache/uv \
759 +RUN --mount=type=cache,target=/opt/uv/cache \
760 uv pip install --system flashinfer-jit-cache==${FLASHINFER_VERSION} \
761 --extra-index-url https://flashinfer.ai/whl/cu$(echo $CUDA_VERSION | cut -d. -f1,2 | tr -d '.')
762
@@ -727,7 +788,7 @@ ARG BITSANDBYTES_VERSION_X86=0.46.1
788 ARG BITSANDBYTES_VERSION_ARM64=0.42.0
789 ARG TIMM_VERSION=">=1.0.17"
790 ARG RUNAI_MODEL_STREAMER_VERSION=">=0.15.7"
730 -RUN --mount=type=cache,target=/root/.cache/uv \
791 +RUN --mount=type=cache,target=/opt/uv/cache \
792 if [ "$TARGETPLATFORM" = "linux/arm64" ]; then \
793 BITSANDBYTES_VERSION="${BITSANDBYTES_VERSION_ARM64}"; \
794 else \
@@ -752,7 +813,7 @@ ARG PYTORCH_NIGHTLY
813 # Check whether to install torch nightly instead of release for this build.
814 COPY --from=base /workspace/torch_lib_versions.txt torch_lib_versions.txt
815 RUN --mount=type=bind,from=build,src=/workspace/dist,target=/vllm-workspace/dist \
755 - --mount=type=cache,target=/root/.cache/uv \
816 + --mount=type=cache,target=/opt/uv/cache \
817 if [ "${PYTORCH_NIGHTLY}" = "1" ]; then \
818 echo "Installing torch nightly..." \
819 && uv pip install --system $(cat torch_lib_versions.txt | xargs) --pre \
@@ -766,7 +827,7 @@ RUN --mount=type=bind,from=build,src=/workspace/dist,target=/vllm-workspace/dist
827 --extra-index-url ${PYTORCH_CUDA_INDEX_BASE_URL}/cu$(echo $CUDA_VERSION | cut -d. -f1,2 | tr -d '.'); \
828 fi
829
769 -RUN --mount=type=cache,target=/root/.cache/uv \
830 +RUN --mount=type=cache,target=/opt/uv/cache \
831 . /etc/environment && \
832 uv pip list
833
@@ -775,7 +836,7 @@ ENV LD_LIBRARY_PATH=/usr/local/cuda/lib64:$LD_LIBRARY_PATH
836
837 # Install EP kernels wheels (DeepEP) that have been built in the `build` stage
838 RUN --mount=type=bind,from=build,src=/tmp/ep_kernels_workspace/dist,target=/vllm-workspace/ep_kernels/dist \
778 - --mount=type=cache,target=/root/.cache/uv \
839 + --mount=type=cache,target=/opt/uv/cache \
840 uv pip install --system ep_kernels/dist/*.whl --verbose \
841 --extra-index-url ${PYTORCH_CUDA_INDEX_BASE_URL}/cu$(echo $CUDA_VERSION | cut -d. -f1,2 | tr -d '.')
842
@@ -830,7 +891,7 @@ COPY requirements/test/cuda.txt requirements/test/cuda.txt
891 COPY requirements/dev.txt requirements/dev.txt
892 COPY use_existing_torch.py use_existing_torch.py
893 COPY --from=base /workspace/torch_lib_versions.txt torch_lib_versions.txt
833 -RUN --mount=type=cache,target=/root/.cache/uv \
894 +RUN --mount=type=cache,target=/opt/uv/cache \
895 CUDA_MAJOR="${CUDA_VERSION%%.*}"; \
896 if [ "$CUDA_MAJOR" -ge 12 ]; then \
897 if [ "${PYTORCH_NIGHTLY}" = "1" ]; then \
@@ -850,7 +911,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
911 fi
912
913 # install development dependencies (for testing)
853 -RUN --mount=type=cache,target=/root/.cache/uv \
914 +RUN --mount=type=cache,target=/opt/uv/cache \
915 uv pip install --system -e tests/vllm_test_utils
916
917 # enable fast downloads from hf (for testing)
@@ -890,7 +951,7 @@ ENV UV_HTTP_TIMEOUT=500
951 # install kv_connectors if requested
952 ARG torch_cuda_arch_list='7.5 8.0 8.6 8.9 9.0 10.0 11.0 12.0+PTX'
953 ENV TORCH_CUDA_ARCH_LIST=${torch_cuda_arch_list}
893 -RUN --mount=type=cache,target=/root/.cache/uv \
954 +RUN --mount=type=cache,target=/opt/uv/cache \
955 --mount=type=bind,source=requirements/kv_connectors.txt,target=/tmp/kv_connectors.txt,ro \
956 CUDA_MAJOR="${CUDA_VERSION%%.*}"; \
957 CUDA_VERSION_DASH=$(echo $CUDA_VERSION | cut -d. -f1,2 | tr '.' '-'); \
@@ -958,5 +1019,32 @@ ENTRYPOINT ["./sagemaker-entrypoint.sh"]
1019
1020 FROM vllm-openai-base AS vllm-openai
1021
1022 +# To run the image as non-root, either build the `vllm-openai-nonroot` target
1023 +# below, or in a derived Dockerfile uncomment the following line and ensure
1024 +# any additional layers chgrp-0 / chmod-g+rwX paths they write to. The `vllm`
1025 +# user (UID 2000, GID 0) is already created in the `vllm-base` stage.
1026 +# See docs/deployment/docker.md.
1027 +# USER vllm
1028 ENTRYPOINT ["vllm", "serve"]
1029 #################### OPENAI API SERVER ####################
1030 +
1031 +#################### OPENAI API SERVER (NON-ROOT, OPT-IN) ####################
1032 +# Non-root-ready variant of `vllm-openai`. Built via:
1033 +# docker build --target vllm-openai-nonroot -t vllm:openai-nonroot \
1034 +# -f docker/Dockerfile .
1035 +#
1036 +# Runtime behavior:
1037 +# * Default USER is `vllm` (UID 2000, GID 0) created in `vllm-base`.
1038 +# * HOME is /home/vllm, pre-created group-0-writable so arbitrary UIDs in
1039 +# group 0 (OpenShift / `--user <uid>:0`) can also use the image.
1040 +# * Entrypoint wrapper handles the "UID not in /etc/passwd" case for truly
1041 +# arbitrary UIDs by falling back HOME/USER to sane writable defaults.
1042 +# * All cache/config envs (HF_HOME, VLLM_CACHE_ROOT, TRITON_CACHE_DIR, ...)
1043 +# remain unset so their library defaults resolve to $HOME/.cache/... ,
1044 +# which is writable.
1045 +FROM vllm-openai AS vllm-openai-nonroot
1046 +
1047 +USER vllm
1048 +WORKDIR /home/vllm
1049 +ENTRYPOINT ["/usr/local/bin/vllm-nonroot-entrypoint.sh"]
1050 +#################### OPENAI API SERVER (NON-ROOT, OPT-IN) ####################
docker/entrypoints/test_vllm_nonroot_entrypoint.sh new
+266
@@ -0,0 +1,266 @@
1 +#!/bin/sh
2 +# Shell-level unit test for vllm-nonroot-entrypoint.sh.
3 +#
4 +# Runs on the host (no Docker, no GPU) by stubbing `vllm` with a shim that
5 +# dumps its env + argv instead of actually serving. Exercises the wrapper's
6 +# HOME/USER fallback behavior that can't be easily tested from buildkite
7 +# (which would need a GPU to run `vllm serve --help`).
8 +#
9 +# Usage:
10 +# bash docker/entrypoints/test_vllm_nonroot_entrypoint.sh
11 +# Exits non-zero on the first failed assertion.
12 +
13 +set -eu
14 +
15 +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
16 +WRAPPER="${SCRIPT_DIR}/vllm-nonroot-entrypoint.sh"
17 +
18 +if [ ! -x "$WRAPPER" ]; then
19 + echo "FAIL: wrapper not found or not executable: $WRAPPER" >&2
20 + exit 1
21 +fi
22 +
23 +WORKDIR="$(mktemp -d)"
24 +trap 'rm -rf "$WORKDIR"' EXIT
25 +
26 +# Stub `vllm` on PATH. It dumps env + argv + cwd to stdout so we can assert.
27 +mkdir -p "$WORKDIR/bin"
28 +cat > "$WORKDIR/bin/vllm" <<'EOF'
29 +#!/bin/sh
30 +echo "ARGV=$*"
31 +echo "HOME=${HOME-__unset__}"
32 +echo "USER=${USER-__unset__}"
33 +echo "LOGNAME=${LOGNAME-__unset__}"
34 +echo "PWD=$(pwd)"
35 +EOF
36 +chmod +x "$WORKDIR/bin/vllm"
37 +
38 +run_wrapper() {
39 + # Usage: run_wrapper <output_file> <env_kv>... -- <wrapper_arg>...
40 + _out="$1"; shift
41 + _env=""
42 + while [ "${1:-}" != "--" ]; do
43 + _env="$_env $1"; shift
44 + done
45 + shift
46 + env -i PATH="$WORKDIR/bin:/usr/bin:/bin" $_env "$WRAPPER" "$@" > "$_out"
47 +}
48 +
49 +fail() { echo "FAIL: $*" >&2; echo "--- stdout ---" >&2; cat "$1" >&2; exit 1; }
50 +
51 +expect_default_home() {
52 + _out="$1"
53 + _case="$2"
54 + if [ -w /home/vllm ]; then
55 + expected_home="/home/vllm"
56 + grep -q "^HOME=$expected_home\$" "$_out" \
57 + || fail "$_out" "$_case: HOME not set to $expected_home"
58 + else
59 + expected_home="/tmp/vllm-home.XXXXXX"
60 + grep -Eq '^HOME=/tmp/vllm-home\.[^/]+$' "$_out" \
61 + || fail "$_out" "$_case: HOME not set to $expected_home"
62 + fi
63 +}
64 +
65 +# -----------------------------------------------------------------------------
66 +# Case 1: writable HOME and USER both set -> wrapper must leave them alone.
67 +# -----------------------------------------------------------------------------
68 +case1_home="$WORKDIR/case1-home"
69 +mkdir -p "$case1_home"
70 +out="$WORKDIR/case1.out"
71 +run_wrapper "$out" "HOME=$case1_home" "USER=alice" "LOGNAME=alice" -- --model foo
72 +grep -q "^HOME=$case1_home\$" "$out" || fail "$out" "case1: HOME not preserved"
73 +grep -q "^USER=alice\$" "$out" || fail "$out" "case1: USER not preserved"
74 +grep -q "^LOGNAME=alice\$" "$out" || fail "$out" "case1: LOGNAME not preserved"
75 +grep -q "^ARGV=serve --model foo\$" "$out" || fail "$out" "case1: ARGV wrong"
76 +echo "PASS: case1 (writable HOME + USER preserved)"
77 +
78 +# -----------------------------------------------------------------------------
79 +# Case 2: HOME unset -> falls back to /home/vllm if writable, else
80 +# /tmp/vllm-home.XXXXXX.
81 +# -----------------------------------------------------------------------------
82 +# The wrapper checks whether the real /home/vllm exists and is writable. On
83 +# dev machines /home/vllm typically does NOT exist, so the
84 +# wrapper should fall to /tmp/vllm-home.XXXXXX.
85 +out="$WORKDIR/case2.out"
86 +run_wrapper "$out" -- --model bar
87 +expect_default_home "$out" "case2"
88 +grep -q "^USER=vllm\$" "$out" || fail "$out" "case2: USER not defaulted to vllm"
89 +grep -q "^LOGNAME=vllm\$" "$out" || fail "$out" "case2: LOGNAME not defaulted to vllm"
90 +grep -q "^ARGV=serve --model bar\$" "$out" || fail "$out" "case2: ARGV wrong"
91 +echo "PASS: case2 (unset HOME falls back to $expected_home, USER defaulted)"
92 +
93 +# -----------------------------------------------------------------------------
94 +# Case 3: HOME set but unwritable -> must also fall back.
95 +# -----------------------------------------------------------------------------
96 +ro_home="$WORKDIR/ro-home"
97 +mkdir -p "$ro_home"
98 +chmod 0500 "$ro_home"
99 +out="$WORKDIR/case3.out"
100 +run_wrapper "$out" "HOME=$ro_home" -- --model baz
101 +expect_default_home "$out" "case3"
102 +grep -q "^USER=vllm\$" "$out" || fail "$out" "case3: USER not defaulted"
103 +chmod 0700 "$ro_home"
104 +echo "PASS: case3 (unwritable HOME overridden)"
105 +
106 +# -----------------------------------------------------------------------------
107 +# Case 4: USER set but LOGNAME unset -> LOGNAME mirrors USER.
108 +# -----------------------------------------------------------------------------
109 +case4_home="$WORKDIR/case4-home"
110 +mkdir -p "$case4_home"
111 +out="$WORKDIR/case4.out"
112 +run_wrapper "$out" "HOME=$case4_home" "USER=carol" -- --model qux
113 +grep -q "^USER=carol\$" "$out" || fail "$out" "case4: USER not preserved"
114 +grep -q "^LOGNAME=carol\$" "$out" || fail "$out" "case4: LOGNAME not mirrored from USER"
115 +echo "PASS: case4 (LOGNAME mirrors USER when unset)"
116 +
117 +# -----------------------------------------------------------------------------
118 +# Case 5: /etc/passwd is writable AND the current UID is not in it -> wrapper
119 +# appends a synthetic entry. Uses the VLLM_PASSWD_FILE test hook so we don't
120 +# touch the real /etc/passwd.
121 +# -----------------------------------------------------------------------------
122 +fake_passwd="$WORKDIR/fake-passwd"
123 +: > "$fake_passwd" # empty file, current UID definitely not present
124 +case5_home="$WORKDIR/case5-home"
125 +mkdir -p "$case5_home"
126 +out="$WORKDIR/case5.out"
127 +run_wrapper "$out" "HOME=$case5_home" "VLLM_PASSWD_FILE=$fake_passwd" -- --model foo
128 +current_uid="$(id -u)"
129 +current_gid="$(id -g)"
130 +expected_line="vllm:x:${current_uid}:${current_gid}:vllm:${case5_home}:/bin/bash"
131 +grep -Fx "$expected_line" "$fake_passwd" > /dev/null \
132 + || { echo "FAIL: case5: expected line not found in fake passwd:"; echo " expected: $expected_line"; echo " file contents:"; cat "$fake_passwd"; exit 1; }
133 +echo "PASS: case5 (passwd entry appended for arbitrary UID)"
134 +
135 +# -----------------------------------------------------------------------------
136 +# Case 6: /etc/passwd is writable but current UID already has an entry ->
137 +# wrapper must NOT duplicate the entry.
138 +# -----------------------------------------------------------------------------
139 +fake_passwd="$WORKDIR/fake-passwd-prepopulated"
140 +printf 'vllm:x:%s:%s:vllm:/home/vllm:/bin/bash\n' "$current_uid" "$current_gid" > "$fake_passwd"
141 +out="$WORKDIR/case6.out"
142 +run_wrapper "$out" "HOME=$case5_home" "VLLM_PASSWD_FILE=$fake_passwd" -- --model foo
143 +line_count="$(wc -l < "$fake_passwd")"
144 +# NOTE: wc may count 0 or 1 depending on trailing newline; accept 1.
145 +# More robust: count lines matching our UID.
146 +uid_lines="$(grep -c ":${current_uid}:" "$fake_passwd" || true)"
147 +[ "$uid_lines" = "1" ] \
148 + || { echo "FAIL: case6: expected exactly one entry for UID $current_uid, got $uid_lines"; cat "$fake_passwd"; exit 1; }
149 +echo "PASS: case6 (existing passwd entry not duplicated)"
150 +
151 +# -----------------------------------------------------------------------------
152 +# Case 7: /etc/passwd is NOT writable -> wrapper must NOT crash, just skip.
153 +# Skipped when running as root, because root's DAC override means [ -w ... ]
154 +# is always true regardless of mode bits -- the case can't be simulated.
155 +# In the real deployment (non-root UID inside the container) this IS the
156 +# relevant behavior and is what `_passwd_file is not writable` encodes.
157 +# -----------------------------------------------------------------------------
158 +if [ "$(id -u)" = "0" ]; then
159 + echo "SKIP: case7 (running as root; DAC override makes unwritable check meaningless)"
160 +else
161 + fake_passwd="$WORKDIR/ro-passwd"
162 + : > "$fake_passwd"
163 + chmod 0444 "$fake_passwd"
164 + out="$WORKDIR/case7.out"
165 + run_wrapper "$out" "HOME=$case5_home" "VLLM_PASSWD_FILE=$fake_passwd" -- --model foo
166 + # File must remain empty (no write happened) and the wrapper exec'd
167 + # `vllm serve` successfully (stdout contains ARGV line).
168 + [ ! -s "$fake_passwd" ] \
169 + || { echo "FAIL: case7: RO passwd file was modified"; cat "$fake_passwd"; exit 1; }
170 + grep -q "^ARGV=serve --model foo\$" "$out" || fail "$out" "case7: wrapper didn't exec vllm"
171 + chmod 0600 "$fake_passwd"
172 + echo "PASS: case7 (unwritable passwd file tolerated)"
173 +fi
174 +
175 +# -----------------------------------------------------------------------------
176 +# Case 8: caller's writable CWD is preserved — wrapper must NOT chdir to HOME
177 +# when cwd is usable. Protects relative-path workflows like
178 +# `docker run -w /models ... --model ./llama.gguf`.
179 +# -----------------------------------------------------------------------------
180 +case8_home="$WORKDIR/case8-home"
181 +mkdir -p "$case8_home"
182 +case8_cwd="$WORKDIR/case8-cwd"
183 +mkdir -p "$case8_cwd"
184 +out="$WORKDIR/case8.out"
185 +(cd "$case8_cwd" && run_wrapper "$out" "HOME=$case8_home" "USER=alice" "LOGNAME=alice" -- --model ./relpath)
186 +grep -q "^PWD=$case8_cwd\$" "$out" \
187 + || fail "$out" "case8: writable cwd not preserved (got $(grep '^PWD=' "$out"))"
188 +grep -q "^ARGV=serve --model \\./relpath\$" "$out" \
189 + || fail "$out" "case8: relative argv not preserved"
190 +echo "PASS: case8 (writable cwd preserved; relative argv still resolves from caller's cwd)"
191 +
192 +# -----------------------------------------------------------------------------
193 +# Case 9: read-only cwd is ALSO preserved. A caller who mounts a read-only
194 +# model directory at the container's cwd (e.g. `docker run -w /models` with
195 +# /models bind-mounted ro) expects relative argv like `--model ./foo.gguf`
196 +# to resolve against /models. An earlier version of this wrapper rewrote
197 +# read-only cwd to $HOME and broke that workflow; this case guards against
198 +# the regression returning.
199 +# -----------------------------------------------------------------------------
200 +case9_home="$WORKDIR/case9-home"
201 +mkdir -p "$case9_home"
202 +case9_ro="$WORKDIR/case9-ro"
203 +mkdir -p "$case9_ro"
204 +chmod 0555 "$case9_ro"
205 +out="$WORKDIR/case9.out"
206 +(cd "$case9_ro" && run_wrapper "$out" "HOME=$case9_home" "USER=alice" "LOGNAME=alice" -- --model ./foo)
207 +grep -q "^PWD=$case9_ro\$" "$out" \
208 + || fail "$out" "case9: read-only cwd was rewritten (got $(grep '^PWD=' "$out"))"
209 +grep -q "^ARGV=serve --model \\./foo\$" "$out" \
210 + || fail "$out" "case9: relative argv not preserved"
211 +chmod 0700 "$case9_ro"
212 +echo "PASS: case9 (read-only cwd preserved; relative argv still resolves from caller's cwd)"
213 +
214 +# -----------------------------------------------------------------------------
215 +# Case 10: truly inaccessible cwd (no search bit) DOES fall back to $HOME.
216 +# Skipped as root because DAC override lets root cd into 0000 directories.
217 +# -----------------------------------------------------------------------------
218 +if [ "$(id -u)" = "0" ]; then
219 + echo "SKIP: case10 (running as root; DAC override makes inaccessible cwd untestable)"
220 +else
221 + case10_home="$WORKDIR/case10-home"
222 + mkdir -p "$case10_home"
223 + case10_cwd="$WORKDIR/case10-cwd"
224 + mkdir -p "$case10_cwd"
225 + out="$WORKDIR/case10.out"
226 + # Make cwd genuinely inaccessible (mode 0000 = no search bit -> cd .
227 + # fails with EACCES). Use absolute paths for chmod so our own test
228 + # cleanup still works without needing search perm on the dir.
229 + (
230 + cd "$case10_cwd"
231 + chmod 0000 "$case10_cwd"
232 + run_wrapper "$out" "HOME=$case10_home" "USER=alice" "LOGNAME=alice" -- --model foo
233 + )
234 + chmod 0700 "$case10_cwd"
235 + grep -q "^PWD=$case10_home\$" "$out" \
236 + || fail "$out" "case10: inaccessible cwd not overridden to HOME (got $(grep '^PWD=' "$out"))"
237 + echo "PASS: case10 (inaccessible cwd falls back to \$HOME)"
238 +fi
239 +
240 +# -----------------------------------------------------------------------------
241 +# Case 11: if /tmp cannot create a private fallback dir, wrapper uses /tmp as
242 +# the last-resort HOME instead of leaving HOME empty under set -eu.
243 +# -----------------------------------------------------------------------------
244 +if [ -w /home/vllm ]; then
245 + echo "SKIP: case11 (/home/vllm is writable; mktemp fallback path is not used)"
246 +else
247 + cat > "$WORKDIR/bin/mktemp" <<'EOF'
248 +#!/bin/sh
249 +exit 1
250 +EOF
251 + chmod +x "$WORKDIR/bin/mktemp"
252 +
253 + out="$WORKDIR/case11.out"
254 + run_wrapper "$out" -- --model no-mktemp
255 + rm -f "$WORKDIR/bin/mktemp"
256 +
257 + grep -q "^HOME=/tmp\$" "$out" \
258 + || fail "$out" "case11: mktemp failure did not fall back to /tmp"
259 + grep -q "^USER=vllm\$" "$out" || fail "$out" "case11: USER not defaulted"
260 + grep -q "^LOGNAME=vllm\$" "$out" || fail "$out" "case11: LOGNAME not defaulted"
261 + grep -q "^ARGV=serve --model no-mktemp\$" "$out" || fail "$out" "case11: ARGV wrong"
262 + echo "PASS: case11 (mktemp failure falls back to /tmp)"
263 +fi
264 +
265 +echo ""
266 +echo "ALL CASES PASSED."
docker/entrypoints/vllm-nonroot-entrypoint.sh new
+87
@@ -0,0 +1,87 @@
1 +#!/bin/sh
2 +# Entrypoint wrapper for the opt-in `vllm-openai-nonroot` image.
3 +#
4 +# The image also ships a `vllm` user (UID 2000, GID 0) with HOME /home/vllm
5 +# and a group-0-writable home directory. When the container is launched with
6 +# `--user 2000:0` (or any other UID in group 0) the passwd entry is enough on
7 +# its own: Docker picks up HOME=/home/vllm, getpass.getuser() resolves to
8 +# "vllm", and every cache dir (HF, Triton, Inductor, vLLM, Numba, Outlines)
9 +# that defaults to `$HOME/.cache/...` lands in a writable location.
10 +#
11 +# This wrapper exists for the *arbitrary-UID* case (e.g. OpenShift's
12 +# `runAsUser: 1000540000` Restricted Pod Security Standard) where the caller
13 +# UID is not in /etc/passwd at all. In that case:
14 +# * $HOME may be unset or resolve to "/" (unwritable).
15 +# * getpass.getuser() falls back to pwd.getpwuid() -> KeyError.
16 +#
17 +# The wrapper re-points $HOME to /home/vllm when writable, /tmp/vllm-home.XXXXXX
18 +# otherwise, and defaults $USER to "vllm" so the pwd-lookup path is never
19 +# taken. Everything else is forwarded to `vllm serve`.
20 +#
21 +# Non-empty caller-set env vars (HOME, USER, LOGNAME) are preserved, so
22 +# existing K8s manifests and `docker run -e ...` keep working unchanged.
23 +# Unset or empty values fall through to the wrapper's defaults, matching
24 +# what shell code typically expects from "unset".
25 +
26 +set -eu
27 +
28 +if [ -z "${HOME:-}" ] || [ ! -w "${HOME}" ]; then
29 + if [ -w /home/vllm ]; then
30 + export HOME=/home/vllm
31 + else
32 + if _h="$(mktemp -d /tmp/vllm-home.XXXXXX 2>/dev/null)"; then
33 + export HOME="$_h"
34 + chmod 0700 "$HOME" 2>/dev/null || true
35 + else
36 + export HOME=/tmp
37 + fi
38 + unset _h
39 + fi
40 +fi
41 +
42 +# Preserve the caller's cwd whenever it's still usable. A read-only mount
43 +# (e.g. `docker run -w /models ... --model ./llama.gguf` where /models is
44 +# the user's model share) is a legitimate, usable cwd — vllm only needs to
45 +# *read* relative paths from there. We only fall back to $HOME when the
46 +# cwd itself is truly inaccessible (no search bit, deleted inode, mount
47 +# gone, etc.), which is when `cd .` actually fails.
48 +#
49 +# This is the accessibility check, not a writability check; the latter
50 +# would silently rewrite cwd for any read-only workflow and break relative
51 +# argv like `--model ./llama.gguf`, `--chat-template ./t.jinja`, relative
52 +# TLS cert paths, etc.
53 +if ! cd . 2>/dev/null; then
54 + cd "$HOME"
55 +fi
56 +
57 +# getpass.getuser() prefers $USER/$LOGNAME/etc. before hitting getpwuid();
58 +# setting it here makes the "UID not in passwd" path a no-op for everything
59 +# in the process tree.
60 +if [ -z "${USER:-}" ]; then
61 + export USER=vllm
62 +fi
63 +if [ -z "${LOGNAME:-}" ]; then
64 + export LOGNAME="$USER"
65 +fi
66 +
67 +# Shell-level tooling (`whoami`, bash's `\u` prompt, `id -un`, `sudo`) does
68 +# NOT consult $USER; it calls getpwuid(geteuid()) directly. For arbitrary
69 +# runtime UIDs in OpenShift-style deploys this returns "I have no name!".
70 +# If /etc/passwd is group-0 writable (set at build time) and doesn't yet
71 +# have an entry for this UID, append a synthetic one so every downstream
72 +# consumer sees a consistent "vllm" identity.
73 +#
74 +# We parse the passwd file directly instead of calling `getent` because
75 +# the container's NSS is typically just files anyway, and this lets us
76 +# unit-test via the VLLM_PASSWD_FILE hook (undocumented; production uses
77 +# /etc/passwd).
78 +_passwd_file="${VLLM_PASSWD_FILE:-/etc/passwd}"
79 +_uid="$(id -u)"
80 +if [ -w "$_passwd_file" ] \
81 + && ! awk -F: -v u="$_uid" '$3==u {found=1; exit} END {exit !found}' "$_passwd_file" 2>/dev/null; then
82 + printf 'vllm:x:%s:%s:vllm:%s:/bin/bash\n' \
83 + "$_uid" "$(id -g)" "$HOME" >> "$_passwd_file"
84 +fi
85 +unset _uid _passwd_file
86 +
87 +exec vllm serve "$@"
docs/assets/contributing/dockerfile-stages-dependency.png
Binary files a/docs/assets/contributing/dockerfile-stages-dependency.png and b/docs/assets/contributing/dockerfile-stages-dependency.png differ
docs/deployment/docker.md
+58
@@ -8,6 +8,64 @@ toc_depth: 2
8
9 --8<-- "docs/getting_started/installation/gpu.md:pre-built-images"
10
11 +## Run as a non-root user
12 +
13 +The CUDA `vllm/vllm-openai` image runs as root by default for backward
14 +compatibility. It is also prepared to run as the built-in `vllm` user
15 +(UID 2000, GID 0):
16 +
17 +```bash
18 +docker run --rm --gpus all \
19 + --user 2000:0 \
20 + -p 8000:8000 \
21 + vllm/vllm-openai:latest \
22 + meta-llama/Llama-3.1-8B-Instruct
23 +```
24 +
25 +When mounting model or cache volumes for a non-root container, mount writable
26 +paths under `/home/vllm` instead of `/root`. For example, mount the Hugging
27 +Face cache at `/home/vllm/.cache/huggingface` and make the mounted directory
28 +writable by group 0.
29 +
30 +```bash
31 +docker run --rm --gpus all \
32 + --user 2000:0 \
33 + -v ~/.cache/huggingface:/home/vllm/.cache/huggingface \
34 + -p 8000:8000 \
35 + vllm/vllm-openai:latest \
36 + meta-llama/Llama-3.1-8B-Instruct
37 +```
38 +
39 +To build an image that defaults to the non-root `vllm` user, use the opt-in
40 +`vllm-openai-nonroot` target:
41 +
42 +```bash
43 +docker build --target vllm-openai-nonroot \
44 + -t vllm-openai-nonroot:local \
45 + -f docker/Dockerfile .
46 +
47 +docker run --rm --gpus all \
48 + -p 8000:8000 \
49 + vllm-openai-nonroot:local \
50 + meta-llama/Llama-3.1-8B-Instruct
51 +```
52 +
53 +The `vllm-openai-nonroot` target also supports OpenShift-style arbitrary UIDs
54 +when the runtime UID is a member of group 0. In Kubernetes manifests, set the
55 +container security context accordingly and keep mounted cache/model paths
56 +writable by group 0:
57 +
58 +```yaml
59 +securityContext:
60 + runAsNonRoot: true
61 + runAsUser: 1000540000
62 + runAsGroup: 0
63 + fsGroup: 0
64 +```
65 +
66 +Runtime UIDs outside group 0 are not part of the documented support matrix
67 +because they may be unable to write to `/home/vllm` or `/opt/uv/cache`.
68 +
69 ## Build image from source
70
71 --8<-- "docs/getting_started/installation/gpu.md:build-image-from-source"