1. 问题背景:手工容器管理已到极限
上个月,我们团队负责的电商后台系统需要从单机部署迁移到容器化方案。最开始图省事,我写了一个bash脚本,里面堆了十几条docker run命令,每个服务都要手动指定网络、端口映射、环境变量。结果维护了不到两周就崩溃了:
- 启动顺序完全靠sleep 5这种玄学等待,Redis没起来后端API就报连接错误
- 每次重启都要翻历史命令找端口映射参数
- 日志分散在各个容器里,排查问题要开五六个终端窗口
- 更糟的是,有一次误操作删除了存储卷,几天的订单数据差点丢失
我意识到必须引入编排工具。之所以选择Docker Compose而不是Kubernetes,原因很简单:我们只有一台4核8G的云服务器,K8s的组件开销太大了。Compose足够轻量,一条命令就能拉起全部服务,符合当前阶段的需求。
2. 环境与版本说明
先交代一下我使用的具体版本,避免大家踩版本兼容的坑:
Docker version 24.0.7
Docker Compose version v2.23.0
操作系统: Ubuntu 22.04.3 LTS
服务器配置: 4核CPU / 8GB内存 / 100GB SSD
项目技术栈:Python 3.11 + FastAPI(后端),PostgreSQL 14(数据库),Redis 7.2(缓存/消息代理),Celery 5.3(异步任务),Nginx 1.24(反向代理)。
3. 方案设计:分层解耦与依赖管理
在设计docker-compose.yml时,我确定了三个核心原则:
第一:所有服务走自定义网络,不暴露到宿主机。 只有Nginx映射80端口到宿主机,其他服务之间通过服务名互相访问。这样既隔离了内部通信,又减少了端口冲突的风险。
第二:数据卷持久化必须单独声明。 PostgreSQL的数据目录、Redis的持久化文件都要挂载到宿主机指定路径,防止容器重建时数据丢失。
第三:健康检查是启动顺序的基石。 不要用depends_on的裸形式——它只能保证容器启动了,不能保证服务就绪了。必须配合healthcheck条件使用。
整体架构图如下:
宿主机:80 → Nginx → backend_api(8000) → PostgreSQL(5432)
↓
Redis(6379) ← Celery Worker
4. 核心实现:完整docker-compose.yml配置
下面是我们生产环境的docker-compose.yml,我删掉了敏感的环境变量值,但保留全部结构:
version: "3.8"
networks:
app_network:
driver: bridge
ipam:
config:
- subnet: 172.28.0.0/16
volumes:
postgres_data:
driver: local
redis_data:
driver: local
services:
postgres:
image: postgres:14.10-alpine
container_name: ecommerce_postgres
restart: unless-stopped
environment:
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: ecommerce
volumes:
- postgres_data:/var/lib/postgresql/data
- ./init-scripts:/docker-entrypoint-initdb.d:ro
networks:
app_network:
ipv4_address: 172.28.0.10
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ecommerce"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
redis:
image: redis:7.2-alpine
container_name: ecommerce_redis
restart: unless-stopped
command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD}
volumes:
- redis_data:/data
networks:
app_network:
ipv4_address: 172.28.0.11
healthcheck:
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
interval: 5s
timeout: 3s
retries: 3
backend_api:
build:
context: ./backend
dockerfile: Dockerfile
container_name: ecommerce_backend
restart: unless-stopped
environment:
DATABASE_URL: postgresql://${DB_USER}:${DB_PASSWORD}@postgres:5432/ecommerce
REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379/0
CELERY_BROKER_URL: redis://:${REDIS_PASSWORD}@redis:6379/1
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
networks:
app_network:
ipv4_address: 172.28.0.12
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
interval: 15s
timeout: 5s
retries: 3
start_period: 20s
celery_worker:
build:
context: ./backend
dockerfile: Dockerfile
container_name: ecommerce_celery
restart: unless-stopped
command: celery -A app.tasks worker --loglevel=info --concurrency=2
environment:
DATABASE_URL: postgresql://${DB_USER}:${DB_PASSWORD}@postgres:5432/ecommerce
REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379/0
CELERY_BROKER_URL: redis://:${REDIS_PASSWORD}@redis:6379/1
depends_on:
backend_api:
condition: service_healthy
redis:
condition: service_healthy
networks:
app_network:
ipv4_address: 172.28.0.13
nginx:
image: nginx:1.24-alpine
container_name: ecommerce_nginx
restart: unless-stopped
ports:
- "80:80"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
- ./static:/usr/share/nginx/html/static:ro
depends_on:
backend_api:
condition: service_healthy
networks:
app_network:
ipv4_address: 172.28.0.14
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost/health"]
interval: 10s
timeout: 5s
retries: 3
对应的.env文件(关键变量):
DB_USER=ecommerce_app
DB_PASSWORD=Str0ng!Passw0rd
REDIS_PASSWORD=Redis_Secret_2024
5. 踩坑记录:两个让我头疼的问题
5.1 健康检查的start_period是个隐形炸弹
最初我给PostgreSQL配置健康检查时,没有设置start_period。结果第一次启动时,PostgreSQL初始化数据目录需要大约15秒,但healthcheck在容器启动后立刻执行pg_isready,连续失败5次(每次间隔10秒)后,Compose直接判定容器不健康。
更坑的是,depends_on只认service_healthy状态,导致backend_api永远等不到PostgreSQL就绪,整个服务组卡死。后来在healthcheck里加了start_period: 30s——这个参数的意思是:在30秒内,健康检查的失败不计入重试次数。这样初始化期间的失败就被忽略了。
5.2 固定IP地址的利与弊
为了调试方便,我给每个容器指定了固定的IP地址(172.28.0.x)。这在早期确实有帮助——比如直接psql -h 172.28.0.10连数据库。但后来发现一个问题:如果某个容器异常退出,Docker会保留它的IP,新容器启动时可能冲突。
解决办法是把ipv4_address的分配从服务配置中移到网络配置里,或者干脆删掉固定IP,改用服务名访问。最终我保留了固定IP,但增加了docker compose down && docker compose up -d的完整重建流程,避免IP残留。
6. 优化效果:从数据看收益
改造完成后,我对整个部署流程做了基准测试。在同样4核8G的服务器上:
| 指标 | 手工脚本 | Docker Compose |
|---|---|---|
| 全量启动时间 | ~3分钟(含sleep等待) | 40秒(健康检查串行) |
| 服务重启恢复 | 需要人工确认顺序 | docker compose restart 一条命令 |
| 日志查看 | docker logs + 多个终端 | docker compose logs -f --tail=100 |
| 数据安全 | 手动备份,易遗漏 | 卷挂载 + 定时备份脚本 |
| 磁盘占用 | 未统计 | 镜像总大小2.3GB |
最直观的感受是:以前凌晨数据库挂了,我要爬起来手动拉容器、等启动、看日志。现在只要docker compose up -d,依赖关系自动处理,healthcheck确认就绪后才启动下游服务——我可以在被窝里用手机完成操作。
7. 总结与建议
Docker Compose不是万能的,但在单机多服务场景下,它确实是性价比最高的编排工具。回顾这次改造,我认为最值得关注的设计决策有三个:
- 健康检查必须配合
start_period和retries调优——这是保证启动顺序可靠的前提 - 网络隔离要彻底——只有入口服务(Nginx)暴露端口,内部通信全走容器网络
- 数据卷单独声明——不要让容器存储任何运行时产生的关键数据
如果你正在考虑从手工docker run迁移到Compose,建议先画出服务依赖图,明确每个服务的就绪条件,再动手写配置。这套方案目前已经稳定运行了三个月,服务可用性达到99.9%。如果你的场景也是单机多服务,完全可以参考这套配置做裁剪。