Files
routewatch/Dockerfile
clawbot 01b18d00ff
check / check (push) Successful in 4m49s
Create the image's app user without the system-user flag (closes #248)
`useradd -r` with UID 1000 made the image build warn, since 1000 is above the system range. The user keeps UID 1000, so the data directory's owner does not change.

Model: opus-5-5
2026-11-10 15:04:21 +00:00

153 lines
6.3 KiB
Docker

# In every stage ARG CACHEBUST comes right after the steps that install
# system packages and download Go's modules, or right after FROM in a
# stage with neither, and every step that lints, tests or compiles
# comes after it. script/ passes a new value on every build, which
# reruns every RUN step after it. Docker keeps the layers above it
# until the base image, their own lines or go.mod and go.sum change.
# The steps that compile mount Go's build cache, and golangci-lint run
# also mounts golangci-lint's; both survive from build to build.
# Lint phase. The image is Debian-based and has gcc, so it can check the
# cgo SQLite package without extra installs.
# golangci/golangci-lint:v2.14.0 (Go 1.27.0), 2026-10-19
FROM golangci/golangci-lint@sha256:ad862ba6b3798cbe0fd9fd7408d498fd74fbd2623a92406b2fd3898faf0bf98f AS lint
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
ARG CACHEBUST
COPY . .
RUN --mount=type=cache,target=/root/.cache/go-build \
--mount=type=cache,target=/root/.cache/golangci-lint \
golangci-lint run --config .golangci.yml ./...
# Test phase. -race needs cgo and so a C compiler, which the Debian Go
# image ships and the alpine one does not. -count=1 because the mounted
# cache also holds test results, which go test would otherwise replay.
# -short skips the live RIS Live feed test, so the suite needs no network.
# golang:1.27.2-bookworm, 2026-10-19
FROM golang@sha256:5cf287a799e6b94384bad13d16b14904c531f51ba65792237e122ce42b392f61 AS test
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
ARG CACHEBUST
COPY . .
RUN --mount=type=cache,target=/root/.cache/go-build \
go test -short -count=1 -timeout 90s -race -cover ./... || \
{ echo "--- Rerunning with -v for details ---"; \
go test -short -count=1 -timeout 90s -race -v ./...; exit 1; }
# Build stage. Nothing is wanted from either phase above; the copies
# are what make BuildKit build them first, so nothing after them can
# run unless lint and test passed.
# golang:1.27.2-bookworm, 2026-10-19
FROM golang@sha256:5cf287a799e6b94384bad13d16b14904c531f51ba65792237e122ce42b392f61 AS builder
# Install build dependencies (zstd for archive, gcc for CGO/sqlite3)
RUN apt-get update && apt-get install -y --no-install-recommends \
zstd \
gcc \
libc6-dev \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
ARG CACHEBUST
COPY --from=lint /src/go.sum /dev/null
COPY --from=test /src/go.sum /dev/null
# A tar-stream context keeps the sender's file owners, which git refuses.
RUN git config --system --add safe.directory /src
COPY . .
# Vendor dependencies for the source archive (must be after copying source)
RUN go mod vendor
# Build the binary with CGO enabled (required for sqlite3). The version the
# page footer shows is the VERSION build arg when one is given, otherwise
# `git describe --tags --always` on the .git in the build context: the tag
# on a tagged commit, tag-N-gHASH after one, the short commit when no tag is
# reachable. With .git present, a version that is still empty, dev or
# unknown fails the build: git is missing or could not read the checkout.
# Without .git the footer says unknown. The footer links to the full commit.
ARG VERSION
RUN --mount=type=cache,target=/root/.cache/go-build \
VERSION="${VERSION:-$(git describe --tags --always)}"; \
if [ -e .git ]; then \
case "$VERSION" in ""|dev|unknown) \
echo "version is '$VERSION' although .git is present" >&2; \
exit 1 ;; \
esac; \
fi; \
REVISION="$(git rev-parse --verify HEAD || echo unknown)"; \
CGO_ENABLED=1 GOOS=linux go build -o /routewatch -ldflags "\
-X git.eeqj.de/sneak/routewatch/internal/version.GitRevision=$REVISION \
-X git.eeqj.de/sneak/routewatch/internal/version.GitRevisionShort=${VERSION:-unknown}" \
./cmd/routewatch
# Create source archive with vendored dependencies
RUN tar --zstd -cf /routewatch-source.tar.zst \
--exclude='.git' \
--exclude='*.tar.zst' \
.
# Runtime stage, and the last one
# debian:bookworm-slim, 2026-09-21
FROM debian@sha256:3783cc01769c7b2b1b83a5c5ad96c815348e28ed7da68e2e3687004faa906251
# Install runtime dependencies
# - ca-certificates: for HTTPS connections
# - curl: for health checks
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
curl \
&& rm -rf /var/lib/apt/lists/*
ARG CACHEBUST
# Create non-root user
RUN useradd -u 1000 -m routewatch
RUN mkdir -p /var/lib/berlin.sneak.app.routewatch && chown routewatch:routewatch /var/lib/berlin.sneak.app.routewatch
RUN mkdir /app
WORKDIR /app
# Copy binary and source archive from builder
COPY --from=builder /routewatch /app/routewatch
COPY --from=builder /routewatch-source.tar.zst /app/source/routewatch-source.tar.zst
# Set ownership
RUN chown -R routewatch:routewatch /app
ENV XDG_DATA_HOME=/var/lib
# Cap the Go heap at 2.5 GiB so the runtime collects harder before the
# container's memory limit is reached. A full routing table takes about 1 GB
# of it (README "Memory"). setpriv in the entrypoint preserves this the way it
# does XDG_DATA_HOME above.
ENV GOMEMLIMIT=2560MiB
# Cap glibc's malloc arenas. The SQLite C library allocates and frees millions
# of small page-cache chunks from many threads; glibc otherwise creates up to
# eight arenas per core (hundreds on a large host) and keeps each arena's freed
# chunks resident, so process RSS climbs far above SQLite's live heap and never
# comes back down. Two arenas keep that retained memory bounded; database writes
# are already serialized, so the lost allocator concurrency costs nothing here.
ENV MALLOC_ARENA_MAX=2
# Expose HTTP port
EXPOSE 8080
COPY ./entrypoint.sh /entrypoint.sh
# Health check using the health endpoint, on the port PORT names. During the
# start period, Docker 25 and later checks every 5 seconds, and the first pass
# makes the container healthy. After it, Docker checks every 30 seconds. The
# start period covers the 60 seconds after which upaas reads the health
# (README "Running under upaas"). Startup reads the routing table first,
# which takes about 25 seconds when the table is full.
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \
CMD curl -sf "http://localhost:${PORT:-8080}/.well-known/healthcheck.json" || exit 1
ENTRYPOINT ["/bin/bash", "/entrypoint.sh" ]