各位同仁,下午好。
今天,我们将深入探讨容器运行时领域的核心组件——containerd,以及它是如何利用Go语言,依据OCI(Open Container Initiative)标准,精妙地管理Linux内核提供的隔离机制:Namespace和Cgroup的。这是一个关于系统编程、标准制定与工程实践的交叉领域,理解它,能让我们对现代容器技术有更深刻的认识。
1. 容器的基石:Linux Namespace与Cgroup
在深入containerd之前,我们必须首先理解容器技术赖以生存的两个核心Linux内核原语:Namespace(命名空间)和Cgroup(控制组)。它们是构建轻量级、隔离的运行环境的基石。
1.1 Namespace:资源隔离的魔法
Namespace是Linux内核提供的一种机制,用于隔离进程视图下的系统资源。每个Namespace都提供一个独立的环境,使得在该Namespace内的进程看不到或无法影响其他Namespace中的同类资源。这就像给每个容器提供了一套独立的“操作系统视图”。
Go语言通过syscall包可以直接与这些内核原语交互。例如,syscall.Clone函数允许我们创建一个新进程,并指定它进入哪些新的Namespace。
1.1.1 核心Namespace类型
| Namespace | 隔离的资源 | 作用 |
|---|---|---|
| PID | 进程ID | 容器内有独立的PID 1,隔离其他进程的PID视图。 |
| UTS | 主机名与域名 | 容器可以有自己的主机名和域名。 |
| MNT | 文件系统挂载点 | 容器有独立的根文件系统和挂载结构。 |
| NET | 网络设备、IP地址、端口 | 容器有独立的网络栈,如独立的网卡、IP地址、路由表。 |
| IPC | 进程间通信(System V IPC, POSIX message queues) | 容器有独立的IPC资源,避免与其他容器或宿主机IPC冲突。 |
| USER | 用户和用户组ID | 容器内的用户ID可以映射到宿主机上的不同ID,增强安全性。 |
| CGROUP | Cgroup层次结构 | 隔离Cgroup文件系统视图,允许容器内进程独立管理其子Cgroup。 |
1.1.2 Go语言与Namespace的初步接触
虽然containerd和runC有更高级的抽象,但理解底层的syscall.Clone是关键。以下是一个简化的Go程序,演示如何创建一个新的PID和UTS Namespace,并在其中执行一个命令:
package main
import (
"fmt"
"os"
"os/exec"
"syscall"
)
func main() {
switch os.Args[1] {
case "run":
run()
case "child":
child()
default:
panic("what??")
}
}
func run() {
fmt.Printf("Running %v as PID %dn", os.Args[2:], os.Getpid())
// 创建一个子进程,并进入新的PID和UTS Namespace
cmd := exec.Command("/proc/self/exe", append([]string{"child"}, os.Args[2:]...)...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWPID | syscall.CLONE_NEWUTS | syscall.CLONE_NEWNS, // CLONE_NEWNS for mount namespace
}
if err := cmd.Run(); err != nil {
fmt.Printf("ERROR: %vn", err)
os.Exit(1)
}
}
func child() {
fmt.Printf("Entering child process as PID %dn", os.Getpid())
// 设置新的主机名
if err := syscall.Sethostname([]byte("my-container")); err != nil {
fmt.Printf("ERROR setting hostname: %vn", err)
os.Exit(1)
}
// 挂载 /proc 文件系统,以便在新PID Namespace中正确显示进程信息
// 这一步对于MNT Namespace至关重要
if err := syscall.Mount("proc", "/proc", "proc", 0, ""); err != nil {
fmt.Printf("ERROR mounting /proc: %vn", err)
// 即使挂载失败,也尝试继续,但结果可能不准确
}
// 执行传递的命令
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.Printf("ERROR: %vn", err)
os.Exit(1)
}
// 卸载 /proc
if err := syscall.Unmount("/proc", 0); err != nil {
fmt.Printf("WARNING unmounting /proc: %vn", err)
}
}
运行这个程序:
go run your_program.go run sh
在新打开的shell中,你可以尝试:
hostname -> 会显示 my-container
ps aux -> 会显示一个非常精简的进程列表,其中sh可能是PID 1。
这个例子直观地展示了Go如何通过syscall.SysProcAttr.Cloneflags来创建不同Namespace的进程。runC(作为containerd的底层运行时)正是大量使用这类底层系统调用来构建容器环境的。
1.2 Cgroup:资源限制与计费
Cgroup(Control Groups)是Linux内核提供的另一种机制,用于限制、记录和隔离进程组的资源使用(CPU、内存、I/O、网络等)。如果说Namespace提供了“视图”隔离,那么Cgroup则提供了“资源”隔离和管理。
Cgroup通过虚拟文件系统暴露给用户空间,通常挂载在/sys/fs/cgroup。每个Cgroup控制器对应一个资源类型,如cpu、memory、blkio等。通过在这些虚拟文件系统中创建目录和写入文件,我们可以对进程组施加资源限制。
1.2.1 核心Cgroup控制器
| 控制器 | 管理的资源 | 作用 |
|---|---|---|
| cpu | CPU 时间 | 限制CPU使用率,分配CPU份额。 |
| cpuacct | CPU 统计 | 统计进程组的CPU使用情况。 |
| memory | 内存 | 限制内存和SWAP使用量。 |
| blkio | 块设备 I/O | 限制块设备的读写带宽和IOPS。 |
| pids | 进程数量 | 限制进程组可以创建的进程/线程数量。 |
| devices | 设备访问 | 控制进程组可以访问哪些设备。 |
| freezer | 进程状态 | 暂停或恢复进程组中的所有进程。 |
| net_cls | 网络分类 | 标记网络包,用于QoS。 |
| net_prio | 网络优先级 | 设置网络流量的优先级。 |
1.2.2 Go语言与Cgroup的初步接触
直接操作Cgroup文件系统涉及创建目录和写入文件。以下是一个简化的Go程序,演示如何创建一个Cgroup并设置内存限制:
package main
import (
"fmt"
"io/ioutil"
"os"
"os/exec"
"path/filepath"
"strconv"
"syscall"
)
const cgroupRoot = "/sys/fs/cgroup"
func main() {
if len(os.Args) < 2 {
fmt.Println("Usage: go run main.go <command> [args...]")
os.Exit(1)
}
args := os.Args[1:]
cmd := exec.Command(args[0], args[1:]...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
// 1. 创建一个唯一的Cgroup路径
cgroupName := "my-container-cgroup-" + strconv.Itoa(os.Getpid())
memoryCgroupPath := filepath.Join(cgroupRoot, "memory", cgroupName)
if err := os.MkdirAll(memoryCgroupPath, 0755); err != nil {
fmt.Printf("ERROR creating cgroup directory: %vn", err)
os.Exit(1)
}
defer func() {
// 清理Cgroup,确保进程不再属于它才能删除
// 实际生产中需要更健壮的清理逻辑
fmt.Printf("Cleaning up cgroup %sn", memoryCgroupPath)
if err := os.RemoveAll(memoryCgroupPath); err != nil {
fmt.Printf("WARNING: could not remove cgroup directory %s: %vn", memoryCgroupPath, err)
}
}()
// 2. 设置内存限制 (例如,100MB)
memoryLimitBytes := 100 * 1024 * 1024 // 100 MB
if err := ioutil.WriteFile(filepath.Join(memoryCgroupPath, "memory.limit_in_bytes"), []byte(strconv.Itoa(memoryLimitBytes)), 0644); err != nil {
fmt.Printf("ERROR setting memory limit: %vn", err)
os.Exit(1)
}
// 3. 将当前进程的PID添加到Cgroup中
// 注意:这里将当前Go程序进程(父进程)加入Cgroup,
// 实际容器运行时会将容器主进程加入。
// 为了演示,我们先将当前进程加入,然后它启动的子进程也会继承。
if err := ioutil.WriteFile(filepath.Join(memoryCgroupPath, "cgroup.procs"), []byte(strconv.Itoa(os.Getpid())), 0644); err != nil {
fmt.Printf("ERROR adding current PID to cgroup: %vn", err)
os.Exit(1)
}
fmt.Printf("Process %d added to cgroup %s with memory limit %d bytesn", os.Getpid(), memoryCgroupPath, memoryLimitBytes)
// 4. 执行命令
if err := cmd.Run(); err != nil {
fmt.Printf("Command finished with error: %vn", err)
os.Exit(1)
}
fmt.Println("Command finished successfully.")
}
运行这个程序:
go run your_program.go stress --vm-bytes 200M --vm-keep -m 1 (需要安装stress工具)
你会发现stress进程由于内存限制而失败,或者被OOM killer杀死,因为它试图分配超过100MB的内存。
这个例子揭示了Go如何通过标准的文件I/O操作来与Cgroup文件系统交互。runC内部也是通过类似的方式,根据OCI配置来创建和配置Cgroup。
2. OCI:容器运行时标准
为了解决容器生态系统中的碎片化问题,保证不同容器工具之间的互操作性,Linux基金会成立了Open Container Initiative (OCI)。OCI定义了两个核心标准:
- Runtime Specification (runtime-spec):定义了容器运行时如何执行容器,包括容器的配置格式、生命周期操作等。
- Image Specification (image-spec):定义了容器镜像的格式和如何存储。
我们的重点是runtime-spec,因为它直接指导了containerd和runC如何创建和管理容器的隔离环境。
2.1 OCI Runtime Specification (config.json)
runtime-spec的核心是一个名为config.json的配置文件。这个JSON文件详细描述了一个容器应该如何运行,包括它的进程、根文件系统、挂载点、网络、以及最重要的——Namespaces和Cgroups的配置。
以下是一个简化的config.json片段,重点展示Namespace和Cgroup相关的配置:
{
"ociVersion": "1.0.2-dev",
"process": {
"terminal": true,
"user": {
"uid": 0,
"gid": 0
},
"args": [
"/bin/sh"
],
"env": [
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
"TERM=xterm"
],
"cwd": "/",
"capabilities": {
"bounding": [
"CAP_CHOWN", "CAP_DAC_OVERRIDE", /* ... more capabilities ... */
],
"effective": [
"CAP_CHOWN", "CAP_DAC_OVERRIDE", /* ... more capabilities ... */
],
"inheritable": [
"CAP_CHOWN", "CAP_DAC_OVERRIDE", /* ... more capabilities ... */
],
"permitted": [
"CAP_CHOWN", "CAP_DAC_OVERRIDE", /* ... more capabilities ... */
],
"ambient": [
"CAP_CHOWN", "CAP_DAC_OVERRIDE", /* ... more capabilities ... */
]
},
"rlimits": [
{
"type": "RLIMIT_NOFILE",
"hard": 1024,
"soft": 1024
}
]
},
"root": {
"path": "rootfs",
"readonly": false
},
"hostname": "my-oci-container",
"mounts": [
{
"destination": "/proc",
"type": "proc",
"source": "proc"
},
{
"destination": "/dev",
"type": "tmpfs",
"source": "tmpfs",
"options": ["nosuid", "strictatime", "mode=0755", "size=65536k"]
},
{
"destination": "/dev/pts",
"type": "devpts",
"source": "devpts",
"options": ["nosuid", "noexec", "newinstance", "ptmxmode=0666", "mode=0620", "gid=5"]
},
{
"destination": "/dev/shm",
"type": "tmpfs",
"source": "shm",
"options": ["nosuid", "noexec", "nodev", "mode=1777", "size=65536k"]
},
{
"destination": "/dev/mqueue",
"type": "mqueue",
"source": "mqueue",
"options": ["nosuid", "noexec", "nodev"]
},
{
"destination": "/sys",
"type": "sysfs",
"source": "sysfs",
"options": ["nosuid", "noexec", "nodev", "ro"]
},
{
"destination": "/sys/fs/cgroup",
"type": "cgroup",
"source": "cgroup",
"options": ["nosuid", "noexec", "nodev", "relatime", "ro"]
}
],
"linux": {
"namespaces": [
{
"type": "pid"
},
{
"type": "network"
},
{
"type": "ipc"
},
{
"type": "uts"
},
{
"type": "mount"
},
{
"type": "cgroup"
}
],
"resources": {
"cpu": {
"shares": 1024,
"quota": 100000,
"period": 100000,
"realtimePeriod": 1000000,
"realtimeRuntime": 990000
},
"memory": {
"limit": 536870912,
"reservation": 0,
"swap": 0,
"kernel": 0,
"kernelTCP": 0
},
"pids": {
"limit": 32768
},
"blockIO": {
"weight": 500
},
"devices": [
{
"allow": false,
"access": "rwm"
}
]
},
"cgroupsPath": "/my-container-cgroup",
"seccomp": {
"defaultAction": "SCMP_ACT_ERRNO",
"syscalls": [
{
"names": ["arch_prctl", "setrlimit"],
"action": "SCMP_ACT_ALLOW"
},
{
"names": ["clone"],
"action": "SCMP_ACT_ALLOW",
"args": [
{
"index": 0,
"value": 2080505856,
"op": "SCMP_CMP_MASKED_EQ"
}
]
}
]
}
}
}
config.json中与Namespace和Cgroup相关的关键字段:
hostname: 定义了UTS Namespace中的主机名。mounts: 定义了MNT Namespace中的挂载点。尤其重要的是/proc和/sys/fs/cgroup的挂载,它们需要在新的MNT Namespace中独立挂载,以反映容器自身的视图。linux.namespaces: 这是一个数组,列出了要创建或加入的Namespace类型。如果一个Namespace类型存在于此数组中,runC就会为容器创建一个新的该类型Namespace。"type": "pid":创建一个新的PID Namespace。"type": "network":创建一个新的网络Namespace。"type": "ipc":创建一个新的IPC Namespace。"type": "uts":创建一个新的UTS Namespace。"type": "mount":创建一个新的MNT Namespace。"type": "cgroup":创建一个新的Cgroup Namespace(Cgroup v2中推荐)。
linux.resources: 这是一个对象,包含了所有Cgroup资源的限制。cpu:CPU份额、配额、周期等。memory:内存限制、交换区限制等。pids:进程数量限制。blockIO:块设备I/O限制。devices:设备访问权限。
linux.cgroupsPath: 指定了Cgroup文件系统中容器特定的Cgroup的路径。例如,/my-container-cgroup意味着会在/sys/fs/cgroup/{controller}/my-container-cgroup下创建Cgroup。process.capabilities: 定义了进程在新的User Namespace(如果启用)中的权限。linux.seccomp: 定义了Seccomp(Secure Computing mode)配置文件,用于限制容器内进程可以调用的系统调用,进一步增强隔离和安全性。
containerd接收到创建容器的请求后,会生成这样一个config.json文件,并将其传递给底层的OCI运行时(如runC)来执行。
3. containerd:容器运行时守护进程
containerd是一个核心的容器运行时,它位于Docker daemon和底层OCI运行时(如runC)之间。它是一个守护进程,负责管理容器的完整生命周期:创建、启动、停止、删除、镜像管理、存储管理等。
3.1 containerd在容器栈中的位置
一个典型的容器启动流程如下:
- 用户:执行
docker run ...。 - Docker Daemon:接收到请求,通过CRI(Container Runtime Interface)将请求发送给
containerd。 containerd:- 从镜像中解压出文件系统层,并创建容器的根文件系统(rootfs)。
- 根据Docker Daemon提供的配置和CRI请求,生成符合OCI
runtime-spec的config.json文件。 - 调用
containerd-shim进程。
containerd-shim:这是一个轻量级进程,由containerd启动,它的主要作用是作为容器主进程的父进程,从而使containerd可以退出或重启而不会影响容器的运行。shim会进一步调用runC。runC:这是一个符合OCI标准的命令行工具,也是一个具体的OCI运行时实现。shim会传入config.json和容器的rootfs路径给runC。runC负责真正地创建和管理容器进程。- 容器进程:由
runC创建,运行在独立的Namespace和Cgroup中。
3.2 containerd的核心职责
- 镜像管理:拉取、存储和管理容器镜像。
- 存储管理:使用快照器(snapshotter)管理容器的文件系统层和可写层。
- 容器生命周期管理:通过CRI API,提供容器的创建、启动、停止、删除等操作。
- OCI运行时集成:与OCI兼容的运行时(如
runC)进行交互,执行容器。 - 网络集成:通过CNI(Container Network Interface)插件管理容器网络。
4. Go语言在containerd及OCI运行时管理中的核心作用
Go语言因其出色的并发模型、高性能、静态链接、以及强大的标准库和生态系统,成为构建系统级工具的理想选择。containerd、runC以及大部分OCI相关的工具都广泛使用Go语言开发。
4.1 Go解析OCI config.json
containerd在生成config.json时,以及runC在消费config.json时,都依赖Go的encoding/json包来序列化和反序列化这个文件。OCI runtime-spec有对应的Go结构体定义,这使得操作config.json变得类型安全且方便。
例如,OCI runtime-spec的Go结构体可能包含以下部分(简化版):
package specs
// Spec is the base configuration for the container
type Spec struct {
Version string `json:"ociVersion"`
Process Process `json:"process,omitempty"`
Root Root `json:"root"`
Hostname string `json:"hostname,omitempty"`
Mounts []Mount `json:"mounts,omitempty"`
Linux *Linux `json:"linux,omitempty"`
// ... other fields
}
// Linux contains platform-specific configuration for linux based containers
type Linux struct {
Namespaces []LinuxNamespace `json:"namespaces,omitempty"`
Resources *LinuxResources `json:"resources,omitempty"`
CgroupsPath string `json:"cgroupsPath,omitempty"`
// ... other fields
}
// LinuxNamespace is the configuration for a Linux namespace
type LinuxNamespace struct {
Type LinuxNamespaceType `json:"type"`
Path string `json:"path,omitempty"` // Path to an existing namespace file
}
// LinuxNamespaceType is the type of a Linux namespace
type LinuxNamespaceType string
const (
PIDNamespace LinuxNamespaceType = "pid"
NetworkNamespace LinuxNamespaceType = "network"
MountNamespace LinuxNamespaceType = "mount"
IPCNamespace LinuxNamespaceType = "ipc"
UTSNamespace LinuxNamespaceType = "uts"
UserNamespace LinuxNamespaceType = "user"
CgroupNamespace LinuxNamespaceType = "cgroup"
)
// LinuxResources contains cgroup information for the container
type LinuxResources struct {
Memory *LinuxMemory `json:"memory,omitempty"`
CPU *LinuxCPU `json:"cpu,omitempty"`
Pids *LinuxPids `json:"pids,omitempty"`
BlockIO *LinuxBlockIO `json:"blockIO,omitempty"`
// ... other resource types
}
// LinuxMemory is the memory resource management configuration
type LinuxMemory struct {
Limit *int64 `json:"limit,omitempty"`
Swap *int64 `json:"swap,omitempty"`
Reservation *int64 `json:"reservation,omitempty"`
Kernel *int64 `json:"kernel,omitempty"`
KernelTCP *int64 `json:"kernelTCP,omitempty"`
Swappiness *uint64 `json:"swappiness,omitempty"`
DisableOOMKiller *bool `json:"disableOOMKiller,omitempty"`
}
// ... other structs for CPU, Pids, etc.
通过这些结构体,containerd和runC可以轻松地将config.json读取到Go对象中,然后根据对象的属性来执行相应的系统调用和文件操作。
4.2 Namespace管理:Go与syscall的深度结合 (在runC中实现)
runC是OCI运行时规范的一个具体实现,它负责接收config.json并根据其内容创建真正的容器。Go语言在这里扮演了至关重要的角色,通过syscall包直接与Linux内核交互。
4.2.1 syscall.Clone与Namespace创建
当runC解析config.json中的linux.namespaces字段时,它会为每个指定的Namespace类型,在创建子进程时设置相应的CLONE_NEW*标志。
// 简化runC内部逻辑
func setupNamespaces(namespaces []specs.LinuxNamespace) (*exec.Cmd, error) {
cmd := exec.Command("/proc/self/exe", "init") // "init" is a special entrypoint in runC to signify child process
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
// 设置SysProcAttr来传递Cloneflags
attr := &syscall.SysProcAttr{}
for _, ns := range namespaces {
switch ns.Type {
case specs.PIDNamespace:
attr.Cloneflags |= syscall.CLONE_NEWPID
case specs.NetworkNamespace:
attr.Cloneflags |= syscall.CLONE_NEWNET
case specs.MountNamespace:
attr.Cloneflags |= syscall.CLONE_NEWNS
case specs.IPCNamespace:
attr.Cloneflags |= syscall.CLONE_NEWIPC
case specs.UTSNamespace:
attr.Cloneflags |= syscall.CLONE_NEWUTS
case specs.UserNamespace:
attr.Cloneflags |= syscall.CLONE_NEWUSER
case specs.CgroupNamespace:
attr.Cloneflags |= syscall.CLONE_NEWCGROUP
// ... handle existing namespaces with ns.Path and syscall.Setns
}
}
cmd.SysProcAttr = attr
// ... 更多设置,如文件描述符传递、rootfs等
return cmd, nil
}
4.2.2 辅助系统调用
在创建了新的Namespace之后,runC还需要进行一系列的辅助系统调用来配置这些Namespace:
- UTS Namespace:
syscall.Sethostname([]byte(config.Hostname))来设置容器的主机名。 - MNT Namespace:
syscall.Mount和syscall.Unmount来设置容器的根文件系统 (pivot_root或chroot)。- 挂载
/proc、/dev、/sys等重要的虚拟文件系统,以确保容器内部的程序能够正常运行并获取到正确的系统信息。 syscall.Mount("", "/", "", syscall.MS_PRIVATE|syscall.MS_REC, ""):这通常是第一步,将宿主机的根挂载点变为私有,以防止容器内部的挂载操作影响到宿主机。
- User Namespace:如果启用了User Namespace,
runC需要配置UID/GID映射,这通常通过写入/proc/<pid>/uid_map和/proc/<pid>/gid_map文件来完成。 - Network Namespace:
- 创建veth对,一端连接到宿主机网络栈,一端连接到容器网络栈。
- 配置容器内的网络接口(如
eth0)、IP地址、路由表等。这部分通常通过CNI插件完成,但runC负责在正确的网络Namespace中执行CNI插件。
4.3 Cgroup管理:Go与文件系统交互 (在runC中实现)
runC通过Go的文件I/O操作来与Cgroup文件系统进行交互,创建和配置Cgroup。虽然可以直接使用os包,但runC通常会使用或自己实现一个更高级别的Cgroup管理库,如github.com/containerd/cgroups(这是一个由containerd维护的库,runC也可能使用其变体或类似逻辑)。
4.3.1 Cgroup的创建与配置流程
-
确定Cgroup路径:根据
config.json中的linux.cgroupsPath和Cgroup v1/v2模式,确定各个控制器对应的Cgroup目录路径。例如,对于memory控制器,路径可能是/sys/fs/cgroup/memory/my-container-cgroup。 -
创建Cgroup目录:使用
os.MkdirAll创建这些路径。 -
写入资源限制:根据
config.json中linux.resources的配置,将限制值写入到Cgroup目录下的相应控制文件。// 简化Cgroup管理逻辑 (类似github.com/containerd/cgroups库中的操作) func applyCgroupResources(pid int, config *specs.Linux) error { if config.Resources == nil { return nil } cgroupBase := config.CgroupsPath if !filepath.IsAbs(cgroupBase) { // runC typically uses an absolute path relative to the cgroup root cgroupBase = filepath.Join("runC", cgroupBase) } // Memory Cgroup if mem := config.Resources.Memory; mem != nil && mem.Limit != nil { memoryPath := filepath.Join("/sys/fs/cgroup/memory", cgroupBase) if err := os.MkdirAll(memoryPath, 0755); err != nil { return fmt.Errorf("failed to create memory cgroup: %w", err) } // Write memory limit if err := ioutil.WriteFile(filepath.Join(memoryPath, "memory.limit_in_bytes"), []byte(strconv.FormatInt(*mem.Limit, 10)), 0644); err != nil { return fmt.Errorf("failed to set memory limit: %w", err) } // Add process to cgroup if err := ioutil.WriteFile(filepath.Join(memoryPath, "cgroup.procs"), []byte(strconv.Itoa(pid)), 0644); err != nil { return fmt.Errorf("failed to add pid to memory cgroup: %w", err) } } // CPU Cgroup if cpu := config.Resources.CPU; cpu != nil && cpu.Shares != nil { cpuPath := filepath.Join("/sys/fs/cgroup/cpu", cgroupBase) if err := os.MkdirAll(cpuPath, 0755); err != nil { return fmt.Errorf("failed to create cpu cgroup: %w", err) } if err := ioutil.WriteFile(filepath.Join(cpuPath, "cpu.shares"), []byte(strconv.FormatUint(*cpu.Shares, 10)), 0644); err != nil { return fmt.Errorf("failed to set cpu shares: %w", err) } if err := ioutil.WriteFile(filepath.Join(cpuPath, "cgroup.procs"), []byte(strconv.Itoa(pid)), 0644); err != nil { return fmt.Errorf("failed to add pid to cpu cgroup: %w", err) } } // ... 其他Cgroup控制器,如pids, blkio等 return nil }注意:在Cgroup v2中,所有控制器都集中在统一的层次结构中,不再是每个控制器一个独立的根目录,这简化了管理,但基本操作模式(创建目录、写入文件)是类似的。
runC会根据系统是Cgroup v1还是v2来调整其操作。 -
将进程添加到Cgroup:将容器主进程的PID写入到每个Cgroup控制器目录下的
cgroup.procs(或tasks,取决于Cgroup版本和控制器)文件中。一旦进程被添加到Cgroup,它就会受到该Cgroup设置的资源限制。
4.4 容器进程的执行
在Namespace和Cgroup都设置完毕后,runC会使用Go的os/exec包,结合syscall.Exec(或者通过fork/exec)在隔离的环境中启动容器的入口点进程。
// 简化runC中执行容器进程的逻辑
func execContainerProcess(config *specs.Spec) error {
// 获取容器根文件系统路径
rootfsPath := filepath.Join(config.Root.Path, "rootfs") // Assuming config.Root.Path is "rootfs"
// 1. 设置根文件系统 (chroot 或 pivot_root)
// runC会在这里进行复杂的chroot/pivot_root操作
// 这里简化为chroot示例
if err := syscall.Chroot(rootfsPath); err != nil {
return fmt.Errorf("chroot failed: %w", err)
}
if err := os.Chdir("/"); err != nil {
return fmt.Errorf("chdir failed: %w", err)
}
// 2. 挂载虚拟文件系统 (/proc, /dev, /sys等)
// 这些挂载必须在新MNT Namespace中完成
// ... (参考 config.json 中的 mounts 字段)
// 3. 执行容器命令
process := config.Process
cmd := exec.Command(process.Args[0], process.Args[1:]...)
cmd.Env = process.Env
cmd.Dir = process.Cwd
// ... 设置其他进程属性,如user, capabilities, rlimits, terminal等
// 将标准输入输出重定向到宿主机
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
// 实际 runC 会在这里使用 syscall.Exec,它会替换当前进程,而不是fork一个子进程
// 这意味着 runC 进程自身会变成容器进程,然后 runC 退出。
// 但为了演示,这里使用 Run。
if err := cmd.Run(); err != nil {
return fmt.Errorf("container command failed: %w", err)
}
return nil
}
在实际的runC实现中,它会创建一个子进程,并在该子进程中执行上述Namespace和Cgroup的设置。完成设置后,子进程会通过syscall.Exec系统调用来替换自身为容器的入口点程序(例如/bin/sh),这样容器进程就成为了这个子进程。runC的父进程(通常是containerd-shim)会等待这个容器进程的完成。
4.5 containerd-shim:容器的守护者
containerd-shim是一个非常关键的Go程序。当containerd启动一个容器时,它会启动一个shim进程,然后shim会负责调用runC来创建容器。一旦runC完成了容器的创建并退出了(因为runC进程自身通常会通过exec系统调用转换成容器进程或者只是一个短暂的设置器),shim就成为了容器主进程的直接父进程。
shim的作用:
- 解耦
containerd和容器:即使containerd守护进程崩溃或重启,容器也会继续运行,因为shim在它们之间充当了一个独立的中间层。 - 处理信号:
shim负责捕获发给容器的信号(如SIGTERM),并将其转发给容器中的进程。 - 管理I/O:
shim负责将容器的标准I/O流(stdin, stdout, stderr)连接到containerd或客户端。 - 报告状态:
shim会监听容器进程的生命周期事件,并向containerd报告容器的状态(运行中、退出码等)。
shim的这种设计是现代容器运行时的一个重要模式,它极大地提高了容器的健壮性和弹性。
5. 总结:Go、OCI与隔离的艺术
通过上述的探讨,我们看到了Go语言在containerd和runC中扮演的核心角色。它通过其强大的syscall包,直接与Linux内核的Namespace和Cgroup原语交互,实现了容器的底层隔离和资源管理。OCI标准则提供了一个统一的语言,使得containerd能够以标准化的方式描述和启动容器,而runC则能忠实地执行这些描述。
这种分层、标准化的架构,结合Go语言的工程优势,共同构建了现代容器技术高效、健壮且高度隔离的运行环境。从用户视角的一个简单docker run命令,到内核层面的syscall.Clone和Cgroup文件系统操作,其间蕴含着精密的系统设计和严谨的编程实践。Go语言的简洁、并发特性和系统编程能力,无疑是实现这一复杂而优雅的系统不可或缺的基石。