[docker] 数据的持久化 - Volume & bind mounts

docker 的数据笼统分类可以分为下面这三种:

  1. 只读数据

    这种数据大多为源码、容器的配置文件,大多数情况下与镜像进行绑定

  2. 临时数据

    这部分的数据大多数情况下与容器进行绑定,属于可写数据

    具体案例为存储与内存的数据,如进行 AJAX 操作后获取的数据会被存在内存中,db 数据可以存在容器里等

    属于经常被读写的数据

  3. 永久数据

    这部分数据属于永久保存数据,并不依托于容器或镜像存在,并且容器被销毁时,永久数据也应当被保存,而不会随着容器的销毁而消失

    目前这部分的数据是还没有接触过的,也是本章笔记的主题——volume

    volume 中的数据季不存在于 images 中,也不存在于 containers 中,它存在于 host 的文件系统中

    volume 是可读写的

demo app 设置

下面依然是一个 express 的 web server,通过案例了解一下 volume 的实现

express 代码

const fs = require("fs").promises;
const exists = require("fs").exists;
const path = require("path");

const express = require("express");
const bodyParser = require("body-parser");

const app = express();

app.use(bodyParser.urlencoded({ extended: false }));

app.use(express.static("public"));
app.use("/feedback", express.static("feedback"));

app.get("/", (req, res) => {
  const filePath = path.join(__dirname, "pages", "feedback.html");
  res.sendFile(filePath);
});

app.get("/exists", (req, res) => {
  const filePath = path.join(__dirname, "pages", "exists.html");
  res.sendFile(filePath);
});

app.post("/create", async (req, res) => {
  const title = req.body.title;
  const content = req.body.text;

  const adjTitle = title.toLowerCase();

  const tempFilePath = path.join(__dirname, "temp", adjTitle + ".txt");
  const finalFilePath = path.join(__dirname, "feedback", adjTitle + ".txt");

  await fs.writeFile(tempFilePath, content);
  exists(finalFilePath, async (exists) => {
    if (exists) {
      res.redirect("/exists");
    } else {
      await fs.copyFile(tempFilePath, finalFilePath);
      await fs.unlink(tempFilePath);
      res.redirect("/");
    }
  });
});

app.listen(80);

大概走一下这个代码的流程:

  1. 会在 80 端口启动一个服务器

    服务器中有若干 endpoints

  2. 当用户填写信息的时候,会在 temp 文件夹下写一个临时文件

  3. 检查 feedback 中是否存在同名的文件

    • 如果存在,则重定向到 /exists

    • 如果不存在,则将 temp 中的临时文件写入 feedback 中,并删除 temp 中的临时文件

      重定向到 /

Dockerfile

这部分和之前实现的基本没什么区别,所以就不加注释了

FROM node

WORKDIR /app

COPY package.json /app

RUN npm install

COPY . /app

EXPOSE 80

CMD [ "node", "server.js" ]

运行

docker build -t feedback-node .
[+] Building 0.5s (10/10) FINISHED                                                                                                                                       docker:desktop-linux
 => [internal] load .dockerignore                                                                                                                                                        0.0s
#  忽略一些build过程
 => => naming to docker.io/library/feedback-node                                                                                                                                         0.0s

❯ docker run -p3000:80 -d --name feedback-app --rm feedback-node
66ffd4249a89d5eb1b1f979ab46ee836d2093917877bd91b8e68896669ec2044
❯ docker ps
CONTAINER ID   IMAGE           COMMAND                  CREATED         STATUS         PORTS                  NAMES
66ffd4249a89   feedback-node   "docker-entrypoint.s…"   4 seconds ago   Up 3 seconds   0.0.0.0:3000->80/tcp   feedback-app

最终 UI 渲染如下:

[docker] 数据的持久化 - Volume & bind mounts-LMLPHP

[docker] 数据的持久化 - Volume & bind mounts-LMLPHP

[docker] 数据的持久化 - Volume & bind mounts-LMLPHP

现在的问题就在于,tempfeedback 只存在于 docker 的容器里。如果这是一个真实世界里的项目,那么当版本迭代,旧容器被删除,新容器生成部署后,用户提供的信息丢失

为了避免这样的情况,就需要持久化数据,使得不管容器的状态是什么,需要持久化的数据都必须存在

配置 volume

前面提到了,volume 是真实存在于 host 的文件系统上,docker 通过 挂载(mounting) 的方式,实现从容器到 host 文件系统的沟通

当前的案例的情况为:

  • temp 中会存储临时文件

  • feedback 是在容器外的 volume

    通过挂载使得容器可以访问 host 文件系统上的存储

  • 如果当前文件不存在于 feedback 中,那么本程序就会将 temp(容器中的数据存储方式) 中的文件复制到 feedback(容器外的数据存储方式,持久化,不会随着容器的销毁而消失) 中去

匿名卷 anonymous volume

anonymous volume 的创建方式有两种,一种是通过 Dockerfile:

# inside the container where should be mapped
VOLUME [ "/app/feedback" ]

另一种方式是通过 cli:

docker run -v <path_in_container> <container_name>

这里选择哪种方式都行,第一种的话需要重新 build

recap 一下,现在所有的 container 都已经停止了,除了 mysql 的 container 之外,其余的 container 已经全都 删除

然后使用 docker volume 查看当前系统所使用的 volume:

# 这里有的 volume 是 mysql 的docker volume ls
DRIVER    VOLUME NAME
local     417ec38d03c862da140a876341fe02adb0aebdd352ca31799d3dd56da43b5b62

发现现在只有 1 个 volume 存在,这是因为在运行 container 的时候使用了 --rm 这个 flag——使用 --rm flag 会在容器停止后自动删除 anonymous volume:

[docker] 数据的持久化 - Volume &amp; bind mounts-LMLPHP

这里之所以提到 --rm,是因为如果不使用 --rm 的话,anonymous volume 就不会被自动删除,从而创建出 Orphaned Volumes(孤儿卷)。Orphaned Volumes 指的是没有任何的容器与它有所关联,但是当前 volume 也没有被删除的情况,这种情况下只能用以下两个指令进行清除:

  • docker volume rm [volume_id]

  • docker volume prune

这个其实跟 docker 的运作有关联……我不太确定这是不是应该被称之为生命周期,官网上没找到对应的资料。看其他的资料虽然也有说生命周期,不过官网上列举的其实是一些指令:

可以看到看的其他资料说的是 stage,官网上并没有明确的说明……

不过简化一下,这三个阶段是比较直接的:

  • 创建

    这个阶段是使用 Dockerfile/指令启动容器时,docker 会创建一个新的 anonymous volume,换言之,anonymous volume 与 container 的生命周期所绑定

    需要注意的一点就是,因为这里没有办法进行 mapping,所以如果一个 container 只是 stop/restart 的话,还能够复用 anounymous volume。但是 delete/restart 就不行了,reference 会丢

    这是因为 anonymous volume 的路径是存储在 container 里的,所以当 container 被删除,那么该 anonymous volume 的关联也就丢了。这种绑定的过程被称之为 direct attach

  • 使用

    就是持久化的这个阶段,因为是挂载在容器上,所以 volume 本质上还是 host 文件系统上的一个文件夹

  • 销毁

    这个阶段是可能会产生 Orphaned Volumes 的阶段

    如果有 --rm 这个 flag,那么 docker 就会自动清理该 container 产生的文件系统,也包括清理对应的 anounymous volume——如果该 anounymous volume 没有被其他容器所使用

    如果没有这个 flag,那么清理就不会被执行,自然也不会清理 anounymous volume。当一个 anounymous volume 没有任何引用,它也就无法自动被删除,这个情况下它就成了 Orphaned Volumes

可以看到,因为会 失去引用 的关系,当容器被删除又重启之后,对应的 volume 还是会被删除,因此 anonymous volume 无法解决持久化的问题

命名卷 named volume

使用命名卷可以保持独立性,因此可以更好的解决当前情况。

⚠️:named volume 只能通过命令行去实现,如:

# remove image and rebuilddocker run -p 3000:80 -d --rm --name feedback-app -v feedback:/app/feedback feedback-node:volumes

[docker] 数据的持久化 - Volume &amp; bind mounts-LMLPHP

虽然二者语法很像,不过 named volume 并不与 container 的生命周期所绑定,它不依附于 container,生命周期是独立的,因此可以被分别引用

绑定挂载 bind mounts

bind mounts 其实和 volume 不是一个东西,不过它们的语法很像。如下面会创建一个 bind mounts 和一个 named volume:

# may need to check the file sharing permissiondocker run
  -p 3000:80
  -d
  --rm
  --name feedback-app
  # named volume
  -v feedback:/app/feedback
  # bind mounts
  # 区别在于这是绝对路径
  -v "$(pwd):/app"
  feedback-node:volumes

如果使用 -v 绝对路径,那么就是创建一个 bind mount 了,它的处理方式也和 volume 不一样

  • volume 是完全由 docker 进行管理的

  • 使用 bind mount 时,docker 提供的管理是有限的

    它会设立 host 的文件系统与容器内的关联,但是当前路径的管理是通过 host 本身的系统进行的实现

    任何变化都可以同步到 docker 的容器里去

换言之,这对开发环境非常的有帮助——可以不用重新 rebuild 整个 docker image,修改的代码就能够被 docker 镜像所检测到

需要注意的一点就是,docker 必须要有权利访问当前被 bind mount 的文件夹 📁,这点可以通过下面这里查看:

[docker] 数据的持久化 - Volume &amp; bind mounts-LMLPHP

同样需要注意的一点就是,docker 在这里是提供 有限 的管理,而且 docker 本身也是提供一个容器化管理的工具,因此 docker 不会根据容器内的文件,去覆写 host 的系统文件。这里也会产生一个冲突——本机的源码贴到 docker 里,会覆盖掉原本的文件夹,那么自然就会将通过 RUN npm install 下载好的 node_modules 所覆盖掉,从而导致找不到依赖的问题:

docker run -p 3000:80  --rm --name feedback-app -v feedback:/app/feedback -v "$(pwd):/app" feedback-node:volumes
node:internal/modules/cjs/loader:1145
  throw err;
  ^

Error: Cannot find module 'express'
Require stack:
- /app/server.js
    at Module._resolveFilename (node:internal/modules/cjs/loader:1142:15)
    at Module._load (node:internal/modules/cjs/loader:983:27)
    at Module.require (node:internal/modules/cjs/loader:1230:19)
    at require (node:internal/modules/helpers:179:18)
    at Object.<anonymous> (/app/server.js:5:17)
    at Module._compile (node:internal/modules/cjs/loader:1368:14)
    at Module._extensions..js (node:internal/modules/cjs/loader:1426:10)
    at Module.load (node:internal/modules/cjs/loader:1205:32)
    at Module._load (node:internal/modules/cjs/loader:1021:12)
    at Function.executeUserEntryPoint [as runMain] (node:internal/modules/run_main:142:12) {
  code: 'MODULE_NOT_FOUND',
  requireStack: [ '/app/server.js' ]
}

Node.js v21.7.3

⚠️:这里说的是 覆盖 而不是 覆写,两边的文件都是同时存在的,只是因为 bind mount 的关系,host 的文件系统具有更高的权重

这个解决方式可以用 anonymous volume 去解决——即生成一个关于 node_modules 的 anonymous volume,实现如下:

使用 Dockerfile:

VOLUME [ "/app/node_modules" ]

或者直接命令行:

docker run
    -p 3000:80
    --rm
    --name feedback-app
    -v feedback:/app/feedback
    -v "$(pwd):/app"
    -v /app/node_modules
    feedback-node:volumes
# 没有 -d 所以会卡在这里,在另一个终端停止当前容器
❯ tree .
.
├── Dockerfile
├── feedback
# 这是一个空的文件夹,它的创立是受到 volume 的影响
# bind mount之后,host 和 container 的影响是相互的
├── node_modules
├── package.json
├── pages
│   ├── exists.html
│   └── feedback.html
├── public
│   └── styles.css
├── server.js
└── temp

6 directories, 6 files

[docker] 数据的持久化 - Volume &amp; bind mounts-LMLPHP

最后一个是关于 node 项目的优化,也就是使用 nodemon 建立一个简易的 dev server,用以检测代码的变化,从而实现热部署:

{
  "devDependencies": {
    "nodemon": "3.1.0"
  },
  "scripts": {
    "start": "nodemon server.js"
  }
}

更新 Dockerfile:

CMD [ "npm", "start" ]

这就需要重新 build 了,效果如下:

docker logs feedback-app

> data-volume-example@1.0.0 start
> nodemon server.js

[nodemon] 3.1.0
[nodemon] to restart at any time, enter `rs`
[nodemon] watching path(s): *.*
[nodemon] watching extensions: js,mjs,cjs,json
[nodemon] starting `node server.js`
[nodemon] restarting due to changes...
[nodemon] starting `node server.js`
[nodemon] clean exit - waiting for changes before restart
[nodemon] restarting due to changes...
[nodemon] starting `node server.js`
nodemon is running

总结

04-18 08:16