// PC530 技术论坛 v1.0
● online 登录 / 注册
返回列表
运维与云原生

Docker Compose 从开发到生产踩过的六个坑——每一条都是凌晨三点学到的


Docker Compose 从开发到生产踩过的六个坑——每一条都是凌晨三点学到的

蹦熊上线初期用的就是一套 docker-compose.yml,开发环境跑得好好的,推到生产服务器上,第一天就翻了两回车。后来花了一个多月逐步把问题修完,现在这套 compose 配置跑了大半年没出过事。

把踩过的六个坑写下来,每个都带实际配置和命令。如果你也在用 Docker Compose 做生产部署,大概率会碰到其中至少三个。

坑一:容器挂了不会自动拉起来

开发的时候你手动 docker compose up -d,容器跑了就不管了。生产环境不一样——OOM、磁盘满、上游依赖超时,任何一个原因都可能让进程崩。崩了之后如果没有重启策略,它就停在那里,直到你人工发现。

最直接的办法是给每个服务加 restart 策略:

```yaml
services:  
  api:  
    restart: always  
  worker:  
    restart: on-failure:5  
  redis:  
    restart: unless-stopped

三种策略的区别: - always:不管什么原因退出都重启,包括你手动 docker stop 之后 daemon 重启时也会拉起来 - on-failure:5:只限非零退出码重启,最多重试 5 次 - unless-stopped:和 always 一样,但手动 stop 的不会自动拉起

我们的选择:核心业务服务(API、Worker)用 always,Redis 和 MySQL 用 unless-stopped。为什么?因为数据库容器如果因为数据损坏而反复重启,always 会让它不断重启直到把磁盘 IO 吃满。unless-stopped 给你一个手动介入的机会。

另外一个容易忽略的细节:光加 restart 不够,还得加健康检查。否则容器进程活着但服务已经不响应了,Docker 不会知道。

```yaml
services:
  api:
    restart: always
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 40s

start_period 这个参数别漏——容器刚启动的时候需要时间初始化,这段时间内健康检查失败不算数。蹦熊的 API 服务启动要连 MySQL 和 Redis,大概 30 秒才能完全就绪,start_period 设了 40 秒。

坑二:日志不做管理,磁盘迟早被撑爆

默认情况下 Docker 把所有容器的标准输出日志存到 /var/lib/docker/containers/ 下,每个容器一个 JSON 文件,不限大小。我们的 API 服务一天大概输出 200MB 日志,一个月下来就是 6GB,再加上 Redis 和 Worker 的日志……两个月后磁盘 80% 满了,报警都发不出来因为报警服务自己也没空间写日志了。

在 compose 里给每个服务加日志限制就行:

```yaml
services:  
  api:  
    logging:  
      driver: json-file  
      options:  
        max-size: "50m"  
        max-file: "3"

max-size 是单个日志文件的大小上限,max-file 是最多保留几个文件。超过限制后 Docker 会自动轮转,删掉最老的文件。我们每个服务都加了,50m/3 个文件,总占用不超过 150MB。

还有一点:别完全依赖容器日志。我们在服务内部也做了日志分级——stdout 只输出 WARN 以上级别,INFO 和 DEBUG 写到服务自己的日志文件(挂载到 volume 里),用 logrotate 管理。这样容器日志体积小,排查问题的时候翻 volume 里的完整日志。

services:  
  api:  
    volumes:
      - /opt/bengxiong/logs:/app/logs
## 坑三:MySQL 数据卷没做备份方案

第一次上线的时候,MySQL 数据就挂了一个 named volume:

```yaml
services:
  mysql:
    volumes:
      - mysql_data:/var/lib/mysql
看着没问题对吧?问题是——这个 volume 在哪?怎么备份?

`docker volume inspect mysql_data` 会告诉你它在 `/var/lib/docker/volumes/mysql_data/_data`。但直接 `tar` 打包这个目录是有风险的:MySQL 在运行状态下数据文件可能正在被写入,你打包的可能是不一致的状态。

靠谱的方案有两条:

**方案一:用 mysqldump 在容器内做逻辑备份**

```bash
# 每天凌晨跑一次,写到挂载的备份目录
docker compose exec mysql mysqldump -u root -p$MYSQL_ROOT_PASSWORD \  
  --all-databases --single-transaction --routines \
  > /opt/bengxiong/backups/db_$(date +%Y%m%d_%H%M%S).sql
`--single-transaction` 是关键参数——它用 InnoDB 的 MVCC 机制保证 dump 过程中读到的数据是一致性视图,不会锁表。这对线上业务几乎零影响。

**方案二:LVM 快照做物理备份**

如果数据库比较大(超过 10GB),mysqldump 可能要跑十几分钟,恢复也慢。这时候可以在宿主机上用 LVM 快照:

```bash
# MySQL data 目录在 LVM 卷上
lvcreate -L 10G -s -n mysql_snap /dev/vg0/mysql_lv
mount /dev/vg0/mysql_snap /mnt/mysql_snap
tar czf /opt/backups/mysql_snap_$(date +%Y%m%d).tar.gz /mnt/mysql_snap
umount /mnt/mysql_snap
lvremove -f /dev/vg0/mysql_snap
快照只需要几秒,之后慢慢打包归档就行。恢复的时候把 tar 解压回 LVM 卷,比 mysqldump 恢复快很多。

我们最终用的是方案一 + 方案二的组合:每天 mysqldump 做逻辑备份(快速恢复小规模数据),每周 LVM 快照做物理备份(灾难恢复用)。两套备份都自动推到 S3 存储。

## 坑四:环境变量在 compose 文件里写死了

最开始我们把所有环境变量直接写在 `docker-compose.yml` 里:

```yaml
services:  
  api:  
    environment:
      - DB_HOST=mysql
      - DB_PORT=3306
      - DB_USER=root
      - DB_PASSWORD=hardcoded_password_here
      - REDIS_HOST=redis
两个问题:第一,密码直接写在 compose 文件里,推到 Git 仓库就完事了。第二,开发环境和生产环境的配置混在一起,改一个怕影响另一个。

改法是用 `.env` 文件 + compose 变量替换:

```yaml
services:
  api:
    environment:
      - DB_HOST=${DB_HOST}
      - DB_PASSWORD=${DB_PASSWORD}
      - REDIS_HOST=${REDIS_HOST}
然后维护两套 `.env`:

.env.dev (开发环境)

DB_HOST=localhost
DB_PASSWORD=dev_password
REDIS_HOST=localhost

.env.prod (生产环境,不进 Git)

DB_HOST=mysql
DB_PASSWORD=real_production_password
REDIS_HOST=redis


部署的时候指定 env 文件:

# 开发
docker compose --env-file .env.dev up -d

# 生产
docker compose --env-file .env.prod up -d
`.env.prod` 放在服务器上,不进版本控制。`.gitignore` 里加一行 `.env.prod`。

再进一步:密码类的敏感变量不要放 `.env` 文件,用 Docker secret 或者直接在宿主机环境变量里设。我们的做法是 CI 部署脚本从 Vault 取密码,写到宿主机的临时环境变量,compose 启动时读取:

```bash
# release-api.sh 里的片段
export DB_PASSWORD=$(vault read -field=password secret/bengxiong/db)  
docker compose --env-file .env.prod up -d  
unset DB_PASSWORD
## 坑五:网络配置没考虑服务间的依赖顺序

compose 默认按服务定义顺序启动,但不会等依赖服务真正就绪。比如 API 服务依赖 MySQL,compose 可能先启动 MySQL 容器,然后马上启动 API——这时候 MySQL 还在初始化,连接直接失败。

`depends_on` 只保证启动顺序,不保证服务就绪:

```yaml
services:
  api:
    depends_on:
      - mysql
      - redis
加上健康检查才能让 compose 等到依赖真正可用:
services:
  api:
    depends_on:
      mysql:
        condition: service_healthy
      redis:
        condition: service_healthy

不过这里有个现实问题:如果 MySQL 初始化需要 60 秒(第一次启动要建库建表),compose 会一直等,但如果你的 CI 脚本设置了超时,等不到就认为部署失败。

我们的解决办法是在入口脚本里做重试:

# entrypoint.sh
until mysql -h "$DB_HOST" -u "$DB_USER" -p"$DB_PASSWORD" -e "SELECT 1" &>/dev/null; do
  echo "Waiting for MySQL..."
  sleep 2
done

until redis-cli -h "$REDIS_HOST" ping | grep -q PONG; do
echo "Waiting for Redis..."
sleep 1
done

echo "Dependencies ready, starting API..."
exec /app/bin/api
compose 配置里用这个 entrypoint:

```yaml
services:  
  api:  
    entrypoint: ["/app/entrypoint.sh"]
`exec` 别漏——它让 API 进程替代 entrypoint 脚本成为 PID 1,这样容器收到 SIGTERM 信号时能正确传递给 API 进程做优雅退出。

## 坑六:没做优雅退出,部署更新时丢请求

`docker compose restart api` 的时候,Docker 发 SIGTERM 给容器里的主进程,等 10 秒(默认 `stop_grace_period`),然后发 SIGKILL 强杀。如果 API 服务没处理 SIGTERM,10 秒后直接被杀——正在处理的请求直接断掉,客户端收到 502。

Go 服务做优雅退出其实不难:
func main() {  
    router := setupRouter()  
    srv := &http.Server{  
        Addr:    ":8080",  
        Handler: router,  
    }
go srv.ListenAndServe()
    quit := make(chan os.Signal, 1)  
    signal.Notify(quit, syscall.SIGTERM, syscall.SIGINT)  
    <-quit
    log.Println("Shutting down server...")  
    ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)  
    defer cancel()  
    srv.Shutdown(ctx)  
    log.Println("Server stopped")  
}

srv.Shutdown(ctx) 会等所有正在处理的请求完成,但不超过 15 秒。配合 compose 的 stop_grace_period

services:  
  api:  
    stop_grace_period: 20s
给 20 秒,比 Go 代码里的 15 秒多一点缓冲,SIGKILL 来的时候 Go 已经自己退出了。

还有一个实操经验:更新部署的时候不要用 `restart`,用 `up`:

```bash
# 错误做法——会短暂中断所有服务
docker compose restart api

# 正确做法——只更新变更的服务,旧容器在收到 SIGTERM 后优雅退出
docker compose up -d --no-deps api

```

--no-deps 让 compose 不去重新创建 mysql 和 redis 这些没变化的服务。up -d 会创建新的 API 容器替代旧的,旧容器走优雅退出流程。


这六个坑不是什么高深的东西,但每个都是我们在蹦熊上线初期真真实实碰到的。Docker Compose 做开发环境编排足够好,做生产部署需要补的东西不少。上面这套配置跑了大半年了,最忙的时候一天几十万请求没出过部署层面的故障。

如果你也在用 Compose 做生产部署,有个建议:别一口气改完。一个坑一个坑地修,每个改完跑几天确认没问题再动下一个。不然同时改好几处配置,出问题了不知道是哪个改法引入的。