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
34 changes: 17 additions & 17 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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

Expand All @@ -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"]
39 changes: 39 additions & 0 deletions tools/healthcheck/main.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
39 changes: 39 additions & 0 deletions tools/healthcheck/main_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
Loading