一、问题背景:为什么单机多服务还需要认真编排

上个月接手一个内部管理系统的容器化改造。项目结构不复杂:一个Nginx做反向代理,一个Node.js API服务,一个React前端构建后的静态资源服务,再加PostgreSQL和Redis。开发同学之前是写了个start.sh,里面一堆docker run命令,顺序全靠sleep硬等。

问题很快就来了:

  • CI机器性能波动大,sleep 10有时候不够,API容器启动时Postgres还没ready,直接崩,然后整个流水线红掉。
  • 数据卷用的是宿主机目录bind mount,换台机器UID不一样,Postgres容器直接chown: Operation not permitted
  • 五个容器全在默认bridge网络里,靠--link(已废弃)通信,Nginx配置里写死容器IP,重启一次IP变了就得改配置。
  • 日志散落在五个地方,排查一个问题要docker logs敲五遍。

这些问题的根因都指向同一个东西:缺少声明式的服务依赖与生命周期管理。Docker Compose恰好就是干这个的,但很多人只用了它up -d的那一面,没把healthcheck、depends_on的condition、自定义网络这些能力用起来。

二、环境与版本

先交代下环境,版本不一致行为差异挺大的,尤其是Compose的depends_on条件语法在v2和v3之间有断层。

  • 操作系统:Ubuntu 22.04 LTS(内核5.15)
  • Docker Engine:24.0.7
  • Docker Compose:v2.24.1(注意是Compose V2插件版,命令是docker compose而不是docker-compose
  • 镜像基础版本:node:20.11-alpine3.19、postgres:16.2-alpine、redis:7.2.4-alpine、nginx:1.25.4-alpine

这里强调一点:depends_oncondition: service_healthy在Compose文件格式3.x里是被移除的,只在2.x(以及V2 CLI实际支持的规范)里可用。网上很多老教程写version: "3.8"然后配condition,跑起来会直接忽略条件,只保证启动顺序不保证就绪。我这次干脆不写version字段,让Compose V2用最新规范。

三、方案设计

整体思路分四层:

  1. 网络层:建一个自定义bridge网络app-net,所有服务加入,容器间用服务名做DNS解析。Nginx和API暴露端口,数据库和Redis完全不映射宿主机端口,只在网络内可达。
  2. 存储层:PostgreSQL数据和Redis持久化用命名卷(named volume),避免bind mount的UID问题;Nginx配置和前端静态资源用只读bind mount,因为需要频繁改动。
  3. 依赖层:API依赖Postgres和Redis的就绪,用depends_on + condition: service_healthy;Nginx依赖API健康。
  4. 健康层:每个服务都配healthcheck,Postgres用pg_isready,Redis用redis-cli ping,API打自己的/healthz,Nginx用wget请求本地。

启动顺序链路:postgres/redis → api → nginx。注意postgres和redis是并行的,没有相互依赖。

四、核心实现

先上完整的compose.yaml,然后逐段解释。

name: admin-platform

services:
  postgres:
    image: postgres:16.2-alpine
    restart: unless-stopped
    environment:
      POSTGRES_USER: appuser
      POSTGRES_PASSWORD: ${DB_PASSWORD:?err}
      POSTGRES_DB: admin_db
      PGDATA: /var/lib/postgresql/data/pgdata
    volumes:
      - pgdata:/var/lib/postgresql/data
    networks:
      - app-net
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U appuser -d admin_db"]
      interval: 5s
      timeout: 3s
      retries: 10
      start_period: 15s

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

  api:
    build:
      context: ./api
      dockerfile: Dockerfile
    image: admin-api:1.4.2
    restart: unless-stopped
    environment:
      NODE_ENV: production
      DATABASE_URL: postgres://appuser:${DB_PASSWORD}@postgres:5432/admin_db
      REDIS_URL: redis://redis:6379
      PORT: 3000
    expose:
      - "3000"
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
    networks:
      - app-net
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://localhost:3000/healthz"]
      interval: 10s
      timeout: 3s
      retries: 5
      start_period: 20s

  web:
    image: nginx:1.25.4-alpine
    restart: unless-stopped
    ports:
      - "8080:80"
    volumes:
      - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
      - ./web/dist:/usr/share/nginx/html:ro
    depends_on:
      api:
        condition: service_healthy
    networks:
      - app-net
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://localhost/healthz"]
      interval: 10s
      timeout: 3s
      retries: 3
      start_period: 5s

networks:
  app-net:
    driver: bridge

volumes:
  pgdata:
  redisdata:

几个关键点展开说:

健康检查参数的含义interval是探测间隔,timeout是单次探测超时,retries是连续失败多少次标记为unhealthy,start_period是容器启动后的宽限期,这期间探测失败不计入retries。Postgres我给了15秒start_period,因为16版的初始化(建库、建用户)在CI低配机器上偶尔要10秒以上。API给20秒是因为Node冷启动加载依赖。这几个值不是拍脑袋定的,是看docker inspect --format='{{json .State.Health}}'的日志调的。

网络设计。所有服务在app-net里,容器间直接用服务名postgres:5432redis:6379访问。API和数据库不映射宿主机端口,只有Nginx映射了8080。这样即使宿主机有防火墙配置疏漏,数据库也不会裸奔。expose: 3000只是声明,实际在自定义网络里所有端口本来就互通,写出来是为了可读性。

卷挂载策略。命名卷pgdataredisdata由Docker管理,落在/var/lib/docker/volumes/下,权限由Docker处理,不用操心UID。Nginx配置和前端产物用bind mount加:ro只读,因为构建流程会往./web/dist写文件,用命名卷反而麻烦。注意PGDATA我特意指向了子目录/var/lib/postgresql/data/pgdata,这是为了避免和卷挂载点的lost+found目录冲突,alpine镜像上吃过这个亏。

然后是API的Dockerfile,健康检查依赖/healthz端点,顺便贴一下:

FROM node:20.11-alpine3.19 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev

FROM node:20.11-alpine3.19
RUN apk add --no-cache wget
WORKDIR /app
COPY --from=builder /app/node_modules ./node_modules
COPY . .
ENV NODE_ENV=production
EXPOSE 3000
USER node
CMD ["node", "server.js"]

注意apk add wget这行。alpine默认没有wget也没有curl,healthcheck里的wget会直接失败,容器永远unhealthy,然后Nginx永远起不来。这是我踩的第一个大坑,下面细说。

五、踩坑与优化

坑一:alpine镜像缺wget导致健康检查永远失败。现象是docker compose up卡住,api容器状态一直是health: starting然后变unhealthy,Nginx因为depends_on条件不满足一直不启动。进容器docker exec -it xxx sh手动跑wget才发现命令不存在。解决办法就是在Dockerfile里装wget。如果不想装,可以用node -e "require('http').get('http://localhost:3000/healthz',r=>process.exit(r.statusCode===200?0:1))"这种原生方式,但可读性差。

坑二:Postgres健康检查通过但API仍然连不上pg_isready只检查Postgres进程是否接受连接,不代表数据库和用户已经建好。在极低配的CI上遇到过pg_isready返回0但admin_db还没建完的情况。后来在API的启动逻辑里加了个重试:连接失败时指数退避重试5次,间隔1、2、4、8、16秒。这属于应用层的健壮性,不能全指望编排。

坑三:depends_on的condition被静默忽略。前面提过,如果compose文件写了version: "3.8"condition会被丢弃,只保留启动顺序。判断方法:docker compose config会报warning,或者直接看docker compose up时API是不是在Postgres healthy之前就起了。去掉version字段即可。

优化:冷启动耗时。优化前用sleep脚本,全链路冷启动(从docker compose up到Nginx可访问)平均48秒,其中约20秒是各种sleep的浪费。改成healthcheck + condition后,实测5次平均22秒,主要时间花在Postgres初始化(约12秒)和API冷启动(约6秒)。另外把start_period调准后,避免了不必要的探测失败重试。

优化:日志。加了logging配置限制单容器日志大小,避免长期运行把磁盘写满:

    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "3"

这段可以加到每个服务下。生产环境如果接了ELK或Loki,可以换driver。

六、效果数据

改造后跑了一个月的观察:

  • CI流水线因容器启动顺序失败的概率从约15%降到0(30次构建0失败)。
  • 冷启动时间从48秒降到22秒。
  • 数据库和Redis不再暴露宿主机端口,安全扫描的高危项少了2个。
  • 换机器部署时,因为用了命名卷,不再有权限问题,docker compose up -d一条命令搞定。

docker compose ps的输出很清爽,五个服务全部healthy

NAME                 SERVICE   STATUS
admin-platform-api-1       api       running (healthy)
admin-platform-web-1       web       running (healthy)
admin-platform-postgres-1  postgres  running (healthy)
admin-platform-redis-1     redis     running (healthy)

七、总结

Docker Compose编排的核心不是把服务写进一个文件,而是把依赖关系、就绪状态、网络边界、数据持久化这四件事声明清楚。healthcheck配合depends_oncondition: service_healthy是解决启动顺序问题的正解,比sleep靠谱得多。自定义bridge网络让服务发现变简单,命名卷避开了权限坑。alpine镜像记得补wget这类小工具,version字段能删就删。

如果服务再往上加,比如超过10个,或者需要跨主机调度,那Compose就不够了,得上Kubernetes。但就单机多服务这个场景,Compose 2.24的能力完全够用,配置清晰、上手快、调试方便。