0 Comments

Docker Compose 编排进阶:多环境、依赖顺序与生产落地的几个关键决策

上一篇我们聊了 Compose 的基本功——一个 YAML 文件把数据库、缓存、API 全拉起来。但真到了实际项目里,问题往往不在”能不能跑起来”,而在”怎么跑得对、跑得稳”。开发、测试、生产三套环境怎么管理?服务多了怎么控制启动顺序?配置和密钥往哪儿放?这篇文章不背概念,专门讲这些落地时要做的决策。

一份文件管三个环境,还是三份文件?

很多人第一反应是:开发环境一份 docker-compose.yml,生产环境再复制一份改改。结果就是两份文件越长越像,最后某个改动只改了一份,上线就翻车。

Compose 给出的答案是覆盖(override)机制。基础配置放 docker-compose.yml,环境差异用 docker-compose.override.ymldocker-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 提供了 secretsconfigs 两个顶层结构来单独管理。

先建一个 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_fileenvironment 同时传同一个变量——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

这样一来,dbcache 只存在于 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 用的,但 resourcesdocker 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 更重要——因为编排的核心思路,两者其实是相通的。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注