概述

很多团队对 Docker Compose 的认知停留在"本地开发环境编排工具"。但事实上,在中小规模生产场景(单节点或少量节点)下,Compose 依然是性价比极高的方案。它语法简洁、学习成本低、不需要一整套 K8s 集群运维能力,就能完成多服务编排、依赖管理、健康检查、资源限制等核心工作。

本文不重复 Compose 基础语法,而是聚焦生产环境中的真实痛点:服务依赖怎么管才不会启动雪崩、健康检查怎么写才靠谱、密钥怎么不硬编码进 compose 文件、日志怎么不把磁盘写满、什么时候该从 Compose 迁移到 K8s。

本文基于 Docker Compose V2(docker compose 子命令),V1(docker-compose 独立二进制)已停止维护。参考 Compose 规范

多服务编排

生产级 Compose 文件结构

一个典型的生产环境应用至少包含:应用服务、数据库、缓存、反向代理。下面是一个完整的 Web 应用编排示例:

# docker-compose.yml
name: myapp

services:
  # ========== 反向代理 ==========
  nginx:
    image: nginx:1.25-alpine
    container_name: myapp-nginx
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
      - ./nginx/conf.d:/etc/nginx/conf.d:ro
      - cert_data:/etc/letsencrypt:ro
      - log_data:/var/log/nginx
    depends_on:
      web:
        condition: service_healthy
    networks:
      - frontend
    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "3"

  # ========== 应用服务 ==========
  web:
    build:
      context: .
      dockerfile: Dockerfile
      args:
        - GIT_COMMIT=${GIT_COMMIT:-unknown}
    image: myapp/web:${TAG:-latest}
    container_name: myapp-web
    restart: unless-stopped
    environment:
      - DB_HOST=postgres
      - DB_PORT=5432
      - DB_NAME=${DB_NAME}
      - REDIS_HOST=redis
      - ENV=production
    env_file:
      - .env.production
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
    healthcheck:
      test: ["CMD", "wget", "--spider", "-q", "http://localhost:8080/health"]
      interval: 10s
      timeout: 5s
      retries: 3
      start_period: 30s
    deploy:
      resources:
        limits:
          cpus: "2.0"
          memory: 1G
        reservations:
          cpus: "0.5"
          memory: 256M
    networks:
      - frontend
      - backend
    logging:
      driver: json-file
      options:
        max-size: "20m"
        max-file: "5"

  # ========== 数据库 ==========
  postgres:
    image: postgres:16-alpine
    container_name: myapp-postgres
    restart: unless-stopped
    environment:
      POSTGRES_DB: ${DB_NAME}
      POSTGRES_USER: ${DB_USER}
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password
    volumes:
      - pg_data:/var/lib/postgresql/data
      - ./sql/init:/docker-entrypoint-initdb.d:ro
    secrets:
      - db_password
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 10s
    networks:
      - backend
    logging:
      driver: json-file
      options:
        max-size: "50m"
        max-file: "3"

  # ========== 缓存 ==========
  redis:
    image: redis:7-alpine
    container_name: myapp-redis
    restart: unless-stopped
    command: >
      redis-server
      --requirepass ${REDIS_PASSWORD}
      --maxmemory 256mb
      --maxmemory-policy allkeys-lru
      --appendonly yes      
    volumes:
      - redis_data:/data
    healthcheck:
      test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
      interval: 10s
      timeout: 3s
      retries: 3
    networks:
      - backend

networks:
  frontend:
    driver: bridge
  backend:
    driver: bridge
    internal: true   # 后端网络不暴露到宿主机

volumes:
  pg_data:
    driver: local
  redis_data:
    driver: local
  cert_data:
    driver: local
  log_data:
    driver: local

secrets:
  db_password:
    file: ./secrets/db_password.txt

网络隔离设计

上面的配置中使用了两个网络:

网络用途特点
frontendNginx ↔ Web暴露端口到宿主机
backendWeb ↔ DB/Redisinternal: true,不路由到宿主机外部

internal: true 是一个经常被忽略的安全加固手段。它阻止后端网络的容器访问外部网络,即使容器被攻破,攻击者也无法直接外连 C2 服务器或反弹 shell。

多环境管理

不要为每个环境维护一个完整的 compose 文件。使用 override 机制:

# 目录结构
# docker-compose.yml          # 基础配置
# docker-compose.override.yml  # 开发环境覆盖(自动加载)
# docker-compose.prod.yml      # 生产环境覆盖
# docker-compose.staging.yml   # 预发环境覆盖
# docker-compose.prod.yml
services:
  web:
    environment:
      - ENV=production
    deploy:
      replicas: 3
      resources:
        limits:
          cpus: "2.0"
          memory: 1G

  postgres:
    environment:
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password
    deploy:
      resources:
        limits:
          cpus: "4.0"
          memory: 4G
# 启动生产环境
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d

健康检查

为什么必须配健康检查

没有 healthcheck 的服务,depends_on 只能保证容器启动顺序,不能保证服务就绪。典型的坑:

web 容器启动 → 尝试连接 postgres → postgres 进程已启动但还没接受连接 → web 报错退出 → restart → 再连 → 循环

配置了健康检查后,depends_on 可以使用 condition: service_healthy,确保依赖的服务真正就绪后才启动。

健康检查写法要点

healthcheck:
  test: ["CMD", "wget", "--spider", "-q", "http://localhost:8080/health"]
  interval: 10s       # 每10秒检查一次
  timeout: 5s         # 超时5秒视为失败
  retries: 3          # 连续失败3次标记为unhealthy
  start_period: 30s   # 启动后30秒内不计入失败(给应用预热时间)

不同服务的健康检查命令:

服务类型健康检查命令说明
HTTP APIwget --spider -q http://localhost:PORT/health检查 HTTP 状态码
PostgreSQLpg_isready -U user -d db官方就绪检查工具
Redisredis-cli -a password ping返回 PONG 即健康
MySQLmysqladmin ping -h localhost返回 mysqld is alive
MongoDBmongosh --eval "db.adminCommand('ping')"健康检查命令
gRPCgrpc_health_probe -addr=localhost:PORT需要 grpc_health_probe 工具

注意:健康检查的 test 中使用 CMD 而不是 CMD-SHELL,可以避免 shell 注入风险,且执行效率更高。CMD-SHELL 只在需要 shell 特性(管道、变量展开)时使用。

/health 端点设计

健康检查端点不只是返回 200,应该做真正的依赖检查:

// Go 示例:深度健康检查
func healthHandler(w http.ResponseWriter, r *http.Request) {
    ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
    defer cancel()

    // 检查数据库
    if err := db.PingContext(ctx); err != nil {
        http.Error(w, "database unreachable", http.StatusServiceUnavailable)
        return
    }

    // 检查 Redis
    if _, err := redis.Ping(ctx).Result(); err != nil {
        http.Error(w, "redis unreachable", http.StatusServiceUnavailable)
        return
    }

    w.WriteHeader(http.StatusOK)
    fmt.Fprint(w, "ok")
}

但要注意:深度健康检查不要太重。如果健康检查端点本身要去查下游服务的数据库,容易形成级联失败。生产建议区分 livenessreadiness

  • /health/live:只检查进程是否活着(返回 200 即可)
  • /health/ready:检查依赖是否就绪(DB、Redis 连通性)

依赖管理

depends_on 的三种 condition

depends_on:
  postgres:
    condition: service_started      # 容器进程启动即满足(默认)
  redis:
    condition: service_healthy      # 健康检查通过才满足
  migration:
    condition: service_completed_successfully  # 容器执行完毕且退出码为0

service_completed_successfully 是一个很实用的条件,常用于数据库迁移:

services:
  migration:
    image: myapp/web:${TAG:-latest}
    command: ["./migrate", "up"]
    depends_on:
      postgres:
        condition: service_healthy
    restart: "no"  # 迁移完就退出,不重启

  web:
    depends_on:
      migration:
        condition: service_completed_successfully

依赖循环问题

Compose 不支持循环依赖。如果 A 依赖 B,B 又依赖 A,Compose 会报错。解决方法是引入一个初始化容器打破循环。

启动顺序陷阱

启动顺序:nginx → web → postgres → redis(错误!)
正确顺序:postgres/redis → web → nginx

依赖链必须是自底向上的:基础设施 → 应用 → 网关。上面的完整示例中依赖链是:

postgres/redis (无依赖)
web (依赖 postgres/redis 健康)
nginx (依赖 web 健康)

资源限制

deploy.resources vs docker-compose v2 资源限制

Compose V2 中,资源限制写在 deploy.resources 下,和 K8s 的资源配置非常相似:

deploy:
  resources:
    limits:
      cpus: "2.0"      # 最大2核
      memory: 1G       # 最大1G内存
      pids: 100        # 最大进程数
    reservations:
      cpus: "0.5"      # 预留0.5核
      memory: 256M     # 预留256M内存
配置项作用生产建议
limits.cpusCPU 上限,超出则限流应用 CPU 的 1.5-2 倍
limits.memory内存上限,超出则 OOM Kill应用内存峰值的 1.2-1.5 倍
limits.pids进程数上限,防 fork 炸弹100-500
reservations.cpusCPU 预留,保证最低算力应用 CPU 均值的 0.5-1 倍
reservations.memory内存预留,保证最低内存应用内存均值的 0.5-1 倍

注意:reservations 在非 Swarm 模式下只做软保证(cgroup 设置),不涉及资源调度。真正的硬保证需要 Swarm 或 K8s 调度器。

OOM 行为理解

容器内存超过 limits.memory 时,内核会 OOM Kill 该容器中内存占用最大的进程。容器内 PID 1 被杀后,容器退出,由 restart 策略决定是否重启。

# 查看容器是否被 OOM Kill
docker inspect myapp-web --format='{{.State.OOMKilled}}'
docker inspect myapp-web --format='{{.State.ExitCode}}'  # 137 = 128+9(SIGKILL)

经验值:如果应用频繁被 OOM Kill,先排查内存泄漏,再考虑调大 limit。不要一上来就加内存——那只是在掩盖问题。

日志配置

日志驱动选择

logging:
  driver: json-file    # 默认,写本地文件
  options:
    max-size: "20m"    # 单个日志文件最大20MB
    max-file: "5"      # 最多保留5个文件
驱动适用场景特点
json-file单节点/小规模简单,需配 max-size/max-file
fluentd集中式日志实时推送到 Fluentd
gelfGraylogGELF 格式
syslog传统日志系统RFC 5424
journaldsystemd 环境和 journald 集成
local优化存储二进制格式,轮转高效

生产红线json-file 驱动如果不配 max-sizemax-file,日志文件会无限增长。这是最常见的磁盘写满事故原因之一。全局配置可以在 /etc/docker/daemon.json 中设置默认值。

全局日志配置

// /etc/docker/daemon.json
{
  "log-driver": "json-file",
  "log-opts": {
    "max-size": "20m",
    "max-file": "5"
  }
}
# 修改后重启 Docker
systemctl restart docker

日志集中化方案

单节点用 json-file 没问题,多节点建议统一收集。在 Compose 中集成 Fluentd:

services:
  web:
    logging:
      driver: fluentd
      options:
        fluentd-address: "localhost:24224"
        fluentd-async: "true"
        fluentd-buffer-limit: "8192"
        tag: "myapp.web"

  fluentd:
    image: fluent/fluentd:v1.16-debian
    volumes:
      - ./fluentd/conf:/fluentd/etc:ro
    ports:
      - "24224:24224"
      - "24224:24224/udp"

密钥管理

三种方案对比

方案安全性复杂度适用场景
环境变量开发环境
.env 文件小规模生产
Docker Secrets生产环境

不要做的事

# ❌ 硬编码密码
environment:
  - POSTGRES_PASSWORD=MySecret123

# ❌ 把 .env 文件提交到 Git
# .gitignore 中必须包含 .env*

Docker Secrets 使用

# 创建密钥文件
echo "my-super-secret-password" > ./secrets/db_password.txt
chmod 600 ./secrets/db_password.txt

# .gitignore
echo "secrets/" >> .gitignore
# docker-compose.yml
secrets:
  db_password:
    file: ./secrets/db_password.txt

services:
  postgres:
    environment:
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password
    secrets:
      - db_password

容器内密钥挂载在 /run/secrets/<secret_name>,是一个 tmpfs 文件系统,不落盘。程序读取该文件获取密码:

func loadSecret(name string) (string, error) {
    data, err := os.ReadFile(fmt.Sprintf("/run/secrets/%s", name))
    if err != nil {
        return "", err
    }
    return strings.TrimSpace(string(data)), nil
}

外部密钥管理

对于更大规模的部署,建议集成 Vault 或云厂商的密钥管理服务:

# 使用 dotenv 从 Vault 拉取密钥
services:
  web:
    env_file:
      - .env.production  # 由部署脚本从 Vault 动态生成
# 部署脚本示例:从 Vault 生成 .env
vault kv get -format=json secret/myapp/prod | \
  jq -r '.data.data | to_entries[] | "\(.key)=\(.value)"' > .env.production

docker compose up -d

# 部署完成后删除
rm .env.production

Compose 与 Swarm

什么时候用 Swarm

场景推荐方案
单节点,<10 容器Docker Compose
2-5 节点,需高可用Docker Swarm
>5 节点,复杂调度Kubernetes

Swarm 的优势在于学习成本极低——Compose 文件几乎可以直接用,只需要加几个 Swarm 特有字段。

Swarm 部署示例

# docker-compose.swarm.yml
services:
  web:
    image: myapp/web:${TAG:-latest}
    deploy:
      replicas: 3
      update_config:
        parallelism: 1
        delay: 10s
        failure_action: rollback
        order: start-first
      rollback_config:
        parallelism: 1
        order: start-first
      restart_policy:
        condition: on-failure
        max_attempts: 3
      placement:
        constraints:
          - node.role == worker
          - node.labels.zone == east
      resources:
        limits:
          cpus: "2.0"
          memory: 1G
    networks:
      - overlay_net

networks:
  overlay_net:
    driver: overlay
    attachable: true
# 初始化 Swarm
docker swarm init

# 部署
docker stack deploy -c docker-compose.swarm.yml myapp

# 查看服务
docker service ls
docker service ps myapp_web

# 滚动更新
docker service update --image myapp/web:v2 myapp_web

# 回滚
docker service rollback myapp_web

Swarm 的局限

  • 没有原生 Ingress 控制器,只能用 Swarm routing mesh 做简单的负载均衡
  • 没有原生 autoscaling,需要外部工具
  • 没有Operator生态,高级运维能力弱
  • 社区活跃度持续下降,新功能迭代缓慢

2024 年起,Mirantis(Swarm 商业支持方)将 Swarm 维护交还给社区。虽然不会立即停止维护,但建议新项目优先考虑 K8s。参考 Mirantis 官方声明

从 Compose 迁移到 K8s

迁移评估清单

迁移前问自己以下问题:

  1. 当前服务规模是否超过 Swarm 的舒适区(>5 节点 / >50 容器)?
  2. 是否需要 HPA 自动扩缩容?
  3. 是否需要多集群容灾?
  4. 团队是否有 K8s 运维能力?
  5. 是否能接受 K8s 的运维复杂度?

如果超过 2 个"是",就应该开始规划迁移。

使用 Kompose 自动转换

# 安装 Kompose
curl -L https://github.com/kubernetes/kompose/releases/download/v1.31.2/kompose-linux-amd64 -o kompose
chmod +x kompose
mv kompose /usr/local/bin/

# 转换
kompose convert -f docker-compose.yml -o k8s/

# 生成的文件
# k8s/web-deployment.yaml
# k8s/web-service.yaml
# k8s/postgres-deployment.yaml
# k8s/postgres-service.yaml

Kompose 转换的局限

Kompose 能完成 70% 的机械转换,但以下内容需要手动处理:

ComposeK8s 对应注意事项
depends_oninit containerK8s 没有原生服务依赖,用 init container 模拟
healthchecklivenessProbe/readinessProbe语法不同,需手动改写
deploy.resourcesresources.limits/requests字段名不同
secretsSecret + volumeMount需要手动创建 Secret
networksNetworkPolicy默认 K8s 网络全通,需手动加策略
deploy.replicasDeployment replicas直接对应
volumesPersistentVolumeClaim需要定义 StorageClass

手动迁移的关键差异

# Compose 的 depends_on  →  K8s 的 init container
# Compose 的 healthcheck  →  K8s 的 probe
# Compose 的 restart     →  K8s 的 deployment strategy
# Compose 的 deploy.replicas  →  K8s 的 deployment replicas
# Compose 的 networks     →  K8s 的 NetworkPolicy + Service

迁移后的 K8s 配置示例

# web-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: myapp-web
  labels:
    app: myapp
    component: web
spec:
  replicas: 3
  selector:
    matchLabels:
      app: myapp
      component: web
  template:
    metadata:
      labels:
        app: myapp
        component: web
    spec:
      initContainers:
      - name: wait-for-db
        image: postgres:16-alpine
        command: ['sh', '-c', 'until pg_isready -h postgres -U myuser; do sleep 2; done']
      containers:
      - name: web
        image: myapp/web:latest
        ports:
        - containerPort: 8080
        env:
        - name: DB_HOST
          value: postgres
        - name: DB_PASSWORD
          valueFrom:
            secretKeyRef:
              name: myapp-secret
              key: db-password
        resources:
          requests:
            cpu: 500m
            memory: 256Mi
          limits:
            cpu: "2"
            memory: 1Gi
        livenessProbe:
          httpGet:
            path: /health/live
            port: 8080
          initialDelaySeconds: 30
          periodSeconds: 10
        readinessProbe:
          httpGet:
            path: /health/ready
            port: 8080
          initialDelaySeconds: 5
          periodSeconds: 5

生产环境运维命令速查

# ========== 日常操作 ==========
docker compose up -d                    # 后台启动全部服务
docker compose up -d --build web        # 重新构建并启动指定服务
docker compose down                     # 停止并删除容器、网络
docker compose down -v                  # 同时删除数据卷(慎用!)
docker compose restart web              # 重启指定服务
docker compose stop web                 # 停止但不删除
docker compose start web                # 启动已停止的服务

# ========== 查看状态 ==========
docker compose ps                       # 查看服务状态
docker compose ps --format json         # JSON 格式输出
docker compose top                      # 查看容器内进程
docker compose logs -f web              # 跟踪日志
docker compose logs --since 10m web     # 最近10分钟日志
docker compose logs --tail 100 web      # 最后100行

# ========== 调试 ==========
docker compose exec web sh              # 进入容器
docker compose exec postgres psql -U myuser mydb  # 连接数据库
docker compose run --rm web ./migrate up  # 一次性运行命令

# ========== 配置验证 ==========
docker compose config                   # 查看最终合并后的配置
docker compose config --services        # 列出所有服务名
docker compose config --volumes         # 列出所有卷

常见生产事故与防范

1. 磁盘写满

原因:日志没有配 max-size/max-file,或数据卷无监控。

防范

# 定期检查磁盘
df -h /var/lib/docker

# 配置告警
# 当 /var/lib/docker 使用率 > 80% 时告警

2. 容器频繁重启

原因:健康检查配置不当(start_period 太短),或依赖服务未就绪。

防范

healthcheck:
  start_period: 30s  # 给足启动时间
  retries: 5          # 多给几次机会

3. 密钥泄露

原因:.env 文件提交到 Git,或密码硬编码在 compose 文件。

防范

# .gitignore
echo ".env*" >> .gitignore
echo "secrets/" >> .gitignore

# 用 docker compose config 检查是否有明文密钥
docker compose config | grep -i password

4. 版本不一致

原因:不同环境使用不同版本的 compose 文件,配置漂移。

防范:所有 compose 文件纳入版本控制,CI/CD 中用 docker compose config 校验配置。

总结

Docker Compose 在生产环境中完全可以用,关键在于配好以下几件事:

  1. 健康检查必须有:没有健康检查的依赖管理等于没有依赖管理。start_period 要给足,test 命令要做真正的就绪检查而非简单的端口探测。
  2. 资源限制必须配:不配 limits 的容器就是定时炸弹,一个内存泄漏能拖垮整台宿主机。
  3. 日志必须限制大小max-sizemax-file 是底线,全局配置在 daemon.json 中兜底。
  4. 密钥不能硬编码:Docker Secrets 是最简单的方案,规模大了上 Vault。
  5. 网络要隔离:后端网络用 internal: true,不要图省事全用一个 bridge 网络。
  6. 迁移要有规划:当规模超过 5 节点或需要高级调度能力时,果断迁移到 K8s。Kompose 能完成 70% 的机械工作,剩下的手动改。

Compose 的核心价值在于简单。如果你的编排需求已经复杂到需要频繁查文档才能写出 compose 文件,那说明该迁移了。工具的选择应该匹配问题规模,而不是追逐技术潮流。

参考资料与致谢

本文在撰写过程中参考了以下资料,感谢原作者的贡献:

  1. Compose 规范 — GitHub 开源社区,参考了Compose 规范相关内容
  2. grpc_health_probe — GitHub 开源社区,参考了grpc_health_probe相关内容
  3. Mirantis 官方声明 — Mirantis,参考了Mirantis 官方声明相关内容