Docker Compose 实战:为中型项目构建可复用的多环境本地开发与部署方案
中型项目一旦进入多人协作,环境问题几乎一定会冒出来:
“我本地能跑,你那边为什么报错?”、“测试环境和开发环境差了一个 Redis 配置”、“预发和线上镜像不是同一次构建”……
我自己在做团队项目时,最怕的不是业务复杂,而是环境不一致导致的隐性成本。Docker Compose 的价值,不只是“把几个容器一起拉起来”,更重要的是:把环境定义成代码,并让不同场景复用同一套骨架。
这篇文章我会带你从零搭一套适合中型项目的方案,目标是:
- 本地开发开箱即用
- 测试/预发环境尽量少改配置
- 服务拆分清晰,可复用、可覆盖
- 常见坑能快速排查
文章以一个典型 Web 项目为例:nginx + app + mysql + redis + worker。
背景与问题
在小项目里,我们常见的做法是:
- 后端自己本机跑
- MySQL 和 Redis 手工安装
- 前端代理到本地服务
- 测试环境手工配一套
- 预发再“凭经验”改一点参数
这种方式前期很快,但到中型项目通常会遇到几个明显问题:
1. 环境不可复制
新同学入组,拉代码之后还要:
- 安装某个版本的 Node/Python/Java
- 手配数据库
- 导入初始化数据
- 改 hosts
- 补一堆
.env
任何一步漏掉,项目都跑不起来。
2. 多环境配置散落
开发、测试、预发的差异可能分散在:
.env- 应用配置文件
- 启动命令
- Nginx 配置
- 数据库连接参数
- CI/CD 脚本
最后没人能完整说清楚“某个环境到底是怎么组装出来的”。
3. 镜像与运行方式脱节
开发环境直接挂源码,测试环境重新 build,预发环境又是另一套 Dockerfile。
结果就是:开发跑得动,不代表部署跑得动。
前置知识与环境准备
建议你提前具备这些基础:
- 会写 Dockerfile
- 知道镜像、容器、卷、网络的基本概念
- 用过
docker compose up/down/logs
本文示例基于:
- Docker Engine
- Docker Compose V2(命令形式为
docker compose)
可以先确认版本:
docker --version
docker compose version
核心原理
要把 Compose 用好,关键不是会写 services,而是理解它在多环境复用里的几个核心点。
1. 基础编排 + 环境覆盖
推荐的思路是:
- 用一个 基础文件 定义通用服务结构
- 用多个 覆盖文件 表达环境差异
- 通过
-f组合加载
比如:
compose.yml:公共配置compose.dev.yml:开发环境覆盖compose.test.yml:测试环境覆盖compose.prod.yml:预发/生产风格覆盖
这样可以做到:
- 通用配置只写一次
- 环境差异局部覆盖
- 同一服务在不同环境下保持一致命名和结构
2. 环境变量分层
Compose 里的变量来源常见有三层:
- Shell 环境变量
.env文件environment/env_file注入到容器
这三层经常被混用。我的经验是:
- Compose 自己需要的变量:放根目录
.env - 应用运行时变量:放
env/*.env - 敏感信息:不要直接写死在 compose 文件里
3. 网络与服务发现
Compose 默认会为项目创建独立网络,服务名就是 DNS 名。
也就是说,应用连接 MySQL 时,主机名不用写 127.0.0.1,而应该写:
mysqlredisapp
这是很多人刚接触 Compose 时最容易犯的错误之一。
4. 数据持久化与源码挂载要分开
开发环境常常会:
- 挂载源码目录,支持热更新
- 给数据库、缓存挂数据卷,避免容器删掉后数据丢失
这两类卷的目的完全不同,不要混在一起。
一张图看整体结构
flowchart TD
A[开发者执行 docker compose up] --> B[Compose 读取 compose.yml]
B --> C[叠加 compose.dev.yml 或其他环境覆盖]
C --> D[创建默认网络]
D --> E[启动 mysql]
D --> F[启动 redis]
D --> G[启动 app]
D --> H[启动 worker]
D --> I[启动 nginx]
G --> E
G --> F
H --> E
H --> F
I --> G
项目目录设计
先给出一个实战可落地的目录结构:
myapp/
├─ compose.yml
├─ compose.dev.yml
├─ compose.test.yml
├─ compose.prod.yml
├─ .env
├─ env/
│ ├─ app.dev.env
│ ├─ app.test.env
│ └─ app.prod.env
├─ docker/
│ ├─ app/
│ │ ├─ Dockerfile
│ │ └─ entrypoint.sh
│ └─ nginx/
│ └─ default.conf
├─ app/
│ ├─ package.json
│ ├─ server.js
│ └─ worker.js
└─ data/
└─ mysql/
这个结构的重点是:
compose*.yml只负责编排docker/放镜像构建文件env/管理应用级变量app/放业务代码
配置设计思路
下面这张图展示“基础配置 + 覆盖配置”的关系。
flowchart LR
A[compose.yml 公共骨架] --> D[最终开发配置]
B[compose.dev.yml 开发覆盖] --> D
A --> E[最终测试配置]
C[compose.test.yml 测试覆盖] --> E
A --> F[最终预发配置]
G[compose.prod.yml 预发覆盖] --> F
实战代码(可运行)
下面我们直接写一套可运行示例。
1. 根目录 .env
这个文件主要给 Compose 自己用,比如项目名、端口等。
COMPOSE_PROJECT_NAME=myapp
APP_PORT=3000
NGINX_PORT=8080
MYSQL_PORT=3306
REDIS_PORT=6379
MYSQL_DATABASE=myapp
MYSQL_USER=myapp
MYSQL_PASSWORD=myapp123
MYSQL_ROOT_PASSWORD=root123
2. 基础编排:compose.yml
公共服务结构放这里。
services:
app:
build:
context: .
dockerfile: docker/app/Dockerfile
image: myapp/app:local
working_dir: /usr/src/app
env_file:
- env/app.dev.env
depends_on:
- mysql
- redis
networks:
- backend
worker:
build:
context: .
dockerfile: docker/app/Dockerfile
image: myapp/app:local
working_dir: /usr/src/app
command: ["node", "worker.js"]
env_file:
- env/app.dev.env
depends_on:
- mysql
- redis
networks:
- backend
mysql:
image: mysql:8.0
environment:
MYSQL_DATABASE: ${MYSQL_DATABASE}
MYSQL_USER: ${MYSQL_USER}
MYSQL_PASSWORD: ${MYSQL_PASSWORD}
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
command:
- --default-authentication-plugin=mysql_native_password
volumes:
- mysql_data:/var/lib/mysql
ports:
- "${MYSQL_PORT}:3306"
networks:
- backend
redis:
image: redis:7-alpine
volumes:
- redis_data:/data
ports:
- "${REDIS_PORT}:6379"
networks:
- backend
nginx:
image: nginx:1.25-alpine
depends_on:
- app
volumes:
- ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
ports:
- "${NGINX_PORT}:80"
networks:
- backend
volumes:
mysql_data:
redis_data:
networks:
backend:
3. 开发环境覆盖:compose.dev.yml
开发环境一般要挂源码、开启热更新、暴露调试端口。
services:
app:
command: ["sh", "-c", "npm install && npm run dev"]
volumes:
- ./app:/usr/src/app
environment:
NODE_ENV: development
worker:
command: ["sh", "-c", "npm install && node worker.js"]
volumes:
- ./app:/usr/src/app
environment:
NODE_ENV: development
mysql:
restart: unless-stopped
redis:
restart: unless-stopped
nginx:
restart: unless-stopped
4. 测试环境覆盖:compose.test.yml
测试环境更接近部署,不建议直接挂源码。
services:
app:
env_file:
- env/app.test.env
command: ["node", "server.js"]
environment:
NODE_ENV: test
worker:
env_file:
- env/app.test.env
command: ["node", "worker.js"]
environment:
NODE_ENV: test
nginx:
ports:
- "8081:80"
5. 预发/部署风格覆盖:compose.prod.yml
这里叫 prod,本质上也可以作为预发风格配置使用。
services:
app:
env_file:
- env/app.prod.env
command: ["node", "server.js"]
restart: always
environment:
NODE_ENV: production
worker:
env_file:
- env/app.prod.env
command: ["node", "worker.js"]
restart: always
environment:
NODE_ENV: production
mysql:
restart: always
redis:
restart: always
nginx:
restart: always
ports:
- "80:80"
6. 应用环境变量
env/app.dev.env
APP_NAME=myapp
DB_HOST=mysql
DB_PORT=3306
DB_NAME=myapp
DB_USER=myapp
DB_PASSWORD=myapp123
REDIS_HOST=redis
REDIS_PORT=6379
env/app.test.env
APP_NAME=myapp-test
DB_HOST=mysql
DB_PORT=3306
DB_NAME=myapp
DB_USER=myapp
DB_PASSWORD=myapp123
REDIS_HOST=redis
REDIS_PORT=6379
env/app.prod.env
APP_NAME=myapp-prod
DB_HOST=mysql
DB_PORT=3306
DB_NAME=myapp
DB_USER=myapp
DB_PASSWORD=myapp123
REDIS_HOST=redis
REDIS_PORT=6379
7. 应用 Dockerfile
docker/app/Dockerfile
FROM node:18-alpine
WORKDIR /usr/src/app
COPY app/package*.json ./
RUN npm install
COPY app/ ./
COPY docker/app/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]
CMD ["node", "server.js"]
8. 启动脚本
docker/app/entrypoint.sh
#!/bin/sh
set -e
echo "Starting container in $NODE_ENV mode..."
exec "$@"
9. Node.js 示例程序
app/package.json
{
"name": "myapp",
"version": "1.0.0",
"scripts": {
"dev": "node server.js"
},
"dependencies": {
"express": "^4.18.2",
"mysql2": "^3.6.5",
"redis": "^4.6.11"
}
}
app/server.js
const express = require('express');
const mysql = require('mysql2/promise');
const { createClient } = require('redis');
const app = express();
const port = 3000;
async function checkMysql() {
const conn = await mysql.createConnection({
host: process.env.DB_HOST,
port: process.env.DB_PORT,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME
});
const [rows] = await conn.query('SELECT 1 AS ok');
await conn.end();
return rows[0];
}
async function checkRedis() {
const client = createClient({
socket: {
host: process.env.REDIS_HOST,
port: Number(process.env.REDIS_PORT)
}
});
await client.connect();
await client.set('health', 'ok');
const value = await client.get('health');
await client.disconnect();
return value;
}
app.get('/', async (req, res) => {
try {
const mysqlResult = await checkMysql();
const redisResult = await checkRedis();
res.json({
app: process.env.APP_NAME,
env: process.env.NODE_ENV,
mysql: mysqlResult,
redis: redisResult
});
} catch (err) {
res.status(500).json({
error: err.message
});
}
});
app.listen(port, () => {
console.log(`app listening on port ${port}`);
});
app/worker.js
setInterval(() => {
console.log(`[worker] running in ${process.env.NODE_ENV} mode`);
}, 5000);
10. Nginx 配置
docker/nginx/default.conf
server {
listen 80;
server_name localhost;
location / {
proxy_pass http://app:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
如何启动不同环境
启动开发环境
docker compose -f compose.yml -f compose.dev.yml up --build
访问:
http://localhost:8080
启动测试环境
docker compose -f compose.yml -f compose.test.yml up --build -d
启动预发风格环境
docker compose -f compose.yml -f compose.prod.yml up --build -d
停止并清理
docker compose down
如果连卷一起删:
docker compose down -v
逐步验证清单
我建议不要一上来就 up 完了认为没问题,最好按这个顺序验:
第一步:检查合并后的最终配置
这个命令非常好用,我几乎每次都会先跑一遍。
docker compose -f compose.yml -f compose.dev.yml config
它能帮你看清楚:
- 到底使用了哪个
env_file ports有没有覆盖成功command最终是什么- 是否有变量没被替换
第二步:单独拉起基础依赖
docker compose -f compose.yml -f compose.dev.yml up -d mysql redis
检查容器状态:
docker compose ps
第三步:启动应用与代理
docker compose -f compose.yml -f compose.dev.yml up -d app worker nginx
查看日志:
docker compose logs -f app
docker compose logs -f nginx
第四步:验证服务连通性
访问:
curl http://localhost:8080
如果返回类似:
{"app":"myapp","env":"development","mysql":{"ok":1},"redis":"ok"}
说明链路已经通了。
服务启动关系图
很多人以为 depends_on 就等于“应用一定等数据库准备好”,其实不是。它只保证启动顺序,不保证服务已经就绪。
sequenceDiagram
participant Dev as Developer
participant C as Compose
participant M as MySQL
participant R as Redis
participant A as App
Dev->>C: docker compose up
C->>M: start
C->>R: start
C->>A: start after depends_on
Note over A,M: 此时 MySQL 可能还没真正 ready
A->>M: connect
M-->>A: maybe refused
A->>R: connect
R-->>A: ok
常见坑与排查
这一部分是最值钱的。我自己在中型项目里踩过的大部分 Compose 坑,基本都和“预期不一致”有关。
1. 容器里连 localhost 失败
现象
应用报错:
connect ECONNREFUSED 127.0.0.1:3306
原因
在容器内部,localhost 指的是当前容器自己,不是宿主机,也不是 MySQL 容器。
正确做法
连接 Compose 服务名:
- MySQL 用
mysql - Redis 用
redis
2. depends_on 不等于服务可用
现象
容器已经启动,但 app 一直报数据库连接失败。
原因
MySQL 启动成功到可接受连接,中间通常还要几秒。
排查方式
查看日志:
docker compose logs -f mysql
docker compose logs -f app
建议做法
在应用里增加重试机制,或者引入健康检查。
例如给 MySQL 增加健康检查:
services:
mysql:
image: mysql:8.0
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-proot123"]
interval: 5s
timeout: 3s
retries: 10
不过要注意,健康检查写得太重也会拖慢启动。
3. 环境变量明明写了,但容器里没生效
常见原因
.env和env_file的职责混淆- Shell 里已经定义了同名变量,把文件里的值覆盖了
compose.test.yml覆盖了compose.yml的同名配置
排查命令
docker compose -f compose.yml -f compose.test.yml config
以及进入容器查看:
docker compose exec app env | sort
4. 挂载源码后,镜像里安装的依赖丢了
现象
开发环境里挂载 ./app:/usr/src/app 后,容器报:
Cannot find module 'express'
原因
宿主机目录把镜像中原本的 /usr/src/app 内容覆盖了。
解决思路
有两种常见方案:
方案 A:启动时再执行 npm install
本文示例就是这样做的:
command: ["sh", "-c", "npm install && npm run dev"]
方案 B:单独挂 node_modules 卷
services:
app:
volumes:
- ./app:/usr/src/app
- app_node_modules:/usr/src/app/node_modules
volumes:
app_node_modules:
如果项目依赖很多,方案 B 往往更稳定。
5. 端口冲突
现象
启动时报:
Bind for 0.0.0.0:3306 failed: port is already allocated
原因
宿主机已经有本地 MySQL,或者另一个 Compose 项目占用了端口。
解决办法
修改 .env 里的端口:
MYSQL_PORT=3307
REDIS_PORT=6380
NGINX_PORT=8088
6. 容器名、网络名、卷名互相污染
原因
多个项目放在同一个目录名,或者默认项目名冲突。
建议
明确设置:
COMPOSE_PROJECT_NAME=myapp
这是团队协作时非常实用的小技巧。
安全/性能最佳实践
中型项目除了能跑,更要考虑“能长期跑”。
1. 不要把敏感信息硬编码进 compose 文件
比如这些内容不要直接写死:
- 数据库正式密码
- 第三方 API Token
- 私钥
开发环境问题不大,但一旦配置被复制到测试/预发,就容易泄露。
更稳妥的方式:
- 本地开发用
env_file - CI/CD 中通过环境变量注入
- 更严格场景使用 secret 管理机制
2. 区分开发镜像和部署镜像
开发环境强调:
- 快速迭代
- 支持挂载源码
- 容错高
部署环境强调:
- 镜像确定性
- 依赖完整
- 启动快
- 尽量只读
如果团队规模稍大,建议后续把 Dockerfile 拆成多阶段构建,例如:
Dockerfile.devDockerfile
或者在一个 Dockerfile 中使用 target。
3. 给关键服务加健康检查
特别是:
- mysql
- redis
- app
这能帮助你在排障时快速区分:
- 容器有没有启动
- 服务有没有 ready
- 是应用逻辑问题还是依赖问题
4. 合理使用卷,减少不必要的 I/O
开发环境挂源码没问题,但不要什么目录都挂。
尤其是:
- 日志目录
- 依赖目录
- 构建产物目录
挂载过多会拖慢文件同步,Mac/Windows 上更明显。我以前就遇到过“容器没慢,挂载慢到怀疑人生”的情况。
5. 控制日志量
Compose 本地开发时很容易把日志打爆,特别是 worker、队列消费者、调试模式。
建议至少配置日志轮转:
services:
app:
logging:
options:
max-size: "10m"
max-file: "3"
6. 不要把 Compose 当成完整生产编排平台
这点很重要。Compose 很适合:
- 本地开发
- CI 集成测试
- 小规模部署
- 预发环境
但如果你要做:
- 自动扩缩容
- 滚动发布
- 跨主机调度
- 高可用编排
那就应该考虑 Kubernetes、Nomad 或其他更完整的平台。
Compose 的边界很清楚:它擅长单机或小规模场景的环境一致性。
推荐的团队落地方式
如果你准备把这套方案真正带进团队,我建议按下面方式推进:
第 1 阶段:统一本地开发环境
先只做一件事:
- 每个人都用
docker compose up启项目
目标不是一步到位,而是先把“环境差异”压下来。
第 2 阶段:统一测试环境启动方式
让测试/CI 也走 Compose:
docker compose -f compose.yml -f compose.test.yml up --build -d
这样你会很快发现哪些配置本来只在某个人电脑上有效。
第 3 阶段:收敛预发配置
把预发环境尽量做成“开发环境同骨架、只替换参数和少量资源限制”。
这一步做完之后,团队对环境的理解会清晰很多。
一个更稳的配置习惯
如果你问我“最值得坚持的 Compose 习惯是什么”,我会给三个:
- 永远保留一个基础
compose.yml - 任何环境差异只写在覆盖文件里
- 上线前先用
docker compose config看最终结果
这个习惯看起来朴素,但能帮你避开很多“配置叠加后失控”的坑。
总结
对于中型项目,Docker Compose 真正解决的不是“容器启动”本身,而是这三个核心问题:
- 环境一致性
- 配置复用
- 多角色协作下的可维护性
本文这套方案的关键点可以概括为:
- 用
compose.yml定义公共骨架 - 用
compose.dev.yml / compose.test.yml / compose.prod.yml表达环境差异 - 用
.env和env/*.env做配置分层 - 用服务名通信,而不是
localhost - 用
docker compose config做最终配置核对 - 对健康检查、日志、卷挂载保持克制
如果你的项目还处在“每个人本地都不太一样”的阶段,这套方式非常适合作为第一步。
但如果你已经进入大规模集群和复杂发布流程,Compose 就不该承担它不擅长的职责。
最后给一个最实际的建议:
先从开发环境和测试环境统一开始,不要一开始就试图把生产也完全塞进 Compose。
把 80% 的环境问题先消掉,团队收益通常立刻就能看见。