Docker Compose 编排进阶:多环境、依赖顺序与生产落地的几个关键决策
上一篇我们聊了 Compose 的基本功——一个 YAML 文件把数据库、缓存、API 全拉起来。但真到了实际项目里,问题往往不在”能不能跑起来”,而在”怎么跑得对、跑得稳”。开发、测试、生产三套环境怎么管理?服务多了怎么控制启动顺序?配置和密钥往哪儿放?这篇文章不背概念,专门讲这些落地时要做的决策。
一份文件管三个环境,还是三份文件?
很多人第一反应是:开发环境一份 docker-compose.yml,生产环境再复制一份改改。结果就是两份文件越长越像,最后某个改动只改了一份,上线就翻车。
Compose 给出的答案是覆盖(override)机制。基础配置放 docker-compose.yml,环境差异用 docker-compose.override.yml 和 docker-compose.prod.yml 叠加。
Compose 默认会自动读 docker-compose.override.yml 作为本地覆盖,所以你本地开发根本不用额外写命令:
# docker-compose.yml(基础,所有环境共享)
services:
api:
build: ./api
depends_on:
- db
db:
image: postgres:16-alpine
# docker-compose.override.yml(本地开发,自动生效)
services:
api:
volumes:
- ./api:/app # 挂载源码,改完代码热重载
environment:
NODE_ENV: development
db:
ports:
- "5432:5432" # 本地直接连库调试
# docker-compose.prod.yml(生产环境,显式指定)
services:
api:
restart: always
environment:
NODE_ENV: production
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
生产部署时这么执行:
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
-f 可以传多个文件,后面的覆盖前面的同名配置。这样基础配置只维护一份,环境的差异被隔离在单独的覆盖文件里。改一个服务的镜像,三套环境一起生效,不再有”改了 A 忘了 B”的问题。
一个容易踩的细节:覆盖文件里想要删除某个基础配置里的键,不是留空,而是写 null。比如生产不想暴露数据库端口,要显式写:
services:
db:
ports: null
profiles:按需启动,别什么都拉起来
一个项目做久了,服务会越攒越多。可能有专门跑数据迁移的、跑定时任务的、还有本地调试才用的工具容器。如果每次 up 都把十几个服务全拉起来,内存先扛不住。
Compose 的 profiles 就是给服务打标签,默认不启动,只有显式指定时才拉:
services:
api:
build: ./api
depends_on:
- db
db:
image: postgres:16-alpine
# 一次性数据迁移服务,平时不用
migrate:
build: ./api
command: node migrate.js
depends_on:
- db
profiles: ["tools"]
# 本地调试用的 pgadmin 界面
pgadmin:
image: dpage/pgadmin4
profiles: ["debug"]
日常开发只跑核心服务:
docker compose up -d
需要迁移数据时再带上 profile:
docker compose --profile tools run --rm migrate
run --rm 跑完即删,不残留容器。profiles 解决了”平时用不上、但偶尔需要的服务”占用资源的问题,比手动注释掉某几行要干净得多。
depends_on 只保证顺序,不保证就绪
这是老手也常掉的坑。depends_on 会先启动数据库、再启动 API,但它只保证启动顺序,不保证数据库已经能接受连接。PostgreSQL 拉起来到真正 pg_isready 就绪,中间可能隔了十几秒。API 如果这时候去连库,直接报错退出。
靠谱的做法是搭配 healthcheck,并且用 Compose 的 condition 语法等它真正健康:
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_PASSWORD: secret
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 3s
timeout: 3s
retries: 10
start_period: 10s
api:
build: ./api
depends_on:
db:
condition: service_healthy
start_period 这个字段别忽略,它告诉 Compose”这 10 秒内先别判定失败”,给慢启动的数据库留出缓冲。没有它,遇到首次初始化慢的容器,可能被反复标记为 unhealthy,然后 condition: service_healthy 一直等不到而超时。
如果依赖的是外部服务(比如用云上的 RDS,而不是容器里的 PostgreSQL),那 depends_on 压根用不上,这时候要靠应用层自己做好重试和熔断,别指望 Compose 帮你兜底。
密钥与配置:别把密码写进 YAML
密码、私钥这些敏感信息一旦硬编码进 docker-compose.yml,而文件又提交进了 Git,就等于把服务器账号公开了。Compose 提供了 secrets 和 configs 两个顶层结构来单独管理。
先建一个 secrets/ 目录放密钥文件,并把它写进 .gitignore:
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
secrets:
- db_password
secrets:
db_password:
file: ./secrets/db_password.txt
secrets 会把文件挂到容器的 /run/secrets/ 下,PostgreSQL 官方的镜像支持 POSTGRES_PASSWORD_FILE 这种 _FILE 后缀的环境变量,值来自文件而不是环境变量,避免了密码出现在 docker inspect 或进程列表里。
对于普通的环境变量,用 .env 文件做变量替换:
services:
api:
environment:
API_KEY: ${API_KEY}
DB_URL: postgresql://${DB_USER}:${DB_PASSWORD}@db:5432/app
Compose 会自动读取同目录下的 .env 文件,${API_KEY} 会被替换成 .env 里的值。.env 同样要加入 .gitignore,只把 .env.example 提交,里面写清楚需要哪些变量、给上示例值。
不要用 env_file 和 environment 同时传同一个变量——environment 的优先级更高,会覆盖 env_file,有时候排查半天发现是这里覆盖错了。
多服务通信与网络隔离
服务多了,网络规划就不能偷懒。默认情况下 Compose 会给所有服务建一个共同的网络,服务之间随便互通——这在开发时方便,但到了生产,数据库和缓存不应该暴露给前端容器。
手动划两个网络,让 API 充当”桥”,前端和 Nginx 在公网侧,数据库、缓存和 worker 在内网侧:
services:
nginx:
image: nginx:alpine
ports: ["80:80"]
networks: [front]
frontend:
build: ./frontend
networks: [front]
api:
build: ./api
networks: [front, back]
worker:
build: ./api
command: node worker.js
networks: [back]
db:
image: postgres:16-alpine
networks: [back]
cache:
image: redis:7-alpine
networks: [back]
networks:
front:
driver: bridge
back:
driver: bridge
这样一来,db 和 cache 只存在于 back 网络,前端容器从网络层面就访问不到它们,即便某个服务被攻破,横向移动的范围也被圈小了。API 是唯一同时挂两个网络的服务,天然承担了网关的角色。
平滑重启与资源限制
生产容器一定要配 restart 策略,否则机器一重启,服务就静悄悄挂了:
services:
api:
restart: unless-stopped # 除非手动 stop,否则自动重启
区别记牢:no 是默认值(不重启);always 是哪怕手动 stop 了,Docker 守护进程重启后也会拉起来(有时候反而添乱);unless-stopped 最常用,手动停就停,意外挂就拉起来。
再给容器设个资源上限,防止单个服务把整台机器拖死:
services:
worker:
build: ./api
deploy:
resources:
limits:
cpus: "1.0"
memory: 512M
注意 deploy 这个字段原本是给 Swarm 用的,但 resources 在 docker compose up 的普通模式(非 Swarm)下也会生效,用来做资源限制没问题。
平滑更新方面,频繁改代码的时候用:
docker compose up -d --build
它会重建镜像并替换容器。想要不中断服务的替换,可以用 --scale 先扩一份、再收一份,不过对大多数中小项目,--build 配合 restart: unless-stopped 已经足够。
排查问题:日志和 exec 是两把刀
服务起不来,第一反应应该是看日志而不是重试:
# 只看某个服务的日志,持续跟踪
docker compose logs -f api
# 看最近 50 行,不用等
docker compose logs --tail=50 api
# 看哪些服务已经退出(排查依赖问题很有用)
docker compose ps
如果容器起来但行为不对,直接钻进容器里看:
docker compose exec api sh
在容器里可以用 curl http://db:5432 之类的方式验证连通性。这里有个高频坑:很多人习惯用 docker exec -it <container_id> sh,但 container_id 每次重建都会变。Compose 的 docker compose exec <service_name> sh 用服务名定位,省去了查 ID 的麻烦。
写在最后
回过来说,Compose 真正解决的不是”启动”,而是如何用声明式的方式描述一整套服务的运行状态。多环境用覆盖文件隔离差异,用 profiles 控制启动范围,用 healthcheck 保证依赖真的就绪,用 secrets 和网络隔离守好安全边界——这些决策做对了,docker compose up -d 这一条命令才会从”能跑”变成”跑得让人放心”。
如果你的服务规模还没大到需要 Kubernetes,Compose 依然是把微服务编排落地的最高性价比选择。先把上面这些细节吃透,比急着上 k8s 更重要——因为编排的核心思路,两者其实是相通的。