# 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"]