一、背景:单机部署的痛点,逼我上了Compose
先交代下项目背景。一个典型的电商后台,包含:
- Nginx(前端静态资源 + 反向代理)
- Django后端API(Gunicorn启动,8000端口)
- Celery Worker(异步任务,依赖Redis和PostgreSQL)
- Redis(缓存 + Celery Broker)
- PostgreSQL(主数据库)
之前是纯手工部署:写了一个200行的deploy.sh,按顺序启动服务,用sleep 5硬等依赖就绪。结果经常遇到:
1. 后端API启动时PostgreSQL还没就绪,导致连接失败,进程退出
2. Celery Worker连接Redis超时,任务队列堆积
3. 数据卷权限变成root,容器内用户无法写入
4. 手动kill进程时,残留的PID文件导致下次启动失败
后来决定用Docker Compose。当时Docker官方文档读了一遍,发现Compose v2已经原生支持depends_on + condition: service_healthy,这意味着可以做到真正的依赖就绪再启动,而不是靠盲目sleep。
二、环境与版本:别用太老的坑
我的环境:
- 操作系统:Ubuntu 22.04 LTS
- Docker Engine:24.0.7(2023年11月版本)
- Docker Compose:v2.24.0(独立二进制,非docker-compose老版)
- 服务器配置:4核8G,SSD磁盘
重要提示:如果你还在用docker-compose v1(Python写的),强烈建议升级到v2。因为condition: service_healthy在v1里支持不完整,且v1已经停止维护。检查版本命令:
docker compose version # 输出 Docker Compose version v2.24.0
三、方案设计:网络、卷、健康检查、启动顺序四件套
我的设计思路遵循以下原则:
- 网络:创建一个自定义bridge网络,服务间用服务名通信(不用IP),隔离外部流量。
- 卷挂载:命名卷(named volume)用于持久化数据(PostgreSQL数据、Redis数据),绑定挂载(bind mount)用于代码热更新。
- 健康检查:每个依赖服务必须定义
healthcheck,包括test、interval、timeout、retries。不能只检查端口,要检查真正可用的能力。 - 启动顺序:使用
depends_on+condition: service_healthy,确保依赖链:PostgreSQL/Redis先健康 → 后端API启动 → Celery Worker启动 → Nginx最后。
四、核心实现:完整的docker-compose.yml
直接上配置,文件名为docker-compose.yml,放在项目根目录。
version: "3.9"
networks:
backend_net:
driver: bridge
ipam:
config:
- subnet: 172.20.0.0/24
volumes:
postgres_data:
driver: local
redis_data:
driver: local
services:
postgres:
image: postgres:16.1-alpine
container_name: backend_postgres
restart: unless-stopped
environment:
POSTGRES_USER: app_user
POSTGRES_PASSWORD: ${DB_PASSWORD} # 从.env读取,不要硬编码
POSTGRES_DB: ecommerce
volumes:
- postgres_data:/var/lib/postgresql/data
- ./init-sql:/docker-entrypoint-initdb.d:ro # 初始化脚本
networks:
backend_net:
ipv4_address: 172.20.0.10
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app_user -d ecommerce -h 127.0.0.1"]
interval: 5s
timeout: 3s
retries: 10
start_period: 10s
ports:
- "5432:5432" # 仅开发环境暴露,生产环境建议去掉
redis:
image: redis:7.2-alpine
container_name: backend_redis
restart: unless-stopped
command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru
volumes:
- redis_data:/data
networks:
backend_net:
ipv4_address: 172.20.0.11
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 10
api:
build:
context: ./backend
dockerfile: Dockerfile
image: backend_api:latest
container_name: backend_api
restart: unless-stopped
env_file:
- .env
environment:
DATABASE_URL: postgresql://app_user:${DB_PASSWORD}@postgres:5432/ecommerce
REDIS_URL: redis://redis:6379/0
volumes:
- ./backend:/app # 绑定挂载,代码热更新(开发模式)
networks:
backend_net:
ipv4_address: 172.20.0.12
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:8000/health/ || exit 1"]
interval: 10s
timeout: 5s
retries: 5
start_period: 20s # 启动较慢,给20秒缓冲
worker:
build:
context: ./backend
dockerfile: Dockerfile
image: backend_api:latest # 复用同一镜像
container_name: backend_worker
restart: unless-stopped
command: celery -A ecommerce worker -l info --concurrency=4
env_file:
- .env
environment:
DATABASE_URL: postgresql://app_user:${DB_PASSWORD}@postgres:5432/ecommerce
REDIS_URL: redis://redis:6379/0
depends_on:
api:
condition: service_healthy # 确保API已就绪,因为Worker会调用API的迁移脚本
networks:
backend_net:
ipv4_address: 172.20.0.13
nginx:
image: nginx:1.25-alpine
container_name: backend_nginx
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx/conf.d:/etc/nginx/conf.d:ro
- ./frontend/dist:/usr/share/nginx/html:ro
networks:
backend_net:
ipv4_address: 172.20.0.14
depends_on:
api:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost/health/ >/dev/null 2>&1 || exit 1"]
interval: 10s
timeout: 5s
retries: 5
对应的.env文件(记得加入.gitignore):
DB_PASSWORD=your_strong_password_here
SECRET_KEY=your_django_secret_key
DEBUG=True
五、踩坑与优化:这六个问题让我折腾了两天
坑1:健康检查的test命令不能用绝对路径的curl
alpine镜像里没有curl,只有wget。我在Nginx的健康检查里用了wget,但在API里用了curl,结果API镜像基于python:3.11-slim,也没装curl。解决办法:在Dockerfile里加上RUN apt-get update && apt-get install -y curl,或者改用Python的urllib。我最终选择在Dockerfile里装curl。
坑2:PostgreSQL数据卷权限
第一次启动后,容器内postgres用户创建的数据文件所有者为uid 999。第二次启动时,如果宿主机上该目录权限不对,会报chmod: changing permissions of '/var/lib/postgresql/data': Operation not permitted。解决办法:初始化时使用docker compose down -v清空卷,然后重新创建。更重要的是,确保宿主机目录(如果用bind mount)的uid/gid与容器内postgres用户一致。
坑3:depends_on只等容器启动,不等服务就绪
这是Compose v1最大的坑。v2的condition: service_healthy解决了这个问题。但注意,depends_on列表里,只有定义在列表中的服务才会被等待。比如celery worker依赖api,但如果api没在worker的depends_on里,它就不会等api。
坑4:启动超时设置
PostgreSQL在冷启动时可能需要恢复WAL日志,第一次启动可能要20秒以上。所以start_period一定要设置,否则健康检查会误报失败,导致依赖服务迟迟无法启动。我设置为start_period: 10s,但实际观察日志发现冷启动需要15秒,后来改成start_period: 20s。
坑5:固定IP导致网络冲突
我在自定义网络里手工指定了静态IP(ipv4_address),但后来新增服务时忘记预留IP段,导致冲突。优化做法:不要手工指定IP,让Docker自动分配。只有需要外部访问固定IP的场景(比如iptables规则)才需要静态IP。我后来把IP指定全去掉了,只保留网络名。
坑6:Celery Worker启动时重复执行迁移
因为worker和api共用同一个镜像,启动命令不同。但worker启动时,Django的AppConfig.ready()方法可能触发数据库操作,如果数据库还没迁移完成就会报错。我的解决办法:在worker的启动命令前加一个等待脚本:
command: sh -c "python manage.py wait_for_db && celery -A ecommerce worker -l info --concurrency=4"
其中wait_for_db是一个自定义management command,循环检查数据库连接。
六、效果数据:部署时间缩短80%,可用性提升
改造完成后,我做了几次对比测试:
| 指标 | 手工部署 | Docker Compose |
|---|---|---|
| 冷启动部署时间(含依赖安装) | 40分钟 | 8分15秒 |
| 增量部署时间(仅代码更新) | 15分钟 | 2分30秒 |
| 服务可用性(30天观测) | 99.2% | 99.95% |
| 迁移到新服务器时间 | 2小时+ | 15分钟(docker compose up -d) |
关键收益:
1. 启动顺序可靠:通过健康检查,PostgreSQL和Redis稳定后,API和Worker才会启动,日志中不再出现连接错误。
2. 环境一致性:团队新成员git clone + cp .env.example .env + docker compose up -d,5分钟就能跑起完整环境。
3. 回滚方便:docker compose down 再 git checkout 旧版本代码,一条命令回滚。
七、总结与建议
Docker Compose不是万能的,但针对中小型多服务项目,它提供了性价比极高的编排能力。我的建议:
- 优先使用命名卷而非bind mount(生产环境),避免权限问题。
- 健康检查是核心:不要只检查端口,要检查真正可用的业务接口(比如
/health/返回200)。 - 不要手工指定IP:让Compose管理网络,减少人为错误。
.env文件管理密钥:不要把密码写死在compose文件里。- 生产环境加限制:如果追求高可用,建议配合
docker swarm或k8s,但中小项目用Compose +restart: unless-stopped已经足够。
最后,贴出部署命令:
# 首次部署
docker compose up -d --build
# 查看日志
docker compose logs -f api
# 停止所有服务
docker compose down
如果你也在迁移到Compose的路上,希望这篇博客能帮你少踩几个坑。有问题欢迎评论区交流。