Files
krow_backend/infrastructure/Dockerfile.api
2026-08-28 12:21:44 +05:30

184 lines
9.0 KiB
Docker

# syntax=docker/dockerfile:1.7
# ============================================================================
# Krow API — production image
#
# Build from the REPOSITORY ROOT, not from this directory: the build needs
# go-api/ and seed/, and a Dockerfile can never reach above its context.
#
# docker build -f infrastructure/Dockerfile.api -t krow-api:latest .
#
# EVERY command in go-api/cmd/ ships in the image, built by a loop rather than
# named one at a time. Naming them individually is how the image came to be
# missing `importagents`, and a deployment with no agents published answers every
# Owliver question with "no agent" while looking perfectly healthy — the API is
# up, the database is migrated, and there is simply nothing to run.
#
# /usr/local/bin/api the HTTP service (the default command)
# /usr/local/bin/seed loads the demo fixture
# /usr/local/bin/setpassword sets a user's password — without it a fresh
# database has no one who can sign in
# /usr/local/bin/importagents publishes agents/ and skills/ into a tenant
# /usr/local/bin/ingest ingests knowledge/ into a tenant
# /usr/local/bin/reembed re-embeds a tenant's corpus
#
# A new command under cmd/ is shipped automatically. That is the point: what
# exists locally is what exists on the server.
#
# Migrations are deliberately NOT run by this image. They are a discrete deploy
# step against the target database BEFORE the new binary rolls out, which is
# what makes expand/migrate/contract possible: see README.md "Staging and
# production". docker-compose.yml runs them as their own one-shot service.
# ============================================================================
# ── Build ───────────────────────────────────────────────────────────────────
# --platform=$BUILDPLATFORM pins this stage to the MACHINE DOING THE BUILDING,
# not the machine that will run the image. Combined with GOARCH below, that
# turns a cross-platform build into cross-COMPILATION rather than emulation.
#
# It matters a lot in practice: building linux/amd64 from an Apple Silicon Mac
# without this runs the entire Go toolchain under QEMU, which is roughly an
# order of magnitude slower. Every binary here is CGO_ENABLED=0 and therefore
# pure Go, so the Go compiler can target another architecture directly and there
# is nothing emulation would buy.
FROM --platform=$BUILDPLATFORM golang:1.27-alpine AS build
# Supplied automatically by buildx from --platform. Declared here so the build
# stage can read them; TARGETARCH is "amd64", "arm64", etc.
ARG TARGETOS=linux
ARG TARGETARCH
WORKDIR /src
# Dependencies first, in their own layer. go.mod and go.sum change far less
# often than source, so an edit to a handler does not re-download the module
# graph.
COPY go-api/go.mod go-api/go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod \
go mod download
COPY go-api/ ./
# CGO_ENABLED=0 produces a static binary, which is what lets the final stage be
# a near-empty image with no libc to keep patched.
# -trimpath keeps build-machine paths out of panics and binaries
# -s -w drops the symbol table and DWARF; roughly a third off the size
# -X main.version stamps the build in, so a running process can say which one
# it is. The arg was declared and plumbed through compose but
# reached no linker flag, so every deployment reported nothing
# and "did my deploy land?" had no answer. A binary with no
# main.version symbol just ignores this.
ARG VERSION=dev
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
set -eux; \
for cmd in ./cmd/*/; do \
name="$(basename "$cmd")"; \
CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build \
-trimpath -ldflags="-s -w -X main.version=${VERSION}" \
-o "/out/$name" "$cmd"; \
done; \
test -x /out/api
# The golang-migrate CLI, built here rather than pulled as a second image.
#
# `go install pkg@version` resolves in its own module context, so this does NOT
# touch go.mod or go.sum. Having it in the image means the Kubernetes manifests
# can run migrations from an initContainer using the SAME image and tag as the
# API — so the schema and the binary that expects it can never be different
# versions, which is the failure a separate migrate image invites.
ARG MIGRATE_VERSION=v4.19.0
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go install -tags 'postgres' \
github.com/golang-migrate/migrate/v4/cmd/migrate@${MIGRATE_VERSION} && \
cp "$(go env GOPATH)/bin/${TARGETOS}_${TARGETARCH}/migrate" /out/migrate 2>/dev/null || \
cp "$(go env GOPATH)/bin/migrate" /out/migrate
# Fail the build rather than the deploy if the tests do not pass. Skipped by
# default because the database-backed tests need PostgreSQL, which a build
# container does not have; turn it on in CI where a service container does.
# Note: this runs on the BUILD architecture, so it is only meaningful when
# building natively. Under cross-compilation the test binaries would target the
# other architecture and could not execute here.
ARG RUN_TESTS=false
RUN if [ "$RUN_TESTS" = "true" ]; then go test ./... ; fi
# ── Runtime ─────────────────────────────────────────────────────────────────
# Alpine rather than distroless, for one concrete reason: HEALTHCHECK needs a
# command inside the container, and distroless has no shell and no wget. The
# trade is ~8 MB and an apk surface to keep updated, in exchange for the
# orchestrator being able to see whether this instance is actually serving.
FROM alpine:3.20 AS runtime
# ca-certificates: the pgx driver needs a trust store to verify a managed
# database's TLS certificate under sslmode=verify-full.
# tzdata: timestamps are timestamptz and the seeder anchors shift dates to now.
RUN apk add --no-cache ca-certificates tzdata wget && \
adduser -D -H -u 10001 -s /sbin/nologin krow
# Everything built above, plus the migrate CLI. One COPY, so adding a command
# needs no change here either.
COPY --from=build /out/ /usr/local/bin/
# The seed fixture, so `seed` works without a bind mount.
COPY seed/fixtures/seed.json /app/seed/fixtures/seed.json
ENV SEED_FIXTURE_PATH=/app/seed/fixtures/seed.json
# The agent specs, skill definitions and knowledge corpus.
#
# importagents and ingest default to ./agents, ./skills and ./knowledge relative
# to the working directory, which is /app below — so the defaults resolve without
# flags. Without these the binaries would ship and have nothing to publish, which
# is the same failure one step later.
#
# They are Markdown, and .dockerignore's `*.md` does NOT exclude them: a
# .dockerignore `*` does not cross a `/`, so that rule only matches Markdown at
# the context root. Verified against the daemon, not assumed.
COPY agents/ /app/agents/
COPY skills/ /app/skills/
COPY knowledge/ /app/knowledge/
# The migration files, so an initContainer can apply them from this image.
# They are read-only at runtime and the process is non-root, so nothing here
# can be rewritten by the service.
COPY migrations/ /app/migrations/
ENV MIGRATIONS_DIR=/app/migrations
WORKDIR /app
# Non-root. The service binds 8080, which needs no privilege, so there is no
# reason for this process to be able to write to its own filesystem or read
# another container's mounts.
USER krow:krow
# 0.0.0.0, NOT the 127.0.0.1 that config.Load defaults to. A container that
# binds loopback is reachable only from inside itself — the symptom is a
# published port that refuses every connection while the process looks healthy.
ENV HTTP_HOST=0.0.0.0 \
HTTP_PORT=8080 \
APP_ENV=production \
LOG_LEVEL=info
EXPOSE 8080
# The check keys on the STATUS CODE, not on the body, because the two say
# different things on purpose (httpserver.handleHealth):
#
# 200 "ok" serving normally
# 200 "degraded" process healthy, schema unmigrated or dirty — still 200,
# because the fault is the database's and pulling this
# instance would not fix it
# 503 "unavailable" database unreachable
#
# Grepping the body for "ok" would mark a degraded instance unhealthy and take
# the whole deployment out of rotation during a migration window, which is
# exactly the behaviour that 200 was chosen to avoid. wget exits non-zero on
# 503 and zero on either 200, which is the intended semantics.
HEALTHCHECK --interval=15s --timeout=3s --start-period=20s --retries=3 \
CMD wget -qO- http://127.0.0.1:8080/health >/dev/null 2>&1 || exit 1
# Exec form: the binary becomes PID 1 and receives SIGTERM directly, which is
# what cmd/api's signal.NotifyContext is waiting for to drain connections.
ENTRYPOINT ["/usr/local/bin/api"]