发布于 2026-01-06 0 阅读
0

Docker DEV 全球展示挑战赛幕后揭秘(由 Mux 呈现):展示你的项目!

Docker 的底层原理

由 Mux 主办的 DEV 全球展示挑战赛:展示你的项目!

Docker 的定义是

一套平台即服务产品,它们利用操作系统级虚拟化技术,以称为容器的软件包形式交付软件。

本质上,Docker 允许我们将应用程序转换为二进制文件,这些二进制文件可以存储在外部,然后可以从任何地方拉取并运行或部署。它最初于 2013 年发布(距今已有 8 年),主要使用 Go 语言编写。Docker 镜像的两个主要公共镜像仓库是 Docker Hub 和 Docker Cloud。前者是 Docker 默认检查的镜像仓库。

这是我的“幕后揭秘”系列文章的一部分

今天的文章将分为以下几个部分:

  1. 概述

  2. 构建我们自己的 Docker


1:概述

Docker 由多个组件构成,我们先从以下组件开始:

  • 客户
  • 注册表
  • 主持人
    • 恶魔
    • 图片
    • 容器
    • 存储/卷
    • 联网

客户

客户端是通过守护进程(见下文)与 Docker 主机交互的途径。客户端通常由命令行界面 (CLI)(用于直接运行命令,例如docker pull x)或一段为您运行这些命令的软件组成(例如Docker Desktop)。

客户端实际上可以同时连接到多个 Docker 守护进程。

注册表

这是用于存储图像的外部数据库。有关注册表数据库的更多详细信息,请参阅我的“NPM 底层原理”文章。

主持人

这是执行和运行容器的环境,它与本地机器不同。要获得访问权限,您必须进入容器,然后按下localhost:<port>.

主机包含以下几个对象:

恶魔

守护进程是一个后台进程,其职责是监听 Docker API 请求并做出响应。它管理镜像、容器、网络和卷等对象。

该二进制文件可以在以下环境下运行

dockerd

示例 - 构建命令
  1. CLI 告诉守护进程
  2. DamonDockerfile每次只执行一条指令——它将每条指令的结果提交到一个新的映像中。
  3. 最后输出镜像 ID - 使用构建缓存。在每个步骤中,在 CLI 中打印消息。
构建缓存
  • 对于每条指令,守护进程都会检查缓存,看是否已存在该指令。
  • 将“父图像”与“指令”(键)配对以查找匹配项
  • 将指令与所有由基础/父图像派生的子图像进行比较。

图片

图片包含很多内容,所以我尽量涵盖了要点。

图片是只读模板,是不可变的快照。

镜像文件包含manifest.json镜像标签和签名等详细信息。
镜像目录包含镜像层和清单文件,每个镜像只有一个镜像层。基础镜像没有父镜像,子镜像的 ID 与其父镜像相同。最底层的镜像称为基础镜像。唯一的 ID 是 SHA256 哈希值。

图像层

图层是中间图像,一张图像包含一个图层,每个图层都是一条指令。指令存储在图像的 JSON 配置中,同时存储的还有文件夹详细信息(例如,文件夹路径、文件路径、文件夹路径等lowermergedupperwork系统鼓励使用较小的图像,因为每个图像都堆叠在前一个图像之上。

每个层都存储在 Docker 主机本地存储区域内各自的目录中。该目录包含(唯一的)镜像内容,但目录名称并非层 ID。

> ls /var/lib/docker/overlay2 # list image layers
> 16802227a96c24dcbeab5b37821e2
> ls 16802227a96c24dcbeab5b37821e2 # image layer contents
> diff link lower merged work
Enter fullscreen mode Exit fullscreen mode

目录

  • 链接 - 缩短的图层 ID
  • diff - 根目录下的图层内容(文件夹和文件)
  • 较低 - 指向父级/上一层(较高层具有此属性)。
  • 合并 - 统一了上层及其自身的内容(上层具有此功能)
  • 工作 - 由 OverlayFS 存储驱动程序内部使用

您可以使用以下命令来验证加密 ID。

> docker image ls
> docker history
Enter fullscreen mode Exit fullscreen mode

使用下面的按钮查看图层 ID 和创建该图层的指令(注意:如果只是添加到元数据,则大小可以为 0)。

> docker image history <image>
Enter fullscreen mode Exit fullscreen mode

容器

容器是虚拟化的运行时环境,它们运行镜像。

容器层

每个新容器都会在底层之上添加一个新的可写层。对运行中的容器所做的所有更改(例如修改文件)都会写入这个轻量级的可写层。
当容器被删除时,这个可写层也会被删除,而底层镜像则保持不变。
多个容器可以共享同一个镜像,但各自拥有独立的数据状态。

如果使用卷,则该卷将成为可写层。

存储/卷

我个人认为这是 Docker 相关主题中最难理解的部分。

存储驱动器

这控制如何管理“镜像层(堆栈)”和“容器(读/写)层”(内容),与卷无关。

通常情况下,容器删除后文件不会被保留,但驱动程序允许将数据写入“容器(可写)层”。驱动程序负责处理镜像层之间的交互细节。根据具体情况,它们各有优缺点。

所有驱动程序都使用相同的可堆叠映像层,并且大多数驱动程序都使用写时复制 (CoW) 策略(见下文)。

Dockeroverlay2存储驱动程序使用OverlayFSLinux 内核驱动程序(见下文)。

存储驱动程序使用底层文件系统驱动程序(位于内核中)来修改文件(Docker 安装所在主机的文件系统)。某些存储驱动程序仅适用于特定的底层文件系统,例如extfs

文字复制策略

这是一种非常高效的存储技术。本质上,如果图像被复制但未被修改,则不会创建新图像。因此,您可以共享图像,直到其中一张图像被更改,但一旦进行修改,就会创建新图像。

OverlayFS

OverlayFS它将单个 Linux 主机上的两个目录层合并为一个目录。它是一种非常现代的联合文件系统(即容器层与镜像层),并且具有高效的 inode 利用率。

卷用于持久化容器中创建的数据,尤其适用于写入密集型应用。与绑定挂载相比,卷更可取,因为卷由 Docker 管理,而绑定挂载则由操作系统管理。

要使用卷点本地内容,请将其指向容器使用的区域,并使用以下格式。

volume:directory
Enter fullscreen mode Exit fullscreen mode

卷被绑定挂载到文件系统镜像中已有的(或创建的)路径,位于镜像层之外。它们保留在其源位置,并成为“容器层”。多个容器可以通过共享单个卷来共享数据。

它们最初创建后即可重复使用,并通过 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
Enter fullscreen mode Exit fullscreen mode

联网

Docker守护进程充当容器的DHCP服务,即分配IPS等。容器使用主机的DNS设置(在中定义/etc/resolv.conf)。

默认网络设置指定容器是 Docker 网络堆栈的一部分,并创建一个网桥(网关和 IP 子网)。属于此网络的所有容器都可以通过 IP 地址相互通信。


我最喜欢的 Docker 架构图是这个——它不仅展示了组件概览,还展示了操作流程(参见构建、拉取、运行)。

图片描述


2:构建我们自己的 Docker

为了验证这一概念,我们将包含以下几个方面:

我的计划是使用写时复制机制和自定义 JS 存储驱动程序,但我时间不够,决定将重点放在图像创建和运行方面。

所以我们的应用将会:

  1. Dockerfile根据父图层,将本地提交指令读取到新的图像图层中,从而创建一个新图像。
  2. 运行新镜像——使用一个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}`);
});
Enter fullscreen mode Exit fullscreen mode

现有图像

在查看命令之前,我在图像中创建了一些虚假的图像图层

以上链接的内容总结如下:

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 对象EnvCmdWorkingDir
    • 命令运行index.dev.js
  • 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();
Enter fullscreen mode Exit fullscreen mode

它非常简单。它接收一个参数和一些详细信息,然后使用 HTTP GET 请求将这些信息传递给守护进程。HTTP GET 机制是对实际 CLI 与守护进程之间通信的简化,但对于概念验证 (PoC) 来说非常方便。

我们的cli软件包脚本将直接运行node cli.js

例如:

  > npm run cli <command> <argument>
Enter fullscreen mode Exit fullscreen mode

命令

建造

我们先从一些实用工具开始,它们返回路径或更新配置值。

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])
    );
  },
};
Enter fullscreen mode Exit fullscreen mode

主构建功能。

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/`, {});
  }
}
Enter fullscreen mode Exit fullscreen mode

它的工作原理如下:

  1. 检查构建镜像,目前仅支持本地文件 ie.
  2. 取出OurDockerfile里面的东西。
  3. 按行分割文件,并运行commitLine程序处理每一行。
  4. 更新新图像的link引用,包括对自身和父图像的引用。lower
  5. 将新图像图层从/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);
}
Enter fullscreen mode Exit fullscreen mode

一次中断流程commitLine(line)被称为

  1. 获取命令
  2. 确保它存在于文件中,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
Enter fullscreen mode Exit fullscreen mode

请注意,fileA.txtfileB.txt两个都位于测试存储库的顶层,并且内部包含简单的内容。

我们可以逐步了解每条指令的处理过程:

  • 获取完整路径
  • middle-layer将位于 中的图像(例如 )移动imagestmp
  • 删除该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();
}
Enter fullscreen mode Exit fullscreen mode

让我们回顾一下

  1. 我们将图像merged内容移至tmp处理区域。
  2. 我们处理config.json获取所需值的过程CmdWorkingDir例如Env环境变量。
  3. 创建一个子进程,并使用给定的工作目录和环境变量运行该命令。
  4. 创建监听器,监听诸如标准输出和错误信息之类的事件,并记录输出日志。

运行应用程序

为了进行测试,我们将执行以下操作:

  1. 运行中间层 -> 运行一个小型 js 应用程序,该应用程序会打印内容run DEV app
  2. 构建新的最高层图像 -> 创建images/highest-layer
  3. 运行最高层 -> 运行的是同一个小型 js 应用run PROD app

加油💪

在终端 A 中运行以下命令启动守护进程

> npm run daemon
Enter fullscreen mode Exit fullscreen mode

在终端 B 中,我们运行其他命令。

运行中间层图像

> npm run cli run middle-layer
Enter fullscreen mode Exit fullscreen mode
  • 使用命令构建npm run start.dev
  • 输出run DEV app。检查

构建最高层图像

> npm run cli build .
Enter fullscreen mode Exit fullscreen mode

运行最高层图像

> npm run cli run highest-layer
Enter fullscreen mode Exit fullscreen mode
  • 使用命令构建npm run start.prod
  • 输出run PROD app

就这样🙌,我们的迷你 Docker 运行正常。


非常感谢您的阅读,我从这项研究中学到了很多关于 Docker 的知识,希望对您有所帮助。您可以在这里找到所有代码的仓库。

谢谢,克雷格😃

文章来源:https://dev.to/craigtaub/under-the-hood-of-docker-2dk2