docker build step. For how a built image then ships to production, see
Releases.
Why Bazel produces the images
We use Bazel for image production for three reasons:- Content-addressed caching all the way through. Every output is hashed, so an unchanged service produces an identical binary, layer, and image digest. CI does no work and pushes nothing on a no-op change, and a developer’s local build can pull CI’s outputs from the remote cache.
- Reproducibility across machines. Two developers and a CI runner produce byte-identical images for the same commit. That property is what makes the shared cache useful in the first place. A Dockerfile build does not give us this without significant extra work.
- One source of truth for service wiring. The same
BUILD.bazelthat compiles a binary also declares how it gets packaged and shipped. Adding a service is oneservice_imagemacro call rather than a Dockerfile, a build script, and a CI snippet that drift apart over time.
What ships in an image
Every service image is the same shape:- A single static Go binary at
/. No libc, no shared libraries, no init scripts. The image entrypoint is the binary path directly, not a shell invocation. - Distroless base. No shell, no busybox, no package manager. Attack
surface is minimal and the image contains exactly what the service needs to
run. This is also why container probes have to be HTTP-based; there is no
shto exec. - Multi-arch manifest. Each release publishes an OCI index referencing
amd64 and arm64 variants built in parallel from the same Go source.
docker pullresolves to the right architecture automatically, so deployments don’t care whether the cluster is x86 or Graviton.
Where things live
Build configuration is colocated with each service:build/<service>/main.gois the entrypoint. It wires a TOML config command viabuild/utiland calls into the service’sRunfunction. Runtime behavior (instance IDs, clocks, TLS loading, normalization) stays insvc/.../run.go. The entrypoint is intentionally thin so it does not become a second service runtime.build/<service>/BUILD.bazeldeclares the per-archgo_binarytargets and calls theservice_imagemacro from//build/util:service_image.bzl. The macro generates everything else: tarballs, per-arch OCI images, the multi-arch index, plus:push(to GHCR) and:load(into local Docker).
Local images
Tilt and the dashboarddocker-compose setup use the same images Bazel
produces, just under the local unkey/<service>:dev tag:
| Local environment | Image source |
|---|---|
dev/k8s (Tilt) | bazel run //build/<service>:load via Tilt’s custom_build |
| Dashboard compose | make oci-load loads all service images via Bazel |
Adding a new service image
- Add
build/<service>/main.gowith a call toutil.RunServiceCommand. - Add
build/<service>/BUILD.bazelwith per-archgo_binarytargets and aservice_image(...)call from//build/util:service_image.bzl. - Add the service’s tag pattern to
.depot/workflows/service-release.yamlso the release workflow picks up<service>/vx.y.zpushes. - Wire it into local dev: a
bazel run //build/<service>:loadline inmake oci-load, a block indev/Tiltfile, and any compose file that needs it. - Run
make bazelto regenerate gazelle-managed BUILD files.
build/<service>/main.go.
