跳转到内容
123xiao | 无名键客

《Docker Compose 实战:为中型项目构建可复用的多环境本地开发与部署方案》

字数: 0 阅读时长: 1 分钟

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 里的变量来源常见有三层:

  1. Shell 环境变量
  2. .env 文件
  3. environment / env_file 注入到容器

这三层经常被混用。我的经验是:

  • Compose 自己需要的变量:放根目录 .env
  • 应用运行时变量:放 env/*.env
  • 敏感信息:不要直接写死在 compose 文件里

3. 网络与服务发现

Compose 默认会为项目创建独立网络,服务名就是 DNS 名。
也就是说,应用连接 MySQL 时,主机名不用写 127.0.0.1,而应该写:

  • mysql
  • redis
  • app

这是很多人刚接触 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. 环境变量明明写了,但容器里没生效

常见原因

  • .envenv_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.dev
  • Dockerfile

或者在一个 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 习惯是什么”,我会给三个:

  1. 永远保留一个基础 compose.yml
  2. 任何环境差异只写在覆盖文件里
  3. 上线前先用 docker compose config 看最终结果

这个习惯看起来朴素,但能帮你避开很多“配置叠加后失控”的坑。


总结

对于中型项目,Docker Compose 真正解决的不是“容器启动”本身,而是这三个核心问题:

  • 环境一致性
  • 配置复用
  • 多角色协作下的可维护性

本文这套方案的关键点可以概括为:

  • compose.yml 定义公共骨架
  • compose.dev.yml / compose.test.yml / compose.prod.yml 表达环境差异
  • .envenv/*.env 做配置分层
  • 用服务名通信,而不是 localhost
  • docker compose config 做最终配置核对
  • 对健康检查、日志、卷挂载保持克制

如果你的项目还处在“每个人本地都不太一样”的阶段,这套方式非常适合作为第一步。
但如果你已经进入大规模集群和复杂发布流程,Compose 就不该承担它不擅长的职责。

最后给一个最实际的建议:
先从开发环境和测试环境统一开始,不要一开始就试图把生产也完全塞进 Compose。
把 80% 的环境问题先消掉,团队收益通常立刻就能看见。


分享到:

上一篇
《安卓逆向实战:基于 Frida 与 JADX 的应用登录流程分析与参数签名定位》
下一篇
《Docker Compose 到 Kubernetes 迁移实战:中型项目的容器编排改造与避坑指南》