diff --git a/.github/workflows/runtime.yml b/.github/workflows/runtime.yml new file mode 100644 index 0000000..e0e736e --- /dev/null +++ b/.github/workflows/runtime.yml @@ -0,0 +1,97 @@ +name: Runtime + +on: + push: + branches: [main] + paths: + - Dockerfile.runtime + - .github/workflows/runtime.yml + - example/** + - tests/runtime.py + pull_request: + paths: + - Dockerfile.runtime + - .github/workflows/runtime.yml + - example/** + - tests/runtime.py + +concurrency: + group: runtime-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + test: + strategy: + fail-fast: false + matrix: + include: + - platform: linux/amd64 + architecture: amd64 + runner: ubuntu-latest + - platform: linux/arm64 + architecture: arm64 + runner: ubuntu-24.04-arm + runs-on: ${{ matrix.runner }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Build runtime and example + env: + PLATFORM: ${{ matrix.platform }} + run: | + docker build --platform="$PLATFORM" -f Dockerfile.runtime -t conda-runtime:test \ + --label "org.opencontainers.image.source=$GITHUB_SERVER_URL/$GITHUB_REPOSITORY" \ + --label "org.opencontainers.image.revision=$GITHUB_SHA" . + docker build --platform="$PLATFORM" --build-arg RUNTIME_IMAGE=conda-runtime:test \ + -f example/Dockerfile -t conda-runtime:example example + - name: Test copied environment and provenance + run: | + docker run --rm --read-only --tmpfs /tmp --user=65532:65532 \ + --mount "type=bind,source=$PWD/tests/runtime.py,target=/runtime.py,readonly" \ + conda-runtime:example python /runtime.py + - name: Export tested runtime + env: + ARCHITECTURE: ${{ matrix.architecture }} + run: docker save --output "runtime-$ARCHITECTURE.tar" conda-runtime:test + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: runtime-${{ matrix.architecture }} + path: runtime-${{ matrix.architecture }}.tar + if-no-files-found: error + + publish: + needs: test + if: github.event_name == 'push' && github.ref_name == 'main' + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + pattern: runtime-* + path: images + merge-multiple: true + - uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 + - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Publish tested platform images and manifest + env: + IMAGE: ghcr.io/prefix-dev/conda-runtime + run: | + for architecture in amd64 arm64; do + docker load --input "images/runtime-$architecture.tar" + tag="$IMAGE:glibc-bash-$GITHUB_SHA-$architecture" + docker tag conda-runtime:test "$tag" + docker push "$tag" + done + docker buildx imagetools create \ + --tag "$IMAGE:glibc-bash-$GITHUB_SHA" \ + --tag "$IMAGE:glibc-bash" \ + "$IMAGE:glibc-bash-$GITHUB_SHA-amd64" \ + "$IMAGE:glibc-bash-$GITHUB_SHA-arm64" diff --git a/Dockerfile.runtime b/Dockerfile.runtime new file mode 100644 index 0000000..f7fd7e6 --- /dev/null +++ b/Dockerfile.runtime @@ -0,0 +1,51 @@ +FROM cgr.dev/chainguard/wolfi-base@sha256:918a593b8268c222afd4e2c4f06860ac984e60719b4697e4c71d796bc8fcd042 AS source + +RUN apk add --no-cache \ + bash=5.3-r13 \ + ncurses=6.6.20260905-r0 \ + ncurses-terminfo-base=6.6.20260905-r0 + +# Extract runtime files and retain the source package SBOM for each component. +# The SBOMs describe upstream packages, not complete packages installed here. +RUN set -eu; \ + mkdir -p /rootfs/bin /rootfs/lib /rootfs/lib64 /rootfs/usr/lib /rootfs/etc /rootfs/tmp \ + /rootfs/usr/share/conda-runtime/sbom; \ + chmod 1777 /rootfs/tmp; \ + copy_runtime_file() { \ + cp -L "$1" "$2"; \ + package="$(apk info --who-owns "$(readlink -f "$1")")"; \ + package="${package##* }"; \ + cp "/var/lib/db/sbom/${package}.spdx.json" /rootfs/usr/share/conda-runtime/sbom/; \ + printf '%s\t%s\t%s\n' "${2#/rootfs}" "$1" "$package" \ + >> /rootfs/usr/share/conda-runtime/origins.tsv; \ + }; \ + apk --repositories-file /dev/null info -L glibc ld-linux > /tmp/glibc-files; \ + while IFS= read -r file; do \ + case "$file" in \ + usr/lib/*/*.so*) ;; \ + usr/lib/*.so*) copy_runtime_file "/$file" "/rootfs/lib/${file##*/}" ;; \ + esac; \ + done < /tmp/glibc-files; \ + for loader in /rootfs/lib/ld-linux-*.so.*; do \ + test -f "$loader"; \ + ln -s "/lib/${loader##*/}" "/rootfs/lib64/${loader##*/}"; \ + done; \ + find /usr/lib/locale -type f > /tmp/locale-files; \ + while IFS= read -r file; do \ + mkdir -p "/rootfs${file%/*}"; \ + copy_runtime_file "$file" "/rootfs$file"; \ + done < /tmp/locale-files; \ + for file in passwd group nsswitch.conf; do \ + copy_runtime_file "/etc/$file" "/rootfs/etc/$file"; \ + done; \ + copy_runtime_file /usr/bin/bash /rootfs/bin/bash; \ + copy_runtime_file /usr/lib/libtinfo.so.6 /rootfs/lib/libtinfo.so.6; \ + cd /rootfs; \ + find bin lib usr/lib etc -type f -exec sha256sum {} + > /tmp/runtime-checksums; \ + LC_ALL=C sort /tmp/runtime-checksums > usr/share/conda-runtime/files.sha256 + +FROM scratch +COPY --from=source /rootfs/ / +ENV PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin +ENV LANG=C.UTF-8 +CMD ["/bin/bash"] diff --git a/README.md b/README.md index d982a89..fc2814f 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,8 @@ This repository contains the docker configuration for the pixi container image. The pixi container image is based on different base images, depending on the use case. -All images have pixi installed in `/usr/local/bin/pixi` and are ready to use. +The Ubuntu, Debian and CUDA images have pixi installed in `/usr/local/bin/pixi`. +The separate `conda-runtime` image contains only the system libraries and Bash needed to run a copied environment. ## Pulling the images @@ -22,10 +23,18 @@ There are different tags for different base images available: - `noble-cuda-13.0.0` - based on `nvidia/cuda:13.0.0-base-ubuntu24.04` - ... and more +The runtime-only image is published as a separate package, +[`ghcr.io/prefix-dev/conda-runtime`](https://github.com/prefix-dev/pixi-docker/pkgs/container/conda-runtime), +because it does not contain pixi and runs any copied conda environment: + +```bash +docker pull ghcr.io/prefix-dev/conda-runtime:glibc-bash +``` + ## Usage with shell-hook The following example uses the pixi docker image as a base image for a multi-stage build. -It also makes use of the `shell-hook` feature of pixi to define a convenient entry point (after executing the `shell-hook` script, the environment is activated. +The `shell-hook` entrypoint activates the environment before executing the container command. ```Dockerfile FROM ghcr.io/prefix-dev/pixi:0.40.0 AS build @@ -44,7 +53,7 @@ RUN pixi shell-hook -e prod > /shell-hook.sh # extend the shell-hook script to run the command passed to the container RUN echo 'exec "$@"' >> /shell-hook.sh -FROM ubuntu:24.04 AS production +FROM ghcr.io/prefix-dev/conda-runtime:glibc-bash AS production # only copy the production environment into prod container # please note that the "prefix" (path) needs to stay the same as in the build container @@ -60,10 +69,74 @@ ENTRYPOINT ["/bin/bash", "/shell-hook.sh"] CMD ["start-server"] ``` +Copy any application files and activation scripts referenced by the shell hook as well. +You can also keep `ubuntu:24.04` here; see [Choosing a final base image](#choosing-a-final-base-image). + ## Images There are images based on `ubuntu`, `debian` and `nvidia/cuda` available. +### Minimal runtime + +`ghcr.io/prefix-dev/conda-runtime:glibc-bash` is built from [`Dockerfile.runtime`](Dockerfile.runtime) +for `linux/amd64` and `linux/arm64`. It contains glibc and its loader, UTF-8 locale +data, Bash, `libtinfo`, and user/group and name-service configuration. +It supports running as UID/GID `65532:65532`. + +This image is for the final stage of a multi-stage build, not for installing environments. +It contains no Pixi, package manager, coreutils, `/bin/sh`, or system CA bundle. +Bash is included for non-interactive shell hooks; terminal descriptions (terminfo) +are not included. Add terminal data to the environment if the application needs it. +Include the application's other dependencies, including `ca-certificates` for HTTPS, +in the Pixi environment. Some clients need `SSL_CERT_FILE` pointed at the environment's +`ssl/cacert.pem`. Use an Ubuntu or Debian base when the application or its activation +scripts require system utilities or `/bin/sh`. +The environment must also provide a discoverable `libgcc_s` when the application +uses glibc thread cancellation or `pthread_exit`; the base image does not include it. + +Runtime images are published on changes to the runtime build, separately from Pixi +releases. The tags are `glibc-bash` and `glibc-bash-`, naming the +runtime components the image provides. Pin an image digest for deployments. + +To build the runtime and example locally: + +```bash +docker build -f Dockerfile.runtime -t conda-runtime:local . +docker build --build-arg RUNTIME_IMAGE=conda-runtime:local -t pixi-example example +docker run --rm -p 8000:8000 pixi-example +``` + +#### SBOM provenance + +The image retains metadata in `/usr/share/conda-runtime/`: + +- `sbom/` contains the upstream SPDX package SBOMs for the extracted components. +- `origins.tsv` maps each extracted file to its source path and package version. +- `files.sha256` records the extracted files' SHA-256 checksums. + +These are source-package SBOMs, not a claim that the complete APK packages are +installed. The APK database is not copied. Generate a final-image SBOM that also +includes the conda environment and application when building your application image. + +#### Choosing a final base image + +Both final stages work with the same `shell-hook` setup, so this is a tradeoff, not a migration. + +| | `conda-runtime:glibc-bash` | `ubuntu:24.04` | +|---|---|---| +| Image size, without the environment | 7.8 MB on amd64, 7.2 MB on arm64 | 78 MB on amd64 | +| Attack surface | glibc, Bash, locale data | full distribution, including a shell, coreutils, and apt | +| Debugging inside the container | Bash only; no `ls`, `cat`, `curl`, or package manager | usual utilities available, plus `apt-get install` | +| System CA bundle | absent, and no way to add one | also absent, but `apt-get install ca-certificates` adds it | +| Non-POSIX activation scripts | supported, Bash is present | supported | +| Security updates for the base | rebuild this repository's runtime image | Ubuntu's own update stream | +| Third-party tooling expecting a distribution | may break on missing `/bin/sh` and utilities | works | + +Use `conda-runtime` for small production images where the conda environment provides +everything the application needs. Use Ubuntu or Debian when you want shell access for +debugging, need system packages next to the environment, or run agents and sidecars +that expect a distribution layout. + ### Ubuntu The [`ubuntu:noble`](https://hub.docker.com/_/ubuntu) (24.04) based image is the default base image. It is used for the `latest` and `0.x.y` tag. diff --git a/example/Dockerfile b/example/Dockerfile index fc55fcb..fd9e414 100644 --- a/example/Dockerfile +++ b/example/Dockerfile @@ -1,3 +1,5 @@ +ARG RUNTIME_IMAGE=ghcr.io/prefix-dev/conda-runtime:glibc-bash + FROM ghcr.io/prefix-dev/pixi:0.18.0 AS build # copy source code, pixi.toml and pixi.lock to the container @@ -14,7 +16,7 @@ RUN pixi shell-hook -e prod > /shell-hook.sh # extend the shell-hook script to run the command passed to the container RUN echo 'exec "$@"' >> /shell-hook.sh -FROM ubuntu:22.04 AS production +FROM ${RUNTIME_IMAGE} AS production # only copy the production environment into prod container # please note that the "prefix" (path) needs to stay the same as in the build container diff --git a/tests/runtime.py b/tests/runtime.py new file mode 100644 index 0000000..522d852 --- /dev/null +++ b/tests/runtime.py @@ -0,0 +1,86 @@ +"""Exercise a copied Pixi environment and verify its base image provenance.""" + +import ctypes +import grp +import hashlib +import json +import locale +import os +from pathlib import Path +import pwd +import sqlite3 +import subprocess +import tempfile +import time +import urllib.error +import urllib.request + + +assert os.getpid() == 1, "The shell hook must exec the application" +pwd.getpwuid(os.getuid()) +grp.getgrgid(os.getgid()) +locale.setlocale(locale.LC_ALL, "") +assert locale.getencoding().lower().replace("-", "") == "utf8" +with tempfile.TemporaryFile() as temporary_file: + temporary_file.write(b"runtime") +with sqlite3.connect(":memory:") as connection: + assert connection.execute("SELECT 6 * 7").fetchone() == (42,) +with urllib.request.urlopen("https://prefix.dev", timeout=30) as response: + assert response.status == 200 + +# Run pthread_exit entirely in native code, without unwinding a Python callback. +libc = ctypes.CDLL("libc.so.6") +thread = ctypes.c_ulong() +libc.pthread_create.argtypes = [ + ctypes.POINTER(ctypes.c_ulong), + ctypes.c_void_p, + ctypes.c_void_p, + ctypes.c_void_p, +] +libc.pthread_create.restype = ctypes.c_int +libc.pthread_join.argtypes = [ctypes.c_ulong, ctypes.POINTER(ctypes.c_void_p)] +libc.pthread_join.restype = ctypes.c_int +assert libc.pthread_create( + ctypes.byref(thread), None, ctypes.cast(libc.pthread_exit, ctypes.c_void_p), None +) == 0 +assert libc.pthread_join(thread, None) == 0 + +metadata = Path("/usr/share/conda-runtime") +checksums = {} +for line in (metadata / "files.sha256").read_text().splitlines(): + checksum, filename = line.split(" ", 1) + path = Path("/") / filename + assert hashlib.sha256(path.read_bytes()).hexdigest() == checksum, path + checksums[str(path)] = checksum + +origins = {} +for line in (metadata / "origins.tsv").read_text().splitlines(): + destination, source, package = line.split("\t") + origins[destination] = source + sbom = json.loads((metadata / "sbom" / f"{package}.spdx.json").read_text()) + assert any( + f"{component['name']}-{component.get('versionInfo', '')}" == package + for component in sbom["packages"] + ), package +assert origins.keys() == checksums.keys(), "Every extracted file needs provenance" + +server = subprocess.Popen( + ["gunicorn", "-w", "1", "docker_project:app", "--bind", "127.0.0.1:8000"] +) +try: + deadline = time.monotonic() + 30 + while time.monotonic() < deadline: + assert server.poll() is None, "Gunicorn exited before serving a request" + try: + with urllib.request.urlopen("http://127.0.0.1:8000", timeout=5) as response: + assert response.read() == b"Hello, Pixi server!" + break + except (urllib.error.URLError, TimeoutError): + time.sleep(0.1) + else: + raise RuntimeError("Gunicorn did not become ready") +finally: + server.terminate() + server.wait(timeout=10) +assert server.returncode == 0, "Gunicorn must shut down cleanly on SIGTERM" +print("Runtime, HTTPS, HTTP server, and file/SBOM provenance checks passed")