Docker 的底层原理
由 Mux 主办的 DEV 全球展示挑战赛:展示你的项目!
Docker 的定义是
一套平台即服务产品,它们利用操作系统级虚拟化技术,以称为容器的软件包形式交付软件。
本质上,Docker 允许我们将应用程序转换为二进制文件,这些二进制文件可以存储在外部,然后可以从任何地方拉取并运行或部署。它最初于 2013 年发布(距今已有 8 年),主要使用 Go 语言编写。Docker 镜像的两个主要公共镜像仓库是 Docker Hub 和 Docker Cloud。前者是 Docker 默认检查的镜像仓库。
这是我的“幕后揭秘”系列文章的一部分:
- Git
- GraphQL
- Web打包工具(例如Webpack)
- 类型系统(例如 TypeScript)
- 测试运行程序(例如 Mocha)
- NPM
- 源地图
- React hooks
- 阿波罗
- 自动格式化工具(例如 Prettier)
今天的文章将分为以下几个部分:
1:概述
Docker 由多个组件构成,我们先从以下组件开始:
- 客户
- 注册表
- 主持人
- 恶魔
- 图片
- 容器
- 存储/卷
- 联网
客户
客户端是通过守护进程(见下文)与 Docker 主机交互的途径。客户端通常由命令行界面 (CLI)(用于直接运行命令,例如docker pull x)或一段为您运行这些命令的软件组成(例如Docker Desktop)。
客户端实际上可以同时连接到多个 Docker 守护进程。
注册表
这是用于存储图像的外部数据库。有关注册表数据库的更多详细信息,请参阅我的“NPM 底层原理”文章。
主持人
这是执行和运行容器的环境,它与本地机器不同。要获得访问权限,您必须进入容器,然后按下localhost:<port>.
主机包含以下几个对象:
恶魔
守护进程是一个后台进程,其职责是监听 Docker API 请求并做出响应。它管理镜像、容器、网络和卷等对象。
该二进制文件可以在以下环境下运行
dockerd
示例 - 构建命令
- CLI 告诉守护进程
- Damon
Dockerfile每次只执行一条指令——它将每条指令的结果提交到一个新的映像中。 - 最后输出镜像 ID - 使用构建缓存。在每个步骤中,在 CLI 中打印消息。
构建缓存
- 对于每条指令,守护进程都会检查缓存,看是否已存在该指令。
- 将“父图像”与“指令”(键)配对以查找匹配项
- 将指令与所有由基础/父图像派生的子图像进行比较。
图片
图片包含很多内容,所以我尽量涵盖了要点。
图片是只读模板,是不可变的快照。
镜像文件包含manifest.json镜像标签和签名等详细信息。
镜像目录包含镜像层和清单文件,每个镜像只有一个镜像层。基础镜像没有父镜像,子镜像的 ID 与其父镜像相同。最底层的镜像称为基础镜像。唯一的 ID 是 SHA256 哈希值。
图像层
图层是中间图像,一张图像包含一个图层,每个图层都是一条指令。指令存储在图像的 JSON 配置中,同时存储的还有文件夹详细信息(例如,文件夹路径、文件路径、文件夹路径等lower)merged。upper该work系统鼓励使用较小的图像,因为每个图像都堆叠在前一个图像之上。
每个层都存储在 Docker 主机本地存储区域内各自的目录中。该目录包含(唯一的)镜像内容,但目录名称并非层 ID。
> ls /var/lib/docker/overlay2 # list image layers
> 16802227a96c24dcbeab5b37821e2
> ls 16802227a96c24dcbeab5b37821e2 # image layer contents
> diff link lower merged work
目录
- 链接 - 缩短的图层 ID
- diff - 根目录下的图层内容(文件夹和文件)
- 较低 - 指向父级/上一层(较高层具有此属性)。
- 合并 - 统一了上层及其自身的内容(上层具有此功能)
- 工作 - 由 OverlayFS 存储驱动程序内部使用
您可以使用以下命令来验证加密 ID。
> docker image ls
> docker history
使用下面的按钮查看图层 ID 和创建该图层的指令(注意:如果只是添加到元数据,则大小可以为 0)。
> docker image history <image>
容器
容器是虚拟化的运行时环境,它们运行镜像。
容器层
每个新容器都会在底层之上添加一个新的可写层。对运行中的容器所做的所有更改(例如修改文件)都会写入这个轻量级的可写层。
当容器被删除时,这个可写层也会被删除,而底层镜像则保持不变。
多个容器可以共享同一个镜像,但各自拥有独立的数据状态。
如果使用卷,则该卷将成为可写层。
存储/卷
我个人认为这是 Docker 相关主题中最难理解的部分。
存储驱动器
这控制如何管理“镜像层(堆栈)”和“容器(读/写)层”(内容),与卷无关。
通常情况下,容器删除后文件不会被保留,但驱动程序允许将数据写入“容器(可写)层”。驱动程序负责处理镜像层之间的交互细节。根据具体情况,它们各有优缺点。
所有驱动程序都使用相同的可堆叠映像层,并且大多数驱动程序都使用写时复制 (CoW) 策略(见下文)。
Dockeroverlay2存储驱动程序使用OverlayFSLinux 内核驱动程序(见下文)。
存储驱动程序使用底层文件系统驱动程序(位于内核中)来修改文件(Docker 安装所在主机的文件系统)。某些存储驱动程序仅适用于特定的底层文件系统,例如extfs:
文字复制策略
这是一种非常高效的存储技术。本质上,如果图像被复制但未被修改,则不会创建新图像。因此,您可以共享图像,直到其中一张图像被更改,但一旦进行修改,就会创建新图像。
OverlayFS
OverlayFS它将单个 Linux 主机上的两个目录层合并为一个目录。它是一种非常现代的联合文件系统(即容器层与镜像层),并且具有高效的 inode 利用率。
卷
卷用于持久化容器中创建的数据,尤其适用于写入密集型应用。与绑定挂载相比,卷更可取,因为卷由 Docker 管理,而绑定挂载则由操作系统管理。
要使用卷点本地内容,请将其指向容器使用的区域,并使用以下格式。
volume:directory
卷被绑定挂载到文件系统镜像中已有的(或创建的)路径,位于镜像层之外。它们保留在其源位置,并成为“容器层”。多个容器可以通过共享单个卷来共享数据。
它们最初创建后即可重复使用,并通过 Docker API 进行管理。您可以在容器范围之外创建和管理它们。您可以为卷命名,使其在容器外部具有来源,或者将其设置为匿名,这样当容器被移除时,守护进程会自动将其移除。
它们位于主机文件系统中(通常位于 docker 数据目录下,例如,/var/lib/docker/volumes但取决于文件系统)。
一些实用命令:
> docker volume create my-vol
> docker volume inspect my-vol
> docker volume rm my-vol
> -v ./packages/website:/usr/src/app
联网
Docker守护进程充当容器的DHCP服务,即分配IPS等。容器使用主机的DNS设置(在中定义/etc/resolv.conf)。
默认网络设置指定容器是 Docker 网络堆栈的一部分,并创建一个网桥(网关和 IP 子网)。属于此网络的所有容器都可以通过 IP 地址相互通信。
我最喜欢的 Docker 架构图是这个——它不仅展示了组件概览,还展示了操作流程(参见构建、拉取、运行)。
2:构建我们自己的 Docker
为了验证这一概念,我们将包含以下几个方面:
我的计划是使用写时复制机制和自定义 JS 存储驱动程序,但我时间不够,决定将重点放在图像创建和运行方面。
所以我们的应用将会:
Dockerfile根据父图层,将本地提交指令读取到新的图像图层中,从而创建一个新图像。- 运行新镜像——使用一个
tmp目录作为可写容器层覆盖在指定的镜像之上。然后在虚拟化运行时环境中启动容器并执行命令。
被忽略的方面:
- 为容器分配 IP 地址的守护进程,充当 DHCP 服务器
- 运行守护进程的主机(将在本地计算机上)
- 停止时删除容器层
- 容器层采用可堆叠的“写时复制”策略
- 允许安装卷
- 容器未使用主机(无主机)的 DNS 设置
最后还有一点被忽略了,那就是在真正的 Docker 中,每条指令都会创建一个新的镜像层,而在我们的 PoC 中,我们把所有的指令都运行在一个镜像层中,希望这能简化事情。
代码!!
恶魔
守护进程是一个简单的 Express 服务器,它在子进程中执行命令。
import express from "express";
import path from "path";
import { promisify } from "util";
import run from "./commands/run.js";
import build from "./commands/build.js";
const app = express();
const port = 3000;
app.get("/:command/:args", async (req, res) => {
console.log("Command: ", req.params.command);
console.log("Args: ", req.params.args);
switch (req.params.command) {
case "run":
await run(req.params.args);
break;
case "build":
await build(req.params.args);
break;
}
res.send("success");
});
app.listen(port, () => {
console.log(`Example app listening at http://localhost:${port}`);
});
现有图像
在查看命令之前,我在图像中创建了一些虚假的图像图层。
以上链接的内容总结如下:
images/lowest-layer 包含:
- /diff
- 包含文件夹 /etc、/home、/lib、/mount、/opt、/user、/var
- 关联
- 内容
LOWEST-ID
- 内容
- config.json
- 包含空值的 JSON 对象
- manifest.json
images/中间层包含:
- /diff
- 包含文件夹 /media + /usr/src/app
- 请注意,/usr/src/app 现在包含一个小型 JS 应用程序,
index.prod.js并且index.dev.js
- /合并
- 包含文件夹 /etc、/home、/lib、/mount、/opt、/user、/var、/media
- /media 是新的
- 关联
- 内容
MIDDLE-ID
- 内容
- 降低
- 内容
l/LOWEST-ID
- 内容
- config.json
- 包含以下值的 JSON 对象
Env:CmdWorkingDir - 命令运行
index.dev.js
- 包含以下值的 JSON 对象
- manifest.json
命令行界面
const daemon = 'http://localhost:3000';
const supported = ['build', 'run'];
async function run() {
const command = process.argv[2];
const details = process.argv[3];
if (!supported.includes(command)) {
console.log("Not supported");
return;
}
const response = await fetch(`${daemon}/${command}/${details}`);
if (response.status) {
console.log("SUCCESS");
return;
}
console.log("Failure :(");
}
run();
它非常简单。它接收一个参数和一些详细信息,然后使用 HTTP GET 请求将这些信息传递给守护进程。HTTP GET 机制是对实际 CLI 与守护进程之间通信的简化,但对于概念验证 (PoC) 来说非常方便。
我们的cli软件包脚本将直接运行node cli.js。
例如:
> npm run cli <command> <argument>
命令
建造
我们先从一些实用工具开始,它们返回路径或更新配置值。
const utils = {
getFullPath: () => path.resolve(path.dirname("")),
grabConfig: async () => {
await delay(1000);
const fullPath = utils.getFullPath();
const fullConfig = await import(`${fullPath}/tmp/config.json`);
return fullConfig.default[0];
},
updateConfig: async (config) => {
const fullPath = utils.getFullPath();
return asyncWriteFile(
`${fullPath}/tmp/config.json`,
JSON.stringify([config])
);
},
};
主构建功能。
export default async function (buildImage) {
// 1
if (buildImage === ".") {
// Default local image
// 2
const dockerFilePath = path.resolve(path.dirname(""), "./OurDockerfile");
const file = await asyncReadFile(dockerFilePath, {
encoding: "utf-8",
});
// 3
// good for small files, NOT big ones
const linesArray = file.split(/\r?\n/);
await linesArray.map(async (line) => await commitLine(line));
// required for above OS ops to finish
await delay(1000);
// create new image
const layerName = "highest-layer";
const fullPath = utils.getFullPath();
// 4
// update link (HIGHEST-LAYER) + lower (MIDDLE-ID)
const link = await asyncReadFile(`${fullPath}/tmp/link`, {
encoding: "utf-8",
});
await asyncWriteFile(`${fullPath}/tmp/link`, layerName.toUpperCase());
await asyncWriteFile(`${fullPath}/tmp/lower`, link);
console.log(`SUCCESS - Created layer: ${layerName}`);
await delay(1000);
// 5
// move tmp to new image
await asyncNcp(`${fullPath}/tmp`, `images/${layerName}`);
// remove tmp
await asyncRimraf(`${fullPath}/tmp/`, {});
}
}
它的工作原理如下:
- 检查构建镜像,目前仅支持本地文件 ie
. - 取出
OurDockerfile里面的东西。 - 按行分割文件,并运行
commitLine程序处理每一行。 - 更新新图像的
link引用,包括对自身和父图像的引用。lower - 将新图像图层从
/tmp原位置移动到原位置/images并移除/tmp
提交映射的任务是执行 Dockerfile 中的命令。在实际的 Docker 环境中,它会为每条指令创建一个新的层。此外,还有一个commitLine针对映射运行的命令。它目前支持 Docker 中最常用的一些命令:
- 从
- 环境
- 工作目录
- 复制
- 命令
const commitMap = {
from: async (layer) => {
// move to tmp for processing
const fullPath = utils.getFullPath();
await asyncNcp(`${fullPath}/images/${layer}`, `tmp`);
// remove diff as specific to layer
await asyncRimraf(`${fullPath}/tmp/diff`, {});
},
env: async (values) => {
const config = await utils.grabConfig();
if (config.Config.Env) {
config.Config.Env.push(...values); // merge incoming array into config one
} else {
config.Config.Env = values;
}
await utils.updateConfig(config);
},
workdir: async ([value]) => {
const config = await utils.grabConfig();
config.Config.WorkingDir = value; // a string
await utils.updateConfig(config);
},
copy: async (values) => {
const fullPath = utils.getFullPath();
const cpyLoc = values.pop();
// required for diff deletion to finish
await delay(1000);
values.map(async (file) => {
// create folder recursively
await asyncMkdir(`${fullPath}/tmp/diff${cpyLoc}/`, { recursive: true });
// copy files
await asyncCopyFile(file, `${fullPath}/tmp/diff${cpyLoc}/${file}`);
});
},
cmd: async (values) => {
const config = await utils.grabConfig();
config.Config.Cmd = values;
await utils.updateConfig(config);
},
};
async function commitLine(line) {
const args = line.split(" ");
// 1
const command = args[0];
if (!command) return; // empty line or something
args.shift();
// 2
// call command function
if (!commitMap[command.toLowerCase()]) return; // invalid instruction
await commitMap[command.toLowerCase()](args);
}
一次中断流程commitLine(line)被称为
- 获取命令
- 确保它存在于文件中,
commitMap然后使用参数执行它。
如果我们考虑以下情况OurDockerfile
FROM middle-layer
ENV NODE_VERSION=13.0.0
WORKDIR /usr/src/app
COPY fileA.txt fileB.txt /usr/src/app
CMD npm run start.prod
请注意,fileA.txt这fileB.txt两个都位于测试存储库的顶层,并且内部包含简单的内容。
我们可以逐步了解每条指令的处理过程:
从
- 获取完整路径
middle-layer将位于 中的图像(例如 )移动images到tmp- 删除该
tmp/diff文件夹,因为它特定于该图层。
我已经构建了两个示例图像。images
- 中间层
- 最底层
环境
- 获取配置
- 如果已设置值
Env,则添加到这些值中;否则,创建该部分并添加此值。 - 更新配置
工作目录
- 获取配置
- 将其设置
WorkingDir为新值 - 更新配置
复制
- 获取副本位置
- 对每个要复制和移动的文件进行映射
/tmp/diff/<copy location>
CMD
- 获取配置
- 将值设置
Cmd为新值,即运行index.prod.js - 更新配置
注意 ENV、WORKDIR 和 CMD 之间的相似之处。它们主要都在更新镜像层config.json文件,以便在运行时能够获取正确的值。
在真正的 Docker 中,每条指令都会被提交到一个新的镜像层,最后才会创建镜像;但是为了简单起见,我们将这两个概念合并了,以便所有指令都创建一个单独的镜像层。
跑步
现在我们将探讨构建新形象需要哪些条件。
export default async function (image) {
// 1. copy image contents, exclude configs
const fullImgPath = path.resolve(path.dirname(""), "./images");
await asyncNcp(`${fullImgPath}/${image}/merged`, `tmp`);
console.log("copied");
// 2. process config
const fullConfig = await import(`../../images/${image}/config.json`);
const config = fullConfig.default[0].Config;
const splitCommand = config.Cmd;
// env is key:value pairs
const environment = config.Env.reduce((acc, curr) => {
const [key, value] = curr.split("=");
acc[key] = value;
return acc;
}, {});
const workingDir = config.WorkingDir;
// 3. run command in child
const startCmd = splitCommand[0];
splitCommand.shift();
const childProcess = spawn(startCmd, splitCommand, {
cwd: `tmp/${workingDir}`,
env: environment,
});
// 4. outputs
childProcess.stdout.on("data", (data) => {
console.log(`stdout: ${data}`);
});
childProcess.stderr.on("data", (data) => {
console.error(`stderr: ${data}`);
});
childProcess.on("error", (error) => {
console.log(`child process error ${error}`);
});
childProcess.on("close", (code) => {
console.log(`child process exited with code ${code}`);
});
// remove ref might close open conn, but not sure it will considering above
childProcess.unref();
}
让我们回顾一下
- 我们将图像
merged内容移至tmp处理区域。 - 我们处理
config.json获取所需值的过程Cmd,WorkingDir例如Env环境变量。 - 创建一个子进程,并使用给定的工作目录和环境变量运行该命令。
- 创建监听器,监听诸如标准输出和错误信息之类的事件,并记录输出日志。
运行应用程序
为了进行测试,我们将执行以下操作:
- 运行中间层 -> 运行一个小型 js 应用程序,该应用程序会打印内容
run DEV app - 构建新的最高层图像 -> 创建
images/highest-layer - 运行最高层 -> 运行的是同一个小型 js 应用
run PROD app
加油💪
在终端 A 中运行以下命令启动守护进程
> npm run daemon
在终端 B 中,我们运行其他命令。
运行中间层图像
> npm run cli run middle-layer
- 使用命令构建
npm run start.dev - 输出
run DEV app。检查
构建最高层图像
> npm run cli build .
运行最高层图像
> npm run cli run highest-layer
- 使用命令构建
npm run start.prod - 输出
run PROD app
就这样🙌,我们的迷你 Docker 运行正常。
非常感谢您的阅读,我从这项研究中学到了很多关于 Docker 的知识,希望对您有所帮助。您可以在这里找到所有代码的仓库。
谢谢,克雷格😃
文章来源:https://dev.to/craigtaub/under-the-hood-of-docker-2dk2
