From bfa856318d2b9580c746b5bcc05fb5b674e271fa Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Mon, 25 May 2026 03:07:22 +0000 Subject: [PATCH] feat: add make watch hot-reload mode for go services via air --- Makefile | 34 ++++++++++++- deploy/docker/air.toml | 64 ++++++++++++++++++++++++ deploy/docker/go-dev.Dockerfile | 34 +++++++++++++ docker-compose.watch.yml | 87 +++++++++++++++++++++++++++++++++ 4 files changed, 218 insertions(+), 1 deletion(-) create mode 100644 deploy/docker/air.toml create mode 100644 deploy/docker/go-dev.Dockerfile create mode 100644 docker-compose.watch.yml diff --git a/Makefile b/Makefile index 838063f6..7a17ba74 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/deploy/docker/air.toml b/deploy/docker/air.toml new file mode 100644 index 00000000..c1fcef9c --- /dev/null +++ b/deploy/docker/air.toml @@ -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 diff --git a/deploy/docker/go-dev.Dockerfile b/deploy/docker/go-dev.Dockerfile new file mode 100644 index 00000000..1161118f --- /dev/null +++ b/deploy/docker/go-dev.Dockerfile @@ -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"] diff --git a/docker-compose.watch.yml b/docker-compose.watch.yml new file mode 100644 index 00000000..90f9afc3 --- /dev/null +++ b/docker-compose.watch.yml @@ -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 `_` 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