一个 Node.js 项目从零容器化:Docker Compose 编排 MySQL 与 Redis 实战
Docker 实战案例分享|附源码
前言
在云原生、微服务和 DevOps 已经成为主流工程实践的今天,Docker 几乎是每一位后端开发、运维工程师、测试工程师都绕不开的基础工具。它解决的核心问题并不复杂:让应用及其运行环境一起打包、分发和运行。过去我们经常会遇到这样的问题:开发环境能跑,测试环境报错;测试环境正常,生产环境又因为某个依赖版本不同而失败。Docker 的出现,正是为了降低这类环境不一致带来的成本。
本文将通过一个完整的实战案例,分享如何使用 Docker 容器化一个 Web 应用,并配合 Docker Compose 编排应用服务、数据库服务和缓存服务。文章会尽量贴近真实项目场景,不只停留在“写一个 Dockerfile”这种入门层面,而是从项目结构、镜像构建、容器运行、服务编排、环境变量、数据持久化、日志查看、常见问题排查等方面进行系统说明。
本文适合以下读者:
- 已经了解 Docker 基本概念,但缺少项目实战经验的开发者;
- 想把本地项目部署到服务器上的后端工程师;
- 想学习 Docker Compose 编排多个服务的初学者;
- 希望规范团队开发环境、降低部署成本的技术负责人。
一、案例目标
本次实战案例的目标是:使用 Docker 部署一个简单的 Web API 项目。
项目包含三个核心服务:
- Web 应用服务:提供 HTTP API;
- MySQL 数据库服务:用于存储业务数据;
- Redis 缓存服务:用于缓存、验证码、会话或热点数据。
最终我们希望通过一条命令完成整个项目的启动:
docker compose up -d
启动后,可以访问接口:
curl http://localhost:8080/health
返回:
{
"status": "ok"
}
同时,Web 应用能够正常连接 MySQL 和 Redis。
二、项目结构设计
一个清晰的项目结构对于 Docker 化部署非常重要。很多项目 Docker 化失败,并不是因为 Docker 本身复杂,而是因为项目目录混乱、配置散落、环境变量硬编码,导致容器运行时难以维护。
本案例采用如下目录结构:
docker-demo/
├── app/
│ ├── package.json
│ ├── package-lock.json
│ └── src/
│ └── index.js
├── Dockerfile
├── docker-compose.yml
├── .dockerignore
└── README.md
其中:
app/:存放 Node.js Web 应用源码;Dockerfile:用于构建 Web 应用镜像;docker-compose.yml:用于编排 Web、MySQL、Redis 三个服务;.dockerignore:用于忽略不需要复制进镜像的文件;README.md:项目说明文档。
这里使用 Node.js 作为示例语言,是因为它启动简单、代码短小,便于把重点放在 Docker 实战本身。如果你使用 Java、Go、Python 或 PHP,整体思路也是类似的。
三、编写 Web 应用源码
我们先创建一个最小可用的 Web 服务。这里使用 Express 框架。
1. package.json
{
"name": "docker-demo-api",
"version": "1.0.0",
"description": "Docker practical demo API",
"main": "src/index.js",
"scripts": {
"start": "node src/index.js"
},
"dependencies": {
"express": "^4.18.3",
"mysql2": "^3.9.2",
"redis": "^4.6.13"
}
}
这个文件定义了项目依赖和启动命令。容器启动时会执行:
npm start
2. src/index.js
const express = require("express");
const mysql = require("mysql2/promise");
const { createClient } = require("redis");
const app = express();
const PORT = process.env.PORT || 8080;
const mysqlConfig = {
host: process.env.MYSQL_HOST || "mysql",
port: Number(process.env.MYSQL_PORT || 3306),
user: process.env.MYSQL_USER || "demo",
password: process.env.MYSQL_PASSWORD || "demo123456",
database: process.env.MYSQL_DATABASE || "demo_db"
};
const redisUrl = process.env.REDIS_URL || "redis://redis:6379";
app.get("/health", async (req, res) => {
res.json({ status: "ok" });
});
app.get("/mysql", async (req, res) => {
try {
const connection = await mysql.createConnection(mysqlConfig);
const [rows] = await connection.execute("SELECT NOW() AS currentTime");
await connection.end();
res.json({
connected: true,
data: rows[0]
});
} catch (error) {
res.status(500).json({
connected: false,
message: error.message
});
}
});
app.get("/redis", async (req, res) => {
const client = createClient({ url: redisUrl });
try {
await client.connect();
await client.set("docker-demo", "hello docker");
const value = await client.get("docker-demo");
await client.disconnect();
res.json({
connected: true,
value
});
} catch (error) {
res.status(500).json({
connected: false,
message: error.message
});
}
});
app.listen(PORT, () => {
console.log(`API server is running on port ${PORT}`);
});
这段代码提供了三个接口:
/health:健康检查接口;/mysql:测试 MySQL 连接;/redis:测试 Redis 连接。
这里有一个非常重要的细节:MySQL 主机名默认是 mysql,Redis 地址默认是 redis://redis:6379。这并不是随意写的,而是因为在 Docker Compose 中,服务名本身就可以作为容器之间互相访问的 DNS 名称。
换句话说,在同一个 Docker Compose 网络中:
- Web 容器访问 MySQL,可以使用
mysql:3306; - Web 容器访问 Redis,可以使用
redis:6379。
这也是 Docker Compose 非常方便的地方。
四、编写 Dockerfile
接下来我们为 Web 应用编写 Dockerfile。
FROM node:20-alpine
WORKDIR /app
COPY app/package*.json ./
RUN npm ci --only=production
COPY app/src ./src
ENV NODE_ENV=production
ENV PORT=8080
EXPOSE 8080
CMD ["npm", "start"]
逐行解释如下:
FROM node:20-alpine
表示使用 Node.js 20 的 Alpine 镜像作为基础镜像。Alpine 体积较小,适合生产环境使用。但需要注意,如果项目依赖某些系统库,Alpine 可能需要额外安装依赖。
WORKDIR /app
设置容器内工作目录为 /app。
COPY app/package*.json ./
先复制依赖声明文件,而不是直接复制全部源码。这是为了利用 Docker 镜像缓存。如果源码变了但依赖没变,Docker 可以复用上一层 npm ci 的缓存,提高构建速度。
RUN npm ci --only=production
安装生产依赖。相比 npm install,npm ci 更适合 CI/CD 和镜像构建,因为它会严格按照 package-lock.json 安装依赖。
COPY app/src ./src
复制业务源码。
EXPOSE 8080
声明容器内部服务监听端口。
CMD ["npm", "start"]
容器启动时执行应用启动命令。
五、编写 .dockerignore
很多初学者会忽略 .dockerignore,但它在真实项目中非常重要。如果没有它,Docker 构建镜像时可能会把 node_modules、日志文件、临时文件、Git 目录等全部发送到 Docker Daemon,导致构建变慢、镜像上下文变大,甚至泄露敏感信息。
示例:
.git
node_modules
npm-debug.log
Dockerfile
docker-compose.yml
README.md
.env
对于 Node.js 项目,通常不建议把本地 node_modules 复制进镜像,因为不同系统环境下安装出的依赖可能不同。正确做法是在镜像内部执行依赖安装。
六、编写 Docker Compose 配置
接下来是本案例最核心的部分:使用 Docker Compose 编排多个服务。
services:
api:
build:
context: .
dockerfile: Dockerfile
container_name: docker-demo-api
ports:
- "8080:8080"
environment:
PORT: 8080
MYSQL_HOST: mysql
MYSQL_PORT: 3306
MYSQL_USER: demo
MYSQL_PASSWORD: demo123456
MYSQL_DATABASE: demo_db
REDIS_URL: redis://redis:6379
depends_on:
- mysql
- redis
restart: unless-stopped
mysql:
image: mysql:8.0
container_name: docker-demo-mysql
environment:
MYSQL_ROOT_PASSWORD: root123456
MYSQL_DATABASE: demo_db
MYSQL_USER: demo
MYSQL_PASSWORD: demo123456
ports:
- "3306:3306"
volumes:
- mysql_data:/var/lib/mysql
restart: unless-stopped
redis:
image: redis:7-alpine
container_name: docker-demo-redis
ports:
- "6379:6379"
volumes:
- redis_data:/data
restart: unless-stopped
volumes:
mysql_data:
redis_data:
这个文件定义了三个服务:api、mysql、redis。
1. API 服务
api:
build:
context: .
dockerfile: Dockerfile
表示 API 服务不是直接拉取现成镜像,而是根据当前目录下的 Dockerfile 构建。
ports:
- "8080:8080"
表示把宿主机的 8080 端口映射到容器内的 8080 端口。这样我们就可以通过 http://localhost:8080 访问容器内服务。
depends_on:
- mysql
- redis
表示 API 服务依赖 MySQL 和 Redis。需要注意的是,depends_on 只保证容器启动顺序,不保证 MySQL 已经完全初始化完成。因此,在真实生产项目中,应用最好具备连接重试机制,或者在 Compose 中配置健康检查。
2. MySQL 服务
mysql:
image: mysql:8.0
表示使用官方 MySQL 8.0 镜像。
volumes:
- mysql_data:/var/lib/mysql
表示将 MySQL 数据目录挂载到 Docker volume。这样即使容器被删除,数据库数据仍然可以保留。
如果不配置数据卷,容器删除后,数据库数据也会随之消失。这是很多初学者在 Docker 实战中容易踩的坑。
3. Redis 服务
redis:
image: redis:7-alpine
表示使用 Redis 7 的 Alpine 镜像。
volumes:
- redis_data:/data
Redis 默认会把持久化数据写入 /data 目录,因此这里也挂载一个 volume。
七、启动项目
在项目根目录执行:
docker compose up -d
如果是第一次启动,Docker 会先构建 API 镜像,然后拉取 MySQL 和 Redis 镜像,最后创建并启动容器。
查看容器状态:
docker compose ps
正常情况下可以看到类似输出:
NAME IMAGE STATUS
docker-demo-api docker-demo-api Up
docker-demo-mysql mysql:8.0 Up
docker-demo-redis redis:7-alpine Up
查看日志:
docker compose logs -f api
如果 API 服务启动成功,会看到:
API server is running on port 8080
八、测试接口
1. 健康检查
curl http://localhost:8080/health
返回:
{
"status": "ok"
}
这说明 API 服务已经正常启动。
2. 测试 MySQL
curl http://localhost:8080/mysql
如果连接成功,返回结果类似:
{
"connected": true,
"data": {
"currentTime": "2025-01-01T10:00:00.000Z"
}
}
如果第一次启动时失败,可能是因为 MySQL 初始化较慢。可以等待十几秒后再次请求,或者查看 MySQL 日志:
docker compose logs -f mysql
当看到类似下面的日志时,说明 MySQL 已经准备就绪:
ready for connections
3. 测试 Redis
curl http://localhost:8080/redis
返回:
{
"connected": true,
"value": "hello docker"
}
这说明 API 已经可以通过 Compose 内部网络访问 Redis。
九、常见问题排查
1. 为什么 API 连接不上 MySQL?
常见原因有以下几种:
第一,MySQL 还没有完全启动。虽然 depends_on 能控制启动顺序,但不能保证数据库已经可连接。解决方法是等待一段时间,或者在应用代码中加入重试逻辑。
第二,数据库连接地址写错。在 Docker Compose 网络内部,不能使用 localhost 连接 MySQL。因为对于 API 容器来说,localhost 指的是 API 容器本身,而不是 MySQL 容器。正确地址应该是服务名 mysql。
第三,用户名、密码或数据库名不一致。需要检查 docker-compose.yml 中 MySQL 服务和 API 服务的环境变量是否一致。
2. 为什么修改代码后没有生效?
如果代码已经复制到镜像中,那么修改本地代码后,需要重新构建镜像:
docker compose up -d --build
如果是开发环境,也可以使用 volume 挂载源码,让容器直接读取本地文件。不过生产环境不建议这样做,生产环境更推荐镜像不可变,即每次发布构建一个新的镜像版本。
3. 为什么数据库数据丢失?
如果没有配置 volume,删除容器后数据就会丢失。正确做法是使用 Docker volume:
volumes:
- mysql_data:/var/lib/mysql
如果想查看当前 volume:
docker volume ls
如果确实需要清理数据,可以执行:
docker compose down -v
注意:-v 会删除数据卷,生产环境慎用。
4. 容器端口冲突怎么办?
如果宿主机已经有服务占用了 8080、3306 或 6379,启动时会报端口占用错误。可以修改 Compose 中左侧的宿主机端口,例如:
ports:
- "18080:8080"
这样容器内部仍然监听 8080,但宿主机通过 18080 访问。
十、生产环境优化建议
上面的案例已经可以运行,但如果要用于生产环境,还需要做一些增强。
1. 不要把密码写死在 Compose 文件中
示例中为了便于演示,直接把数据库密码写在了 docker-compose.yml 中。真实项目中更推荐使用 .env 文件或密钥管理系统。
示例 .env:
MYSQL_ROOT_PASSWORD=root123456
MYSQL_DATABASE=demo_db
MYSQL_USER=demo
MYSQL_PASSWORD=demo123456
Compose 中引用:
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
MYSQL_DATABASE: ${MYSQL_DATABASE}
MYSQL_USER: ${MYSQL_USER}
MYSQL_PASSWORD: ${MYSQL_PASSWORD}
同时,.env 文件不要提交到 Git 仓库。
2. 添加健康检查
可以给 MySQL 和 Redis 添加健康检查,让服务状态更清晰。
mysql:
image: mysql:8.0
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 10s
timeout: 5s
retries: 5
健康检查不能替代应用层重试,但可以帮助我们更快判断容器是否可用。
3. 控制镜像体积
镜像体积越小,构建和分发速度越快,攻击面也更小。常见优化方式包括:
- 使用 Alpine 或 slim 镜像;
- 使用多阶段构建;
- 不复制无关文件;
- 合理配置
.dockerignore; - 只安装生产依赖。
4. 日志交给平台管理
容器内应用应该尽量把日志输出到标准输出和标准错误,而不是写死到某个文件。这样 Docker、Kubernetes 或日志采集系统都可以统一收集日志。
在 Node.js 中,使用:
console.log("message");
console.error("error");
Docker 可以通过以下命令查看日志:
docker compose logs -f api
5. 镜像版本要可追踪
不要总是使用 latest 标签。生产环境建议使用明确版本号,例如:
docker build -t demo-api:1.0.0 .
这样当线上出现问题时,可以快速回滚到上一个稳定版本。
十一、完整源码汇总
为了方便复制,下面给出核心源码。
1. Dockerfile
FROM node:20-alpine
WORKDIR /app
COPY app/package*.json ./
RUN npm ci --only=production
COPY app/src ./src
ENV NODE_ENV=production
ENV PORT=8080
EXPOSE 8080
CMD ["npm", "start"]
2. docker-compose.yml
services:
api:
build:
context: .
dockerfile: Dockerfile
container_name: docker-demo-api
ports:
- "8080:8080"
environment:
PORT: 8080
MYSQL_HOST: mysql
MYSQL_PORT: 3306
MYSQL_USER: demo
MYSQL_PASSWORD: demo123456
MYSQL_DATABASE: demo_db
REDIS_URL: redis://redis:6379
depends_on:
- mysql
- redis
restart: unless-stopped
mysql:
image: mysql:8.0
container_name: docker-demo-mysql
environment:
MYSQL_ROOT_PASSWORD: root123456
MYSQL_DATABASE: demo_db
MYSQL_USER: demo
MYSQL_PASSWORD: demo123456
ports:
- "3306:3306"
volumes:
- mysql_data:/var/lib/mysql
restart: unless-stopped
redis:
image: redis:7-alpine
container_name: docker-demo-redis
ports:
- "6379:6379"
volumes:
- redis_data:/data
restart: unless-stopped
volumes:
mysql_data:
redis_data:
3. .dockerignore
.git
node_modules
npm-debug.log
Dockerfile
docker-compose.yml
README.md
.env
4. app/package.json
{
"name": "docker-demo-api",
"version": "1.0.0",
"description": "Docker practical demo API",
"main": "src/index.js",
"scripts": {
"start": "node src/index.js"
},
"dependencies": {
"express": "^4.18.3",
"mysql2": "^3.9.2",
"redis": "^4.6.13"
}
}
5. app/src/index.js
const express = require("express");
const mysql = require("mysql2/promise");
const { createClient } = require("redis");
const app = express();
const PORT = process.env.PORT || 8080;
const mysqlConfig = {
host: process.env.MYSQL_HOST || "mysql",
port: Number(process.env.MYSQL_PORT || 3306),
user: process.env.MYSQL_USER || "demo",
password: process.env.MYSQL_PASSWORD || "demo123456",
database: process.env.MYSQL_DATABASE || "demo_db"
};
const redisUrl = process.env.REDIS_URL || "redis://redis:6379";
app.get("/health", async (req, res) => {
res.json({ status: "ok" });
});
app.get("/mysql", async (req, res) => {
try {
const connection = await mysql.createConnection(mysqlConfig);
const [rows] = await connection.execute("SELECT NOW() AS currentTime");
await connection.end();
res.json({
connected: true,
data: rows[0]
});
} catch (error) {
res.status(500).json({
connected: false,
message: error.message
});
}
});
app.get("/redis", async (req, res) => {
const client = createClient({ url: redisUrl });
try {
await client.connect();
await client.set("docker-demo", "hello docker");
const value = await client.get("docker-demo");
await client.disconnect();
res.json({
connected: true,
value
});
} catch (error) {
res.status(500).json({
connected: false,
message: error.message
});
}
});
app.listen(PORT, () => {
console.log(`API server is running on port ${PORT}`);
});
十二、总结
通过这个案例,我们完整体验了一次 Docker 项目实战:从编写 Web 应用,到创建 Dockerfile,再到使用 Docker Compose 编排 MySQL 和 Redis,最后完成接口验证和问题排查。这个过程看似简单,但已经覆盖了日常工作中非常常见的 Docker 使用场景。
Docker 的价值不仅仅是“把应用跑在容器里”,更重要的是它让环境变得可描述、可复制、可迁移。团队成员不再需要花大量时间配置本地环境,新同事拉取代码后只需要执行一条命令,就可以启动完整的开发环境;测试环境和生产环境也可以基于相同的镜像发布,减少“环境不一致”造成的问题。
当然,真正的生产环境还会涉及更多内容,例如镜像仓库、CI/CD 自动构建、日志采集、监控告警、滚动发布、服务发现、Kubernetes 编排等。但这些高级能力都建立在本文介绍的基础之上。只要理解了镜像、容器、网络、数据卷、环境变量和 Compose 编排这几个核心概念,后续学习云原生体系就会顺畅很多。
如果你正在维护一个传统部署方式的项目,可以从本文这种小规模改造开始:先把应用容器化,再把数据库和缓存服务纳入 Compose,最后逐步完善配置、日志、健康检查和自动化发布。Docker 最适合从一个小场景切入,边用边优化,而不是一开始就追求复杂架构。
一句话总结:Docker 不是为了让部署看起来更酷,而是为了让应用交付更加稳定、标准和高效。