Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 97 additions & 0 deletions .github/workflows/runtime.yml
Original file line number Diff line number Diff line change
@@ -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
Comment on lines +6 to +16

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
paths:
- Dockerfile.runtime
- .github/workflows/runtime.yml
- example/**
- tests/runtime.py
pull_request:
paths:
- Dockerfile.runtime
- .github/workflows/runtime.yml
- example/**
- tests/runtime.py
pull_request:


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"
51 changes: 51 additions & 0 deletions Dockerfile.runtime
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
FROM cgr.dev/chainguard/wolfi-base@sha256:918a593b8268c222afd4e2c4f06860ac984e60719b4697e4c71d796bc8fcd042 AS source

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do they also have tags that dependabot can update next to the digest? this currently is hard to understand from which version it's coming from


RUN apk add --no-cache \
bash=5.3-r13 \
ncurses=6.6.20260905-r0 \
ncurses-terminfo-base=6.6.20260905-r0
Comment on lines +4 to +6

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

how are we planning on updating those?


# 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"]
79 changes: 76 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand All @@ -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
Expand All @@ -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-<full-git-commit>`, 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.
Expand Down
4 changes: 3 additions & 1 deletion example/Dockerfile
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down
86 changes: 86 additions & 0 deletions tests/runtime.py
Original file line number Diff line number Diff line change
@@ -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")
Loading