一、从手工部署到编排:一次痛苦的运维经历

上个月接手一个Node.js项目,三个服务要部署到测试服务器:Nginx做反向代理、Express API服务、Redis做缓存。最初按老习惯手工部署,SSH登录后一个个装依赖、写systemd服务文件、配环境变量。结果踩了一堆坑:Redis忘了开持久化,重启服务器数据全丢;Nginx配置写错一个分号,服务直接挂掉;更别提每次发布新版本要手动执行六七个命令。

最崩溃的一次是周五下午,测试环境Redis连接数爆了,排查半天发现是Nginx upstream配置里没加keepalive,导致每个请求都新建TCP连接。当时就下定决心:必须用Docker Compose做编排,把这些服务的生命周期管理交给工具,而不是靠人肉记忆。

二、环境与版本:明确技术栈

先交代一下具体环境,方便大家对照:

  • 服务器:CentOS 7.9,2核4G内存,50G SSD
  • Docker版本:24.0.7(用docker version确认)
  • Docker Compose版本:v2.23.0(注意新版compose命令是docker compose,中间有空格,不是docker-compose
  • 镜像版本:
  • nginx:1.25.3-alpine(alpine版体积小,只有23MB)
  • node:20-alpine(生产环境用alpine跑Node,体积比debian版小60%)
  • redis:7.2.3-alpine(Redis 7.x自带多线程,性能比6.x提升明显)

这里有个小建议:镜像tag一定要指定具体版本,不要用latest。我吃过亏,某次latest标签的Node镜像更新后,项目依赖的某个原生模块编译失败,部署直接中断。

三、方案设计:网络、卷、健康检查三件套

设计这套编排方案时,重点考虑三个维度:

1. 网络隔离
用自定义bridge网络app-network,而不是默认的default网络。原因:自定义网络支持DNS解析,服务间能用服务名互访(比如Nginx里配proxy_pass http://api:3000),而默认网络只能用IP。另外自定义网络可以指定子网,避免和公司内网网段冲突。

2. 数据持久化
Redis数据必须落盘,否则容器重启就是一场灾难。Nginx日志也要持久化,方便排查问题。这里用命名卷(named volume),不推荐bind mount(绑定挂载),因为bind mount依赖宿主机目录结构,换台机器就得改路径。

3. 健康检查与启动顺序
Compose的depends_on有两个坑:
- 默认depends_on只控制容器创建顺序,不保证服务就绪。比如Redis容器起来了,但Redis服务可能还没初始化完,Node服务就开始连接,会报ECONNREFUSED
- 解决办法:depends_on里加condition: service_healthy,配合每个服务的healthcheck探针。

四、核心实现:可运行的docker-compose.yml

直接上代码,这个配置我排过雷,复制就能用:

version: '3.8'

services:
  redis:
    image: redis:7.2.3-alpine
    container_name: myapp-redis
    restart: unless-stopped
    volumes:
      - redis-data:/data
      - ./conf/redis.conf:/usr/local/etc/redis/redis.conf:ro
    command: ["redis-server", "/usr/local/etc/redis/redis.conf"]
    networks:
      - app-network
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 3s
      retries: 3
      start_period: 5s

  api:
    build: ./api
    image: myapp-api:1.0.0
    container_name: myapp-api
    restart: unless-stopped
    expose:
      - "3000"
    environment:
      - NODE_ENV=production
      - REDIS_HOST=redis
      - REDIS_PORT=6379
    volumes:
      - api-logs:/app/logs
    depends_on:
      redis:
        condition: service_healthy
    networks:
      - app-network
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://localhost:3000/health"]
      interval: 15s
      timeout: 5s
      retries: 3
      start_period: 10s

  nginx:
    image: nginx:1.25.3-alpine
    container_name: myapp-nginx
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./conf/nginx.conf:/etc/nginx/nginx.conf:ro
      - ./certs:/etc/nginx/certs:ro
      - nginx-logs:/var/log/nginx
    depends_on:
      api:
        condition: service_healthy
    networks:
      - app-network
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://localhost/healthz"]
      interval: 10s
      timeout: 3s
      retries: 3

networks:
  app-network:
    driver: bridge
    ipam:
      config:
        - subnet: 172.20.0.0/16

volumes:
  redis-data:
  api-logs:
  nginx-logs:

再补充关键的Nginx配置片段,注意upstream里的DNS解析和keepalive:

# conf/nginx.conf 关键部分
upstream node_api {
    # 关键:用服务名而不是IP,compose的DNS会自动解析
    server api:3000;
    keepalive 32;  # 保持长连接,避免频繁握手
}

server {
    listen 80;
    server_name api.example.com;

    location / {
        proxy_pass http://node_api;
        proxy_http_version 1.1;
        proxy_set_header Connection "";
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;

        # 超时设置,后端接口慢时优化体验
        proxy_connect_timeout 5s;
        proxy_read_timeout 30s;
    }
}

五、踩坑记录:三个血泪教训

坑1:Redis持久化配置丢失
第一次部署时,我只写了volumes: redis-data:/data,但Redis默认快照策略是save 900 1,即900秒内至少1次写操作才触发RDB。结果容器重启后数据丢了。后来在redis.conf里显式配置:

appendonly yes
appendfsync everysec

开启了AOF持久化,配合RDB双保险。测试环境压测时,每秒写入5000条记录,重启容器后数据零丢失。

坑2:健康检查探针选错命令
Node服务的healthcheck最初用curl,但node:20-alpine镜像里没有curl!容器启动后一直报healthcheck failed。换成wget解决了(alpine自带BusyBox wget)。记住:alpine镜像默认不装curl,要么改用wget,要么在Dockerfile里手动装curl。

坑3:depends_on条件不生效
depends_on: - redis时,Node容器总是比Redis先启动,导致连接失败。后来加了condition: service_healthy,但Compose会等Redis的healthcheck通过后才启动Node容器。注意:healthcheck的start_period要设置合理,Redis启动很快,5秒够了;Node应用加载依赖可能需要10秒,所以给了10秒。

六、效果数据:对比手工部署

改造成Compose编排后,最直观的变化:

指标 手工部署 Docker Compose
首次部署耗时 45分钟(装依赖、配服务) 12分钟(写Dockerfile+compose文件)
重启服务耗时 手动杀进程+重启,约45秒 docker compose restart,12秒
服务器资源占用 3个进程各自为战,内存峰值2.1G 容器共享内核,内存峰值1.7G
故障恢复 宕机后需人工SSH操作 restart: unless-stopped自动拉起,1分钟内恢复

另外说个细节:用docker compose config可以校验配置正确性,避免直接启动报错。上线前建议跑一遍docker compose up -d --build,然后docker compose ps查看健康状态。

七、总结与建议

这套编排方案现在稳定跑了两周,零故障。几点心得:

  1. 版本锁定是底线:镜像版本、Compose文件格式(version: '3.8')都要锁定,别追新。
  2. 健康检查必须配:没有healthcheck的depends_on只是摆设,服务未就绪照样启动。
  3. 日志和数据的持久化要提前规划:别等数据丢了再后悔,命名卷是正解。

最后留个思考题:如果你的Node服务有多副本需求(比如replicas: 2),Nginx的upstream配置该怎么改?提示:需要加resolver指令。下篇文章聊这个。