Docker Compose 实战:为中型项目构建可复用的多环境开发与部署配置
中型项目往往最尴尬:规模还没大到值得上 Kubernetes,但服务已经不止一个,环境也不止一种。
这时候如果还靠“每个人本地手搓一份配置”“测试环境手工改端口”“生产环境复制粘贴 compose 文件”,后面一定会乱。
这篇文章我想带你做一套可复用、可扩展、能覆盖开发/测试/生产的 Docker Compose 配置方案。重点不是把 docker compose up 跑起来,而是让配置结构能撑住中型项目的真实复杂度。
背景与问题
一个典型的中型项目,通常会有这些组件:
web:前端静态资源或 SSR 服务api:后端应用worker:异步任务消费者db:MySQL / PostgreSQLredis:缓存和队列nginx:统一入口代理- 若干环境差异:
- 开发环境需要挂载源码、热更新、暴露调试端口
- 测试环境需要独立数据库、固定镜像版本
- 生产环境不希望挂载本地目录,也不希望暴露数据库端口
很多团队一开始会这样做:
docker-compose.ymldocker-compose-dev.ymldocker-compose-test.ymldocker-compose-prod.yml
文件越堆越多,最后会出现几个问题:
- 重复配置太多:服务名、网络、卷、健康检查写了三四遍
- 环境差异混在一起:到底哪个文件是“基线”,没人说得清
- 本地和线上行为不一致:本地能跑,上线就挂
- 环境变量管理混乱:
.env、shell 导出、CI 注入互相覆盖 - 排查困难:容器启动失败时,不知道是镜像、依赖、网络还是变量问题
我踩过一个很典型的坑:开发环境为了方便,把数据库端口映射成 3306:3306,到了测试环境也直接沿用,结果服务器上原本已有一个 MySQL,Compose 一启动就报端口冲突。这个问题本质上不是“端口配错了”,而是环境职责没有分层。
核心原理
Compose 多环境配置,建议抓住三件事:
- 基线配置只描述共性
- 环境差异通过 override 文件或 profile 管理
- 敏感信息和可变参数交给环境变量,不写死
1. Compose 配置分层思路
推荐结构:
compose.yml:基础配置,定义所有服务的共性compose.dev.yml:开发环境覆盖项compose.test.yml:测试环境覆盖项compose.prod.yml:生产环境覆盖项
执行时通过多个文件叠加:
docker compose -f compose.yml -f compose.dev.yml up -d
docker compose -f compose.yml -f compose.test.yml up -d
docker compose -f compose.yml -f compose.prod.yml up -d
覆盖规则可以简单理解为:
- 同名服务会合并
- 后面的文件优先级更高
- 某些字段是追加,有些字段是覆盖
这意味着你要把稳定不变的部分放在基线里,把只属于某个环境的差异放在 override 文件里。
2. 用 profiles 管理“可选服务”
有些组件不是每个环境都要启动,比如:
- 本地调试用的 MailHog
- 开发环境专用的 Adminer
- 压测时临时加的 mock 服务
这种很适合用 profiles:
profiles:
- debug
启动时指定:
docker compose --profile debug up -d
3. 健康检查 + 依赖关系,不只靠启动顺序
depends_on 只能保证“启动顺序”,不能保证“服务可用”。
例如数据库容器启动了,但数据库进程还没接受连接,这时 API 已经开始连接,结果报错退出。
更稳妥的方式是加 healthcheck,并让应用自己支持重试。
4. 网络、卷、环境变量是三大基础设施
- 网络:服务之间用服务名互相访问,不要写死 IP
- 卷:数据库数据、缓存数据要持久化
- 环境变量:镜像标签、端口、连接串、运行模式全部参数化
前置知识 / 环境准备
建议环境:
- Docker Engine 24+
- Docker Compose v2
- Linux / macOS / WSL2
- 基础目录结构:
myapp/
├── api/
│ ├── Dockerfile
│ ├── package.json
│ └── server.js
├── nginx/
│ └── default.conf
├── .env.dev
├── .env.test
├── .env.prod
├── compose.yml
├── compose.dev.yml
├── compose.test.yml
└── compose.prod.yml
本文示例用一个 Node.js API + Nginx + MySQL + Redis + Worker 的组合来演示。
整体架构图
flowchart LR
User[浏览器/客户端] --> Nginx
Nginx --> API[api 服务]
API --> MySQL[(MySQL)]
API --> Redis[(Redis)]
Worker[worker 服务] --> Redis
Worker --> MySQL
这套结构有几个特点:
nginx作为统一入口api和worker共用同一份应用镜像db、redis通过内部网络暴露给应用,不直接暴露给外部- 环境差异主要体现在:
- 是否挂载源码
- 是否暴露端口
- 镜像构建还是直接拉取
- 副本数和资源限制
配置分层设计
基线配置的职责
基线文件只做这些事:
- 定义服务名称
- 定义镜像或构建方式
- 定义网络和卷
- 定义默认环境变量
- 定义健康检查
- 定义共用依赖
环境覆盖文件的职责
覆盖文件只描述差异:
- 开发环境:
- 挂载源码
- 暴露调试端口
- 使用开发命令
- 测试环境:
- 固定标签镜像
- 注入测试专用变量
- 生产环境:
- 不挂载源码
- 不暴露数据库端口
- 更严格的重启策略和资源限制
实战代码(可运行)
下面直接给出一套可跑的示例。
1. Node.js API 示例
api/package.json
{
"name": "compose-medium-project-demo",
"version": "1.0.0",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "node server.js",
"worker": "node worker.js"
},
"dependencies": {
"express": "^4.19.2",
"mysql2": "^3.11.0",
"redis": "^4.7.0"
}
}
api/server.js
const express = require("express");
const mysql = require("mysql2/promise");
const { createClient } = require("redis");
const app = express();
const port = process.env.APP_PORT || 3000;
const dbConfig = {
host: process.env.DB_HOST || "db",
port: Number(process.env.DB_PORT || 3306),
user: process.env.DB_USER || "app",
password: process.env.DB_PASSWORD || "app123",
database: process.env.DB_NAME || "app_db"
};
const redisUrl = `redis://${process.env.REDIS_HOST || "redis"}:${process.env.REDIS_PORT || 6379}`;
async function waitForMysql(retries = 20, delay = 3000) {
for (let i = 1; i <= retries; i++) {
try {
const conn = await mysql.createConnection(dbConfig);
await conn.query(`
CREATE TABLE IF NOT EXISTS visits (
id INT AUTO_INCREMENT PRIMARY KEY,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
`);
await conn.end();
console.log("MySQL ready");
return;
} catch (err) {
console.log(`MySQL not ready, retry ${i}/${retries}: ${err.message}`);
await new Promise((r) => setTimeout(r, delay));
}
}
throw new Error("MySQL connection failed after retries");
}
async function createRedisClient() {
const client = createClient({ url: redisUrl });
client.on("error", (err) => console.error("Redis error:", err.message));
await client.connect();
return client;
}
async function bootstrap() {
await waitForMysql();
const redis = await createRedisClient();
app.get("/health", async (req, res) => {
try {
const conn = await mysql.createConnection(dbConfig);
await conn.query("SELECT 1");
await conn.end();
await redis.ping();
res.json({ ok: true });
} catch (err) {
res.status(500).json({ ok: false, error: err.message });
}
});
app.get("/", async (req, res) => {
const conn = await mysql.createConnection(dbConfig);
await conn.query("INSERT INTO visits () VALUES ()");
const [rows] = await conn.query("SELECT COUNT(*) AS count FROM visits");
await conn.end();
const count = rows[0].count;
await redis.set("last_visit_count", String(count));
res.json({
message: "Hello from Docker Compose multi-env demo",
visits: count,
env: process.env.APP_ENV || "unknown"
});
});
app.listen(port, () => {
console.log(`API listening on ${port}`);
});
}
bootstrap().catch((err) => {
console.error(err);
process.exit(1);
});
api/worker.js
const mysql = require("mysql2/promise");
const { createClient } = require("redis");
const dbConfig = {
host: process.env.DB_HOST || "db",
port: Number(process.env.DB_PORT || 3306),
user: process.env.DB_USER || "app",
password: process.env.DB_PASSWORD || "app123",
database: process.env.DB_NAME || "app_db"
};
async function run() {
const redis = createClient({
url: `redis://${process.env.REDIS_HOST || "redis"}:${process.env.REDIS_PORT || 6379}`
});
redis.on("error", (err) => console.error("Redis error:", err.message));
await redis.connect();
while (true) {
try {
const conn = await mysql.createConnection(dbConfig);
const [rows] = await conn.query("SELECT COUNT(*) AS count FROM visits");
await conn.end();
const count = rows[0].count;
await redis.set("stats:visit_count", String(count));
console.log("Updated stats:visit_count =", count);
} catch (err) {
console.error("Worker loop error:", err.message);
}
await new Promise((r) => setTimeout(r, 5000));
}
}
run().catch((err) => {
console.error(err);
process.exit(1);
});
api/Dockerfile
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 3000
CMD ["npm", "start"]
2. Nginx 配置
nginx/default.conf
server {
listen 80;
server_name _;
location / {
proxy_pass http://api:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
3. 基线 Compose 文件
compose.yml
name: medium-project-demo
services:
nginx:
image: nginx:1.27-alpine
depends_on:
api:
condition: service_started
ports:
- "${NGINX_PORT:-8080}:80"
volumes:
- ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
networks:
- app_net
restart: unless-stopped
api:
build:
context: ./api
dockerfile: Dockerfile
image: medium-project-demo-api:${APP_IMAGE_TAG:-latest}
environment:
APP_ENV: ${APP_ENV:-dev}
APP_PORT: 3000
DB_HOST: db
DB_PORT: 3306
DB_NAME: ${DB_NAME:-app_db}
DB_USER: ${DB_USER:-app}
DB_PASSWORD: ${DB_PASSWORD:-app123}
REDIS_HOST: redis
REDIS_PORT: 6379
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
networks:
- app_net
restart: unless-stopped
worker:
image: medium-project-demo-api:${APP_IMAGE_TAG:-latest}
command: ["npm", "run", "worker"]
environment:
APP_ENV: ${APP_ENV:-dev}
DB_HOST: db
DB_PORT: 3306
DB_NAME: ${DB_NAME:-app_db}
DB_USER: ${DB_USER:-app}
DB_PASSWORD: ${DB_PASSWORD:-app123}
REDIS_HOST: redis
REDIS_PORT: 6379
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
networks:
- app_net
restart: unless-stopped
db:
image: mysql:8.4
environment:
MYSQL_DATABASE: ${DB_NAME:-app_db}
MYSQL_USER: ${DB_USER:-app}
MYSQL_PASSWORD: ${DB_PASSWORD:-app123}
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD:-root123}
volumes:
- db_data:/var/lib/mysql
healthcheck:
test: ["CMD-SHELL", "mysqladmin ping -h localhost -uroot -p$$MYSQL_ROOT_PASSWORD"]
interval: 5s
timeout: 3s
retries: 20
networks:
- app_net
restart: unless-stopped
redis:
image: redis:7.4-alpine
command: ["redis-server", "--appendonly", "yes"]
volumes:
- redis_data:/data
networks:
- app_net
restart: unless-stopped
networks:
app_net:
volumes:
db_data:
redis_data:
4. 开发环境覆盖文件
开发环境通常强调:
- 源码热更新
- 开放更多端口
- 允许调试
- 可以附加辅助工具
compose.dev.yml
services:
api:
env_file:
- .env.dev
volumes:
- ./api:/app
- /app/node_modules
command: ["npm", "run", "dev"]
ports:
- "3000:3000"
worker:
env_file:
- .env.dev
volumes:
- ./api:/app
- /app/node_modules
db:
env_file:
- .env.dev
ports:
- "3307:3306"
redis:
ports:
- "6379:6379"
adminer:
image: adminer:4
profiles:
- debug
ports:
- "8081:8080"
networks:
- app_net
depends_on:
db:
condition: service_started
.env.dev
APP_ENV=development
APP_IMAGE_TAG=dev
DB_NAME=app_db_dev
DB_USER=app
DB_PASSWORD=app123
DB_ROOT_PASSWORD=root123
NGINX_PORT=8080
5. 测试环境覆盖文件
测试环境更像“轻量部署环境”,重点是:
- 不挂载源码
- 使用构建产物或固定镜像
- 端口尽量明确
- 数据隔离
compose.test.yml
services:
api:
env_file:
- .env.test
worker:
env_file:
- .env.test
db:
env_file:
- .env.test
ports:
- "3308:3306"
nginx:
env_file:
- .env.test
.env.test
APP_ENV=test
APP_IMAGE_TAG=test
DB_NAME=app_db_test
DB_USER=app
DB_PASSWORD=test123
DB_ROOT_PASSWORD=testroot123
NGINX_PORT=8088
6. 生产环境覆盖文件
生产环境要尽量减少“开发式便利”。
compose.prod.yml
services:
nginx:
env_file:
- .env.prod
ports:
- "${NGINX_PORT:-80}:80"
api:
env_file:
- .env.prod
build: null
image: ${API_IMAGE:?API_IMAGE is required}
worker:
env_file:
- .env.prod
image: ${API_IMAGE:?API_IMAGE is required}
db:
env_file:
- .env.prod
ports: []
healthcheck:
test: ["CMD-SHELL", "mysqladmin ping -h localhost -uroot -p$$MYSQL_ROOT_PASSWORD"]
interval: 10s
timeout: 5s
retries: 12
redis:
ports: []
command: ["redis-server", "--appendonly", "yes", "--requirepass", "${REDIS_PASSWORD:?REDIS_PASSWORD is required}"]
.env.prod
APP_ENV=production
DB_NAME=app_db_prod
DB_USER=app
DB_PASSWORD=prod_app_password
DB_ROOT_PASSWORD=prod_root_password
NGINX_PORT=80
API_IMAGE=registry.example.com/myteam/medium-project-demo-api:1.0.0
REDIS_PASSWORD=prod_redis_password
提醒一下:真正生产环境里,
.env.prod最好不要直接放仓库,应该通过 CI/CD、密钥管理系统或服务器安全注入。
启动流程图
sequenceDiagram
participant U as 运维/开发者
participant C as Docker Compose
participant D as db
participant R as redis
participant A as api
participant W as worker
participant N as nginx
U->>C: docker compose -f compose.yml -f compose.dev.yml up -d
C->>D: 启动 MySQL
C->>R: 启动 Redis
D-->>C: healthcheck 通过
C->>A: 启动 API
C->>W: 启动 Worker
C->>N: 启动 Nginx
U->>N: 访问 8080
N->>A: 反向代理请求
A->>D: 读写数据
A->>R: 写缓存/状态
7. 运行与验证
开发环境启动
docker compose -f compose.yml -f compose.dev.yml up -d --build
如果想带上 Adminer:
docker compose -f compose.yml -f compose.dev.yml --profile debug up -d --build
测试环境启动
docker compose -f compose.yml -f compose.test.yml up -d --build
生产环境启动
docker compose -f compose.yml -f compose.prod.yml up -d
验证 API
curl http://localhost:8080/
预期返回:
{
"message": "Hello from Docker Compose multi-env demo",
"visits": 1,
"env": "development"
}
验证健康检查
curl http://localhost:8080/health
查看日志
docker compose -f compose.yml -f compose.dev.yml logs -f api
docker compose -f compose.yml -f compose.dev.yml logs -f worker
docker compose -f compose.yml -f compose.dev.yml logs -f db
停止并清理
docker compose -f compose.yml -f compose.dev.yml down
如果要连同数据卷一起删:
docker compose -f compose.yml -f compose.dev.yml down -v
逐步验证清单
建议你不要一上来就全启动,按下面顺序验证,出问题更容易定位:
docker compose config看最终合并结果- 单独启动
db、redis - 检查
dbhealthcheck 是否通过 - 启动
api,观察连接数据库是否成功 - 启动
worker - 最后启动
nginx - 用
curl验证/和/health - 重启一遍确认数据卷是否保留
查看合并后的最终配置
docker compose -f compose.yml -f compose.dev.yml config
这个命令特别有用。很多“我明明写了怎么没生效”的问题,跑一次它基本就能看出问题。
核心原理再落地:如何避免配置失控
中型项目里,我最推荐下面这套规则:
规则 1:基线文件不要写“开发便利项”
比如:
- 不要在基线里直接暴露数据库端口
- 不要在基线里挂载源码目录
- 不要在基线里写测试专用环境变量
因为这些都不是“共性”。
规则 2:应用镜像尽量复用
上面的 api 和 worker 共用一个镜像,只通过 command 区分入口。
这能减少:
- Dockerfile 重复
- 依赖版本漂移
- CI 构建时间
规则 3:服务间访问统一用服务名
比如 API 连数据库写:
db:3306redis:6379
不要写:
127.0.0.1- 宿主机 IP
- 手动分配的容器 IP
规则 4:环境变量分“默认值”和“必填值”
Compose 里很适合这样写:
image: ${API_IMAGE:?API_IMAGE is required}
这样生产环境少传变量时会直接失败,而不是等服务启动后才发现拉不到镜像。
常见坑与排查
这部分很重要。很多 Compose 问题不是不会写,而是“不知道为什么没按预期工作”。
1. depends_on 不等于服务真正可用
现象
api 已启动,但日志里报:
ECONNREFUSED
原因
db 容器进程启动了,但数据库还没完成初始化。
处理方法
- 给
db加healthcheck - 应用侧实现重试逻辑
- 不要迷信单纯的
depends_on
2. 覆盖文件没生效
现象
你明明在 compose.dev.yml 里写了端口映射,但启动后没看到。
排查
先执行:
docker compose -f compose.yml -f compose.dev.yml config
看最终配置里有没有你期望的字段。
常见原因
- 文件顺序反了
- 服务名写错
- 实际使用的是
docker-compose老命令或旧版本行为不同 .env和env_file变量来源搞混了
3. 宿主机端口冲突
现象
启动时报:
Bind for 0.0.0.0:3306 failed: port is already allocated
处理
查看占用:
lsof -i :3306
或者干脆不要对外暴露数据库端口。
我一般建议:
- 开发环境映射成
3307:3306 - 测试环境映射成
3308:3306 - 生产环境不映射
4. 挂载源码后依赖丢失
现象
开发环境启动时报:
Error: Cannot find module 'express'
原因
宿主机目录挂载把镜像内 /app 覆盖了,连同容器里的 node_modules 一起“遮住”了。
处理
像这样保留匿名卷:
volumes:
- ./api:/app
- /app/node_modules
这个写法在 Node.js 开发环境里非常常见。
5. 环境变量优先级理解错误
Compose 里变量来源不少,容易混:
- shell 环境变量
- 项目目录下
.env env_fileenvironment
一个经验结论:
- 插值主要看 Compose 解析阶段拿到的变量
- 容器内环境变量最终看
environment/env_file
如果你发现 ${VAR} 没替换,先检查是不是 Compose 启动时那个变量根本不存在。
6. 生产环境仍然在本机构建镜像
现象
服务器启动很慢,还依赖本地源码目录。
原因
生产覆盖文件没把 build 清掉,或者仍然使用本地构建逻辑。
处理
像本文这样在生产覆盖里显式指定:
build: null
image: ${API_IMAGE:?API_IMAGE is required}
安全最佳实践
中型项目不一定有专职平台团队,所以更要避免“方便但危险”的配置。
1. 不把敏感信息写死进仓库
尤其是:
DB_ROOT_PASSWORDREDIS_PASSWORD- 第三方 API Key
- JWT Secret
更推荐:
- CI/CD 注入
- Docker secrets(如果运行环境支持)
- 服务器环境变量
- 专门的密钥管理系统
2. 生产环境不要随意暴露内部服务端口
常见的安全错误是:
- MySQL 对公网开放
- Redis 对公网开放且没密码
- Adminer / phpMyAdmin 长期开着
建议:
db、redis只放内网- 调试服务用
profiles - 仅在临时维护时启用
3. 用非 root 用户运行应用
示例为了简洁没有展开,但实际项目里建议在 Dockerfile 里切换非 root 用户。
例如:
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN addgroup -S app && adduser -S app -G app
USER app
EXPOSE 3000
CMD ["npm", "start"]
4. 固定镜像版本,不要长期用 latest
开发环境可以灵活一点,但测试、生产环境最好固定版本:
image: mysql:8.4
image: redis:7.4-alpine
应用镜像也要打版本标签,比如:
registry.example.com/myteam/medium-project-demo-api:1.0.0
性能最佳实践
Compose 虽然不是大规模编排工具,但也不是只能“凑合用”。做好几个点,稳定性会明显提升。
1. 给数据库和缓存挂持久化卷
这不是性能优化的唯一手段,但绝对是稳定性的底线。
没有卷的话,容器一删数据就没了,后面的排查全是噪音。
2. 不要把所有流量都打到应用直端口
用 Nginx 做入口有几个好处:
- 统一暴露端口
- 更容易加 gzip、缓存、限流
- 后续切 HTTPS 更顺
3. 尽量减少开发环境和生产环境的镜像差异
理想状态是:
- 同一份 Dockerfile
- 相同依赖安装方式
- 不同环境只改少量变量和挂载策略
这样“本地没问题、线上不行”的概率会小很多。
4. 合理设置重启策略
restart: unless-stopped
对多数中型项目已经够用。
但要注意,重启策略不是兜底修复工具。如果应用启动逻辑有 bug,它只会帮你无限重启。
5. 做好健康检查和观测
至少要能做到:
docker compose ps看状态docker compose logs -f看日志- HTTP
/health可探活 - 数据库/缓存连接失败时日志可读
环境配置关系图
flowchart TD
A[compose.yml 基线] --> B[compose.dev.yml]
A --> C[compose.test.yml]
A --> D[compose.prod.yml]
B --> E[开发环境<br/>挂载源码/开放调试端口]
C --> F[测试环境<br/>固定配置/数据隔离]
D --> G[生产环境<br/>仅镜像部署/最小暴露面]
什么时候 Compose 够用,什么时候该升级方案?
这篇文章讲的是中型项目,所以也要说清边界。
Compose 很适合的场景
- 服务数量在个位数到十来个
- 团队主要需求是统一开发环境和简单部署
- 单机或少量服务器部署
- 还不需要复杂的自动扩缩容和服务治理
Compose 不太适合的场景
- 多机集群高可用
- 动态扩缩容频繁
- 强依赖服务发现、灰度发布、复杂滚动升级
- 需要细粒度资源编排和大规模可观测性
如果项目已经进入这些阶段,就该考虑:
- Kubernetes
- Nomad
- 或更成熟的 PaaS / GitOps 体系
所以别把 Compose 神化,它很强,但也有边界。
总结
如果你想为中型项目构建一套可复用的多环境 Docker Compose 配置,我建议直接落实这几条:
- 一份基线文件 + 多份环境覆盖文件
- 共性放基线,差异放 override
- 服务间通信统一用服务名
- 数据库加 healthcheck,应用侧加重试
- 开发环境允许便利,生产环境坚持最小暴露
- 生产部署优先拉镜像,不在服务器现构建
- 先用
docker compose config看最终结果,再排障
如果你今天就准备改造现有项目,我建议从最小一步开始:
- 先把现有
docker-compose.yml拆成compose.yml + compose.dev.yml - 再把数据库、缓存、应用的环境变量整理出来
- 最后补上测试和生产覆盖文件
这样成本不高,但收益很快能看到:
配置更清晰,环境更一致,排查也更省心。
对中型项目来说,这往往就是从“能跑”走向“可维护”的分水岭。