源码地址
https://github.com/opencontainers/runc
tagv1.2.5
整体流程
一个容器启动主要分为三大部分,如文章题目所示
-
create
: 主要是为了解析、组装容器启动的配置和与子进程的消息通道等; -
init
: 主要根据容器配置启动容器整个运行环境,包括熟知ns,cgroups, seccomp, apparmor, caps等; -
start
: 主要是为了通知init 进程启动容器;
runc create:
-
运行
runc create
时,后台生成该命令的进程,我们称该进程为parent; -
parent进程中运行
runc init
,我们称runc init
进程为child进程;
runc init:
-
child进程开始准备用户进程的运行环境,此时parent和child进程通过pipe进行通信;
-
child进程准备好用户进程的运行环境后,通知parent退出,自己则被exec.fifo阻塞;
-
由于parent退出(即
runc create
退出),child成孤独进程,进而被1进程接收; -
child进程一直被exec.fifo阻塞;
runc start:
-
运行
runc start
时,会打开exec.fifo,使child的阻塞消除,runc start
退出; -
由于阻塞消除,child进程继续往下执行;
-
child进程使用用户定义的命令替换
runc init
,从而child进程成为容器内的主进程; -
容器启动完成。
runc create
startContainer
-
生成容器配置
-
启动runc init 进程
// utils_linux.go
func startContainer(context *cli.Context, action CtAct, criuOpts *libcontainer.CriuOpts) (int, error) {
if err := revisePidFile(context); err != nil {
return -1, err
}
// 生成容器配置
// 读取容器运行时的 config.json 配置, 转化为spec 结构体
spec, err := setupSpec(context)
if err != nil {
return -1, err
}
// ...
// 使用spec 构建容器其他配置
container, err := createContainer(context, id, spec)
if err != nil {
return -1, err
}
// ...
// 创建容器
// runner 是装载 init 进程的核心,在此前的工作都是以组装配置和校对配置为主,
// 现在正式把配置内容装载后运行init进程;
r := &runner{
// 是否指定当前进程不收集僵尸进程,托孤行为
enableSubreaper: !context.Bool("no-subreaper"),
shouldDestroy: !context.Bool("keep"),
container: container,
listenFDs: listenFDs,
notifySocket: notifySocket,
consoleSocket: context.String("console-socket"),
pidfdSocket: context.String("pidfd-socket"),
detach: context.Bool("detach"),
pidFile: context.String("pid-file"),
preserveFDs: context.Int("preserve-fds"),
action: action,
// 热迁移工具的参数,在create 命令下该参数是空的
criuOpts: criuOpts,
// 是否需要初始化
init: true,
}
return r.run(spec.Process)
}
createContainer
// utils_linux.go
func createContainer(context *cli.Context, id string, spec *specs.Spec) (*libcontainer.Container, error) {
//是否使用非root 的cgroup
rootlessCg, err := shouldUseRootlessCgroupManager(context)
if err != nil {
return nil, err
}
// 根据OCI 规范创建 container 配置文件
// [进入CreateLibcontainerConfig]
config, err := specconv.CreateLibcontainerConfig(&specconv.CreateOpts{
CgroupName: id,
// 是否使用systemd-cgroup, 不使用的话默认选择 user.slice
UseSystemdCgroup: context.GlobalBool("systemd-cgroup"),
//是否不 pivotroot, 一般只有rootfs 在闪存上才不固定rootfs
NoPivotRoot: context.Bool("no-pivot"),
NoNewKeyring: context.Bool("no-new-keyring"),
Spec: spec,
RootlessEUID: os.Geteuid() != 0,
//获取EUID, 用于系统决定用户对系统资源的访问权限,通常情况下等于RUID。 非root 情况启动;
RootlessCgroups: rootlessCg,
})
if err != nil {
return nil, err
}
root := context.GlobalString("root")
// 创建容器
return libcontainer.Create(root, id, config)
}
CreateLibcontainerConfig
创建container 配置,主要包含
-
指定工作路径
-
指定根目录
-
增加挂载设备
-
绑定cgroup
-
设置oom selinux等
-
注册启动钩子
// libcontainer/specconv/spec_linux.go
func CreateLibcontainerConfig(opts *CreateOpts) (*configs.Config, error) {
//让runc 的工作目录固定在 spec.Root.Path 指定的目录下,没有指定即当前目录
cwd, err := getwd()
if err != nil {
return nil, err
}
spec := opts.Spec
if spec.Root == nil {
return nil, errors.New("root must be specified")
}
// 指定rootfs, 在 config.json 里面指定了当前目录的 rootfs 文件夹
rootfsPath := spec.Root.Path
if !filepath.IsAbs(rootfsPath) {
rootfsPath = filepath.Join(cwd, rootfsPath)
}
labels := []string{}
for k, v := range spec.Annotations {
labels = append(labels, k+"="+v)
}
// 将已有的createOpts 组装到最终的 config 上
config := &configs.Config{
Rootfs: rootfsPath,
NoPivotRoot: opts.NoPivotRoot,
Readonlyfs: spec.Root.Readonly,
Hostname: spec.Hostname,
Domainname: spec.Domainname,
Labels: append(labels, "bundle="+cwd),
NoNewKeyring: opts.NoNewKeyring,
RootlessEUID: opts.RootlessEUID,
RootlessCgroups: opts.RootlessCgroups,
}
// 根据规范挂载目录,对应的是config.json 的 mounts 字段
// 如: /proc, /dev, /dev/pts, /dev/shm, /dev/mqueue, /sys/, /sys/fs/cgroup 等
for _, m := range spec.Mounts {
cm, err := createLibcontainerMount(cwd, m)
if err != nil {
return nil, fmt.Errorf("invalid mount %+v: %w", m, err)
}
config.Mounts = append(config.Mounts, cm)
}
defaultDevs, err := createDevices(spec, config)
if err != nil {
return nil, err
}
// 创建cgroup 资源控制的配置, 传入默认分区, 返回 cgroup 资源配置
/* 可控的资源对象
var legacySubsystems = []subsystem{
&fs.CpusetGroup{},
&fs.DevicesGroup{},
&fs.MemoryGroup{},
&fs.CpuGroup{},
&fs.CpuacctGroup{},
&fs.PidsGroup{},
&fs.BlkioGroup{},
&fs.HugetlbGroup{},
&fs.PerfEventGroup{},
&fs.FreezerGroup{},
&fs.NetPrioGroup{},
&fs.NetClsGroup{},
&fs.NameGroup{GroupName: "name=systemd"},
&fs.RdmaGroup{},
&fs.NameGroup{GroupName: "misc"},
}
*/
c, err := CreateCgroupConfig(opts, defaultDevs)
if err != nil {
return nil, err
}
config.Cgroups = c
// ...
// ...
if spec.Process != nil {
// 设置 oom scoret
config.OomScoreAdj = spec.Process.OOMScoreAdj
// privileges
config.NoNewPrivileges = spec.Process.NoNewPrivileges
// umask
config.Umask = spec.Process.User.Umask
// selinux
config.ProcessLabel = spec.Process.SelinuxLabel
// 赋予容器部分root的能力
if spec.Process.Capabilities != nil {
config.Capabilities = &configs.Capabilities{
Bounding: spec.Process.Capabilities.Bounding,
Effective: spec.Process.Capabilities.Effective,
Permitted: spec.Process.Capabilities.Permitted,
Inheritable: spec.Process.Capabilities.Inheritable,
Ambient: spec.Process.Capabilities.Ambient,
}
}
if spec.Process.Scheduler != nil {
s := *spec.Process.Scheduler
config.Scheduler = &s
}
if spec.Process.IOPriority != nil {
ioPriority := *spec.Process.IOPriority
config.IOPriority = &ioPriority
}
}
// 容器生命周期钩子
createHooks(spec, config)
config.Version = specs.Version
return config, nil
}
runner.run
-
设置文件描述符
-
设置容器的主进程为收容者
-
设置IO, 前台启动还是后台启动IO输出不同
-
启动容器
// utils_linux.go
func (r *runner) run(config *specs.Process) (int, error) {
// 这里就是前面提到ExtraFiles, 设定的fd 从3开始加, ExtraFiles
// 常用于增加容器 输入或 输出的 文件描述 符, 如将容器日志导出到文件。
if len(r.listenFDs) > 0 {
process.Env = append(process.Env, "LISTEN_FDS="+strconv.Itoa(len(r.listenFDs)), "LISTEN_PID=1")
process.ExtraFiles = append(process.ExtraFiles, r.listenFDs...)
}
baseFd := 3 + len(process.ExtraFiles)
procSelfFd, closer := utils.ProcThreadSelf("fd/")
defer closer()
for i := baseFd; i < baseFd+r.preserveFDs; i++ {
_, err = os.Stat(filepath.Join(procSelfFd, strconv.Itoa(i)))
if err != nil {
return -1, fmt.Errorf("unable to stat preserved-fd %d (of %d): %w", i-baseFd, r.preserveFDs, err)
}
process.ExtraFiles = append(process.ExtraFiles, os.NewFile(uintptr(i), "PreserveFD:"+strconv.Itoa(i)))
}
// 启用 containerd enableSubreaper 选项后,containerd 将在容器内部
// 的主进程上设置子进程收容者属性。这意味着容器内的子进程将由容器的主进程作为其子进程收容者,
// 而不是由容器的直接父进程收容。
handler := newSignalHandler(r.enableSubreaper, r.notifySocket)
// 设置进程的IO
// console 启动,还是后台启动,输入和输出的通道不同
tty, err := setupIO(process, rootuid, rootgid, config.Terminal, detach, r.consoleSocket)
if err != nil {
return -1, err
}
defer tty.Close()
switch r.action {
case CT_ACT_CREATE:
// 其实这几个action 最终实现的动作都差不多,后面还会有新的文章进行详解;
// 本次主要讲的是 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)
default:
panic("Unknown action")
}
// ...
}
Container.start
process.Init
作为一个布尔值(true/false
)用于标识 当前进程是否是容器的 init 进程。它的作用如下:
1. 当 process.Init == true
时,表示 该进程是容器的第一个进程(即 init
进程)。这个进程:
-
负责 创建新的 Namespace(如
PID
,NET
,MNT
等)。 -
负责 初始化容器(如
Rootfs
挂载、Cgroups 配置)。 -
作为 容器的主进程,它的 PID 通常是 1(容器内部)。
-
如果该进程退出,整个容器会停止。
2. 当 process.Init == false
时,表示 该进程是附加到现有容器中的新进程,通常用于 runc exec
:
-
该进程 不会创建新的 Namespace,而是进入已有容器的 Namespace。
-
该进程 不会影响容器的生命周期,即使它退出,容器仍然运行。
-
由
newSetnsProcess
处理,用于runc exec
运行附加进程。
// libcontainer/container_linux.go
func (c *Container) start(process *Process) (retErr error) {
if c.config.Cgroups.Resources.SkipDevices {
return errors.New("can't start container with SkipDevices set")
}
// 容器的主进程创建
if process.Init {
if c.initProcessStartTime != 0 {
return errors.New("container already has init process")
}
// 创建exec.fifo ,用于后续阻塞容器进程,,待外部启动。
if err := c.createExecFifo(); err != nil {
return err
}
defer func() {
if retErr != nil {
// 执行完毕后,删除exec.fifo文件,看到这句应该大概猜到上面exec.fifo
//文件可能和 start 进程的执行有关系
c.deleteExecFifo()
}
}()
}
// 创建容器进程(场景为docker run) 或 创建已有容器附加进程(场景为docker exec)
parent, err := c.newParentProcess(process)
//...
// 启动进程
if err := parent.start(); err != nil {
return fmt.Errorf("unable to start container process: %w", err)
}
}
c.createExecFifo()
作用
当 runc exec
在一个正在运行的容器中执行新进程时:
-
createExecFifo()
创建一个 FIFO 文件,路径通常是:
/run/containerd/io.containerd.runtime.v2.task/<container-id>/exec.fifo
exec.fifo
的用途
场景 1:正常执行 runc exec
runc exec
进程启动后,会尝试 打开 exec.fifo
并阻塞等待。
容器的 setns
过程(进入容器的 Namespace)完成后,runc
写入 exec.fifo
。
exec
进程检测到 FIFO 被写入,解除阻塞,开始执行。
场景 2:防止 runc exec
竞态
如果 exec
进程 在 Namespace 还未完全切换时就执行,可能会导致:
进程启动时仍在宿主机 Namespace,而不是容器的环境。
可能会导致 exec
进程访问错误的 Cgroups 或文件系统。
交互流程
假设 runc exec
运行 /bin/bash
进入容器:
r