系列说明: 这是「Docker Compose 避坑指南」三篇系列的最后一篇。前两篇讲了基础坑(YAML、依赖、环境变量)和进阶坑(网络、数据卷、多环境),本篇专攻健康检查和生产部署——这两块的坑不会在本地暴露,只会在生产环境的凌晨三点找上你。

前两篇的坑,绝大多数在开发阶段就能踩到、修掉。这一篇不一样。

健康检查写错了,服务会反复重启;镜像版本不固定,某天拉到不兼容版本直接挂;日志没有限制,磁盘被撑满;容器跑在 root 下,安全漏洞被利用……这些问题全都是「能跑很久没问题,但一旦出问题就是大问题」。

8 个坑,全是血泪经验。


环境说明

项目 版本
Docker Engine 24.x / 25.x / 26.x
Docker Compose v2.x(docker compose 命令)
操作系统 Ubuntu 22.04 / CentOS 7·8

一、健康检查坑

坑 1:健康检查命令写错,容器永远 `unhealthy`

报错现象:

$ docker compose ps
NAME    STATUS
db      unhealthy
app     unhealthy

依赖这些服务的容器无法启动,整个项目起不来。

常见错误写法:

healthcheck:
  # 错误1:字符串形式但没用 CMD-SHELL,shell 语法不生效
  test: "pg_isready -U postgres"

  # 错误2:镜像内根本没有 curl
  test: ["CMD", "curl", "http://localhost:8080/health"]

  # 错误3:命令本身有问题,但没有去容器里手动验证过
  test: ["CMD-SHELL", "mysql -u root -p${MYSQL_PASSWORD} -e 'SELECT 1'"]

根本原因:

健康检查命令在容器内部执行,依赖容器内已安装的工具。不同镜像内置的命令不同,Alpine 镜像普遍很精简,curl、bash 等工具默认不存在。

解决方案:

常用服务的正确健康检查写法:

services:
  # PostgreSQL
  db:
    image: postgres:16-alpine
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres} -d ${POSTGRES_DB:-postgres}"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 30s    # 给 PG 初始化留出时间

  # MySQL
  mysql:
    image: mysql:8.0
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${MYSQL_ROOT_PASSWORD}"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 30s

  # Redis
  redis:
    image: redis:7-alpine
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]    # redis 镜像内有 redis-cli
      interval: 10s
      timeout: 3s
      retries: 5

  # Nginx
  nginx:
    image: nginx:alpine
    healthcheck:
      test: ["CMD-SHELL", "wget -qO- http://localhost/health || exit 1"]    # alpine 有 wget
      interval: 30s
      timeout: 5s
      retries: 3

  # Node.js 应用(有 curl)
  app:
    image: myapp:latest
    healthcheck:
      test: ["CMD-SHELL", "curl -f http://localhost:3000/health || exit 1"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 40s    # 应用启动通常比数据库慢

不确定镜像里有没有某个命令,先进去确认:docker compose exec service_name which curl

调试健康检查:

# 查看健康检查历史和失败原因
docker inspect --format='{{json .State.Health}}' container_name | jq .

# 手动执行健康检查命令测试
docker compose exec db pg_isready -U postgres
docker compose exec redis redis-cli ping

坑 2:健康检查通过了,但服务其实还没完全就绪

现象:

app 依赖 dbdb 健康检查通过了,app 启动连接数据库,仍然失败。

根本原因:

健康检查只验证「进程在跑」,不代表「业务完全就绪」。比如:

  • PostgreSQL pg_isready 返回成功,但数据库初始化脚本还没跑完
  • MySQL 能 ping 通,但主从同步还没建立好
  • 应用的 /health 接口返回 200,但数据库连接池还没完成预热

解决方案一: 调整 start_period,给足启动宽限期:

healthcheck:
  test: ["CMD-SHELL", "pg_isready -U postgres"]
  interval: 5s
  retries: 10
  start_period: 60s    # 宽限期内失败不计入 retries

解决方案二: 健康检查接口做真实的依赖验证:

// Node.js 示例:不只返回 200,真实检查依赖
app.get('/health', async (req, res) => {
  const checks = {}
  try {
    await db.query('SELECT 1')
    checks.database = 'ok'
  } catch (err) {
    checks.database = 'error: ' + err.message
  }

  try {
    await redis.ping()
    checks.redis = 'ok'
  } catch (err) {
    checks.redis = 'error: ' + err.message
  }

  const allOk = Object.values(checks).every(v => v === 'ok')
  res.status(allOk ? 200 : 503).json({ status: allOk ? 'ok' : 'degraded', checks })
})
# Python Flask 示例
@app.route('/health')
def health():
    try:
        db.session.execute('SELECT 1')
        redis_client.ping()
        return jsonify({'status': 'ok'}), 200
    except Exception as e:
        return jsonify({'status': 'error', 'detail': str(e)}), 503

二、生产使用注意事项

坑 3:镜像版本不固定,某次更新把服务拉挂了

问题场景:

# ❌ 这三行配置埋了定时炸弹
image: redis:latest
image: nginx:latest
image: node:alpine

某天 docker compose pull 拉到了新版本,出现接口不兼容或行为变化,服务挂了。

根本原因:

latest 和不带版本的 alpine 标签,镜像内容随时会变,不具有可重复性。

解决方案:

生产环境固定到精确版本,最好精确到 patch 版本:

services:
  redis:
    image: redis:7.2.4-alpine
  nginx:
    image: nginx:1.25.4-alpine
  db:
    image: postgres:16.2-alpine
  rabbitmq:
    image: rabbitmq:3.12.12-management-alpine

自建服务使用语义版本 tag 或 Git commit SHA:

services:
  app:
    image: registry.example.com/myapp:${APP_VERSION}
    # APP_VERSION=v1.2.3 或 APP_VERSION=abc1234

版本升级时,在测试环境验证后再更新生产配置,而不是靠 latest 自动漂移。


坑 4:容器日志无限增长,磁盘被撑满

报错现象:

No space left on device

/var/lib/docker/containers/ 目录占用了几十 GB。

根本原因:

Docker 默认日志驱动 json-file 没有大小限制,高频写日志的服务(如访问日志、慢查询日志)长期运行后会把磁盘吃满。

解决方案一: 全局配置(推荐),修改 Docker Daemon 默认日志限制:

// /etc/docker/daemon.json
{
  "log-driver": "json-file",
  "log-opts": {
    "max-size": "100m",
    "max-file": "3"
  }
}
# 修改后重启 Docker 生效(影响新创建的容器)
sudo systemctl restart docker

解决方案二: 在 Compose 文件中按服务配置:

services:
  app:
    logging:
      driver: json-file
      options:
        max-size: "50m"    # 单个日志文件最大 50MB
        max-file: "5"      # 保留最多 5 个轮转文件

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

接入集中日志系统(规模较大时推荐):

services:
  app:
    logging:
      driver: loki
      options:
        loki-url: "http://loki:3100/loki/api/v1/push"
        loki-batch-size: "400"
        loki-retries: "3"

坑 5:不限制容器资源,单个服务把宿主机内存耗尽

报错现象:

宿主机 OOM,其他服务被系统强制 kill:

Killed
Out of memory: Kill process 12345 (java) score 900 or sacrifice child

根本原因:

容器默认可以使用宿主机的全部内存和 CPU,一个有内存泄漏的 Java 应用可以把整台机器的资源吃光。

解决方案:

为每个服务设置资源限制:

services:
  app:
    image: myapp:latest
    deploy:
      resources:
        limits:
          cpus: "1.5"      # 最多 1.5 个 CPU 核
          memory: 512M     # 最多 512 MB 内存
        reservations:
          cpus: "0.25"     # 预留 0.25 核(调度时保证)
          memory: 256M

  java-app:
    image: myjavaapp:latest
    environment:
      # JVM 内存也要限制,不然 JVM 会按宿主机内存计算默认堆大小
      JAVA_OPTS: "-Xms256m -Xmx512m -XX:MaxMetaspaceSize=128m"
    deploy:
      resources:
        limits:
          memory: 768M     # 最大堆 512m + 元空间 128m + 其他,留余量

  redis:
    image: redis:7-alpine
    command: redis-server --maxmemory 256mb --maxmemory-policy allkeys-lru
    deploy:
      resources:
        limits:
          memory: 384M

deploy.resourcesdocker compose 命令下完全生效,不需要 Swarm 模式。


坑 6:容器以 root 用户运行,存在安全隐患

根本原因:

很多镜像默认以 root 用户运行进程。容器内的 root 虽然受到一定限制,但在某些漏洞场景下仍可能影响宿主机。这是容器安全的基本要求。

解决方案:

方式一:在 compose.yaml 直接指定 UID:

services:
  app:
    image: myapp:latest
    user: "1000:1000"    # UID:GID

  nginx:
    image: nginx:alpine
    user: "101:101"      # nginx 官方镜像的 nginx 用户 UID

方式二(更推荐):在 Dockerfile 里创建专用用户:

FROM node:20-alpine

# 创建非 root 用户
RUN addgroup -g 1001 -S appgroup && \
    adduser -u 1001 -S appuser -G appgroup

WORKDIR /app
COPY --chown=appuser:appgroup package*.json ./
RUN npm ci --only=production
COPY --chown=appuser:appgroup . .

# 切换到非 root 用户
USER appuser

EXPOSE 3000
CMD ["node", "server.js"]

验证容器内实际运行用户:

docker compose exec app whoami
docker compose exec app id

坑 7:Secrets 只用 `.env` 文件,安全性不够

问题场景:

.env 文件以明文存储在服务器磁盘,任何有宿主机权限的人都能看到。而且:

# docker inspect 可以直接看到容器的环境变量明文
docker inspect container_name | grep -A 5 "Env"

解决方案:

使用 Compose secrets 机制,敏感数据通过文件形式注入,而非环境变量:

services:
  db:
    image: postgres:16
    secrets:
      - db_password
    environment:
      # 从文件读取,而不是直接的明文变量
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password
      POSTGRES_USER: myapp

  app:
    image: myapp:latest
    secrets:
      - db_password
      - jwt_secret
    environment:
      DB_PASSWORD_FILE: /run/secrets/db_password
      JWT_SECRET_FILE: /run/secrets/jwt_secret

secrets:
  db_password:
    file: ./secrets/db_password.txt    # 开发用本地文件
  jwt_secret:
    file: ./secrets/jwt_secret.txt
# 创建 secrets 目录(不提交到 Git)
mkdir -p secrets
echo "your_strong_db_password" > secrets/db_password.txt
echo "your_jwt_secret_key" > secrets/jwt_secret.txt
chmod 600 secrets/*.txt
# .gitignore
secrets/
.env

应用代码读取 secret 文件:

// Node.js:从文件读取 secret
const fs = require('fs')
const dbPassword = process.env.DB_PASSWORD_FILE
  ? fs.readFileSync(process.env.DB_PASSWORD_FILE, 'utf8').trim()
  : process.env.DB_PASSWORD

生产规模更大时,可以对接 HashiCorp Vault、AWS Secrets Manager、Azure Key Vault 等专业方案。


坑 8:`docker compose up` 不 rebuild,代码更新没有生效

根本原因:

如果 Compose 文件配置了 builddocker compose up 只在镜像不存在时才构建,不会检测代码变化。本地改了代码,重新 up 跑的还是旧镜像。

services:
  app:
    build: .    # 有 build 配置

解决方案:

# 强制重新构建所有服务
docker compose up --build

# 只重新构建指定服务
docker compose build app

# 不用缓存构建(依赖包有更新时用)
docker compose build --no-cache app

# 构建并更新单个服务,不重启其他服务
docker compose build app && docker compose up -d --no-deps app

生产 CI/CD 标准流程:

#!/bin/bash
set -euo pipefail

APP_VERSION=$(git rev-parse --short HEAD)

# 1. 构建镜像,打上版本 tag
docker build -t registry.example.com/myapp:${APP_VERSION} .
docker build -t registry.example.com/myapp:latest .

# 2. 推送到镜像仓库
docker push registry.example.com/myapp:${APP_VERSION}
docker push registry.example.com/myapp:latest

# 3. 在生产服务器上拉取并更新
ssh prod-server "
  export APP_VERSION=${APP_VERSION}
  docker compose -f compose.yaml -f compose.prod.yaml pull app
  docker compose -f compose.yaml -f compose.prod.yaml up -d --no-deps app
  docker image prune -f    # 清理旧镜像
"

生产部署完整 Checklist

健康检查

x所有服务配置了健康检查,且命令在容器内验证过可以正常执行

xstart_period 给了足够的启动宽限期

x应用的健康检查接口做了真实的依赖验证,不只是返回 200

    镜像管理

    x所有镜像固定到精确版本号,无 latest 或仅带大版本的 tag

    x自建服务使用语义版本或 commit SHA 标识

      日志

      x配置了日志大小限制(max-size + max-file

      x或者接入了集中日志系统

        资源限制

        x所有服务设置了内存上限(deploy.resources.limits.memory

        xJava 服务同时设置了 JVM 堆内存参数

          安全

          x所有服务以非 root 用户运行,user 字段已配置

          x数据库、Redis 等内部服务没有暴露端口到宿主机

          x敏感信息通过 .env 或 Compose secrets 管理,不硬编码

            部署

            xCI/CD 脚本明确指定所有 -f--env-file,不依赖隐式加载

            x更新单个服务时使用 --no-deps,不影响其他正在运行的服务

            x旧镜像定期清理,避免磁盘积累


              常见问题 Q&A

              Q1:docker compose up -d 之后如何确认所有服务都正常?

              # 查看所有服务状态(含健康状态)
              docker compose ps
              
              # 实时跟踪日志(Ctrl+C 退出)
              docker compose logs -f
              
              # 只看某个服务的日志
              docker compose logs -f app
              
              # 查看最近 50 行
              docker compose logs --tail=50 app
              

              Q2:如何优雅地重启单个服务?

              # 重启单个服务(不影响其他服务)
              docker compose restart app
              
              # 重新构建并更新单个服务(推荐)
              docker compose up -d --no-deps --build app
              
              # 查看重启后的状态
              docker compose ps app
              

              Q3:生产环境 Compose 和 Kubernetes 该怎么选?

              考量维度 Docker Compose Kubernetes
              部署规模 单机或少数几台 多机集群
              团队规模 小团队(< 10 人) 中大型团队
              运维复杂度 低,学习曲线平缓 高,需要专职运维
              自动扩缩容 不支持 支持
              高可用 需要额外方案 内置支持
              适用场景 中小规模生产、内部工具 大规模、高可用需求

              小团队用 Kubernetes 的运维成本往往远大于收益。Compose + 稳定的单机部署方案,完全能撑起日均百万级请求的服务。


              Q4:容器内进程崩溃和整个容器退出有什么区别,怎么监控?

              进程崩溃通常导致容器退出(退出码非 0),配合 restart: unless-stopped 容器会自动重启。

              监控方式:

              # 查看容器退出历史
              docker compose ps -a
              
              # 查看容器退出码
              docker inspect container_name | grep ExitCode
              
              # 配合监控工具(如 Prometheus + cAdvisor)实时采集容器状态
              

              系列总结

              三篇系列到这里结束,把覆盖的内容串一下:

              第一篇(基础): YAML 语法(Tab、类型转换、多行字符串、锚点)+ 依赖控制(depends_on 本质、循环依赖、重启策略)+ 环境变量(密钥管理、优先级、多 env_file)

              第二篇(进阶): 网络配置(localhost 误用、expose vs ports、网络隔离)+ 数据卷(权限、bind mount 滥用、down -v 风险)+ 多环境(覆盖机制、CI/CD 一致性)

              第三篇(生产): 健康检查(命令写法、真实就绪检查)+ 生产注意事项(版本固定、日志限制、资源限制、非 root、secrets、镜像更新)

              Docker Compose 本身并不复杂,坑大多来自「以为和预期一样,但其实不是」。把这 26 个坑过一遍,日常使用中 90% 的问题都能提前绕开。

              更多《软件安装&避坑&实践》系列

              公关众注号我:IT安装手册

              Logo

              腾讯云面向开发者汇聚海量精品云计算使用和开发经验,营造开放的云计算技术生态圈。

              更多推荐