From f1e76b0f8d2a01260a712624772c44b833d3d832 Mon Sep 17 00:00:00 2001 From: Gerard Louis Recinto Date: Fri, 11 Sep 2026 16:13:29 -0700 Subject: [PATCH] move the runtime image off alpine onto distroless nonroot, matching what quickstart already does pulled sop-server down to gcr.io/distroless/static-debian12:nonroot, same base the quickstart image already runs on. no shell, no package manager, no wget, so the old HEALTHCHECK couldn't work as-is, added a tiny static healthcheck binary (tools/healthcheck) that just GETs the health endpoint and exits 0/1, wired it into the HEALTHCHECK directive in exec form. verified locally: no /bin/sh in the image, runs as nonroot:nonroot, container reports healthy through docker's own healthcheck within a few seconds of boot. --- Dockerfile | 34 ++++++++++++++--------------- tools/healthcheck/main.go | 39 ++++++++++++++++++++++++++++++++++ tools/healthcheck/main_test.go | 39 ++++++++++++++++++++++++++++++++++ 3 files changed, 95 insertions(+), 17 deletions(-) create mode 100644 tools/healthcheck/main.go create mode 100644 tools/healthcheck/main_test.go diff --git a/Dockerfile b/Dockerfile index 22a73c9e6..fc55eefeb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -28,10 +28,15 @@ RUN --mount=type=cache,target=/go/pkg/mod \ # Copy full application source tree COPY . . -# Compile statically linked binary with stripped symbols and debug information +# Compile statically linked binaries with stripped symbols and debug information RUN --mount=type=cache,target=/go/pkg/mod \ --mount=type=cache,target=/root/.cache/go-build \ - CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/sop-server ./tools/httpserver + CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/sop-server ./tools/httpserver && \ + CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/healthcheck ./tools/healthcheck + +# Prepare the runtime data directory here since the distroless runtime +# stage has no shell or package manager to create/chown it itself. +RUN mkdir -p /out/var/lib/sop && chown -R 65532:65532 /out/var/lib/sop # ============================================================================== # Stage 2: Integration Test Runner (Optional Target: --target test) @@ -49,22 +54,16 @@ CMD ["docker-entrypoint.sh"] # ============================================================================== # Stage 3: Minimal Production Runtime # ============================================================================== -FROM alpine:3.21 AS runtime - -# Install certificates for secure TLS and timezone data -RUN apk add --no-cache ca-certificates tzdata - -# Create unprivileged system user and group (UID/GID 10001) -RUN addgroup -g 10001 -S sop && \ - adduser -u 10001 -S sop -G sop && \ - mkdir -p /var/lib/sop && \ - chown -R sop:sop /var/lib/sop +FROM gcr.io/distroless/static-debian12:nonroot AS runtime -# Copy statically compiled binary from builder stage +# Statically compiled binaries from the builder stage; the distroless +# image already carries CA certs and a nonroot (65532:65532) user/group, +# nothing left to install. COPY --from=builder /out/sop-server /usr/local/bin/sop-server +COPY --from=builder /out/healthcheck /usr/local/bin/healthcheck +COPY --from=builder --chown=65532:65532 /out/var/lib/sop /var/lib/sop -# Drop root privileges and configure execution environment -USER 10001:10001 +USER nonroot:nonroot WORKDIR /var/lib/sop ENV datapath=/var/lib/sop @@ -74,9 +73,10 @@ EXPOSE 8080 # Handle container lifecycle signals cleanly STOPSIGNAL SIGTERM -# Health check against built-in HTTP health endpoint +# Health check against the built-in HTTP health endpoint. No shell or +# wget on this base image, so the probe is its own static binary. HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \ - CMD wget --no-verbose --tries=1 --spider http://localhost:8080/api/health || exit 1 + CMD ["/usr/local/bin/healthcheck"] ENTRYPOINT ["sop-server"] CMD ["-database", "/var/lib/sop", "-port", "8080", "-open-browser=false"] diff --git a/tools/healthcheck/main.go b/tools/healthcheck/main.go new file mode 100644 index 000000000..dd3d0ad0a --- /dev/null +++ b/tools/healthcheck/main.go @@ -0,0 +1,39 @@ +// Command healthcheck hits a local HTTP endpoint and exits 0 on a 2xx +// response, non-zero otherwise. Built as its own static binary so the +// runtime image can carry it without a shell or curl/wget, the tools a +// distroless base deliberately leaves out. +package main + +import ( + "flag" + "fmt" + "net/http" + "os" + "time" +) + +// probe issues the GET and returns an error unless the response is 2xx. +func probe(client *http.Client, url string) error { + resp, err := client.Get(url) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("unhealthy status %d", resp.StatusCode) + } + return nil +} + +func main() { + url := flag.String("url", "http://127.0.0.1:8080/api/health", "endpoint to probe") + timeout := flag.Duration("timeout", 3*time.Second, "request timeout") + flag.Parse() + + client := &http.Client{Timeout: *timeout} + if err := probe(client, *url); err != nil { + fmt.Fprintln(os.Stderr, "healthcheck:", err) + os.Exit(1) + } +} diff --git a/tools/healthcheck/main_test.go b/tools/healthcheck/main_test.go new file mode 100644 index 000000000..682626611 --- /dev/null +++ b/tools/healthcheck/main_test.go @@ -0,0 +1,39 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func TestProbeHealthy(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + client := &http.Client{Timeout: time.Second} + if err := probe(client, srv.URL); err != nil { + t.Fatalf("expected healthy, got error: %v", err) + } +} + +func TestProbeUnhealthyStatus(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusServiceUnavailable) + })) + defer srv.Close() + + client := &http.Client{Timeout: time.Second} + if err := probe(client, srv.URL); err == nil { + t.Fatal("expected error for 503 response, got nil") + } +} + +func TestProbeConnectionRefused(t *testing.T) { + client := &http.Client{Timeout: time.Second} + if err := probe(client, "http://127.0.0.1:1"); err == nil { + t.Fatal("expected error for unreachable endpoint, got nil") + } +}