侧边栏壁纸
博主头像
ZHD的小窝博主等级

行动起来,活在当下

  • 累计撰写 79 篇文章
  • 累计创建 53 个标签
  • 累计收到 1 条评论

目 录CONTENT

文章目录

构建自己的容器

江南的风
2024-08-01 / 0 评论 / 0 点赞 / 22 阅读 / 9810 字 / 正在检测是否收录...

本文通过构建自己的容器来讲述容器化的核心技术。参考资料:https://github.com/codecrafters-io/build-your-own-x?tab=readme-ov-file#build-your-own-docker

1. 关键要数

1.1. 命名空间(Namespace

命名空间(Namespace)是一种强大的特性,它允许将操作系统的全局资源抽象为多个独立的实例,从而实现进程隔离和资源限制。这种技术对于容器化、虚拟化和系统安全等方面具有重要意义。

命名空间是一种操作系统级别的虚拟化技术,它允许在同一系统上运行多个隔离的进程,每个进程都认为自己在一个独立的系统上运行。命名空间通过为进程提供独立的资源视图和配置信息,实现了对进程、网络、文件系统、IPC(进程间通信)等资源的隔离,减少了潜在的安全风险,提高了系统的安全性和稳定性。

以下是几种常见的命名空间类型:

  • PID命名空间:用于隔离进程ID。每个PID命名空间都有独立的进程ID,使得在不同的PID命名空间中进程ID是唯一的。

  • 网络命名空间:用于隔离网络资源,包括网络设备、IP地址、路由表等。每个网络命名空间都有独立的网络栈,使得网络配置和状态在不同命名空间中相互隔离。

  • 挂载命名空间:用于隔离文件系统挂载点。每个挂载命名空间都有独立的文件系统挂载点,使得在不同的挂载命名空间中文件系统是隔离的。

  • UTS命名空间:用于隔离主机名和域名。每个UTS命名空间都有独立的主机名和域名,使得在不同的UTS命名空间中主机名和域名是隔离的。

  • IPC命名空间:用于隔离进程间通信资源,如消息队列、信号量和共享内存。每个IPC命名空间都有独立的IPC资源,使得不同命名空间中的进程无法直接访问其他命名空间的IPC资源。

  • 用户命名空间:用于隔离用户和用户组ID。每个用户命名空间都有独立的用户和组ID,使得在不同的用户命名空间中用户和组ID是隔离的。

1.2 控制组(CGroups

CGroups允许系统管理员为运行中的进程组设置资源限制、优先级控制、统计和审计等,从而有效管理系统的资源使用。通过CGroups,可以实现精细化的资源管理和控制(包括CPU、内存、磁盘I/O、网络带宽等),提高系统的稳定性和性能。

CGroups由以下几个关键组件组成:

  • CGroup:一个CGroup包含一组进程,并可以在该CGroup上设置资源限制和控制参数。

  • Subsystem:子系统是一组资源控制模块,每个子系统负责控制一种类型的资源。常见的子系统包括CPU、内存、磁盘I/O、网络等。

  • Hierarchy:Hierarchy是CGroup树的集合,一个Hierarchy可以包含多个CGroup,每个CGroup都可以是另一个CGroup的子节点,形成树状结构。每个Subsystem只能附加到一个Hierarchy上,但一个Hierarchy可以附加多个Subsystem。

1.3 分层文件系统(Filesystem Hierarchy Standard,FHS

分层文件系统(Filesystem Hierarchy Standard,FHS)是组织文件和目录的一种标准方式,它为Linux系统提供了一个清晰、统一的文件组织结构。

以下是分层文件系统的主要特点:

  • 根目录(/):** 根目录是整个文件系统的起点,所有的文件和目录都挂载在根目录下。

  • 目录结构标准化: Linux的文件系统遵循FHS标准,不同的Linux发行版在目录结构上具有很高的一致性,这便于用户和管理员跨发行版操作。

  • 文件类型丰富: Linux中的一切都是文件,包括普通文件、目录、设备文件、管道文件等。

  • 权限控制: 每个文件和目录都有相应的权限设置,用于控制不同用户或用户组对文件和目录的访问。

目录结构概述:

  • 根目录(/)**:是整个文件系统的入口点,所有其他目录和文件都挂载在根目录下。

  • 系统启动和基本命令目录

    • /bin:存放系统启动时需要的基本命令和程序,这些命令对所有用户都是可用的。

    • /sbin:存放系统管理命令,这些命令通常只能由超级用户(root)执行。

  • 设备文件目录(/dev):包含设备文件,这些文件代表了系统中的硬件设备,如硬盘、打印机等。

  • 配置文件目录(/etc):存放系统的配置文件,这些文件对于系统的正常运行和各种应用程序的配置至关重要。

  • 用户目录

    • /home:普通用户的主目录,每个用户都有一个以其用户名命名的子目录。

    • /root:超级用户(root)的主目录。

  • 临时文件目录(/tmp):用于存放临时文件,系统重启时通常会清空该目录。

  • 系统程序和数据目录

    • /usr:包含了绝大多数的用户级程序和文件,如命令、库文件、文档等。

      • /usr/bin:存放系统用户级的可执行文件。

      • /usr/lib:存放库文件,包括系统程序运行时所需的共享库。

      • /usr/local:用于存放手动安装的软件,通常用于安装第三方程序。

      • /usr/share:存放共享文件,如文档、图标等。

    • /var:用于存放系统运行时经常变动的文件,如日志、缓存等。

      • /var/log:存放系统日志文件。

      • /var/cache:存放应用程序的缓存数据。

  • 挂载点目录(/mnt 和 /media):通常用作临时挂载点,用于挂载其他文件系统或存储设备。

  • 其他重要目录

    • /boot:包含引导加载程序和内核镜像文件,是系统启动时必需的。

    • /proc:一个虚拟文件系统,提供有关内核、进程和系统信息的动态信息。

    • /opt:通常用于存放可选的应用程序,这些程序不是系统安装时自动安装的。

2. 构建容器

第一步:设置骨架

package main

import (
	"fmt"
	"os"
	"os/exec"
	"syscall"
)

func main() {
	switch os.Args[1] {
	case "run":
		parent()
	case "child":
		child()
	default:
		panic("wat should I do")
	}
}

func parent() {
	cmd := exec.Command("/proc/self/exe", append([]string{"child"}, os.Args[2:]...)...)
	cmd.Stdin = os.Stdin
	cmd.Stdout = os.Stdout
	cmd.Stderr = os.Stderr

	if err := cmd.Run(); err != nil {
		fmt.Println("ERROR", err)
		os.Exit(1)
	}
}

func child() { 
	cmd := exec.Command(os.Args[2], os.Args[3:]...)
	cmd.Stdin = os.Stdin
	cmd.Stdout = os.Stdout
	cmd.Stderr = os.Stderr

	if err := cmd.Run(); err != nil {
		fmt.Println("ERROR", err)
		os.Exit(1)
	}
}

func must(err error) {
	if err != nil {
		panic(err)
	}
}

那么这有什么用呢?我们从 main.go 开始,读取第一个参数。如果是“run”,则运行 parent() 方法,如果是 child(),则运行 child 方法。parent 方法运行 /proc/self/exe,这是一个特殊文件,其中包含当前可执行文件的内存映像。换句话说,我们重新运行自己,但将 child 作为第一个参数传递。

这是什么疯狂行为?嗯,目前来说,没什么。它只是让我们执行另一个程序,该程序执行用户请求的程序(在 os.Args[2:] 中提供)。不过,有了这个简单的框架,我们就可以创建一个容器。

第二步:添加命名空间

要为我们的程序添加一些命名空间,我们只需在 parent() 方法的第二行添加一行,以告诉 go 在运行子进程时传递一些额外的标志。

cmd.SysProcAttr = &syscall.SysProcAttr{
	Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWPID | syscall.CLONE_NEWNS,
}

如果您现在运行您的程序,您的程序将在 UTS、PID 和 MNT 命名空间内运行!

第三步:根文件系统

目前,你的进程位于一组独立的命名空间中(你可以随意尝试将其他命名空间添加到上面的 Cloneflags 中)。但文件系统看起来与主机相同。这是因为你位于挂载命名空间中,但初始挂载是从创建命名空间继承的。

让我们改变这一点。我们需要以下四行简单的代码来切换到根文件系统。将它们放在 child() 函数的开头。

must(syscall.Mount("rootfs", "rootfs", "", syscall.MS_BIND, ""))
	must(os.MkdirAll("rootfs/oldrootfs", 0700))
	must(syscall.PivotRoot("rootfs", "rootfs/oldrootfs"))
	must(os.Chdir("/"))

最后两行是重点,它们告诉操作系统将当前目录 / 移动到 rootfs/oldrootfs ,并将新的 rootfs 目录交换到 /pivotroot 调用完成后,容器中的 / 目录将引用 rootfs。(需要绑定挂载调用来满足 pivotroot 命令的一些要求 - 操作系统要求使用 pivotroot 来交换不属于同一棵树的两个文件系统,将 rootfs 绑定挂载到自身即可实现)。

第四步:整合

package main

import (
	"fmt"
	"os"
	"os/exec"
	"syscall"
)

func main() {
	switch os.Args[1] {
	case "run":
		parent()
	case "child":
		child()
	default:
		panic("wat should I do")
	}
}

func parent() {
	cmd := exec.Command("/proc/self/exe", append([]string{"child"}, os.Args[2:]...)...)
	cmd.SysProcAttr = &syscall.SysProcAttr{
		Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWPID | syscall.CLONE_NEWNS,
	}
	cmd.Stdin = os.Stdin
	cmd.Stdout = os.Stdout
	cmd.Stderr = os.Stderr

	if err := cmd.Run(); err != nil {
		fmt.Println("ERROR", err)
		os.Exit(1)
	}
}

func child() {
	must(syscall.Mount("rootfs", "rootfs", "", syscall.MS_BIND, ""))
	must(os.MkdirAll("rootfs/oldrootfs", 0700))
	must(syscall.PivotRoot("rootfs", "rootfs/oldrootfs"))
	must(os.Chdir("/"))

	cmd := exec.Command(os.Args[2], os.Args[3:]...)
	cmd.Stdin = os.Stdin
	cmd.Stdout = os.Stdout
	cmd.Stderr = os.Stderr

	if err := cmd.Run(); err != nil {
		fmt.Println("ERROR", err)
		os.Exit(1)
	}
}

func must(err error) {
	if err != nil {
		panic(err)
	}
}

0

评论区