184 lines
9.0 KiB
Docker
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"]
|