runc-create
# 1.简介
# 1.1.ns
Namespace是内核提供的隔离机制,用于限制进程看到的PID树、挂载表、网络栈、IPC对象、用户及CGroup视图。机制 核心职责 示例 namespace 隔离内核资源视图 独立 PID树、网络栈和挂载表cgroup 统计、限制和控制资源 cpu/memory/pid资源限制capability/LSM/seccomp 限制操作权限和系统调用 CAP_SYS_ADMIN/SELinux/syscall filter注意
1.
runc会读取config.json的命名空间,将OCI声明转为libcontainer配置2.
nsexec.c会调用setns(2)/unshare(2)/clone(2)建立目标namespace
# 1.2.分类
Linux Namespace基于类型对应不同的隔离边界,进程可以组合隔离能力创建虚拟环境,同一组进程共享视图。--- 隔离边界 1.Mount,隔离挂载表及传播挂载关系,拥有独立的rootfs、/proc、/dev及挂载点 2.PID,隔离进程树视图,容器init作为内部的PID 1 3.Network,隔离网络栈,拥有独立的网卡、地址、路由、端口和netfilter 4.UTS,隔离主机名、域名,具备独立hostname 5.IPC,隔离共享内存、信号量和消息队列 6.USER,隔离用户,容器内root支持映射为宿主机普通用户 7.CGroup,隔离资源视图,容器内看到的是虚拟化的Cgroup树1
2
3
4
5
6
7
8
注意
这些
Namespace不是相互独立的,容器会组装不同类型命名空间实现资源隔离和权限控制
# 2.入口
# 2.1.action
runc的create/run action执行startContainer会解析Namespace,创建容器阶段基于系统调用初始化命名空间。var createCommand = cli.Command{ ... Action: func(context *cli.Context) error { ... // runc create <id> status, err := startContainer(context, CT_ACT_CREATE, nil) ... }, } // default action is to start a container var runCommand = cli.Command{ ... Action: func(context *cli.Context) error { ... // runc run <id> status, err := startContainer(context, CT_ACT_RUN, nil) ... }, } func startContainer(context *cli.Context, action CtAct, ...) (int, error) { ... // 读取bundle/config.json,解析OCI Spec // /run/containerd/io.containerd.runtime.v2.task/k8s.io/<container-id>... // ├── config.json // ├── init.pid // ├── rootfs/ // └── ... spec, err := setupSpec(context) ... // OCI Spec -> configs.Config -> linuxContainer container, err := createContainer(context, id, spec) ... // 创建Process、配置终端、调用start/run/restore // 最终将进程写入PID文件及处理信号和退出状态 r := &runner{ ... // linux container对象 container: container, ... action: action, ... // 表示本次创建的是容器初始进程 init: true, } // OCI Process转为libcontainer Process及执行 return r.run(spec.Process) }1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55注意
runc创建容器是完成Namespace/CGroup...初始化动作,不会执行用户进程,容器悬停在created
# 2.2.factory
createContainer()会创建容器对象转换配置,这里需要关注下loadFactory,会重新设置初始化入口。func createContainer(context *Context, id string, spec *Spec) (Container, error) { ... // OCI spec → libcontainer config config, err := specconv.CreateLibcontainerConfig(&specconv.CreateOpts{ ...}) ... // 创建LinuxFactory factory, err := loadFactory(context) ... // 容器对象 return factory.Create(id, config) } // loadFactory returns the configured factory instance for execing containers. func loadFactory(context *cli.Context) (libcontainer.Factory, error) { ... return libcontainer.New(...) } // New returns a linux based container factory based in the root directory. func New(root string, options ...func(*LinuxFactory) error) (Factory, error) { ... l := &LinuxFactory{ Root: root, InitPath: "/proc/self/exe", // 设置init参数,触发runc init执行 InitArgs: []string{os.Args[0], "init"}, Validator: validate.New(), CriuPath: "criu", } ... return l, nil }1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38注意
runc create进行会补充init参数实现重新执行初始化,后面会单独介绍
# 2.3.runner
runner.run()会将spec.Process转为libcontainer.Process,设置进程(init/exec)运行环境及分发请求。func (r *runner) run(config *specs.Process) (int, error) { ... // 配置格式转为libcontainer格式 process, err := newProcess(*config) ... // 容器IO-->输出IO // 非TTY: 继承shim传递的fd 0/1/2 // TTY: 通过--console-socket将PTY master FD传给shim tty, err := setupIO(process, rootuid, rootgid, config.Terminal, detach, r.consoleSocket) ... switch r.action { case CT_ACT_CREATE: // 创建容器init/Namespace/rootfs err = r.container.Start(process) case CT_ACT_RESTORE: // 通过CRIU恢复容器及其进程状态 err = r.container.Restore(process, r.criuOpts) case CT_ACT_RUN: // 内部调用Start完成Namespace初始化,打开exec FIFO及立即运行用户程序。 err = r.container.Run(process) default: panic("Unknown action") } ... // 返回容器用户进程退出码和运行错误 return status, err }1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33注意
setupIO用于容器IO拷贝,namespace相关逻辑主要位于r.container.Start
# 3.创建
# 3.1.start
c.Start()会创建进程的FIFO命名管道及设置归属,调用start()启动init进程进入容器环境准备阶段。func (c *linuxContainer) Start(process *Process) error { ... // 创建FIFO,用于init不同阶段隔离 if process.Init { c.createExecFifo() ... } // 启动init进程 c.start(process) ... return nil } func (c *linuxContainer) createExecFifo() error { // uid映射 rootuid, err := c.Config().HostRootUID() ... // gid映射 rootgid, err := c.Config().HostRootGID() ... // 路径为/run/containerd/runc/k8s.io/<container-id>/exec.fifo fifoName := filepath.Join(c.root, execFifoFilename) ... // 创建命名管道,owner读写/group只写/other只写 unix.Mkfifo(fifoName, 0o622) ... // FIFO的持有者设置为容器root映射后的宿主机uid/gid return os.Chown(fifoName, rootuid, rootgid) }1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34注意
这个
exec.fifo用于协调runc create和runc start,控制容器初始进程什么时候执行用户程序
# 3.2.parent
c.start()会创建进程启动对象,调用parent.start()完成namespace/cgroup/state-2初始化。func (c *linuxContainer) start(process *Process) (retErr error) { // 进程启动对象,这里做的是环境准备,未真正启动用户进程 // - runc init命令 // - init socketpair // - 日志pipe // - namespace bootstrapData parent, err := c.newParentProcess(process) ... // 启动进程 // - create/run,parent为initProcess // - exec,parent为setnsProcess parent.start() ... // 初始化进程执行的生命周期操作 if process.Init { // 关闭parent持有的exec.fifo O_PATH FD // cmd.Start阶段,该FD已经复制给state-0,间接继承给state-2 // 这里仅关闭父进程自己的副本 c.fifo.Close() if c.config.Hooks != nil { // 获取OCI状态,包括容器ID、state-2宿主PID、bundle路径和当前状态 s, err := c.currentOCIState() ... // 执行OCI postStart Hook // Hook在宿主环境执行,会接收容器状态,用于通知、审计和外围资源配置 c.config.Hooks[configs.Poststart].RunHooks(s) ... } } return nil } func (c *linuxContainer) newParentProcess(p *Process) (parentProcess, error) { // 创建init socket pair(init pipe) // parentInitPipe由当前Go Parent持有 // childInitPipe通过ExtraFiles传递给后续runc init // 用于传输namespace bootstrapData/state-1 PID/state-2 PID/完整initConfig/父子进程同步 parentInitPipe, childInitPipe, err := utils.NewSockPair("init") ... messageSockPair := filePair{parentInitPipe, childInitPipe} // 创建log pipe // childLogPipe传给runc init供其写日志 // parentLogPipe由Go Parent持有转发到runc日志 parentLogPipe, childLogPipe, err := os.Pipe() ... logFilePair := filePair{parentLogPipe, childLogPipe} // 构造重新执行runc自身命令——/proc/self/exe init // 透传childInitPipe/childLogPipe/ConsoleSocket/用户保留的ExtraFiles cmd := c.commandTemplate(p, childInitPipe, childLogPipe) // exec附加进程 if !p.Init { // 这里不会创建容器,也不会使用exec.fifo // newSetnsProcess会读取容器init状态,获取已有namespace path // /proc/<init-pid>/ns/mnt、/proc/<init-pid>/ns/net、/proc/<init-pid>/ns/pid... // 通过nsexec执行setns return c.newSetnsProcess(p, cmd, messageSockPair, logFilePair) } // createExecFifo已经创建了exec.fifo // 这里以O_PATH方式打开FIFO,将FD透传给runc init c.includeExecFifo(cmd) ... // 构造initProcess,这里没有启动进程 // - 设置init类型为standard // - 收集Path非空的namespace到nsMaps // - 将path为空的namespace转为cloneFlags // - 生成bootstrapData return c.newInitProcess(p, cmd, messageSockPair, logFilePair) }1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77注意
这里主要作用就是初始化
process,对应parent进程
# 3.3.command
c.commandTemplate()主要是构建exec.Cmd模版生成init命令,设置很多环境变量、FD及退出信号监听。func (c *linuxContainer) commandTemplate(p *Process, childInitPipe *os.File, childLogPipe *os.File) *exec.Cmd { // /proc/self/exe init cmd := exec.Command(c.initPath, c.initArgs[1:]...) cmd.Args[0] = c.initArgs[0] // runc执行路径 // runc init的IO绑定 // - 非TTY: 一般是shim继承的fd 0/1/2 // - TTY: 一般为nil,后续state-2创建PTY,将PTY slave复制到fd 0/1/2 cmd.Stdin = p.Stdin cmd.Stdout = p.Stdout cmd.Stderr = p.Stderr ... cmd.ExtraFiles = append(cmd.ExtraFiles, childInitPipe) cmd.Env = append(cmd.Env, "_LIBCONTAINER_INITPIPE="+strconv.Itoa(stdioFdCount+len(cmd.ExtraFiles)-1), "_LIBCONTAINER_STATEDIR="+c.root, ) cmd.ExtraFiles = append(cmd.ExtraFiles, childLogPipe) cmd.Env = append(cmd.Env, "_LIBCONTAINER_LOGPIPE="+strconv.Itoa(stdioFdCount+len(cmd.ExtraFiles)-1), "_LIBCONTAINER_LOGLEVEL="+p.LogLevel, ) // 配置父进程死亡信号,执行runc init的Go Parent退出 // 内核会向runc init发送指定信号,避免留下失去管理者的初始化进程 if c.config.ParentDeathSignal > 0 { cmd.SysProcAttr.Pdeathsig = unix.Signal(c.config.ParentDeathSignal) } return cmd }1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32注意
这里仅渲染
init命令的执行参数,还未真正运行
# 3.4.init
parent process创建完成后,会执行parent.Start()正式启动init进程,通过socket pair进行握手。func (p *initProcess) start() (retErr error) { ... // 启动/proc/self/exe init // cmd.Start()返回表示state-0成功创建 // namespace或容器环境还未初始化完成 p.cmd.Start() ... // 异步等待nsexec写入一个值为0的字节 // 字节表示runc init进入nsexec, init pipe可以正常通信 // 此时namespace未创建 waitInit := initWaiter(p.messageSockPair.parent) ... // 发送bootstrapData前将将state-0加入cgroup // nsexec收到bootstrapData会创建state-1/2 // 子进程会继承cgroup,避免进程逃出目标cgroup p.manager.Apply(p.pid()) ... // 应用Intel RDT的cache/内存带宽限制 if p.intelRdtManager != nil { p.intelRdtManager.Apply(p.pid()) ... } // 向nsexec发送namespace引导数据 // - cloneFlags,需要unshare的namespace // - nsPaths,需要setns的namespace // - UID/GID mapping // - newuidmap/newgidmap路径 io.Copy(p.messageSockPair.parent, p.bootstrapData) ... // 确认nsexec初始存活握手成功 <-waitInit ... // 从state-0接受state-1和state-2的PID // stage-0写入: {"stage1_pid": ..., "stage2_pid": ...} childPid, err := p.getChildPid() ... // state-2通过dup2修改标准IO前,保存当前pipe FD供checkpoint使用 fds, err := getPipeFds(childPid) ... p.setExternalDescriptors(fds) // 等待直接启动的state-0退出,waitForChildExit会将p.cmd.Process切换为state-2 p.waitForChildExit(childPid) ... // 此后p.pid()返回stage-2的宿主PID // 宿主机侧创建网络接口移动到state-2 p.createNetworkInterfaces() ... // 更新initConfig中的OCI State供后续Hook使用 p.updateSpecState() ... // 向state-2发送完整配置,rootfs/进程参数/网络/hostname/capability/seccomp/rlimit... p.sendConfig() ... // 读取state-2发来的JSON同步消息 ierr := parseSync(p.messageSockPair.parent, func(sync *syncT) error { switch sync.Type { case procSeccomp: ... // state-2已安装seccomp notify过滤器 // 请求父进程获取及转发seccomp FD seccompFd, err := recvSeccompFd(uintptr(childPid), uintptr(sync.Fd)) ... // 将seccomp FD和容器状态发送给外部的seccomp agent sendContainerProcessState(p.config.Config.Seccomp.ListenerPath, containerProcessState, seccompFd) ... // 通知state-2可以继续 writeSync(p.messageSockPair.parent, procSeccompDone) ... case procReady: // state-2已完成rootfs、console、hostname等主要初始化,准备执行剩余配置 // rlimit由宿主父进程设置,因为进入user namespace权限可能不足 setupRlimits(p.config.Rlimits, p.pid()) ... // 无独立mount namespace不会在prepareRootfs产生procHooks // 因此会在procReady阶段配置资源及运行Hook if !p.config.Config.Namespaces.Contains(configs.NEWNS) { // 设置cgroup资源 p.manager.Set(p.config.Config.Cgroups.Resources) ... // hooks[Prestart].RunHooks(state) // hooks[CreateRuntime].RunHooks(state) } // 容器状态切换为created p.container.created = time.Now().UTC() p.container.state = &createdState{ c: p.container } // 回复procRun前保存state.json,否则回复父进程异常退出 // 注意一下,这里的PID获取到的是state-2宿主机侧PID // 这个宿主机侧PID是state-2和用户进程共用的,所以可以直接存 state, uerr := p.container.updateState(p) ... p.container.initProcessStartTime = state.InitProcessStartTime // 允许state-2执行剩余初始化,procRun不是exec.fifo放行 // 用户程序此时仍为执行 writeSync(p.messageSockPair.parent, procRun) ... sentRun = true case procHooks: // state-2已经位于新的Mount Namespace完成挂载,尚未pivot_root // 此时父进程配置cgroup资源执行Hook,Hook可以操作容器挂载 p.manager.Set(p.config.Config.Cgroups.Resources) ... // Prestart和CreateRuntime Hook ... // 通知state-2继续prepareRootfs writeSync(p.messageSockPair.parent, procResume) ... sentResume = true default: return errors.New("invalid JSON payload from child") } return nil }) ... // 关闭父进程写方向,确保异常退出时state-2仍可读到EOF退出 unix.Shutdown(int(p.messageSockPair.parent.Fd()), unix.SHUT_WR) ... return nil }1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143注意
1.由父进程视角看,
procReady是create流程的关键同步点,表示初始化握手已经到了可收敛阶段2.
runc create不是简单的拉起一个进程,而是基于管道和父子同步握手将容器推进到created状态
# 3.总结
# 3.1.流程
--- runc init会拆分三个进程,定位不同 - state-0,作为nsexec的父阶段,协助映射UID/GID及转发state-1/state-2 PID - state-1,执行setns/unshare,加入或创建命名空间,完成后创建state-2 - state-2,切换到namespace执行容器环境初始化 --- runc create控制流 1.父进程是执行runc create的进程,子进程是执行runc init的进程 2.p.cmd.Start()会先拉起runc init,基于initWaiter建立init侧状态和错误监听 3.父进程拿到子进程PID执行p.manager.Apply将其加入目标cgroup 4.runc init会拆分为state-1、state-1和state-2三个阶段 5.父进程通过io.Copy将初始化所需的bootstrapData写入state-0 6.state-0阶段处理完成,父进程会切换到state-2握手,发送完成配置 7.state-2根据完成配置进行rootfs、console、hostname等资源初始化 8.父进程基于parseSync循环处理state-2的同步消息直到容器推进到created1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 3.2.状态
容器创建后,最后一个进程是
state-2,对应的宿主机侧PID后续会复用给用户进程,记录到state.json。func (p *initProcess) start() (retErr error) { ... // 直接启动的state-0退出,子进程会切换到state-2 p.waitForChildExit(childPid) ... // 读取state-2发来的JSON同步消息 ierr := parseSync(p.messageSockPair.parent, func(sync *syncT) error { switch sync.Type { ... case procReady: ... // 容器状态切换为created p.container.created = time.Now().UTC() p.container.state = &createdState{ c: p.container, } // state.json记录的PID就是state-2宿主机侧的PID state, uerr := p.container.updateState(p) ... } return nil }) ... return nil } func (c *linuxContainer) updateState(process parentProcess) (*State, error) { ... // 这里获取最新state会读取PID state, err := c.currentState() ... // 保存state.json,/run/containerd/runc/k8s.io/<id>/state.json c.saveState(state) ... return state, nil } func (c *linuxContainer) currentState() (*State, error) { ... if c.initProcess != nil { // c.initProcess就是p // p.cmd.Process已经切换为stage-2 pid = c.initProcess.pid() ... } ... state := &State{ BaseState: BaseState{ ... InitProcessPid: pid, ... }, ... } if pid > 0 { // namespace路径同样基于state-2 PID for _, ns := range c.config.Namespaces { state.NamespacePaths[ns.Type] = ns.GetPath(pid) } ... } return state, nil }1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74注意
state.json记录的PID是容器内init对应宿主机侧PID,对于容器来说是不变的
# 3.3.进程
容器创建完成后,
state-2对应的宿主机侧PID会写入容器目录下的init.pid,后续用户进程会复用。func (r *runner) run(config *specs.Process) (int, error) { ... switch r.action { case CT_ACT_CREATE: err = r.container.Start(process) case CT_ACT_RESTORE: err = r.container.Restore(process, r.criuOpts) case CT_ACT_RUN: err = r.container.Run(process) ... } ... if r.pidFile != "" { // PID 文件:/run/containerd/io.containerd.runtime.v2.task/k8s.io/<id>/init.pid createPidFile(r.pidFile, process) ... } ... return status, err } // 将容器init的宿主PID原子写入init.pid文件 func createPidFile(path string, process *libcontainer.Process) error { // 此时process已经绑定到initProcess // initProcess.cmd.Process已从stage-0切换为stage-2 // 因此这里返回stage-2的宿主PID,后续也会作为用户进程的宿主PID pid, err := process.Pid() ... // path: /run/containerd/.../abc123/init.pid // tmpName: /run/containerd/.../abc123/.init.pid // O_EXCL 防止覆盖已存在的临时文件。 // O_SYNC 保证写入同步到底层存储。 f, err := os.OpenFile(tmpName, os.O_RDWR|os.O_CREATE|os.O_EXCL|os.O_SYNC, 0o666) ... // 写入十进制 PID,不添加换行符。 _, err = f.WriteString(strconv.Itoa(pid)) _ = f.Close() ... // 将临时文件原子重命名为正式 pid-file, // 避免其他进程读取到只写了一部分的内容。 return os.Rename(tmpName, path) }1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48注意
1.
state-2完成环境准备会阻塞于exec.fifo写端,会被替换为用户进程2.这是唯一常驻的容器命名空间进程,对应宿主
PID不会被机器占用