[docker] 数据的持久化 - Volume & bind mounts
docker 的数据笼统分类可以分为下面这三种:
-
只读数据
这种数据大多为源码、容器的配置文件,大多数情况下与镜像进行绑定
-
临时数据
这部分的数据大多数情况下与容器进行绑定,属于可写数据
具体案例为存储与内存的数据,如进行 AJAX 操作后获取的数据会被存在内存中,db 数据可以存在容器里等
属于经常被读写的数据
-
永久数据
这部分数据属于永久保存数据,并不依托于容器或镜像存在,并且容器被销毁时,永久数据也应当被保存,而不会随着容器的销毁而消失
目前这部分的数据是还没有接触过的,也是本章笔记的主题——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);
大概走一下这个代码的流程:
-
会在 80 端口启动一个服务器
服务器中有若干 endpoints
-
当用户填写信息的时候,会在
temp
文件夹下写一个临时文件 -
检查
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 渲染如下:
现在的问题就在于,temp
和 feedback
只存在于 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:
这里之所以提到 --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 rebuild
❯ docker run -p 3000:80 -d --rm --name feedback-app -v feedback:/app/feedback feedback-node:volumes
虽然二者语法很像,不过 named volume 并不与 container 的生命周期所绑定,它不依附于 container,生命周期是独立的,因此可以被分别引用
绑定挂载 bind mounts
bind mounts 其实和 volume 不是一个东西,不过它们的语法很像。如下面会创建一个 bind mounts 和一个 named volume:
# may need to check the file sharing permission
❯ docker 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 在这里是提供 有限 的管理,而且 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
最后一个是关于 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