160 lines
6.4 KiB
Text
160 lines
6.4 KiB
Text
|
|
# syntax=docker/dockerfile:1.7
|
||
|
|
#
|
||
|
|
# Go Bananas!! container image.
|
||
|
|
#
|
||
|
|
# Design notes
|
||
|
|
# ------------
|
||
|
|
# * The Go build is fully static. modernc.org/sqlite is a pure-Go translation of
|
||
|
|
# SQLite, so CGO is not needed and the runtime image needs no libc at all. That
|
||
|
|
# is what lets the final stage be `scratch`-adjacent: a smaller image, and no
|
||
|
|
# shared-library CVEs to patch.
|
||
|
|
# * The frontend and the SDK are built before the Go binary and copied in, so the
|
||
|
|
# image build fails if the frontend fails. A container that starts and then 404s
|
||
|
|
# on its own dashboard is worse than one that does not start.
|
||
|
|
# * Dependencies are installed from a lockfile in their own layer, so a source-only
|
||
|
|
# change does not re-download the module cache.
|
||
|
|
# * BuildKit cache mounts are used for the module and package caches. They are a
|
||
|
|
# cache, not an artefact, so they are not part of the image.
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------- arguments --
|
||
|
|
|
||
|
|
ARG GO_VERSION=1.27.1
|
||
|
|
ARG NODE_VERSION=24.9.0
|
||
|
|
ARG ALPINE_VERSION=3.22
|
||
|
|
|
||
|
|
# ------------------------------------------------------------- web and sdk ----
|
||
|
|
|
||
|
|
FROM node:${NODE_VERSION}-alpine AS assets
|
||
|
|
|
||
|
|
# Corepack is enabled so that the pinned package manager in package.json is the one
|
||
|
|
# that runs, rather than whatever npm happens to ship with the Node release.
|
||
|
|
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||
|
|
|
||
|
|
WORKDIR /src/web
|
||
|
|
COPY web/package.json web/package-lock.json* ./
|
||
|
|
|
||
|
|
# `npm ci` when a lockfile is present, `npm install` otherwise. The fallback keeps
|
||
|
|
# the image buildable from a source tree that has not installed yet, while CI
|
||
|
|
# always takes the reproducible path.
|
||
|
|
RUN --mount=type=cache,target=/root/.npm \
|
||
|
|
if [ -f package-lock.json ]; then npm ci --no-audit --no-fund; \
|
||
|
|
else npm install --no-audit --no-fund; fi
|
||
|
|
|
||
|
|
COPY web/ ./
|
||
|
|
RUN npm run build
|
||
|
|
|
||
|
|
WORKDIR /src/sdk
|
||
|
|
COPY sdk/package.json sdk/package-lock.json* ./
|
||
|
|
RUN --mount=type=cache,target=/root/.npm \
|
||
|
|
if [ -f package-lock.json ]; then npm ci --no-audit --no-fund; \
|
||
|
|
else npm install --no-audit --no-fund; fi
|
||
|
|
|
||
|
|
COPY sdk/ ./
|
||
|
|
RUN npm run build
|
||
|
|
|
||
|
|
# ------------------------------------------------------------------- go ------
|
||
|
|
|
||
|
|
FROM golang:${GO_VERSION}-alpine AS build
|
||
|
|
|
||
|
|
ARG VERSION=dev
|
||
|
|
ARG COMMIT=unknown
|
||
|
|
ARG BUILD_DATE=unknown
|
||
|
|
ARG TARGETOS=linux
|
||
|
|
ARG TARGETARCH=amd64
|
||
|
|
|
||
|
|
# git is needed for the version stamping below when the build runs outside a
|
||
|
|
# checkout; ca-certificates for module downloads.
|
||
|
|
RUN apk add --no-cache git ca-certificates
|
||
|
|
|
||
|
|
WORKDIR /src
|
||
|
|
|
||
|
|
# Dependencies first, in their own layer. go.sum is copied and verified so that a
|
||
|
|
# tampered or truncated lockfile fails the build rather than silently resolving
|
||
|
|
# something different.
|
||
|
|
COPY go.mod go.sum ./
|
||
|
|
RUN --mount=type=cache,target=/go/pkg/mod \
|
||
|
|
go mod download && go mod verify
|
||
|
|
|
||
|
|
COPY internal/ ./internal/
|
||
|
|
COPY cmd/ ./cmd/
|
||
|
|
|
||
|
|
ARG CGO_ENABLED=0
|
||
|
|
ARG GOFLAGS=-trimpath
|
||
|
|
|
||
|
|
# -trimpath removes local filesystem paths from the binary, which keeps build paths
|
||
|
|
# out of any stack trace a user might paste into a bug report.
|
||
|
|
# -s -w drop the symbol table and DWARF data: neither is useful without a matching
|
||
|
|
# local binary, and together they are a substantial fraction of the image size.
|
||
|
|
RUN --mount=type=cache,target=/go/pkg/mod \
|
||
|
|
--mount=type=cache,target=/root/.cache/go-build \
|
||
|
|
GOOS=${TARGETOS} GOARCH=${TARGETARCH} CGO_ENABLED=${CGO_ENABLED} GOFLAGS=${GOFLAGS} \
|
||
|
|
go build \
|
||
|
|
-ldflags "-s -w -X github.com/urpi/go-bananas/internal/app.Version=${VERSION} -X main.version=${VERSION} -X github.com/urpi/go-bananas/internal/app.Commit=${COMMIT} -X github.com/urpi/go-bananas/internal/app.BuildDate=${BUILD_DATE}" \
|
||
|
|
-o /out/go-bananas ./cmd/go-bananas
|
||
|
|
|
||
|
|
# Verify the binary is genuinely static before shipping it. A build that silently
|
||
|
|
# picked up cgo would produce an image that fails at run time with a confusing
|
||
|
|
# "no such file or directory" rather than at build time with a clear one.
|
||
|
|
RUN if [ "${CGO_ENABLED}" = "0" ]; then \
|
||
|
|
if go version -m /out/go-bananas | grep -q 'CGO_ENABLED=1'; then \
|
||
|
|
echo "error: binary is dynamically linked but CGO_ENABLED=0" >&2; exit 1; \
|
||
|
|
fi; \
|
||
|
|
fi
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------- runtime ----
|
||
|
|
|
||
|
|
# Alpine rather than distroless or scratch: the service needs a shell for a health
|
||
|
|
# check and a writable directory for SQLite, and Alpine supplies both in ~8 MB. The
|
||
|
|
# attack surface difference against distroless is small next to the operational
|
||
|
|
# benefit, and the runtime user is unprivileged either way.
|
||
|
|
FROM alpine:${ALPINE_VERSION} AS runtime
|
||
|
|
|
||
|
|
ARG VERSION=dev
|
||
|
|
|
||
|
|
# curl is only here for the container health check. wget from busybox would do, but
|
||
|
|
# it is already present and curl's exit codes are the ones the check reasons about.
|
||
|
|
# tzdata is needed because timestamps are rendered in the configured locale.
|
||
|
|
RUN apk add --no-cache ca-certificates tzdata curl \
|
||
|
|
&& addgroup -g 65532 -S bananas \
|
||
|
|
&& adduser -u 65532 -S -G bananas -H -s /sbin/nologin bananas
|
||
|
|
|
||
|
|
COPY --from=build /out/go-bananas /usr/local/bin/go-bananas
|
||
|
|
|
||
|
|
COPY --from=assets /src/web/dist /srv/web
|
||
|
|
COPY --from=assets /src/sdk/dist /srv/sdk
|
||
|
|
|
||
|
|
# The data directory holds the SQLite file. It is a volume in every real deployment;
|
||
|
|
# creating it here means an unconfigured local run still starts.
|
||
|
|
RUN mkdir -p /srv/data && chown -R bananas:bananas /srv
|
||
|
|
|
||
|
|
USER 65532:65532
|
||
|
|
WORKDIR /srv
|
||
|
|
|
||
|
|
ENV BANANAS_HTTP_ADDR=:8080 \
|
||
|
|
BANANAS_STATIC_DIR=/srv/web \
|
||
|
|
BANANAS_SDK_DIR=/srv/sdk \
|
||
|
|
BANANAS_DATABASE_PATH=/srv/data/go-bananas.db \
|
||
|
|
BANANAS_LOG_FORMAT=json
|
||
|
|
|
||
|
|
EXPOSE 8080
|
||
|
|
VOLUME ["/srv/data"]
|
||
|
|
|
||
|
|
# The health check uses the liveness probe rather than readiness: a container that
|
||
|
|
# is alive but not ready should stay in the load-balancer pool's view of "starting"
|
||
|
|
# rather than be restarted, and Docker's HEALTHCHECK does not have that concept.
|
||
|
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
|
||
|
|
CMD curl --fail --silent --show-error http://127.0.0.1:8080/healthz || exit 1
|
||
|
|
|
||
|
|
ENTRYPOINT ["/usr/local/bin/go-bananas"]
|
||
|
|
|
||
|
|
# --------------------------------------------------------------- final ------
|
||
|
|
|
||
|
|
FROM runtime AS final
|
||
|
|
ARG VERSION=dev
|
||
|
|
LABEL org.opencontainers.image.title="go-bananas" \
|
||
|
|
org.opencontainers.image.description="Runtime design-token service: author themes, serve them to any application at run time." \
|
||
|
|
org.opencontainers.image.version="${VERSION}" \
|
||
|
|
org.opencontainers.image.source="https://github.com/urpi/go-bananas" \
|
||
|
|
org.opencontainers.image.licenses="MIT" \
|
||
|
|
org.opencontainers.image.base.name="docker.io/library/alpine:3.22"
|