第五章: 从单体到多服务:容器网络与持久化存储
第五章: 从单体到多服务:容器网络与持久化存储
Prorise第五章: 从单体到多服务:容器网络与持久化存储
摘要: 在上一章,我们成功地将一个独立的前端应用打磨成了生产级的镜像。然而,现实世界的应用很少是孤立存在的。本章,我们将迈出从“单体”到“多服务”的关键一步。我们将通过实战,为一个前后端分离的应用,解决两个核心问题:身处隔离环境中的容器们,该如何相互发现并进行通信?服务产生的数据,又该如何安全地永久保存?本章的知识,是通往 docker-compose
服务编排的必经之路。
在本章中,我们将通过构建一个更完整的应用,来掌握服务间协作的命脉:
- 首先,我们将揭秘 容器间通信 的原理,通过创建一个专属的内部网络,让服务之间可以轻松对话。
- 接着,我们将为需要永久保存的数据,找到一个安全的“家”,学习 Docker 的第一种持久化方案:为 生产数据 而生的
Volumes
。 - 然后,我们将学习第二种持久化方案:为 开发效率 而生的
Bind Mounts
,用它来实现代码的热重载。 - 最后,我们将对这两种方案进行场景化总结,让您在未来的实践中能做出最恰当的选择。
5.1. 跨越鸿沟:揭秘容器间通信
首先我们需要解决一个根本问题:身处隔离环境中的容器们,该如何相互发现并进行通信?
第一步:准备我们的后端 API 服务
为了模拟真实场景,我们需要第二个服务。我们将快速创建一个极简的 Node.js/Express 后端 API。
- 在您的 WSL2 环境中,于
my-react-app
项目旁边,创建一个新目录backend-api
并进入。
1 | # 确保你位于 my-react-app 的上级目录 |
- 在
backend-api
目录中,创建package.json
文件来定义项目和依赖。
1 | { |
- 创建
index.js
,这是我们的 API 服务器核心代码。
1 | // index.js |
- 最后,为此 API 创建一个简单的
Dockerfile
。
1 | # Dockerfile |
- 构建后端 API 镜像。
1 | docker build -t my-api:1.0 . |
第二步:制造“通信失败”的场景
现在,我们拥有了 my-react-app:production-v1.0
(前端) 和 my-api:1.0
(后端) 两个镜像。让我们像往常一样,将它们分别启动:
1 | # 回到上级目录,以便同时看到两个项目 |
两个容器都在独立运行。现在,让我们进入前端容器,尝试从它内部去访问后端 API。
1 | # 进入前端容器的 shell |
1
curl: (6) Could not resolve host: backend-api
这就是痛点所在! 默认情况下,Docker 的容器是相互隔离的“孤岛”。尽管我们知道另一个容器的名字叫 backend-api
,但在前端容器的世界里,这个名字是无法被解析的,它不知道这个名字指向哪个 IP 地址。
第三步:架设“桥梁”—— 自定义 Bridge 网络
要解决这个问题,我们不能依赖 Docker 默认的网络,而需要创建一个 自定义的 bridge
网络。这就像是为我们的应用服务们建立一个专属的、内部的局域网。凡是连接到同一个自定义网络中的容器,Docker 都会为其提供一个 内置的 DNS 服务,让它们可以通过 容器名 直接相互通信。
实战操作:
首先,清理掉我们刚才创建的“孤岛”容器。
1
docker stop frontend backend-api && docker rm frontend backend-api
创建一个新的网络。
1
docker network create my-app-network/
重新启动我们的两个容器,但这次,使用
--network
标志,将它们都连接到我们新建的网络上。+1
2docker 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最终验证:再次进入前端容器,执行完全相同的
curl
命令。1
2docker exec -it frontend sh
/ # curl http://backend-api: 3000/api/data
1
{"message":"Hello from the Backend API!","timestamp":"2025-09-19T13:44:55.123Z"}
成功了! 通过自定义网络,我们优雅地解决了服务发现和服务通信的问题。frontend
容器现在可以通过一个稳定、可读的服务名 backend-api
来访问后端。
关于 --link
: 在旧的教程或资料中,你可能会看到 --link
这个参数。请注意,这是 已被废弃的、过时的技术。自定义网络在功能、灵活性和安全性上都全面超越了 --link
,请在所有新项目中忘记它的存在。
至此,我们已经掌握了多服务应用架构的第一个基石:网络通信。在下一节,我们将解决另一个同样重要的问题:数据持久化。
5.2. 数据永存之道(上):为数据而生的 Volumes
承上启下: 我们已经成功地让前端和后端容器通过网络连接起来。但目前为止,我们的后端 API 是一个 无状态 (stateless) 服务。现在,我们要给它增加一个“状态”:一个访问计数器。这个需求,将直接引出 Docker 中一个至关重要的话题:数据持久化。
第一步:改造我们的后端 API,使其“有状态”
我们需要修改 API 代码,让它在每次被请求时,将一个数字(保存在文件中)加一。
请确保您位于
backend-api
项目目录中。用以下内容 完全覆盖
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}`);
});代码已更新,现在,基于新的代码 重新构建 一个
v1.1
版本的镜像。1
docker build -t my-api:1.1 .
第二步:亲身体验“数据丢失”的痛点
现在,我们将启动这个新版容器,并 明确地将它的端口暴露给主机,以便我们直接测试。
启动新版后端容器,并使用
-p
标志进行端口映射。1
2# 我们将主机的 3001 端口映射到容器内部的 3000 端口
docker run -d --name backend-api --network my-app-network -p 3001:3000 my-api:1.1端口已映射,现在可以从 主机终端(您当前所在的 WSL2 终端)直接用
curl
访问。请连续执行三次:1
2
3curl http://localhost:3001/api/data
curl http://localhost:3001/api/data
curl http://localhost:3001/api/data您会看到返回的
hits
值依次为1
,2
,3
。一切正常。现在,我们模拟一次常规的服务更新或重启:删除这个容器。
1
docker stop backend-api && docker rm backend-api
然后,我们从同一个
my-api:1.1
镜像,启动一个“全新”的容器实例,使用完全相同的命令。1
docker run -d --name backend-api --network my-app-network -p 3001:3000 my-api:1.1
最后,再次访问 API 端点。
1
curl http://localhost:3001/api/data
1
{"message":"Hello from the Backend API!","hits":1,"timestamp":"2025-09-19T15:05:10.456Z"}
数据丢失了! 计数器从 1 重新开始。这是因为我们写入的 hits.txt
文件,保存在了容器的“可写层”上。当容器被删除时,它的可写层会随之一同被销毁。
第三步:使用 Volumes
实现数据永存
为了解决这个问题,我们需要一种能将数据存储在 容器之外 的、并由 Docker 负责管理和持久化的机制。这,就是 数据卷。数据卷的生命周期独立于任何容器,即使容器被删除,数据卷及其中的数据依然安然无恙。
实战操作:
首先,清理掉刚才的容器。
1
docker stop backend-api && docker rm backend-api
创建一个具名数据卷。给数据卷起一个有意义的名字是一个好习惯。
1
docker volume create my-api-data
重新启动后端容器,但这次,我们使用
-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
这个数据卷。最终验证:重复我们的实验
- 连续用
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
- 连续用
1
{"message":"Hello from the Backend API!","hits":6,"timestamp":"2025-09-19T15:07:20.789Z"}
成功了! 计数器从 6 开始,这意味着数据被完美地持久化了。my-api-data
数据卷就像一个独立于容器的“数据保险箱”,确保了数据的安全与永存。
5.3. 数据永存之道(下):为开发而生的 Bind Mounts
承上启下: 上一节,我们使用 Volumes
成功地解决了生产数据的持久化问题。但随之而来的是一个开发效率的痛点:我们每修改一行后端代码,都必须重新构建镜像、停止旧容器、启动新容器,整个过程繁琐且耗时。我们渴望的,是像本地开发一样,保存代码后立即看到效果的“热重载”体验。
痛点背景:容器化开发中的“慢反馈循环”
在容器内运行应用,享受了环境一致性的好处,但却牺牲了开发的即时反馈。我们当前 修改 -> 重建 -> 重启
的循环,严重拖慢了开发速度。
解决方案:绑定挂载
Bind Mounts
是另一种挂载机制。与 Volumes
(由 Docker 管理的“数据保险箱”)不同,Bind Mounts
是将我们 宿主机(Host)上的一个文件或目录,直接“映射”进容器的指定路径。其核心机制是宿主机与容器之间建立了一个实时、双向的文件同步。你在 VS Code 中对宿主机文件的任何修改,都会立即、原封不动地 反映在容器内部。
第一步:为我们的 API 项目添加热重载能力
为了让 Node.js 应用能够监控文件变化并自动重启,我们需要一个工具,最常用的是 nodemon
。
请确保您位于
backend-api
项目目录中。为项目添加
nodemon
作为开发依赖。
1 | pnpm install nodemon --save-dev |
- 修改
package.json
,添加一个新的dev
脚本来使用nodemon
。
1 | // package.json |
第二步:使用 Bind Mount
启动开发容器
现在,我们将以一种全新的方式启动后端容器,不再使用镜像中固化的代码,而是直接挂载我们本地的源代码。
- 首先,清理掉上一节的容器(如果它还在运行)。
1 | docker stop backend-api && docker rm backend-api |
- 执行以下命令,启动一个“开发模式”的容器。
1 | # 我们仍然在 backend-api 目录下执行此命令 |
让我们来仔细解析这个强大的命令:
--name backend-api-dev
: 我们为开发容器起一个新名字,以示区别。-v "$(pwd)":/app
: 这正是 Bind Mount 的核心语法。-v
标志的格式是[宿主机绝对路径]:[容器内绝对路径]
。"$(pwd)"
会自动替换为当前宿主机的绝对路径(即backend-api
项目的完整路径),并将其完整地挂载到容器内的/app
目录。my-api:1.1
: 我们依然使用v1.1
镜像,因为它为我们提供了已经安装好npm install
的node_modules
目录和 Node.js 运行环境。npm run dev
: 我们在命令的最后覆盖了 Dockerfile 中默认的CMD
,转而执行我们新增的dev
脚本,启动nodemon
。
第三步:见证热重载的“魔法”
- 首先,实时跟踪我们开发容器的日志。
1 | docker logs -f backend-api-dev |
在 VS Code 中,打开
backend-api/index.js
文件。找到
res.json
中的message
字段,将其修改为:
1 | // index.js |
- 保存文件! 在你按下
Ctrl+S
的瞬间,观察docker logs
的终端。
1 | [nodemon] starting `node index.js` |
nodemon
已经自动重启了服务。现在,再次用curl
访问 API。
1 | curl http://localhost:3001/api/data |
1
{"message":"Hot-Reloading is AWESOME!","hits":1,"timestamp":"2025-09-19T15:03:12.123Z"}
成功了! 我们实现了完美的开发体验。你现在可以在 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
。
- 生产环境 或由应用管理的数据,首选 使用
高频面试题与陷阱
你好,看你对 Docker 比较熟悉。那你能深入讲讲 Docker 中 Volumes
和 Bind 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
,从而让容器使用自己在镜像内部安装的、正确的依赖版本。