feat: add make watch hot-reload mode for go services via air

This commit is contained in:
Matthew Meszaros
2026-05-25 03:07:22 +00:00
parent becb8f2d10
commit bfa856318d
4 changed files with 218 additions and 1 deletions
+33 -1
View File
@@ -14,7 +14,8 @@ PROTO_GEN_FILES := $(PROTO_DIR)/tasks.pb.go
.PHONY: setup-tools lint proto check-proto \
dev sim seed reset logs status stop down tools test-seed \
restart restart-go restart-all cache-clean
restart restart-go restart-all cache-clean \
watch watch-down watch-logs
setup-tools:
@echo "Installing required Go tools into $(GO_BIN)"
@@ -123,6 +124,37 @@ restart-all:
$(DOCKER_ENV) docker compose build --parallel backend consumer worker-shared-1 tracking realtime
docker compose up -d backend consumer worker-shared-1 tracking realtime
# ─── hot-reload (air) ───────────────────────────────────────────────────
#
# Bind-mounted source + air file watcher inside a long-running golang
# container. Edit a .go file → air rebuilds (~2-5s) → restarts the
# binary in place. No docker image rebuild, no container recreate.
#
# First boot takes ~30s to populate the warmbly_gomodcache /
# warmbly_gocache volumes, but those persist across worktrees (the
# volume names skip the per-project prefix), so subsequent worktrees
# start watching almost instantly.
#
# The default `make dev` / `make restart-go` flow still uses the
# production-style Dockerfiles. Watch is opt-in.
WATCH_COMPOSE := docker compose -f docker-compose.yml -f docker-compose.watch.yml
WATCH_SVCS := backend consumer worker-shared-1
watch:
$(DOCKER_ENV) $(WATCH_COMPOSE) up -d --build $(WATCH_SVCS)
@echo ""
@echo "Hot-reload running. Edit any .go file under cmd/ or internal/ to trigger a rebuild."
@echo "Stream logs: make watch-logs"
@echo "Stop watch: make watch-down"
watch-down:
$(WATCH_COMPOSE) stop $(WATCH_SVCS)
@echo "watch services stopped. Run 'make restart-go' to return to production-style builds."
watch-logs:
$(WATCH_COMPOSE) logs -f --tail=200 $(WATCH_SVCS)
# Force-drop the BuildKit cache. Useful when something's gone weird
# (corrupted cache, debugging a "works on a clean build but not after
# a rebuild" issue). The shared Go module + build caches will rebuild
+64
View File
@@ -0,0 +1,64 @@
# air config for the warmbly hot-reload dev container.
#
# Generic across all three Go services — the build target is read from
# the WARMBLY_CMD env var, which is set per service in
# docker-compose.yml (backend / consumer / worker).
#
# Tradeoffs:
# - `delay = 800` debounces save-bursts. Below ~500ms editors can fire
# two file events for one save (write + flush), which kicks two
# builds.
# - `kill_delay = "2s"` gives the old binary time to drain in-flight
# work (Kafka commits, HTTP requests) before the new one starts.
# - `include_ext` deliberately omits `.sql` — migrations are embedded
# and require an explicit re-embed (go build) which the .go change
# in migrate.go will trigger anyway.
root = "."
tmp_dir = "tmp"
testdata_dir = "testdata"
[build]
# Built with the same flags as the production Dockerfiles so the
# behavior under watch matches what ships. -tags musl is required
# because we're on alpine.
cmd = "go build -tags musl -o ./tmp/main ./cmd/${WARMBLY_CMD}"
bin = "./tmp/main"
full_bin = ""
include_ext = ["go", "tpl", "tmpl", "html"]
exclude_dir = [
"web",
"tracking",
"realtime",
"docs",
"tmp",
"bin",
"dist",
"build",
".git",
".github",
".agentd",
"node_modules",
]
include_dir = []
exclude_file = []
exclude_regex = ["_test\\.go"]
exclude_unchanged = true
follow_symlink = false
log = "build-errors.log"
delay = 800
stop_on_error = false
send_interrupt = true
kill_delay = "2s"
[color]
main = "magenta"
watcher = "cyan"
build = "yellow"
runner = "green"
[log]
time = true
[misc]
clean_on_exit = true
+34
View File
@@ -0,0 +1,34 @@
# syntax=docker/dockerfile:1.7
#
# Hot-reload image for Go services. Used only by the `watch` profile in
# docker-compose.yml — production builds still use the per-service
# Dockerfiles next to this one.
#
# The container expects:
# - Source bind-mounted at /app
# - $GOMODCACHE / $GOCACHE on named volumes (so caches survive
# container recreates AND restarts of the host machine)
# - $WARMBLY_CMD set to one of: backend, consumer, worker, seed
#
# air watches for changes under /app and rebuilds the cmd binary into
# /tmp/main in place, then restarts it. No docker layer pipeline, no
# image rebuild — just `go build` against a warm cache.
FROM golang:1.25-alpine
RUN apk add --no-cache git ca-certificates gcc musl-dev librdkafka-dev curl
# Pin air so the dev image is reproducible. air-verse/air is the
# successor to cosmtrek/air (same project, new org).
RUN go install github.com/air-verse/air@v1.61.5
ENV GOMODCACHE=/go/pkg/mod
ENV GOCACHE=/root/.cache/go-build
ENV GOTMPDIR=/tmp
ENV CGO_ENABLED=1
WORKDIR /app
COPY deploy/docker/air.toml /etc/air.toml
ENTRYPOINT ["air", "-c", "/etc/air.toml"]
+87
View File
@@ -0,0 +1,87 @@
# Hot-reload override for the Go services.
#
# Activated via `make watch` (or `docker compose -f docker-compose.yml
# -f docker-compose.watch.yml up backend consumer worker-shared-1`).
#
# What changes vs. the default compose:
# - backend / consumer / worker-shared-1 swap to the go-dev image
# (golang + air + librdkafka) instead of their thin runtime alpine.
# - Source is bind-mounted at /app so saves are visible inside the
# container without any image rebuild.
# - Module + build caches live on named volumes (`warmbly_gomodcache`
# and `warmbly_gocache`) so they survive container recreates AND
# are shared across worktrees that point at the same Docker daemon.
# - WARMBLY_CMD tells the shared air.toml which `cmd/` to build.
#
# What stays the same:
# - Every env var, depends_on, healthcheck, port mapping. All the
# interesting wiring is in the base compose; this file only swaps
# the build/runtime mechanics.
#
# Notes:
# - First start of a watch container takes ~30s to compile the binary
# into the cache. Every save after that is ~2-5s.
# - We disable the existing backend healthcheck because air's restart
# loop causes brief health flaps that depends_on doesn't tolerate.
# The consumer + worker still wait on `service_started` (alive) so
# dependent containers wake up at the right time anyway.
services:
backend:
image: warmbly-go-dev
build:
context: .
dockerfile: deploy/docker/go-dev.Dockerfile
environment:
WARMBLY_CMD: backend
volumes:
- .:/app
- warmbly_gomodcache:/go/pkg/mod
- warmbly_gocache:/root/.cache/go-build
healthcheck:
disable: true
consumer:
image: warmbly-go-dev
build:
context: .
dockerfile: deploy/docker/go-dev.Dockerfile
environment:
WARMBLY_CMD: consumer
volumes:
- .:/app
- warmbly_gomodcache:/go/pkg/mod
- warmbly_gocache:/root/.cache/go-build
# Consumer waits on backend in the base file; under watch the
# backend has no healthcheck, so relax to service_started so
# consumer doesn't refuse to come up.
depends_on:
backend:
condition: service_started
kafka:
condition: service_healthy
schema-registry:
condition: service_started
worker-shared-1:
image: warmbly-go-dev
build:
context: .
dockerfile: deploy/docker/go-dev.Dockerfile
environment:
WARMBLY_CMD: worker
WORKER_TIER: shared
volumes:
- .:/app
- warmbly_gomodcache:/go/pkg/mod
- warmbly_gocache:/root/.cache/go-build
volumes:
# `name:` overrides the default `<project>_<volume>` prefixing. Every
# worktree that brings up this override file mounts the same physical
# volume on the host, so the Go module + build cache populated in one
# worktree is immediately warm in every other worktree.
warmbly_gomodcache:
name: warmbly_gomodcache
warmbly_gocache:
name: warmbly_gocache