一、问题背景:为什么不用docker run了

去年接手一个内部数据平台,服务清单是这样的:PostgreSQL 15、Redis 7.2、RabbitMQ 3.12、一个Go写的API、两个Python Worker、Nginx、一个Vue打包后的静态前端。最初的部署方式是运维同学写了一个300行的deploy.sh,里面全是docker run,每条命令都带着一长串-e、-v、--network参数。

问题在三个月后集中爆发:

  1. 新增一个环境变量,要改4个地方(脚本、README、CI配置、某台机器上手动改过的容器),总有漏的。
  2. API容器启动比数据库快,连不上PG直接退出,restart: always让它无限重启,日志被刷爆。
  3. 上传目录用绑定挂载,容器内uid 1000写出来的文件,宿主机上root都改不动。
  4. 想在本机复现生产环境,得照着脚本一行行敲,还得记住先起哪个后起哪个。

迁移到Docker Compose不是为了"时髦",就是想让"环境定义"变成一份可版本控制的文件。最终效果:新同事clone仓库后执行docker compose up -d,45秒后整个栈可用(首次拉镜像除外)。

二、环境与版本

先把版本钉死,Compose的文件格式在不同版本间差异不小,尤其是depends_on的condition支持。

  • Docker Engine:24.0.7(docker version里的Server端)
  • Docker Compose:v2.24.5(注意是v2,命令是docker compose而不是docker-compose
  • Compose文件格式:不写version:字段(Compose Spec已废弃它,写了会告警)
  • 宿主机:Ubuntu 22.04,4C8G,内核5.15

有一点要强调:depends_oncondition: service_healthy在Compose v2里是原生支持的,v1时代需要version: "2.4"这种写法,现在不需要了。网上很多老教程还在教version: "3.8",跟着写会踩坑。

三、方案设计

整体思路分四层:

网络层:只用一个自定义bridge网络app-net。不用默认网络的原因有两个,一是默认网络里容器名解析不稳定(某些场景下要加--link),二是自定义网络自带DNS,服务名直接当主机名用。对外只暴露Nginx的80端口,其他服务一律不映射到宿主机,靠expose声明即可。

存储层:区分两类。数据库、Redis、RabbitMQ用命名卷(named volume),因为这些是"数据",不该被宿主机文件系统干扰;上传目录、Nginx配置、前端静态文件用绑定挂载(bind mount),因为需要开发时实时改、或者需要备份到指定路径。

健康检查层:每个有状态服务都写healthcheck,API和Worker通过depends_oncondition: service_healthy等待。这里的关键是健康检查命令要真正反映"可服务"状态,而不是"进程还活着"。

启动顺序:PG/Redis/RabbitMQ → API(等三个healthy)→ Worker(等API healthy)→ Nginx(等API和前端)。Nginx放最后是因为它是入口,提前起来只会返回502。

四、核心实现

先看完整的compose.yaml,后面逐段解释。

name: data-platform

x-common-env: &common-env
  TZ: Asia/Shanghai
  LOG_LEVEL: info

services:
  postgres:
    image: postgres:15.5-alpine
    environment:
      <<: *common-env
      POSTGRES_USER: appuser
      POSTGRES_PASSWORD: ${PG_PASSWORD:?PG_PASSWORD is required}
      POSTGRES_DB: appdb
      PGDATA: /var/lib/postgresql/data/pgdata
    volumes:
      - pg-data:/var/lib/postgresql/data
      - ./initdb:/docker-entrypoint-initdb.d:ro
    networks:
      - app-net
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U appuser -d appdb -h 127.0.0.1"]
      interval: 5s
      timeout: 3s
      retries: 12
      start_period: 20s
    restart: unless-stopped
    deploy:
      resources:
        limits:
          memory: 1g

  redis:
    image: redis:7.2.3-alpine
    command: ["redis-server", "--appendonly", "yes", "--maxmemory", "256mb", "--maxmemory-policy", "allkeys-lru"]
    volumes:
      - redis-data:/data
    networks:
      - app-net
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 10
    restart: unless-stopped

  rabbitmq:
    image: rabbitmq:3.12.10-management-alpine
    environment:
      <<: *common-env
      RABBITMQ_DEFAULT_USER: appuser
      RABBITMQ_DEFAULT_PASS: ${MQ_PASSWORD:?MQ_PASSWORD is required}
    volumes:
      - mq-data:/var/lib/rabbitmq
    networks:
      - app-net
    healthcheck:
      test: ["CMD", "rabbitmq-diagnostics", "-q", "check_running"]
      interval: 10s
      timeout: 5s
      retries: 12
      start_period: 30s
    restart: unless-stopped

  api:
    build:
      context: ./backend
      dockerfile: Dockerfile
      args:
        GO_VERSION: "1.21"
    image: data-platform/api:1.4.2
    environment:
      <<: *common-env
      DATABASE_URL: postgres://appuser:${PG_PASSWORD}@postgres:5432/appdb?sslmode=disable
      REDIS_ADDR: redis:6379
      RABBITMQ_URL: amqp://appuser:${MQ_PASSWORD}@rabbitmq:5672/
      UPLOAD_DIR: /data/uploads
    volumes:
      - uploads:/data/uploads
    networks:
      - app-net
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
      rabbitmq:
        condition: service_healthy
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8080/healthz"]
      interval: 10s
      timeout: 3s
      retries: 6
      start_period: 15s
    restart: unless-stopped
    deploy:
      resources:
        limits:
          memory: 512m

  worker:
    image: data-platform/worker:1.4.2
    build:
      context: ./worker
    command: ["python", "-m", "worker.main", "--concurrency", "4"]
    environment:
      <<: *common-env
      API_BASE: http://api:8080
      RABBITMQ_URL: amqp://appuser:${MQ_PASSWORD}@rabbitmq:5672/
      REDIS_ADDR: redis:6379
    volumes:
      - uploads:/data/uploads
    networks:
      - app-net
    depends_on:
      api:
        condition: service_healthy
    restart: unless-stopped

  web:
    build:
      context: ./frontend
      dockerfile: Dockerfile
      target: prod
    image: data-platform/web:1.4.2
    networks:
      - app-net
    restart: unless-stopped

  nginx:
    image: nginx:1.25.3-alpine
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
      - ./nginx/certs:/etc/nginx/certs:ro
      - uploads:/var/www/uploads:ro
    networks:
      - app-net
    depends_on:
      api:
        condition: service_healthy
      web:
        condition: service_started
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://127.0.0.1/healthz"]
      interval: 15s
      timeout: 3s
      retries: 4
    restart: unless-stopped

networks:
  app-net:
    driver: bridge
    driver_opts:
      com.docker.network.bridge.name: br-app

volumes:
  pg-data:
  redis-data:
  mq-data:
  uploads:

网络部分:只声明一个app-net,指定bridge驱动,driver_opts里把网桥名固定成br-app,方便在宿主机上用tcpdump抓包排查。所有服务都接这个网络,容器之间通过服务名互访,比如API连数据库就是postgres:5432,不用记IP。Nginx只映射80/443,其他服务完全没有ports,安全面小很多。

卷挂载部分pg-dataredis-datamq-datauploads都是命名卷。命名卷比绑定挂载好在两点——Docker自己管权限,且docker volume inspect能看到真实路径。上传目录uploads同时挂给api(读写)、worker(读写)、nginx(只读),三个容器看到同一份文件,Nginx直接sendfile返回。Nginx配置用绑定挂载加:ro,改完配置docker compose exec nginx nginx -s reload即可,不用重建容器。

健康检查部分:这是整份配置里最容易写错的地方。PG用pg_isready,注意要加-h 127.0.0.1,否则会走unix socket,某些初始化阶段会误判。RabbitMQ用rabbitmq-diagnostics -q check_running,比rabbitmqctl status轻量得多,后者在3.12上会额外启动一个Erlang节点,启动慢还占内存。API的/healthz端点我们特意让它检查DB连接和Redis ping,返回200才算健康。

start_period这个参数值得单独说:它表示"这段时间内的失败不计入retries"。PG首次初始化要跑initdb脚本,可能20秒以上,如果不设start_period,前几次探测失败会直接把容器标成unhealthy,depends_on就永远等不到。我们给PG设了20s,RabbitMQ设了30s。

启动顺序部分:靠depends_on + condition。注意condition只有三个合法值:service_started(默认)、service_healthyservice_completed_successfully。API等三个基础设施healthy,worker等API healthy,nginx等API healthy + web started。这样即使某个服务挂了重启,依赖方也不会跟着乱起。

再给一个环境变量文件的示例,配合上面的${PG_PASSWORD:?}写法,没设就报错退出,避免用默认密码上线:

# .env (不要提交到git)
PG_PASSWORD=Zk9pL2mQx7vN
MQ_PASSWORD=Rt4wYb8nHs3d

五、踩坑与优化

坑1:depends_on不控制重启顺序。 它只在首次启动时生效。如果PG后来崩了重启,API不会自动跟着重启(它可能已经连不上DB了)。我们的做法是API内部做连接重试,加上restart: unless-stopped兜底。

坑2:绑定挂载的uid问题。 最初uploads./uploads:/data/uploads,容器内进程uid 1000写文件,宿主机上目录属主是root,运维改不动。换成命名卷后,Docker把卷的属主设成镜像里定义的uid,问题消失。如果非要用绑定挂载,得在Dockerfile里chown或者启动脚本里gosu切用户。

坑3:buildimage同时写。 两个都写时,Compose会先build再tag成image名。好处是本地构建的镜像有稳定名字,CI里推送到registry后,生产环境直接docker compose pull就能用同一份配置。我们就是开发用up --build,生产用pull && up -d

坑4:docker compose down -v会删卷。 这个-v删的是命名卷,数据全没。生产环境我们禁用这条命令,只允许down(不删卷)。测试环境才用-v做干净重来。

优化1:资源限制。 deploy.resources.limits.memory在Compose v2里对非swarm模式也生效了。给PG 1g、API 512m,防止某个服务OOM拖垮整机。

优化2:x-common-env锚点。 YAML的锚点语法(&定义、*引用、<<:合并)能省掉重复的TZLOG_LEVEL。注意<<:是合并键,遇到同名键时以当前为准。

优化3:健康检查的interval别太短。 一开始API设了interval: 2s,结果docker stats里看到wget进程频繁起停,CPU有3%左右的额外开销。调到10s后基本无感。

六、效果数据

同一台4C8G机器上实测(镜像已就绪,冷启动):

指标 docker run脚本 Docker Compose
全栈启动到可用 约6分10秒(含人工敲命令) 45秒
新增环境变量改动点 4处 1处(compose.yaml)
API因DB未就绪重启次数 平均3-5次/次部署 0
空载内存占用 2.1GB 1.8GB(资源限制生效)
新同事上手时间 半天 20分钟

docker compose ps的输出里,7个服务全部healthydocker compose logs -f一个终端看全栈日志,排查问题时不用再docker logs一个个容器名去敲。

七、总结

Docker Compose编排的核心不是把docker run翻译成YAML,而是把"服务之间的依赖关系和就绪状态"显式表达出来。三个点最值得花时间:自定义网络让服务发现变简单、健康检查配合depends_oncondition解决启动顺序、命名卷和绑定挂载按数据性质分开用。

这份配置在三个环境(本地、测试、生产)跑了半年,改过的只有镜像tag和环境变量文件。如果你的项目服务数超过3个、还在用shell脚本拼docker run,迁移到Compose的收益是很直接的。下一步可以考虑上Swarm或K8s,但在单机场景下,Compose的性价比目前还是最高的。