本文通过构建自己的容器来讲述容器化的核心技术。参考资料: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)
}
}
评论区