从 sleep 5 到工程化:一套真正能落地的 Go + Docker Compose 最佳实践

001024 零零后程序员
2026年04月22日 09:44

我见过的几乎每个 Go 项目,开局都一模一样。

有人执行一句:

docker compose up

然后应用启动失败,因为数据库还没准备好。接着他们在某个角落里塞一个 sleep 5 ,拍一拍手,觉得问题解决了,然后顺手提交。

三个月之后, compose.yml 已经膨胀成一个 200 行的神秘卷轴,没人敢碰,没人想读,大家只会祈祷它别炸。

这不是 Docker Compose 的问题。
这是 你怎么用它 的问题。

下面这些模式,会让你的本地开发循环更快,集成测试更稳定,生产镜像更轻,而且不会一边用工具、一边和工具打架。


典型配置到底哪里有问题

先看一下平均意义上的 Go 项目 Compose 文件,通常长这样:

services:
  app:

    build:
 .
    ports:

      -
 "8080:8080"
    environment:

      DATABASE_URL:
 postgres://user:pass@db:5432/mydb
    depends_on:

      -
 db

  db:

    image:
 postgres:17
    environment:

      POSTGRES_USER:
 user
      POSTGRES_PASSWORD:
 pass
      POSTGRES_DB:
 mydb

纸面上看,好像也没什么毛病。

但在真实世界里,它会带来这些问题:

  • depends_on 只会等容器“启动”,不会等 Postgres 真正“可以接受连接”。你的应用第一次 db.Ping() 很可能就直接崩。
  • build: . 意味着每次 docker compose up 都可能触发重建。只要缓存没中好,你就得等。
  • • 开发环境、测试环境、CI 环境,全挤在一个文件里。看着统一,实际上是混乱。
  • • 密钥直接硬编码在配置里,等于把后续治理成本提前埋进仓库。

那就一件一件把它修好。


模式 1:用健康检查,不要再用 sleep

这是你能做的、对日常体验提升最大的一件事。

depends_on 的“容器已启动”依赖,升级成“服务真的健康可用”的依赖。

services:
  db:

    image:
 postgres:16
    environment:

      POSTGRES_USER:
 user
      POSTGRES_PASSWORD:
 pass
      POSTGRES_DB:
 mydb
    healthcheck:

      test:
 ["CMD-SHELL", "pg_isready -U user -d mydb"]
      interval:
 5s
      timeout:
 5s
      retries:
 5
      start_period:
 10s

  app:

    build:
 .
    depends_on:

      db:

        condition:
 service_healthy

这里的关键是:

condition: service_healthy

这会告诉 Compose:
先别启动 app,等 db 的健康检查通过再说。

于是你就获得了:

  • • 没有 sleep 5
  • • 没有启动时序赌运气
  • • 没有“数据库明明起来了但其实还没准备好”这种反复横跳的假象

当你的 Go 程序执行 sql.Open 的时候,Postgres 才是真的 ready 了。

Redis、Kafka 这些依赖也一样可以这么做:

redis:
  image:
 redis:7-alpine
  healthcheck:

    test:
 ["CMD", "redis-cli", "ping"]
    interval:
 5s
    timeout:
 3s
    retries:
 5

这种改法看起来只是小修小补,但它会直接把你项目里最烦人的“偶发启动失败”从日常里抹掉。


模式 2:把 Compose 文件拆开

把所有环境都塞进一个 Compose 文件里,是个非常经典的坑。

尤其是新同事入职时,你跟他说一句:

docker compose up

结果他不只是起了数据库和应用,顺便还把观测栈、worker、migration job、Kafka broker 全部拉起来了。

而他今天明明只是想改一个 HTTP handler。

这显然不合理。

更好的方式是:
按职责拆文件,再按环境叠加。

现在更推荐使用 compose.yaml ,它本质上就是 docker-compose.yaml 的现代写法,行为一样,标准更清爽。然后再配合 override 文件,去做环境分层,而不是复制粘贴一堆大段配置。

推荐的拆法大概是这样:

compose.yml          # 基础服务:db、redis、kafka
compose.dev.yml      # 热重载、代码挂载、调试端口
compose.test.yml     # 临时数据库、测试专用环境变量
compose.override.yml # gitignore,个人机器特有配置

其中:

  • docker-compose.override.yml 在存在时会被 Compose 自动合并
  • • 所以它很适合放个人机器差异配置,不污染团队共享文件

基础文件负责定义基础设施:

services:
  db:

    image:
 postgres:17
    environment:

      POSTGRES_USER:
 ${POSTGRES_USER}
      POSTGRES_PASSWORD:
 ${POSTGRES_PASSWORD}
      POSTGRES_DB:
 ${POSTGRES_DB}
    healthcheck:

      test:
 ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
      interval:
 5s
      timeout:
 5s
      retries:
 5

  redis:

    image:
 redis:7.2-alpine
    healthcheck:

      test:
 ["CMD", "redis-cli", "ping"]
      interval:
 5s
      timeout:
 3s
      retries:
 5

开发环境覆盖文件再把应用和开发工具加上去:

services:
  app:

    build:

      context:
 .
      dockerfile:
 Dockerfile.dev
    volumes:

      -
 .:/app
    ports:

      -
 "8080:8080"
      -
 "2345:2345" # delve debugger
    environment:

      DATABASE_URL:
 postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}?sslmode=disable
      REDIS_URL:
 redis://redis:6379
    depends_on:

      db:

        condition:
 service_healthy
      redis:

        condition:
 service_healthy

启动时组合使用:

docker compose -f docker-compose.yml -f docker-compose.dev.yml up

你也可以给它做个 shell alias,或者直接包进 Makefile。等你真的这么拆过一次之后,你会发现团队沟通成本都低了不少。


模式 3:给 Go 写一个像样的多阶段 Dockerfile

这是很多团队浪费性能最严重的地方。

他们打出来的镜像往往:

  • • 1GB 往上
  • • 带着完整 Go 工具链
  • • CI 每次都从头编译
  • • 最终镜像里装满了运行时根本不需要的东西

一个靠谱的 Go Dockerfile,应该至少拆成三个阶段:

# ---- Stage 1: dependency cache ----
FROM
 golang:1.26.1-alpine AS deps
WORKDIR
 /app

COPY
 go.mod go.sum ./
RUN
 go mod download

# ---- Stage 2: builder ----

FROM
 deps AS builder

COPY
 . .
RUN
 CGO_ENABLED=0 GOOS=linux go build \
    -trimpath \
    -ldflags="-s -w" \
    -o /bin/server ./cmd/server


# ---- Stage 3: final image ----

FROM
 scratch

COPY
 --from=builder /bin/server /server
COPY
 --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/

ENTRYPOINT
 ["/server"]

这里面每个决策都很值钱。

先说第一阶段:

只复制 go.mod go.sum ,然后执行 go mod download

这意味着什么?

意味着 Docker 的缓存只会在依赖变动时失效,而不会因为你改了一行业务代码就把整个依赖下载阶段重跑。

这通常是你能从构建缓存里拿到的最大加速。

再看:

CGO_ENABLED=0

它会尽量产出静态链接二进制,减少外部依赖。
没有 libc,没有奇怪的动态库依赖,部署时省心很多。

再看:

-ldflags="-s -w"

这会去掉调试符号和 DWARF 表,一般能把二进制体积再砍掉 20%~30%。

最后:

FROM scratch

这是零字节基础镜像。最终镜像里只有:

  • • 你的二进制
  • • TLS 证书

对大多数 Go 服务来说,生产镜像压到 20MB 以下 是完全现实的。


本地开发别硬套生产镜像,单独做一个 Dockerfile.dev

生产镜像追求小、稳、纯净。
开发镜像追求的是快、顺手、热更新。

所以别混着来。

开发环境可以单独写一个 Dockerfile.dev

FROM golang:1.23-alpine

RUN
 apk add --no-cache git
RUN
 go install github.com/air-verse/air@latest

WORKDIR
 /app

COPY
 go.mod go.sum ./
RUN
 go mod download

ENTRYPOINT
 ["air", "-c", ".air.toml"]

air 会监听你的源码变更,保存后自动重编译。

这种体验很像脚本语言的热更新,但底层仍然保留 Go 的编译期保障。这个组合,用起来是很舒服的。


模式 4:把 .env 真正用起来

别再把账号密码直接写在 Compose 文件里了。

Compose 会自动读取同目录下的 .env 文件,所以正确玩法其实很简单。

.env 放本地值,并且加入 .gitignore

POSTGRES_USER=devuser
POSTGRES_PASSWORD=devpassword
POSTGRES_DB=myapp_dev
REDIS_PASSWORD=
APP_ENV=development
LOG_LEVEL=debug

仓库里提交一个 .env.example 作为模板:

POSTGRES_USER=
POSTGRES_PASSWORD=
POSTGRES_DB=
REDIS_PASSWORD=
APP_ENV=
LOG_LEVEL=

然后在 Compose 文件里统一引用:

environment:
  POSTGRES_USER:
 ${POSTGRES_USER}
  POSTGRES_PASSWORD:
 ${POSTGRES_PASSWORD}
  POSTGRES_DB:
 ${POSTGRES_DB}

这样做的好处非常直接:

  • • 密钥不进 Git
  • • 新同事只要复制 .env.example 改成 .env
  • • CI 里直接注入环境变量,Compose 文件不用改

这就是工程化里很重要的一种感觉:
配置在流动,但结构不变。


模式 5:把集成测试环境隔离出来

和开发数据库共用一套环境跑集成测试,是个非常危险的习惯。

任何一个测试,只要做了这些事:

  • • truncate 某张表
  • • migration 跑到一半失败
  • • 写入了脏数据没清干净

就可能把你本地开发依赖的状态一并搞乱。

更稳妥的方式是:
给测试单独一套 Compose 配置,单独一套数据库。

比如:

services:
  db_test:

    image:
 postgres:16
    environment:

      POSTGRES_USER:
 testuser
      POSTGRES_PASSWORD:
 testpass
      POSTGRES_DB:
 myapp_test
    ports:

      -
 "5433:5432"
    healthcheck:

      test:
 ["CMD-SHELL", "pg_isready -U testuser -d myapp_test"]
      interval:
 2s
      timeout:
 2s
      retries:
 10
    tmpfs:

      -
 /var/lib/postgresql/data

这里最妙的一招是:

tmpfs:
  -
 /var/lib/postgresql/data

意思是 Postgres 数据放内存,不落磁盘。

好处很明显:

  • • 写入更快
  • • 测试更快
  • • 停掉就全清空
  • • 不会留下陈年旧数据

你的集成测试脚本可以这样写:

#!/usr/bin/env bash
set
 -Eeuo pipefail

COMPOSE="docker compose -f docker-compose.test.yml"

$COMPOSE
 up -d
trap
 '$COMPOSE down -v --remove-orphans' EXIT

# 等待数据库健康

until
 [ "$($COMPOSE ps --format json | jq -r '.[] | select(.Service=="db_test") | .Health')" = "healthy" ]; do
  sleep
 1
done


DATABASE_URL="postgres://testuser:testpass@localhost:5433/myapp_test?sslmode=disable" \
  go test ./... -tags=integration -count=1

这里我顺手把原来的脚本做了两个优化:

  • • 加了 set -Eeuo pipefail ,失败时更安全
  • • 用健康状态轮询替代那个模糊的 wait-for 占位写法

还有这句:

trap '$COMPOSE down -v --remove-orphans' EXIT

非常关键。
即使测试失败,Compose 也会被收干净。不会让你的测试环境在后台越积越多,最后把机器也搞得像事故现场。


模式 6:Makefile 才是项目入口

让工程师记住一长串四五个 flag 的 Compose 命令,是个陷阱。

每个新同事几乎都会输错一次。
而这种错误没有任何价值。

正确做法是:
用 Makefile 把项目暴露成一个稳定、可发现、可传承的接口。

.PHONY: up down infra test build logs shell

COMPOSE_DEV=docker compose -f docker-compose.yml -f docker-compose.dev.yml
COMPOSE_TEST=docker compose -f docker-compose.test.yml

up:

    $(COMPOSE_DEV)
 up --build

down:

    $(COMPOSE_DEV)
 down --remove-orphans

infra:

    docker compose -f docker-compose.yml up -d

test:

    ./scripts/test.sh

build:

    docker build -t myapp:latest .

logs:

    $(COMPOSE_DEV)
 logs -f app

shell:

    $(COMPOSE_DEV)
 exec app sh

以后团队里所有人就只需要记这些:

make up
make test
make logs

这才是真正适合 onboarding 的接口。

新工程师第一天上手,不应该先背命令拼图,而应该先理解系统结构。命令层面的复杂度,就让 Makefile 吃掉。


最终目录结构应该长这样

.
├── cmd/
│   └── server/
│       └── main.go
├── internal/
├── docker-compose.yml          # infra only
├── docker-compose.dev.yml      # app + dev tooling
├── docker-compose.test.yml     # test-isolated infra
├── Dockerfile                  # multi-stage production build
├── Dockerfile.dev              # air hot-reload for development
├── .env.example                # committed template
├── .env                        # gitignored, local values
├── Makefile
└── scripts/
    └── test.sh

这个结构最好的地方在于:
每个文件都有明确职责。

  • • 开发环境启动快
  • • 测试环境相互隔离
  • • 生产镜像足够轻
  • • 新同事 clone 下来以后,复制 .env.example 、执行 make up ,五分钟内就能开始干活

这才是一个项目该有的样子。

不是“勉强能跑”,而是“任何人来都能快速理解并稳定使用”。

谢谢阅读。