From 4c34d230998451e64258666d9a1af0034a4422da Mon Sep 17 00:00:00 2001 From: Feng Ruohang Date: Thu, 6 Aug 2026 16:40:04 +0800 Subject: [PATCH] build(docker): add the distroless image variant as a pilot Publish pgsty/silo:-distroless (and a rolling :distroless tag) alongside the classic image: gcr.io/distroless/static-debian12 plus exactly one program, the silo binary. No shell, no mc, no curl, no entrypoint script - the binary is the ENTRYPOINT and the baked-in exec-form HEALTHCHECK runs 'silo healthcheck ready'. The classic image and its mc-based health checks are deliberately unchanged. Design: silo.pgsty.com/compatibility/feature/healthcheck/ /data is created in the image layer, world-writable, because Docker seeds fresh volumes from the layer mountpoint and no entrypoint exists to repair ownership at runtime (issue #55); the parent directory is copied from a throwaway busybox stage since COPY of a directory copies contents rather than the entry itself, which would silently leave /data at root:0755 and break every non-root run. The MINIO_USERNAME drop-user path is not supported in this variant; use --user. test-release.yml now builds the real Dockerfile.distroless on every gate run (it has no download stages, so it stays offline) and asserts: the HEALTHCHECK survives into the image config, /data ships 0777, no shell and no /usr/bin/minio are present, Docker's health state turns healthy from the baked probe alone, the probe binary execs without a shell, and SIGTERM still stops the server gracefully - as root and as --user 1001:1001. docker-release.yml gains the distroless build lanes, multi-arch manifests, SBOM and provenance attestations, and a release-blocking check that the pushed manifest still carries the HEALTHCHECK (a Docker extension absent from the OCI image spec). Verified locally on linux/arm64: full gate assertions plus bare 'docker run ... healthcheck' exit-code semantics and --version passthrough. Co-Authored-By: Claude Fable 5 --- .github/workflows/docker-release.yml | 103 ++++++++++++++++++++++++++- .github/workflows/test-release.yml | 69 ++++++++++++++++++ Dockerfile.distroless | 48 +++++++++++++ Makefile | 11 +++ buildscripts/verify-rebrand.sh | 4 ++ 5 files changed, 234 insertions(+), 1 deletion(-) create mode 100644 Dockerfile.distroless diff --git a/.github/workflows/docker-release.yml b/.github/workflows/docker-release.yml index af49d8f66..14c5e2ee4 100644 --- a/.github/workflows/docker-release.yml +++ b/.github/workflows/docker-release.yml @@ -126,7 +126,7 @@ jobs: archive="${assets_dir}/silo_${PKG_VERSION}_linux_${arch}.tar.gz" mkdir -p "${context}/dockerscripts" tar -xzf "${archive}" -C "${context}" silo - cp Dockerfile.goreleaser LICENSE NOTICE CREDITS "${context}/" + cp Dockerfile.goreleaser Dockerfile.distroless LICENSE NOTICE CREDITS "${context}/" cp dockerscripts/docker-entrypoint.sh dockerscripts/download-static-curl.sh \ "${context}/dockerscripts/" done @@ -184,6 +184,42 @@ jobs: org.opencontainers.image.created=${{ env.PUBLISHED_AT }} org.opencontainers.image.revision=${{ env.RELEASE_REVISION }} + # The distroless variant is a pilot published alongside the classic + # image; it ships the silo binary alone and relies on the native + # `silo healthcheck` subcommand for container health. + # Design: https://silo.pgsty.com/compatibility/feature/healthcheck/ + - name: Build and push amd64 distroless image + id: build-amd64-distroless + uses: docker/build-push-action@v7 + with: + context: docker-release/amd64 + file: docker-release/amd64/Dockerfile.distroless + platforms: linux/amd64 + push: true + tags: | + pgsty/silo:${{ env.RELEASE_TAG }}-distroless-amd64 + pgsty/silo:distroless-amd64 + labels: | + org.opencontainers.image.version=${{ env.RELEASE_TAG }} + org.opencontainers.image.created=${{ env.PUBLISHED_AT }} + org.opencontainers.image.revision=${{ env.RELEASE_REVISION }} + + - name: Build and push arm64 distroless image + id: build-arm64-distroless + uses: docker/build-push-action@v7 + with: + context: docker-release/arm64 + file: docker-release/arm64/Dockerfile.distroless + platforms: linux/arm64 + push: true + tags: | + pgsty/silo:${{ env.RELEASE_TAG }}-distroless-arm64 + pgsty/silo:distroless-arm64 + labels: | + org.opencontainers.image.version=${{ env.RELEASE_TAG }} + org.opencontainers.image.created=${{ env.PUBLISHED_AT }} + org.opencontainers.image.revision=${{ env.RELEASE_REVISION }} + - name: Publish multi-architecture manifests run: | set -euo pipefail @@ -201,6 +237,19 @@ jobs: docker buildx imagetools inspect "pgsty/silo:${RELEASE_TAG}" docker buildx imagetools inspect "pgsty/silo:latest" + docker buildx imagetools create \ + --tag "pgsty/silo:${RELEASE_TAG}-distroless" \ + --metadata-file docker-release/metadata/release-distroless.json \ + "pgsty/silo:${RELEASE_TAG}-distroless-amd64" \ + "pgsty/silo:${RELEASE_TAG}-distroless-arm64" + docker buildx imagetools create \ + --tag "pgsty/silo:distroless" \ + --metadata-file docker-release/metadata/distroless.json \ + "pgsty/silo:distroless-amd64" \ + "pgsty/silo:distroless-arm64" + docker buildx imagetools inspect "pgsty/silo:${RELEASE_TAG}-distroless" + docker buildx imagetools inspect "pgsty/silo:distroless" + RELEASE_DIGEST="$(jq -r '."containerimage.descriptor".digest' docker-release/metadata/release.json)" LATEST_DIGEST="$(jq -r '."containerimage.descriptor".digest' docker-release/metadata/latest.json)" if ! [[ "${RELEASE_DIGEST}" =~ ^sha256:[0-9a-f]{64}$ ]]; then @@ -213,10 +262,35 @@ jobs: fi echo "SILO_IMAGE_DIGEST=${RELEASE_DIGEST}" >> "${GITHUB_ENV}" + DISTROLESS_RELEASE_DIGEST="$(jq -r '."containerimage.descriptor".digest' docker-release/metadata/release-distroless.json)" + DISTROLESS_ROLLING_DIGEST="$(jq -r '."containerimage.descriptor".digest' docker-release/metadata/distroless.json)" + if ! [[ "${DISTROLESS_RELEASE_DIGEST}" =~ ^sha256:[0-9a-f]{64}$ ]]; then + echo "Invalid distroless manifest digest: ${DISTROLESS_RELEASE_DIGEST}" >&2 + exit 1 + fi + if [ "${DISTROLESS_RELEASE_DIGEST}" != "${DISTROLESS_ROLLING_DIGEST}" ]; then + echo "Distroless release and rolling tags resolved to different manifests" >&2 + exit 1 + fi + echo "SILO_DISTROLESS_DIGEST=${DISTROLESS_RELEASE_DIGEST}" >> "${GITHUB_ENV}" + + - name: Verify HEALTHCHECK survived the distroless publish + run: | + set -euo pipefail + # HEALTHCHECK is a Docker extension absent from the OCI image + # spec, and OCI-media-type publishes can drop it silently. If the + # pushed image lost it, the pilot's zero-config compose health + # gating disappears - treat that as a release blocker. + docker pull "pgsty/silo:${RELEASE_TAG}-distroless" >/dev/null + docker inspect -f '{{json .Config.Healthcheck.Test}}' "pgsty/silo:${RELEASE_TAG}-distroless" \ + | grep -F '"healthcheck"' + - name: Generate architecture image SBOMs env: AMD64_DIGEST: ${{ steps.build-amd64.outputs.digest }} ARM64_DIGEST: ${{ steps.build-arm64.outputs.digest }} + DISTROLESS_AMD64_DIGEST: ${{ steps.build-amd64-distroless.outputs.digest }} + DISTROLESS_ARM64_DIGEST: ${{ steps.build-arm64-distroless.outputs.digest }} SYFT_CHECK_FOR_APP_UPDATE: "false" run: | set -euo pipefail @@ -225,6 +299,10 @@ jobs: --output "spdx-json=docker-release/sbom/linux-amd64.spdx.json" syft "registry:index.docker.io/pgsty/silo@${ARM64_DIGEST}" \ --output "spdx-json=docker-release/sbom/linux-arm64.spdx.json" + syft "registry:index.docker.io/pgsty/silo@${DISTROLESS_AMD64_DIGEST}" \ + --output "spdx-json=docker-release/sbom/linux-amd64-distroless.spdx.json" + syft "registry:index.docker.io/pgsty/silo@${DISTROLESS_ARM64_DIGEST}" \ + --output "spdx-json=docker-release/sbom/linux-arm64-distroless.spdx.json" - name: Attest amd64 image SBOM uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 @@ -242,9 +320,32 @@ jobs: sbom-path: docker-release/sbom/linux-arm64.spdx.json push-to-registry: true + - name: Attest amd64 distroless image SBOM + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 + with: + subject-name: index.docker.io/pgsty/silo + subject-digest: ${{ steps.build-amd64-distroless.outputs.digest }} + sbom-path: docker-release/sbom/linux-amd64-distroless.spdx.json + push-to-registry: true + + - name: Attest arm64 distroless image SBOM + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 + with: + subject-name: index.docker.io/pgsty/silo + subject-digest: ${{ steps.build-arm64-distroless.outputs.digest }} + sbom-path: docker-release/sbom/linux-arm64-distroless.spdx.json + push-to-registry: true + - name: Attest multi-architecture image provenance uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 with: subject-name: index.docker.io/pgsty/silo subject-digest: ${{ env.SILO_IMAGE_DIGEST }} push-to-registry: true + + - name: Attest multi-architecture distroless image provenance + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 + with: + subject-name: index.docker.io/pgsty/silo + subject-digest: ${{ env.SILO_DISTROLESS_DIGEST }} + push-to-registry: true diff --git a/.github/workflows/test-release.yml b/.github/workflows/test-release.yml index 3397977b7..9a9c23778 100644 --- a/.github/workflows/test-release.yml +++ b/.github/workflows/test-release.yml @@ -7,6 +7,7 @@ on: - ".github/goreleaser.yml" - ".github/nfpm.yml" - "Dockerfile.goreleaser" + - "Dockerfile.distroless" - "dockerscripts/download-static-curl.sh" - "dockerscripts/docker-entrypoint.sh" - "dockerscripts/docker-entrypoint_test.sh" @@ -387,6 +388,74 @@ jobs: test "$(docker exec silo-rt-legacy cat /proc/1/comm)" = silo docker rm -f silo-rt-legacy >/dev/null + - name: Build distroless runtime image and verify native healthcheck + run: | + set -euo pipefail + # Unlike the classic image, Dockerfile.distroless has no release + # download stages, so the real shipped file can be built and gated + # here. It must keep working with nothing in it but the silo + # binary: no shell, no mc, no entrypoint script. + ctx="$(mktemp -d)" + tar -xzf "dist/silo_${PKG_VERSION}_linux_amd64.tar.gz" -C "${ctx}" silo + cp Dockerfile.distroless LICENSE NOTICE CREDITS "${ctx}/" + docker build -t silo-distroless-test:snapshot -f "${ctx}/Dockerfile.distroless" "${ctx}" + + # HEALTHCHECK is a Docker extension absent from the OCI image + # spec; assert it survived into the built image config. + docker inspect -f '{{json .Config.Healthcheck.Test}}' silo-distroless-test:snapshot \ + | grep -F '"healthcheck"' >/dev/null + + # /data ships in the image layer world-writable (issue #55): + # there is no entrypoint left to repair ownership at runtime. The + # same export proves the image carries the binary and the license + # set, and neither a shell nor a legacy /usr/bin/minio. + probe="$(docker create silo-distroless-test:snapshot server /data)" + docker export "${probe}" | tar -tvf - data usr/bin/silo licenses > "${ctx}/listing.txt" + grep -E '^drwxrwxrwx.* data/$' "${ctx}/listing.txt" >/dev/null + grep -E ' licenses/(LICENSE|NOTICE|CREDITS)$' "${ctx}/listing.txt" >/dev/null + if docker export "${probe}" | tar -tf - bin/sh usr/bin/minio 2>/dev/null | grep -q .; then + echo "distroless image unexpectedly contains a shell or /usr/bin/minio" + exit 1 + fi + docker rm "${probe}" >/dev/null + + # The baked-in healthcheck must drive Docker's health state on its + # own, the probe binary must be directly exec-able without any + # shell, and SIGTERM must still reach PID 1 (the server binary is + # the entrypoint) for a graceful stop. + assert_distroless() { + name="$1"; shift + docker rm -f "${name}" >/dev/null 2>&1 || true + docker run -d --name "${name}" \ + -e MINIO_CI_CD=1 -e MINIO_ROOT_USER=ciadmin -e MINIO_ROOT_PASSWORD=ciadmin-secret-123 \ + "$@" silo-distroless-test:snapshot server /data --address :9000 >/dev/null + status="" + for _ in $(seq 1 90); do + status="$(docker inspect -f '{{.State.Health.Status}}' "${name}" 2>/dev/null || echo '?')" + if [ "${status}" = "healthy" ]; then break; fi + if [ "$(docker inspect -f '{{.State.Running}}' "${name}")" != "true" ]; then break; fi + sleep 1 + done + if [ "${status}" != "healthy" ]; then + echo "container never became healthy (${name}): status=${status}" + docker logs "${name}" 2>&1 | tail -5 + exit 1 + fi + docker exec "${name}" /usr/bin/silo healthcheck ready + docker exec "${name}" /usr/bin/silo healthcheck cluster + start="$(date +%s)"; docker stop -t 15 "${name}" >/dev/null; end="$(date +%s)" + code="$(docker inspect -f '{{.State.ExitCode}}' "${name}")" + elapsed=$((end - start)) + graceful=0; docker logs "${name}" 2>&1 | grep -q "Exiting on signal" && graceful=1 + echo "${name}: health=${status} stop=${elapsed}s exit=${code}" + docker rm -f "${name}" >/dev/null 2>&1 || true + [ "${graceful}" = "1" ] || { echo "no graceful-shutdown log (${name}) - signal not forwarded"; exit 1; } + [ "${code}" = "0" ] || { echo "non-zero exit (${name}): ${code}"; exit 1; } + [ "${elapsed}" -lt 10 ] || { echo "shutdown too slow (${name}): ${elapsed}s - signal not forwarded"; exit 1; } + } + assert_distroless silo-dl-default + assert_distroless silo-dl-rootless --user 1001:1001 + - name: Validate release scripts run: | set -euo pipefail diff --git a/Dockerfile.distroless b/Dockerfile.distroless new file mode 100644 index 000000000..e540d43e0 --- /dev/null +++ b/Dockerfile.distroless @@ -0,0 +1,48 @@ +# The distroless variant ships exactly one program: the silo binary. +# No shell, no mc, no curl, no entrypoint script; health checking is +# provided by the binary itself (`silo healthcheck`). +# Design note: https://silo.pgsty.com/compatibility/feature/healthcheck/ + +# A distroless final stage cannot RUN anything, so /data is prepared in a +# throwaway stage. It ships world-writable (see pgsty/silo#55): Docker +# seeds fresh volumes from the image-layer mountpoint, no entrypoint +# exists to repair ownership at runtime, and 0777 is what keeps every +# privilege mode working, --user included. +FROM busybox:1.37.0 AS prep +RUN mkdir -p /prep/data && chmod 0777 /prep/data + +FROM gcr.io/distroless/static-debian12:latest + +LABEL org.opencontainers.image.title="Silo" \ + org.opencontainers.image.description="S3-Interface Libre Object Storage (distroless)" \ + org.opencontainers.image.url="https://silo.pgsty.com" \ + org.opencontainers.image.source="https://github.com/pgsty/silo" \ + org.opencontainers.image.licenses="AGPL-3.0-or-later" \ + maintainer="PGSTY " + +ENV MINIO_ACCESS_KEY_FILE=access_key \ + MINIO_SECRET_KEY_FILE=secret_key \ + MINIO_ROOT_USER_FILE=access_key \ + MINIO_ROOT_PASSWORD_FILE=secret_key \ + MINIO_KMS_SECRET_KEY_FILE=kms_master_key \ + MINIO_CONFIG_ENV_FILE=config.env \ + HOME=/tmp + +COPY --chmod=0755 silo /usr/bin/silo +# COPY of a directory copies its contents, not the directory entry, so an +# empty /prep/data would arrive as a default root:0755 /data and non-root +# runs would fail storage init. Copying the parent makes data/ itself a +# copied entry, which --chmod then actually applies to. +COPY --from=prep --chmod=0777 /prep/ / +COPY LICENSE NOTICE CREDITS /licenses/ + +EXPOSE 9000 +VOLUME ["/data"] + +# Exec form is mandatory: there is no /bin/sh in this image. `ready` +# rather than `live` because Docker health feeds start-order gating +# (readiness semantics); the two are identical unless KMS/etcd are used. +HEALTHCHECK --interval=30s --timeout=5s --start-period=2m --start-interval=2s --retries=3 \ + CMD ["/usr/bin/silo", "healthcheck", "ready"] + +ENTRYPOINT ["/usr/bin/silo"] diff --git a/Makefile b/Makefile index bd7afbcaa..96cfe0162 100644 --- a/Makefile +++ b/Makefile @@ -221,6 +221,17 @@ docker: checks build-debugging ## builds the local Linux Silo container image docker build -q --no-cache --platform linux/$(GOARCH) -t $(TAG) --build-arg TARGETARCH=$(GOARCH) \ -f "$$context/Dockerfile.goreleaser" "$$context" +docker-distroless: checks build-debugging ## builds the local Linux Silo distroless container image + @echo "Building Silo distroless container image '$(TAG)-distroless'" + @set -e; \ + context=$$(mktemp -d); \ + trap 'rm -rf "$$context"' EXIT; \ + CGO_ENABLED=0 GOOS=linux GOARCH=$(GOARCH) go build -tags kqueue -trimpath \ + --ldflags "$(LDFLAGS)" -o "$$context/silo"; \ + cp Dockerfile.distroless LICENSE NOTICE CREDITS "$$context/"; \ + docker build -q --no-cache --platform linux/$(GOARCH) -t $(TAG)-distroless \ + -f "$$context/Dockerfile.distroless" "$$context" + test-resiliency: build @echo "Running resiliency tests" @(DOCKER_COMPOSE_FILE=$(PWD)/docs/resiliency/docker-compose.yaml env bash $(PWD)/docs/resiliency/resiliency-tests.sh) diff --git a/buildscripts/verify-rebrand.sh b/buildscripts/verify-rebrand.sh index d707c18dd..91e9e7c2d 100755 --- a/buildscripts/verify-rebrand.sh +++ b/buildscripts/verify-rebrand.sh @@ -35,6 +35,7 @@ for file in \ buildscripts/package/lifecycle_test.sh \ buildscripts/verify-helm-migration.sh \ Dockerfile.goreleaser \ + Dockerfile.distroless \ dockerscripts/download-static-curl.sh \ dockerscripts/docker-entrypoint.sh \ helm/silo/Chart.yaml \ @@ -97,6 +98,9 @@ require_text Dockerfile.goreleaser "COPY silo /usr/bin/silo" require_text Dockerfile.goreleaser 'CMD ["silo"]' require_text Dockerfile.goreleaser "MC_AMD64_SHA256=" require_text Dockerfile.goreleaser "Published checksum drift" +require_text Dockerfile.distroless 'COPY --chmod=0755 silo /usr/bin/silo' +require_text Dockerfile.distroless 'ENTRYPOINT ["/usr/bin/silo"]' +require_text Dockerfile.distroless '"/usr/bin/silo", "healthcheck", "ready"' require_text dockerscripts/download-static-curl.sh "sha256sum -c" require_text helm/silo/Chart.yaml "name: silo" require_text helm/silo/values.yaml "repository: pgsty/silo"