diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index f3e6d46..c849baa 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -7,6 +7,7 @@ on: - feat/build - feat/schema_upgrade - env_var_namechange + - harden-image tags: - "v*" workflow_dispatch: @@ -46,12 +47,14 @@ jobs: type=ref,event=tag type=raw,value=${{ env.SHORT_SHA }} - - name: Build and push Docker image + - name: Build and push the image, with its SBOM and provenance uses: docker/build-push-action@v5 with: context: . file: docker/server/Dockerfile push: true + sbom: true + provenance: mode=max build-args: | SOURCE_COMMIT=${{ env.SHORT_SHA }} tags: ${{ steps.meta.outputs.tags }} diff --git a/docker/server/Dockerfile b/docker/server/Dockerfile index 4455e87..15ddf97 100644 --- a/docker/server/Dockerfile +++ b/docker/server/Dockerfile @@ -1,33 +1,45 @@ -# The sync server as an image: built from the locked dependencies, run as an -# unprivileged user on a slim Debian. Configured entirely by environment -# variables (docs/deploy.md lists every one); nothing is baked in but the -# listen address and the JSON log format a container platform expects. +# The sync server as an image: built from the locked dependencies, and run on +# Google's distroless runtime, which has no shell, no package manager and no +# coreutils, so a compromised server has no tools to run. The runtime is +# `cc`: glibc and libgcc, which the binary links, and nothing else it needs; +# TLS is rustls with its roots compiled in. It runs as `nonroot` (uid 65532). +# Configured entirely by environment variables (docs/deploy.md lists every +# one); nothing is baked in but the listen address and the JSON log format a +# container platform expects. +# +# There is no HEALTHCHECK, since there is no shell to run one: the platform +# probes `/healthz` and `/readyz` over HTTP (docs/deploy.md). # # docker build -f docker/server/Dockerfile -t streamgres . -ARG BASE_REGISTRY=docker.io/library -FROM ${BASE_REGISTRY}/rust:1.89-bookworm AS builder +# +# Both stages are on Debian 13, so the binary is linked against the glibc it +# runs on. A mirror of the same images is given whole: +# +# docker build --build-arg BUILD_IMAGE=/rust:1.89-trixie \ +# --build-arg RUNTIME_IMAGE=/distroless/cc-debian13:nonroot ... +ARG BUILD_IMAGE=docker.io/library/rust:1.89-trixie +ARG RUNTIME_IMAGE=gcr.io/distroless/cc-debian13:nonroot + +FROM ${BUILD_IMAGE} AS builder WORKDIR /build COPY Cargo.toml Cargo.lock clippy.toml ./ COPY src ./src -RUN cargo build --release --locked --bin server +RUN cargo build --release --locked --bin server \ + && mkdir -p /out/app /out/var/lib/streamgres \ + && cp target/release/server /out/app/server -FROM ${BASE_REGISTRY}/debian:bookworm-slim AS runtime -RUN apt-get update \ - && apt-get install -y --no-install-recommends ca-certificates curl \ - && rm -rf /var/lib/apt/lists/* \ - && useradd --system --uid 10001 --home-dir /app --shell /usr/sbin/nologin streamgres \ - && mkdir -p /app /var/lib/streamgres /var/lib/xyne-sync \ - && chown -R streamgres:streamgres /app /var/lib/streamgres /var/lib/xyne-sync +FROM ${RUNTIME_IMAGE} AS runtime +# The binary is root-owned and read-only to the server; only the plan +# file's directory is its to write. +COPY --from=builder --chown=0:0 /out/app /app +COPY --from=builder --chown=65532:65532 /out/var/lib/streamgres /var/lib/streamgres WORKDIR /app -COPY --from=builder /build/target/release/server /app/server ARG SOURCE_COMMIT=unknown ENV SOURCE_COMMIT=${SOURCE_COMMIT} \ STREAMGRES_ADDR=0.0.0.0:4848 \ STREAMGRES_LOG_FORMAT=json -USER streamgres +USER 65532:65532 EXPOSE 4848 -HEALTHCHECK --interval=10s --timeout=3s --start-period=30s --retries=6 \ - CMD curl -fsS http://127.0.0.1:4848/health || exit 1 CMD ["/app/server"] diff --git a/docs/deploy.md b/docs/deploy.md index 8ac93e9..f1cfcce 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -26,12 +26,18 @@ For a local first run, see the README's Quick start. docker build -f docker/server/Dockerfile -t streamgres . ``` -The image is a slim Debian image of about 125 MB: -- It has one binary, `/app/server`, run as the unprivileged user `streamgres` - (uid 10001). +The image runs on Google's distroless `cc-debian13` (built on +`rust:1.89-trixie`). The runtime has no shell, no package manager and no +coreutils: +- It has one binary, `/app/server`, run as the unprivileged user `nonroot` + (uid 65532). - It listens on port 4848 and writes JSON log lines to stderr. +- It has no Docker `HEALTHCHECK` (there is no curl to run one): probe + `/healthz` and `/readyz` over HTTP from the platform instead, which + answer 503 until the server is ready (section 6). - It needs no file system, except an optional writable directory for - `STREAMGRES_PLAN_FILE` (section 5). + `STREAMGRES_PLAN_FILE` (section 5), and a writable `/tmp` for heap + profiles when Pyroscope is on and the root file system is read-only. `.github/workflows/docker-publish.yml` publishes the image to the GitHub Container Registry as `ghcr.io//streamgres`. It runs on pushes to the