上一篇 下一篇 分享链接 返回 返回顶部

一个 Node.js 项目从零容器化:Docker Compose 编排 MySQL 与 Redis 实战

发布人:慈云数据-客服中心 发布时间:6小时前 阅读量:2

Docker 实战案例分享|附源码

前言

在云原生、微服务和 DevOps 已经成为主流工程实践的今天,Docker 几乎是每一位后端开发、运维工程师、测试工程师都绕不开的基础工具。它解决的核心问题并不复杂:让应用及其运行环境一起打包、分发和运行。过去我们经常会遇到这样的问题:开发环境能跑,测试环境报错;测试环境正常,生产环境又因为某个依赖版本不同而失败。Docker 的出现,正是为了降低这类环境不一致带来的成本。

本文将通过一个完整的实战案例,分享如何使用 Docker 容器化一个 Web 应用,并配合 Docker Compose 编排应用服务、数据库服务和缓存服务。文章会尽量贴近真实项目场景,不只停留在“写一个 Dockerfile”这种入门层面,而是从项目结构、镜像构建、容器运行、服务编排、环境变量、数据持久化、日志查看、常见问题排查等方面进行系统说明。

本文适合以下读者:

  • 已经了解 Docker 基本概念,但缺少项目实战经验的开发者;
  • 想把本地项目部署到服务器上的后端工程师;
  • 想学习 Docker Compose 编排多个服务的初学者;
  • 希望规范团队开发环境、降低部署成本的技术负责人。

一、案例目标

本次实战案例的目标是:使用 Docker 部署一个简单的 Web API 项目。

项目包含三个核心服务:

  1. Web 应用服务:提供 HTTP API;
  2. MySQL 数据库服务:用于存储业务数据;
  3. 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 installnpm 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:

这个文件定义了三个服务:apimysqlredis

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. 容器端口冲突怎么办?

如果宿主机已经有服务占用了 808033066379,启动时会报端口占用错误。可以修改 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 不是为了让部署看起来更酷,而是为了让应用交付更加稳定、标准和高效。

目录结构
全文