一、问题背景:为什么单机多服务还需要认真编排
上个月接手一个内部管理系统的容器化改造。项目结构不复杂:一个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_on的condition: service_healthy在Compose文件格式3.x里是被移除的,只在2.x(以及V2 CLI实际支持的规范)里可用。网上很多老教程写version: "3.8"然后配condition,跑起来会直接忽略条件,只保证启动顺序不保证就绪。我这次干脆不写version字段,让Compose V2用最新规范。
三、方案设计
整体思路分四层:
- 网络层:建一个自定义bridge网络
app-net,所有服务加入,容器间用服务名做DNS解析。Nginx和API暴露端口,数据库和Redis完全不映射宿主机端口,只在网络内可达。 - 存储层:PostgreSQL数据和Redis持久化用命名卷(named volume),避免bind mount的UID问题;Nginx配置和前端静态资源用只读bind mount,因为需要频繁改动。
- 依赖层:API依赖Postgres和Redis的就绪,用
depends_on+condition: service_healthy;Nginx依赖API健康。 - 健康层:每个服务都配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:5432、redis:6379访问。API和数据库不映射宿主机端口,只有Nginx映射了8080。这样即使宿主机有防火墙配置疏漏,数据库也不会裸奔。expose: 3000只是声明,实际在自定义网络里所有端口本来就互通,写出来是为了可读性。
卷挂载策略。命名卷pgdata和redisdata由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_on的condition: service_healthy是解决启动顺序问题的正解,比sleep靠谱得多。自定义bridge网络让服务发现变简单,命名卷避开了权限坑。alpine镜像记得补wget这类小工具,version字段能删就删。
如果服务再往上加,比如超过10个,或者需要跨主机调度,那Compose就不够了,得上Kubernetes。但就单机多服务这个场景,Compose 2.24的能力完全够用,配置清晰、上手快、调试方便。