我见过的几乎每个 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
都可能触发重建。只要缓存没中好,你就得等。
那就一件一件把它修好。
这是你能做的、对日常体验提升最大的一件事。
把
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
这种改法看起来只是小修小补,但它会直接把你项目里最烦人的“偶发启动失败”从日常里抹掉。
把所有环境都塞进一个 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。等你真的这么拆过一次之后,你会发现团队沟通成本都低了不少。
这是很多团队浪费性能最严重的地方。
他们打出来的镜像往往:
一个靠谱的 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
这是零字节基础镜像。最终镜像里只有:
对大多数 Go 服务来说,生产镜像压到 20MB 以下 是完全现实的。
生产镜像追求小、稳、纯净。
开发镜像追求的是快、顺手、热更新。
所以别混着来。
开发环境可以单独写一个
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 的编译期保障。这个组合,用起来是很舒服的。
.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}
这样做的好处非常直接:
.env.example
改成
.env
这就是工程化里很重要的一种感觉:
配置在流动,但结构不变。
和开发数据库共用一套环境跑集成测试,是个非常危险的习惯。
任何一个测试,只要做了这些事:
就可能把你本地开发依赖的状态一并搞乱。
更稳妥的方式是:
给测试单独一套 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 也会被收干净。不会让你的测试环境在后台越积越多,最后把机器也搞得像事故现场。
让工程师记住一长串四五个 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
这个结构最好的地方在于:
每个文件都有明确职责。
.env.example
、执行
make up
,五分钟内就能开始干活
这才是一个项目该有的样子。
不是“勉强能跑”,而是“任何人来都能快速理解并稳定使用”。
谢谢阅读。