Docker 容器在运行时会产生大量数据,这些数据如何持久化和管理是一个重要的话题。
本节我们将通过一个 Nginx Web 服务器的案例,来深入探讨 Docker 的三种数据管理方式。

Docker 存储基础

Docker 提供了三种主要的数据管理方式:

  1. 默认存储:容器内的数据随容器删除而丢失
  2. Volumes(卷):由 Docker 管理的持久化存储空间,完全独立于容器的生命周期
  3. Bind Mounts(绑定挂载):将主机上的目录或文件直接挂载到容器中

让我们通过一个 Nginx Web 服务器的例子来理解这三种方式的区别。我们将在每种方式下执行相同的操作:创建一个 HTML 文件,然后测试数据的持久性。

场景一:默认存储(非持久化)

在这个场景中,我们直接在容器内创建文件,看看数据会发生什么:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 运行一个 nginx 容器
docker run -d --name web-default -p 8080:80 nginx

# 在容器中创建一个测试页面
docker exec -it web-default sh -c 'echo "<h1>Hello from Default Storage</h1>" > /usr/share/nginx/html/index.html'

# 访问页面验证内容
curl http://localhost:8080

# 删除容器
docker rm -f web-default

# 用同样的配置重新运行容器
docker run -d --name web-default -p 8080:80 nginx

# 再次访问页面,会看到默认的 Nginx 欢迎页面,之前的内容已经丢失
curl http://localhost:8080

image-20250626014150661

核心思想:容器默认是“无状态”且“易消亡”的 (Stateless and Ephemeral)。 我的操作完美地证明了这一点。对容器内部文件系统的任何写入,都会随着容器的删除而消失。这在设计上保证了容器的可移植性和一致性——无论在哪里启动,它都基于同一个镜像,表现完全一致。

如何让数据持久化? 这就引出了 Docker 一个极其重要的概念:**卷 (Volume)**。如果你希望数据(比如网站文件、数据库数据、日志)在容器删除后依然存在,我就需要把这些数据存放在容器“外部”,也就是宿主机上,然后把宿主机的目录“挂载”到容器内部。这个“外部存储”就是卷。

场景二:使用 Volume

在这个场景中,我们使用 Docker 管理的卷来存储数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# 创建一个 Docker volume
docker volume create nginx_data

# 运行 Nginx 容器并挂载卷
docker run -d --name web-volume -p 8081:80 -v nginx_data:/usr/share/nginx/html nginx

# 在容器中创建一个测试页面
docker exec -it web-volume sh -c 'echo "<h1>Hello from Volume Storage</h1>" > /usr/share/nginx/html/index.html'

# 访问页面验证内容
curl http://localhost:8081

# 删除容器
docker rm -f web-volume

# 用同样的配置重新运行容器
docker run -d --name web-volume-2 -p 8081:80 \
-v nginx_data:/usr/share/nginx/html nginx

# 再次访问页面,内容仍然存在
curl http://localhost:8081

# 查看卷的详细信息
docker volume inspect nginx_data

image-20250626014255532

第 1 步: docker volume create nginx_data

  • docker volume create: 这是一个专门用来创建卷的命令。

  • nginx_data: 你为这个卷起的名字。

  • 作用与原理

    : 这条命令告诉 Docker:“请帮我创建一个由你来管理的、专门用来存放数据的区域,我以后就用

    1
    nginx_data

    这个名字来引用它。”

    • 关键点:你不需要关心这个数据区域到底存放在你电脑的哪个具体位置,Docker 会在它自己的一个专属目录(在 Linux 上通常是 /var/lib/docker/volumes/)里进行统一管理。这实现了数据与宿主机文件系统的解耦

第 2 步: docker run ... -v nginx_data:/usr/share/nginx/html nginx

  • 这条命令的大部分我们已经熟悉了,核心在于 -v 参数的新用法。
  • -v nginx_data:/usr/share/nginx/html: 这就是**卷挂载 (Volume Mount)**。
    • 格式: -v <卷的名称>:<容器内的路径>

第 3、4、5 步: exec, curl, rm

  • 这几步的逻辑和上次完全一样。
  • docker exec 修改了 index.html,由于 /usr/share/nginx/html 目录现在已经链接到了 nginx_data 卷,所以这个修改实际上是写进了 nginx_data 卷里。
  • curl 验证了写入成功。
  • docker rm -f web-volume 销毁了 web-volume 这个容器。但是,nginx_data 是一个独立于容器的资源,它并不会被删除,它静静地待在 Docker 的管理目录里,保存着我们写入的数据。

第 6、7 步: 再次 runcurl

  • 你启动了一个全新的容器 web-volume-2,并且再次使用 -v nginx_data:/usr/share/nginx/html同一个卷挂载了进去。
  • 当这个新容器启动后,它内部的 /usr/share/nginx/html 目录指向的正是那个包含了 "<h1>Hello from Volume Storage</h1>"nginx_data 卷。
  • 因此,curl 的结果证明了数据被完美地持久化和复用了。

第 8 步: docker volume inspect nginx_data

  • 这条命令是用来“解密”卷的,它让你能看到一个卷的详细信息。
  • docker volume inspect: 查看一个或多个卷的元数据。
  • 输出解读:
    • "Name": "nginx_data": 卷的名字。
    • "Driver": "local": 驱动类型。local 表示这个卷的数据存储在本机。对于更复杂的场景,还可以有其他驱动,比如将数据存到云存储上。
    • "Mountpoint": "/var/lib/docker/volumes/nginx_data/_data": 这是最重要的信息。它揭示了 nginx_data 这个卷在你的宿主机上实际存储数据的物理位置。虽然我们不应该手动去操作这个目录,但它清楚地告诉你,数据确实是存在宿主机上的,只是由 Docker 在这个特定路径下代为保管。

场景三:使用 Bind Mount

在这个场景中,我们将主机上的目录直接挂载到容器中:

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
# 创建本地目录
mkdir nginx-content
echo "<h1>Hello from Bind Mount Storage</h1>" > nginx-content/index.html

# 运行 Nginx 容器并挂载本地目录
docker run -d --name web-bind \
-p 8082:80 \
-v $(pwd)/nginx-content:/usr/share/nginx/html nginx

# 访问页面验证内容
curl http://localhost:8082

# 在主机上修改文件
echo "<h1>Updated content from host</h1>" > nginx-content/index.html

# 无需重启容器,直接访问更新后的内容
curl http://localhost:8082

# 删除容器
docker rm -f web-bind

# 用同样的配置重新运行容器
docker run -d --name web-bind-2 -p 8082:80 \
-v $(pwd)/nginx-content:/usr/share/nginx/html nginx

# 再次访问页面,内容仍然存在
curl http://localhost:8082

image-20250626014818464

第 1 & 2 步: mkdir nginx-contentecho ... > nginx-content/index.html

  • 这两条是标准的 Linux/macOS shell 命令。
  • mkdir nginx-content: 在你当前所在的目录(/workspace)下,创建一个名为 nginx-content 的新目录。
  • echo "..." > ...: 将一段 HTML 文本写入到 nginx-content 目录下的 index.html 文件中。
  • 核心思想: 这个工作流的特点是先在宿主机上准备好数据。数据源是明确、可见、且由你完全控制的。

第 3 步: docker run ... -v $(pwd)/nginx-content:/usr/share/nginx/html nginx

  • 这条命令是本次操作的核心。
  • -v $(pwd)/nginx-content:/usr/share/nginx/html: 这就是我们反复强调的绑定挂载 (Bind Mount)。
    • $(pwd): 这是一个 shell 变量,意思是“print working directory”(打印当前工作目录)。在这里,它会被替换成你的当前路径,例如 /workspace。所以完整的宿主机路径就是 /workspace/nginx-content。Docker 要求绑定挂载时必须使用绝对路径,使用 $(pwd) 是一个确保路径正确的常用技巧。
    • :: 分隔符,左边是宿主机路径,右边是容器内路径。
    • 原理: 这条命令建立了一个直接的、实时的链接。它告诉 Docker:“把容器内的 /usr/share/nginx/html 目录下的所有文件操作,都直接映射到我宿主机上的 /workspace/nginx-content 目录。” 这就像给容器里的目录创建了一个指向宿主机目录的“快捷方式”或“传送门”。容器内对这个目录的任何读写,实际上都是在读写你宿主机上的文件。

第 4 步:curl http://localhost:8082

  • curl http://localhost:8082: 由于绑定挂载,Nginx 读取 /usr/share/nginx/html/index.html 时,实际上读取的是你宿主机上的 nginx-content/index.html 文件,所以你看到了 “Hello from Bind Mount Storage”。

第 5 & 6 步: echo "<h1>updated</h1>" > nginx-content/index.htmlcurl

  • 关键操作: 注意,这次的 echo 命令是直接在你的宿主机终端里执行的。你没有使用 docker exec 进入容器内部。
  • 你修改了宿主机上的 nginx-content/index.html 文件。
  • 当你再次 curl 时,请求到达容器内的 Nginx。Nginx 再次去读取 /usr/share/nginx/html/index.html,通过那个“实时传送门”,它读取到的是你刚刚在宿主机上修改的最新内容 “updated”。
  • 这完美地展示了绑定挂载的实时性。宿主机和容器共享着同一个文件,任何一方的修改对另一方都立即可见。

第 7, 8, 9 步: rm, run, curl

  • docker rm -f web-bind: 你删除了容器。但数据的源头——你宿主机上的 nginx-content 目录和其中的文件——毫发无损
  • 当你用同样的命令启动一个新容器 web-bind-2 时,Docker 重新建立了那个从容器到宿主机的链接。
  • 新容器的 Nginx 服务自然就读取到了宿主机上那个依然是 “updated” 内容的 index.html 文件。数据持久化得以实现,因为数据的生命周期完全跟宿主机上的文件绑定,与容器无关

三种方式的对比

  1. 默认存储

    • 数据随容器删除而丢失
    • 适合存储临时数据
    • 容器间数据隔离
    • 无需额外配置
  2. Volume

    • 数据持久化,独立于容器生命周期
    • Docker 统一管理,方便备份和迁移
    • 可以在多个容器间共享
    • 数据存储在 Docker 管理区域,安全性好
  3. Bind Mount

    • 数据持久化,存储在主机指定位置
    • 可以直接在主机上修改文件
    • 开发环境中方便调试和修改
    • 依赖主机文件系统结构

常见错误:挂载空目录导致容器内原有文件“消失”

这是一个非常常见的绑定挂载“陷阱”。假设 nginx 镜像的 /usr/share/nginx/html 目录里原本有 5 个默认文件。

如果你在宿主机上创建一个空目录 my-empty-dir,然后运行: docker run -v $(pwd)/my-empty-dir:/usr/share/nginx/html nginx

这时,宿主机的空目录 my-empty-dir 会“覆盖”掉容器内的 /usr/share/nginx/html 目录。你进入容器查看,会发现 /usr/share/nginx/html 变成了一个空目录,镜像里原有的 5 个文件都看不到了。

而命名卷的行为不同:当你第一次将一个新的空卷挂载到包含内容的容器目录时,Docker 会聪明地将容器目录里的内容复制到这个空卷中。这样就避免了数据丢失。这也是在处理应用数据时,卷通常更安全、更方便的原因之一。

清理操作

完成实验后,可以进行清理:

1
2
3
4
5
6
7
8
# 清理容器
docker rm -f web-default web-volume web-volume-2 web-bind web-bind-2

# 清理卷
docker volume rm nginx_data

# 清理本地目录
rm -rf nginx-content

实践案例:使用 Volume 部署 MySQL 数据库

我们将通过一个 MySQL 数据库的例子来演示如何使用 Volume 持久化数据。

创建并管理 Volume

1
2
3
4
5
6
7
8
# 创建一个命名卷
docker volume create mysql_data

# 查看卷信息
docker volume inspect mysql_data

# 列出所有卷
docker volume ls

image-20250626020326360

docker volume create/inspect/ls

  • 这三条命令是你管理卷的“三板斧”,我们在上一个练习中已经很熟悉了。
  • create mysql_data: 申请一块由 Docker 管理的、名为 mysql_data 的专用存储空间。
  • inspect mysql_data: 查看这块空间的详细信息,尤其是它的物理存储位置 Mountpoint
  • ls: 列出当前 Docker 环境中存在的所有卷。
  • 目的: 这是运行任何有状态服务的第一步,即“先准备好放数据的保险箱”。

使用 Volume 运行 MySQL

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 运行 MySQL 容器并挂载卷
docker run -d \
--name mysql_db \
-e MYSQL_ROOT_PASSWORD=mysecret \
-v mysql_data:/var/lib/mysql \
mysql:8.0

# 进入容器创建测试数据
docker exec -it mysql_db mysql -uroot -pmysecret

# 在 MySQL 中创建测试数据
mysql> CREATE DATABASE test_db;
mysql> USE test_db;
mysql> CREATE TABLE users (id INT, name VARCHAR(50));
mysql> INSERT INTO users VALUES (1, 'John Doe');
mysql> exit

image-20250626020354836


docker run ... (首次运行)

这条命令是整个流程的重中之重,包含了两个新的关键知识点。

  • -e MYSQL_ROOT_PASSWORD=mysecret:
    • -e--env 的缩写,意思是设置**环境变量 (Environment Variable)**。
    • 什么是环境变量? 它们是注入到容器内部的键值对,可以用来动态配置容器里运行的程序,而无需修改程序代码或镜像本身。
    • 为什么这么设计? 官方的 mysql 镜像被设计成在第一次启动时,会检查一个名为 MYSQL_ROOT_PASSWORD 的环境变量。如果发现了这个变量,它就会在初始化数据库时,将 root 用户的密码设置为你提供的值(这里是 mysecret)。这是一种非常通用和优雅的容器配置方式。
  • -v mysql_data:/var/lib/mysql:
    • 这就是我们之前学的卷挂载
    • /var/lib/mysql: 这是 MySQL 数据库在 Linux 系统中默认的数据存储目录。所有的数据库、表、索引、日志等核心文件都存放在这里。
    • 作用: 这条命令的含义是:“将 mysql_data 这个卷挂载到容器的 /var/lib/mysql 目录下”。这样一来,MySQL 服务写入的所有数据,实际上都直接被保存到了容器外部的 mysql_data 卷里,从而实现了数据和容器运行环境的分离。
  • mysql:8.0:
    • 使用 mysql 镜像,并明确指定了 8.0 这个版本标签。在生产环境中,强烈建议指定明确的版本号,而不是依赖于默认的 latest 标签。这可以确保你在任何地方重新部署时,使用的都是完全相同的软件版本,避免了因版本更新导致意外行为。

docker exec -it mysql_db mysql ... (进入数据库)

  • docker exec -it mysql_db: 以交互模式进入 mysql_db 容器。

  • mysql -uroot -pmysecret

    : 这是在容器内部执行的命令。它启动了 MySQL 的命令行客户端。

    • -u root: 指定登录用户为 root
    • -pmysecret: 提供 root 用户的密码。注意 -p 和密码之间没有空格
    • 你看到 mysql: [Warning] Using a password on the command line... 的警告,是因为在命令行直接写密码有安全风险(可能会被系统日志或历史记录捕获),这在测试时很方便,但在生产环境中需要更安全的方式。
  • SQL 操作: 你执行的 create database, create table, insert 等所有操作,都在修改数据库文件。因为我们做了卷挂载,这些修改都真实地发生在 mysql_data 卷里。

验证数据持久化

1
2
3
4
5
6
7
8
9
10
11
12
13
# 删除原容器
docker rm -f mysql_db

# 使用同一个卷启动新容器
docker run -d \
--name mysql_db2 \
-e MYSQL_ROOT_PASSWORD=mysecret \
-v mysql_data:/var/lib/mysql \
mysql:8.0

# 验证数据是否存在
docker exec -it mysql_db2 \
mysql -uroot -pmysecret -e "USE test_db; SELECT * FROM users;"

image-20250626020409137

第 4 & 5 步: rm 和再次 run

  • docker rm -f mysql_db: 容器被彻底销毁了。但存放着 test_db 数据库的 mysql_data 卷安然无恙。
  • 第二次 docker run:
    • 你用完全相同的命令启动了一个新容器。
    • 当这个新的 MySQL 实例启动时,它会检查它的数据目录 /var/lib/mysql
    • 因为我们挂载了已经有数据mysql_data 卷,新实例会发现里面已经存在一个初始化好的数据库。它会跳过初始化步骤(不再需要 MYSQL_ROOT_PASSWORD 来设置密码,而是用它来验证身份),直接加载现有数据并对外提供服务。

docker exec ... -e "..." (验证数据)

  • 这是一个非常高效的非交互式执行命令的方式。
  • docker exec mysql_db ...: 注意这里没有 -it,因为我们不需要交互,只想执行一条命令并获取结果。
  • mysql ... -e "use test_db; select * from users;":
    • mysql 客户端的 -e 参数(不要和 docker run-e 搞混)表示“执行引号内的命令,然后立即退出”。
    • 你成功查询出了 id: 1, name: John 的记录。
    • 这雄辩地证明了:数据与容器的生命周期是分离的,只要卷还在,数据就安全。

实践案例:使用 Bind Mounts 运行 DeepSeek-R1

在这个案例中,我们将演示如何使用 Bind Mounts 来运行 DeepSeek-R1 大语言模型。
这个案例很好地展示了 Bind Mounts 在处理大型文件时的优势:模型文件通常很大(几十GB),
如果打包到镜像中会导致镜像体积过大,而使用 Bind Mounts 可以直接挂载主机上的模型文件。

运行 ollama 容器

1
2
3
4
5
6
7
8
9
10
11
12
13
# 查看本地的模型文件是否存在
ls -l deepseek-models

# 运行 ollama 容器
docker run \
--name deepseek-r1 \
-it -d \
-v $(pwd)/deepseek-models:/root/.ollama/models \
ollama/ollama

# 查看容器状态
docker ps
docker logs deepseek-r1

image-20250626021853349

运行 deepseek r1 模型

1
2
3
4
5
# 查看模型是否被识别
docker exec -it deepseek-r1 ollama ls
# 运行 deepseek r1 模型
docker exec -it deepseek-r1 ollama run deepseek-r1:8b-0528-qwen3-q8_0

image-20250626022019898