第五章: 从单体到多服务:容器网络与持久化存储

第五章: 从单体到多服务:容器网络与持久化存储

摘要: 在上一章,我们成功地将一个独立的前端应用打磨成了生产级的镜像。然而,现实世界的应用很少是孤立存在的。本章,我们将迈出从“单体”到“多服务”的关键一步。我们将通过实战,为一个前后端分离的应用,解决两个核心问题:身处隔离环境中的容器们,该如何相互发现并进行通信?服务产生的数据,又该如何安全地永久保存?本章的知识,是通往 docker-compose 服务编排的必经之路。

在本章中,我们将通过构建一个更完整的应用,来掌握服务间协作的命脉:

  1. 首先,我们将揭秘 容器间通信 的原理,通过创建一个专属的内部网络,让服务之间可以轻松对话。
  2. 接着,我们将为需要永久保存的数据,找到一个安全的“家”,学习 Docker 的第一种持久化方案:为 生产数据 而生的 Volumes
  3. 然后,我们将学习第二种持久化方案:为 开发效率 而生的 Bind Mounts,用它来实现代码的热重载。
  4. 最后,我们将对这两种方案进行场景化总结,让您在未来的实践中能做出最恰当的选择。

5.1. 跨越鸿沟:揭秘容器间通信

首先我们需要解决一个根本问题:身处隔离环境中的容器们,该如何相互发现并进行通信?

第一步:准备我们的后端 API 服务

为了模拟真实场景,我们需要第二个服务。我们将快速创建一个极简的 Node.js/Express 后端 API。

  1. 在您的 WSL2 环境中,于 my-react-app 项目旁边,创建一个新目录 backend-api 并进入。
1
2
3
# 确保你位于 my-react-app 的上级目录
cd ..
mkdir backend-api && cd backend-api
  1. backend-api 目录中,创建 package.json 文件来定义项目和依赖。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{
"name": "backend-api",
"version": "1.0.0",
"main": "index.js",
"scripts": {
"start": "node index.js",
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"description": "",
"dependencies": {
"express": "^4.21.2"
}
}
  1. 创建 index.js,这是我们的 API 服务器核心代码。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// index.js
const express = require('express');
const app = express();
const port = 3000;

app.get('/api/data', (req, res) => {
res.json({
message: "Hello from the Backend API!",
timestamp: new Date().toISOString()
});
});

app.listen(port, () => {
console.log(`API server listening on port ${port}`);
});
  1. 最后,为此 API 创建一个简单的 Dockerfile
1
2
3
4
5
6
7
8
# Dockerfile
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 3000
CMD ["npm", "start"]
  1. 构建后端 API 镜像。
1
docker build -t my-api:1.0 .

第二步:制造“通信失败”的场景

现在,我们拥有了 my-react-app:production-v1.0 (前端) 和 my-api:1.0 (后端) 两个镜像。让我们像往常一样,将它们分别启动:

1
2
3
4
5
6
7
8
9
# 回到上级目录,以便同时看到两个项目
cd ..

# 启动后端容器
docker run -d --name backend-api my-api:1.0

# 启动前端容器
# (如果镜像不存在,请先进入 my-react-app 目录重新构建)
docker run -d --name frontend -p 8080:80 my-react-app:production-v1.0

两个容器都在独立运行。现在,让我们进入前端容器,尝试从它内部去访问后端 API。

1
2
3
4
5
6
7
# 进入前端容器的 shell
docker exec -it frontend sh

# 在容器内部,尝试通过“服务名”访问后端
# 我们需要先安装 curl 工具
/ # apk add --no-cache curl
/ # curl http://backend-api: 3000/api/data

这就是痛点所在! 默认情况下,Docker 的容器是相互隔离的“孤岛”。尽管我们知道另一个容器的名字叫 backend-api,但在前端容器的世界里,这个名字是无法被解析的,它不知道这个名字指向哪个 IP 地址。

第三步:架设“桥梁”—— 自定义 Bridge 网络

要解决这个问题,我们不能依赖 Docker 默认的网络,而需要创建一个 自定义的 bridge 网络。这就像是为我们的应用服务们建立一个专属的、内部的局域网。凡是连接到同一个自定义网络中的容器,Docker 都会为其提供一个 内置的 DNS 服务,让它们可以通过 容器名 直接相互通信。

实战操作:

  1. 首先,清理掉我们刚才创建的“孤岛”容器。

    1
    docker stop frontend backend-api && docker rm frontend backend-api
  2. 创建一个新的网络。

    1
    docker network create my-app-network/
  3. 重新启动我们的两个容器,但这次,使用 --network 标志,将它们都连接到我们新建的网络上。+

    1
    2
    docker run -d --name backend-api --network my-app-network my-api:1.0
    docker run -d --name frontend --network my-app-network -p 8080:80 my-react-app:production-v1.0
  4. 最终验证:再次进入前端容器,执行完全相同的 curl 命令。

    1
    2
    docker exec -it frontend sh
    / # curl http://backend-api: 3000/api/data

成功了! 通过自定义网络,我们优雅地解决了服务发现和服务通信的问题。frontend 容器现在可以通过一个稳定、可读的服务名 backend-api 来访问后端。

关于 --link: 在旧的教程或资料中,你可能会看到 --link 这个参数。请注意,这是 已被废弃的、过时的技术。自定义网络在功能、灵活性和安全性上都全面超越了 --link,请在所有新项目中忘记它的存在。

至此,我们已经掌握了多服务应用架构的第一个基石:网络通信。在下一节,我们将解决另一个同样重要的问题:数据持久化。


5.2. 数据永存之道(上):为数据而生的 Volumes

承上启下: 我们已经成功地让前端和后端容器通过网络连接起来。但目前为止,我们的后端 API 是一个 无状态 (stateless) 服务。现在,我们要给它增加一个“状态”:一个访问计数器。这个需求,将直接引出 Docker 中一个至关重要的话题:数据持久化

第一步:改造我们的后端 API,使其“有状态”

我们需要修改 API 代码,让它在每次被请求时,将一个数字(保存在文件中)加一。

  1. 请确保您位于 backend-api 项目目录中。

  2. 用以下内容 完全覆盖 index.js 文件:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    // index.js (v1.1 - with hit counter)
    const express = require('express');
    const fs = require('fs').promises;
    const path = require('path');
    const app = express();
    const port = 3000;

    const DATA_DIR = path.join(__dirname, 'data');
    const HITS_FILE = path.join(DATA_DIR, 'hits.txt');

    // 确保数据目录存在
    async function ensureDataDir() {
    try {
    await fs.mkdir(DATA_DIR);
    } catch (err) {
    if (err.code !== 'EEXIST') throw err;
    }
    }

    app.get('/api/data', async (req, res) => {
    await ensureDataDir();
    let hits = 0;
    try {
    const data = await fs.readFile(HITS_FILE, 'utf-8');
    hits = parseInt(data, 10);
    } catch (err) {
    // 如果文件不存在,就当做是第一次访问
    if (err.code !== 'ENOENT') throw err;
    }

    hits++;
    await fs.writeFile(HITS_FILE, hits.toString());

    res.json({
    message: "Hello from the Backend API!",
    hits: hits,
    timestamp: new Date().toISOString()
    });
    });

    app.listen(port, () => {
    console.log(`API server listening on port ${port}`);
    });
  3. 代码已更新,现在,基于新的代码 重新构建 一个 v1.1 版本的镜像。

    1
    docker build -t my-api:1.1 .

第二步:亲身体验“数据丢失”的痛点

现在,我们将启动这个新版容器,并 明确地将它的端口暴露给主机,以便我们直接测试。

  1. 启动新版后端容器,并使用 -p 标志进行端口映射。

    1
    2
    # 我们将主机的 3001 端口映射到容器内部的 3000 端口
    docker run -d --name backend-api --network my-app-network -p 3001:3000 my-api:1.1
  2. 端口已映射,现在可以从 主机终端(您当前所在的 WSL2 终端)直接用 curl 访问。请连续执行三次:

    1
    2
    3
    curl http://localhost:3001/api/data
    curl http://localhost:3001/api/data
    curl http://localhost:3001/api/data

    您会看到返回的 hits 值依次为 1, 2, 3。一切正常。

  3. 现在,我们模拟一次常规的服务更新或重启:删除这个容器

    1
    docker stop backend-api && docker rm backend-api
  4. 然后,我们从同一个 my-api:1.1 镜像,启动一个“全新”的容器实例,使用完全相同的命令。

    1
    docker run -d --name backend-api --network my-app-network -p 3001:3000 my-api:1.1
  5. 最后,再次访问 API 端点。

    1
    curl http://localhost:3001/api/data

数据丢失了! 计数器从 1 重新开始。这是因为我们写入的 hits.txt 文件,保存在了容器的“可写层”上。当容器被删除时,它的可写层会随之一同被销毁。

第三步:使用 Volumes 实现数据永存

为了解决这个问题,我们需要一种能将数据存储在 容器之外 的、并由 Docker 负责管理和持久化的机制。这,就是 数据卷。数据卷的生命周期独立于任何容器,即使容器被删除,数据卷及其中的数据依然安然无恙。

实战操作:

  1. 首先,清理掉刚才的容器。

    1
    docker stop backend-api && docker rm backend-api
  2. 创建一个具名数据卷。给数据卷起一个有意义的名字是一个好习惯。

    1
    docker volume create my-api-data
  3. 重新启动后端容器,但这次,我们使用 -v 标志来 挂载 我们刚创建的数据卷。

    1
    2
    3
    # -v 或 --volume 的格式是 [数据卷名]:[容器内绝对路径]
    # 确保命令中包含所有必要的参数:--name, --network, -p, 和 -v
    docker run -d --name backend-api --network my-app-network -p 3001:3000 -v my-api-data:/app/data my-api:1.1

    这行命令告诉 Docker:“请把名为 my-api-data 的数据卷,‘连接’到容器内的 /app/data 目录上”。现在,我们 API 应用所有对 /app/data 目录的读写操作,实际上都会直接作用于 my-api-data 这个数据卷。

  4. 最终验证:重复我们的实验

    • 连续用 curl http://localhost:3001/api/data 访问 API 数次,让计数器增长到比如 5
    • docker stop backend-api && docker rm backend-api 再次删除容器
    • 完全相同docker run ... -v my-api-data:/app/data ... 命令启动一个 的容器实例。
    • 再次访问 API。
    1
    curl http://localhost:3001/api/data

成功了! 计数器从 6 开始,这意味着数据被完美地持久化了。my-api-data 数据卷就像一个独立于容器的“数据保险箱”,确保了数据的安全与永存。


5.3. 数据永存之道(下):为开发而生的 Bind Mounts

承上启下: 上一节,我们使用 Volumes 成功地解决了生产数据的持久化问题。但随之而来的是一个开发效率的痛点:我们每修改一行后端代码,都必须重新构建镜像、停止旧容器、启动新容器,整个过程繁琐且耗时。我们渴望的,是像本地开发一样,保存代码后立即看到效果的“热重载”体验。

痛点背景:容器化开发中的“慢反馈循环”

在容器内运行应用,享受了环境一致性的好处,但却牺牲了开发的即时反馈。我们当前 修改 -> 重建 -> 重启 的循环,严重拖慢了开发速度。

解决方案:绑定挂载

Bind Mounts 是另一种挂载机制。与 Volumes(由 Docker 管理的“数据保险箱”)不同,Bind Mounts 是将我们 宿主机(Host)上的一个文件或目录,直接“映射”进容器的指定路径。其核心机制是宿主机与容器之间建立了一个实时、双向的文件同步。你在 VS Code 中对宿主机文件的任何修改,都会立即、原封不动地 反映在容器内部。

第一步:为我们的 API 项目添加热重载能力

为了让 Node.js 应用能够监控文件变化并自动重启,我们需要一个工具,最常用的是 nodemon

  1. 请确保您位于 backend-api 项目目录中。

  2. 为项目添加 nodemon 作为开发依赖。

1
pnpm install nodemon --save-dev
  1. 修改 package.json,添加一个新的 dev 脚本来使用 nodemon
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// package.json
{
"name": "backend-api",
"version": "1.0.0",
"main": "index.js",
"scripts": {
"start": "node index.js",
"dev": "nodemon index.js"
},
"dependencies": {
"express": "^4.19.2"
},
"devDependencies": {
"nodemon": "^3.1.2"
}
}

第二步:使用 Bind Mount 启动开发容器

现在,我们将以一种全新的方式启动后端容器,不再使用镜像中固化的代码,而是直接挂载我们本地的源代码。

  1. 首先,清理掉上一节的容器(如果它还在运行)。
1
docker stop backend-api && docker rm backend-api
  1. 执行以下命令,启动一个“开发模式”的容器。
1
2
3
4
5
6
7
# 我们仍然在 backend-api 目录下执行此命令
docker run -d --name backend-api-dev \
--network my-app-network \
-p 3001:3000 \
-v "$(pwd)":/app \
my-api:1.1 \
npm run dev

让我们来仔细解析这个强大的命令:

  • --name backend-api-dev: 我们为开发容器起一个新名字,以示区别。
  • -v "$(pwd)":/app: 这正是 Bind Mount 的核心语法-v 标志的格式是 [宿主机绝对路径]:[容器内绝对路径]"$(pwd)" 会自动替换为当前宿主机的绝对路径(即 backend-api 项目的完整路径),并将其完整地挂载到容器内的 /app 目录。
  • my-api:1.1: 我们依然使用 v1.1 镜像,因为它为我们提供了已经安装好 npm installnode_modules 目录和 Node.js 运行环境。
  • npm run dev: 我们在命令的最后覆盖了 Dockerfile 中默认的 CMD,转而执行我们新增的 dev 脚本,启动 nodemon

image-20250920095430829

第三步:见证热重载的“魔法”

  1. 首先,实时跟踪我们开发容器的日志。
1
docker logs -f backend-api-dev
  1. 在 VS Code 中,打开 backend-api/index.js 文件。

  2. 找到 res.json 中的 message 字段,将其修改为:

1
2
3
4
5
6
7
8
// index.js
// ...
res.json({
message: "Hot-Reloading is AWESOME!", // <-- 修改这行
hits: hits,
timestamp: new Date().toISOString()
});
// ...
  1. 保存文件! 在你按下 Ctrl+S 的瞬间,观察 docker logs 的终端。
1
2
3
4
5
6
[nodemon] starting `node index.js`
API server listening on port 3000
[nodemon] clean exit - waiting for changes before restart
[nodemon] restarting due to changes...
[nodemon] starting `node index.js`
API server listening on port 3000
  1. nodemon 已经自动重启了服务。现在,再次用 curl 访问 API。
1
curl http://localhost:3001/api/data

成功了! 我们实现了完美的开发体验。你现在可以在 VS Code 中尽情编写代码,每一次保存,更改都会立即在运行的容器中生效,彻底告别了“修改 -> 重建 -> 重启”的漫长等待。

关于 WSL2 的性能

我们当前的这套工作流性能极高,因为您的项目文件(宿主机端)和 Docker 引擎都运行在 WSL2 的原生 Linux 文件系统中,文件事件通知和读写都接近原生性能。如果您将项目文件放在 Windows 文件系统(如 /mnt/c/Users/...)再进行绑定挂载,性能会急剧下降。


5.4. 场景化决策:Volumes vs. Bind Mounts

承上启下: 在前两节中,我们已经亲手实践了两种将数据存放在容器之外的方式:

  • 我们使用 Volumes (数据卷) 来持久化我们 API 的“访问计数”,确保了即使容器被删除,数据依然安全。
  • 我们使用 Bind Mounts (绑定挂载) 来映射本地的源代码,实现了“热重载”的高效开发体验。

本节将对这两种技术进行全面对比,让您在未来的任何场景下,都能毫不犹豫地做出最正确的选择。

Volumes:生产数据的保险箱

数据卷 (Volumes) 是由 `Docker 引擎` 来创建和管理的数据存储区域。它是 Docker 世界中持久化数据的 一等公民,也是官方 `首选` 的方式。

把它想象成一个由 Docker 托管的、专供容器使用的“外置硬盘”。

核心特性:

  • Docker 管理: 数据卷的创建、删除、查看等操作都通过 Docker CLI (docker volume ...) 进行,其在宿主机上的具体存储位置由 Docker 统一管理,我们通常不应直接操作。
  • 生命周期独立: 数据卷的生命周期与容器完全解耦。删除所有使用某个数据卷的容器,数据卷本身及其中的数据 不会 被删除。
  • 高性能与平台无关: Docker 会保证数据卷在所有平台(Linux, Windows, macOS)上都以最高效的方式进行 I/O 操作。
  • 更安全: 它将容器的数据需求与宿主机的特定文件系统布局隔离开来,避免了容器意外修改宿主机重要文件的风险。

最佳应用场景:

  • 数据库数据: 例如 PostgreSQL、MySQL、MongoDB 等数据库的数据文件目录。
  • 用户上传内容: 网站用户上传的图片、视频、文档等。
  • 应用生成的状态: 需要在容器重启或更新后依然保持的状态数据,例如我们的“访问计数器”。
  • 需要跨容器共享的数据: 多个容器可以同时挂载同一个数据卷,以共享配置或数据。

Bind Mounts:开发环境的任意门

绑定挂载 (Bind Mounts) 则是将 `宿主机` 上的一个已存在的文件或目录,直接“共享”或“映射”到容器内部。

把它想象成一个在你本地电脑和容器之间建立的“实时同步的共享文件夹”。

核心特性:

  • 宿主机管理: 挂载的源头是宿主机上的一个具体路径,文件的所有权和管理权都在宿主机这边。
  • 实时同步: 宿主机上对文件的任何修改都会立刻反映在容器内,反之亦然。
  • 路径依赖: 这种方式依赖于宿主机的文件系统结构,可移植性相对较差。
  • 性能: 性能极高,特别是当源文件和 Docker 引擎都位于同一个原生文件系统时(例如我们的 WSL2 环境)。

最佳应用场景:

  • 开发时的代码同步: 这是其最核心、最广泛的用途,用于实现代码热重载。
  • 共享配置文件: 将宿主机上的配置文件(如 nginx.conf)直接挂载到容器中,方便在不重建镜像的情况下修改配置。
  • 共享构建产物: 在 CI/CD 流程中,可能会将在宿主机上构建好的产物,挂载到容器中进行测试。

决策框架:一句话总结

当面临选择时,问自己一个简单的问题:

  • “这份数据是由我的应用在生产中产生的,需要被安全地‘托管’起来吗?”

    • 如果是,请使用 Volumes
  • “这份数据是我作为开发者在宿主机上编写的源代码或配置文件,需要实时同步到容器里进行调试吗?”

    • 如果是,请使用 Bind Mounts

在结束本章的核心内容前,如果您还在运行上一节的开发容器,请清理它:

1
docker stop backend-api-dev && docker rm backend-api-dev

5.5. 本章核心速查总结与面试题

承上启下: 在本章中,我们完成了从单体容器到多服务协作的关键一步。通过引入自定义网络,我们解决了服务间的通信问题;通过学习数据卷和绑定挂载,我们掌握了生产数据持久化和开发环境优化的核心技巧。这些知识是后续学习 docker-compose 进行复杂服务编排的绝对基石。

本节,我们将所有关键点浓缩成一张速查表和一道高频面试题,以便您日后快速回顾。


核心速查总结

分类关键命令 / 标志核心描述
网络docker network create <net_name>(推荐) 创建一个自定义的 bridge 网络,为容器提供基于服务名的 DNS 解析能力。
网络docker network ls列出当前主机上所有的 Docker 网络。
网络docker run --network <net_name>将一个容器连接到指定的网络。
持久化docker volume create <vol_name>创建一个由 Docker 管理的具名数据卷 (Volume),用于持久化生产数据。
持久化docker volume ls列出当前主机上所有的 Docker 数据卷。
持久化docker run -v <vol_name>:<ctn_path>(Volume 挂载) 将一个具名数据卷挂载到容器的指定路径。
持久化docker run -v <host_path>:<ctn_path>(Bind Mount) 将一个宿主机的目录或文件挂载到容器的指定路径。

总结要点:

  • 容器间的通信,首选 通过将它们连接到同一个 自定义 bridge 网络 来实现。
  • 容器的数据持久化,根据场景选择:
    • 生产环境 或由应用管理的数据,首选 使用 Volumes
    • 开发环境 需要同步源代码和配置文件,首选 使用 Bind Mounts

高频面试题与陷阱

面试官深度追问
2025-09-20

你好,看你对 Docker 比较熟悉。那你能深入讲讲 Docker 中 VolumesBind Mounts 这两种数据持久化方式的 核心区别,以及它们各自 最适合的应用场景 吗?

当然可以。它们最核心的区别在于数据的 管理者生命周期

Volumes 是由 Docker 引擎 来管理的,它的生命周期完全独立于任何容器。您可以把它想象成一个专供容器使用的、可插拔的“数据盘”。它的主要应用场景是 生产环境的数据持久化,比如数据库文件、用户上传的内容、需要长期保存的应用状态等。它的优点是平台无关、性能由 Docker 优化,且更安全,因为它将应用数据和宿主机的文件系统隔离开了。

Bind Mounts 则是直接将 宿主机 上的一个具体文件或目录映射到容器里。数据的管理者是宿主机上的用户或进程。它的核心应用场景是 开发环境,我们可以通过它将本地的源代码目录直接映射到容器里,配合 nodemon 这样的工具,实现代码的 热重载,极大地提升开发效率。

很好,回答得很清晰。那你刚才提到的,在开发环境中使用 Bind Mounts 挂载源代码,有没有什么常见的“陷阱”需要注意?

有一个非常经典的陷阱,就是关于 node_modules 目录的处理。如果在宿主机(比如 macOS 或 Windows)上运行过 npm install,然后在 docker run 时,将整个项目目录(包含了宿主机生成的 node_modules)通过 Bind Mount 挂载到基于 Linux 的容器中,通常会导致应用崩溃。

这是因为宿主机上为特定操作系统编译的原生模块,与容器内 Alpine Linux 所需的模块不兼容。解决方案通常是在 Dockerfile 中正常执行 RUN npm install,然后在 docker run 时,使用一个匿名的 Volume 来“覆盖”掉被 Bind Mount 进来的 node_modules,从而让容器使用自己在镜像内部安装的、正确的依赖版本。