后浪笔记一零二四

简介

在虚拟化领域,Linux逐渐增加了Cgroups、Namespace、Seccomp、capability、Apparmor等一些功能。 Docker重度使用这些特性,而且目前风靡大江南北。实际上,容器技术是一系列晦涩难懂甚至有些神秘的系统特性的集合, 因此Docker公司将这些底层的技术合并在一起,开源出了一个项目runC,并托管于OCI组织。

Linux基金会在2015年6月成立了OCI(Open Container Initiative)组织,旨在围绕容器网格定义和运行时的配置指定一个 开放的工业化标准。该组织主要由Docker、Google、IBM、Microsoft、Red Hat和其他许多合作伙伴创立。

runC是一个轻量级的容器运行引擎,包括所有Docker使用的和容器相关的系统调用的代码,其基本功能点如下:

  1. 完全支持Linux Namespace,包括User Namespace
  2. 原生支持所有Linux提供的安全特性:Selinux、Apparmor、Seccomp、control groups、capability、pivot_root等。 只要是Linux能做的,runC都能做
  3. 在CRIU项目的支持下原生支持容器热迁移
  4. 一份正式的容器标准,由Open Container Project管理,并挂靠在Linux基金会下,可以说这是真正的业界标准。

可以这么理解,runC的目标就是去构造到处都可以运行的标准容器。

OCI标准包(bundle)

一个标准的容器运行时需要文件系统,也就是镜像。那么,OCI是怎么定义一个基本的容器运行包的呢? 这个容器标准包的定义仅仅考虑如何把容器和它的配置数据存储到磁盘上以便运行时读取。一般来说,应该包含如下2个模块:

  1. config.json包括容器的配置数据。这个文件必须在容器的root文件系统内。
  2. 一个文件夹,代表容器的root文件系统。这个文件夹的名字理论上是可以随意的,但是按照一般命名规则,叫rootfs比较合适。 当然,这个文件夹内必须包含上面提到的config.json

config.json

config.json包含容器必须的元数据,主要包括容器需要去运行的进程、环境变量、沙盒环境等。 下面把config.json含有的一些元素讲解下:

  1. ociVersion: 这里是指定OCI容器的版本号
  2. root: 配置容器的root文件系统 path指定root文件系统的路径,可以是以/开头的绝对路径,也可以是相对路径 readonly如果为true,那么root文件系统在容器内就是只读的,默认是false 距离如下:
1
2
3
4
"root": {
    "path": "rootfs",
    "readonly": true
}

mounts

mounts配置额外的挂载点

  • destination: 挂载点在容器内的目标位置,必须是绝对路径
  • type: 需要挂载的文件系统类型。其中类型必须是Linux Kernel支持的类型,比如minix, ext2, ext3, jfs, xfs, reiserfs, proc, nfs
  • source: 设备名或文件名
  • options: 挂载点需要的额外信息。

举例如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
"mounts": {
  {
    "destination": "/tmp",
    "type": "tmpfs",
    "source": "tmpfs",
    "options": ["nosuid", "strictatime", "mode=755", "size=65536k"]
  },
  {
    "destination": "/data",
    "type": "bind",
    "source": "/volumes/testing",
    "options": ["rbind", "rw"]
  }
}

process

process配置容器进程信息如下:

  1. terminal: 指定是否需要连接一个终端到此进程,默认是false
  2. consoleSize: 在terminal连接时,用来指定控制台的大小。它包含下面两个属性: height,width
  3. cwd: 可执行文件的工作目录,这个路径必须是绝对路径
  4. env: 包含一系列需要传递给进程的环境变量,其中变量格式必须是KEY=value格式
  5. args: 传递给可执行文件的参数
  6. capabilities: 是一系列指定给容器进程的capabilities值
  7. rlimits: 限制容器内执行的进程资源使用量

user

指定容器内运行进程的用户信息

  • uid指定用户ID
  • gid指定group ID
  • additionalGids指定附加的groupID 举例如下:
 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
"process": {
  "terminal": true,
  "consoleSize": {
    "height": 25,
    "width": 80
  },
  "user": {
    "uid": 1,
    "gid": 1,
    "additionalGids": [5,6]
  },
  "env": [
    "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
    "TERM=xterm"
  ],
  "cwd": "/root",
  "args": [
    "sh"
  ],
  "apparmorProfile": "acme_secure_profile",
  "selinuxLabel": "system_u:system_r:svirt_lxc_net_t:s0:c124,c675",
  "noNewPrivileges": true,
  "capabilities": [
    "CAP_AUDIT_WRITE",
    "CAP_KILL",
    "CAP_NET_BIND_SERVICE"
  ],
  "rlimits": [
    {
      "type": "RLIMIT_NOFILE",
      "hard": 1024,
      "soft": 1024
    }
  ]
}

hostname

hostname配置容器的主机名,只有容器创建了UTS Namespace才可以指定

platform

platform指定容器运行的类型信息,其中的两个参数如下。

  1. os指定容器运行的系统类型
  2. arch指定系统的架构

举例如下:

1
2
3
4
"platform": [
  "os": "linux",
  "arch": "amd64"
]

钩子(Hook)

配置文件内还提供了钩子的特性,它可以让开发者扩展容器运行态的动作,可以在容器运行前和停止后执行一些命令, 这样用户可以做一些复杂的网络配置和volume的垃圾收集等动作。

  1. prestart: pre-start钩子是在容器进程创建后执行的,但是在用户还没有开始执行前触发。 在Linux上,它是在Namespace创建成功后触发的,因此它能提供一个配置容器初始化环境的机会。
  2. poststart: post-start钩子是在用户进程启动之后执行的。这个钩子可以用来告诉用户,进程已经启动起来了
  3. poststop: post-stop钩子是在容器进程停止后执行的。这个钩子可以用来清理容器运行时产生的垃圾

举例如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
"hooks": {
  "prestart": [
    {
      "path": "/usr/bin/fix-mounts",
      "args": ["fix-mounts", "arg1", "arg2"],
      "env": ["key1=value1"]
    },
    {
      "path": "/usr/bin/setup-network"
    }
  ],
  "poststart": [
    {
      "path": "/usr/bin/notify-start",
      "timeout": 5
    }
  ],
  "poststop": [
    {
      "path": "/usr/sbin/cleanup.sh",
      "args": ["cleanup.sh", "-f"]
    }
  ]
}

path是需要执行脚本的路径。args和env都是可选参数,timeout是执行脚本的超时时间。

这样就将runC基本的运行态所需要的配置和配置的信息讲解完了,下面会通过一个容器的创建过程来讲解runC的源码。

runC创建容器的流程

输入runc run <container-id>就会根据当前路径下面的config.json文件去创建一个容器。

这里主要来介绍一下runC里面的createContainer流程,首先来看一下函数定义

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
func createContainer(context *cli.Context, id string, spec *specs.Spec) (libcontainer.Container, error) {
  config, err := specconv.CreateLibcontainerConfig(&specconv.CreateOpts{
    CgroupName:          id,
    UserSystemdCgroup:   context.GlobalBool("systemd-cgroup"),
    NoPivotRoot:         context.Bool("no-pivot"),
    NoNewKeyring:        context.Bool("no-new-keyring"),
    Spec:                spec,
  })
  if err != nil {
    return nil, err
  }

  factory, err := loadFactory(context)
  if err != nil {
    return nil, err
  }
  return factory.Create(id, config)
}

createContainer函数的参数列表接收上下文和关于容器的描述spec,然后根据spec描述来配置容器需要的信息, 最后把这些配置信息传递给factory的create方法。factory可以结余很多系统实现,比如Linux、Solaris、Windows、 Unix,这里主要看一下基于Linux的实现。

 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
func (l *ListenFactory) Create(id string, config *configs.Config) (Container, error) {
  // 检查配置信息
  if err := l.Validator.Validate(config); err != nil {
    return nil, newGenericError(err, ConfigInvalid)
  }
  logrus.Infof("Factory create containerPort %s", containerRoot)
  // 创建容器root filesystem
  if err := os.MkdirAll(containerRoot, 0711); err != nil {
    return nil, newGenericError(err, SystemError)
  }
  if err := os.Chown(containerRoot, uid, gid); err != nil {
    return nil, newGenericError(err, SystemError)
  }
  fifoName := filepath.Join(containerRoot, execFifoFilename)
  logrus.Infof("infoName %s", fifoName)
  oldMask := syscall.Umask(0000)
  // 创建进程间通信管道
  if err := syscall.Mkfifo(fifoName, 0622); err != nil {
    syscall.Umask(oldMask)
    return nil, newGenericError(err, SystemError)
  }
  syscall.Umask(oldMask)
  if err := os.Chown(fifoName, uid, gid); err != nil {
    return nil, newGenericError(err, SystemError)
  }
  // 生成包含容器信息的struct
  c := &linuxContainer {
    id:               id,
    root:             containerRoot,
    config:           config,
    initArgs:         l.InitArgs,
    criuPath:         l.CriuPath,
    cgroupManager:    l.NewCgroupsManager(config.Cgroups, nil),
  }
  return c, nil
}

这里截取了Create函数实现的一部分, 其实主要工作就是检查容器配置,然后根据目录结构初始化一下容器的root file system, 最后把包含所有信息的struct返回。

容器信息创建完毕,就需要真正创建容器进程了,下面列出创建容器进程的newParentProcess函数。

1
2
3
4
5
6
7
8
9
func (c *linuxContainer) newParentProcess(p *Process, doInit bool) (parentProcess, error) {
  // 创建匿名管道用于父子进程通信
  parentPipe, childPipe, err := newPipe()
  rootDir, err := os.Open(c.root)
  // 创建command信息
  cmd, err := c.commandTemplate(p, childPipe, rootDir)
  // 返回创建好的初始化进程信息
  return c.newInitProcess(p, cmd, parentPipe, childPipe, rootDir)
}

可以看到,newProcessProcess函数里面最重要的就是创建容器所属的command信息,下面来仔细看一下它的实现

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
func (c *linuxContainer) commandTemplate(p *Process, childPipe, rootDir *os.File) (*exec.Cmd, error) {
  // 创建command
  cmd := exec.Command(c.initArgs[0], c.initArgs[1:]...)
  logrus.Infof("command template args1 %s args2 %v", c.initArgs[0], c.initArgs[1:])
  cmd.Stdin = p.Stdin
  cmd.Stdout = p.Stdout
  cmd.Stderr = p.Stderr
  cmd.Dir = c.config.Rootfs
  if cmd.SysProcAttr == nil {
    cmd.SysProcAttr = &syscall.SysProcAttr{}
  }
  cmd.ExtraFiles = append(p.ExtraFiles, childPipe, rootDir)
  cmd.Env = append(cmd.Env, fmt.Sprintf("_LIBCONTAINER_INITPIPE=%d", stdioFdCount+len(cmd.ExtraFiles)-2), 
                         fmt.Sprintf("_LIBCONTAINER_INITPIPE=%d", stdioFdCount+len(cmd.ExtraFiles)-1))
  // NOTE: when running a container with no PID namespace and the parent
  // process spawning the container is
  // PID1 the pdeathsig is being delivered to the container's init process by
  // the kernel for some reason
  // even with the parent still running
  if c.config.ParentDeathSignal > 0 {
    cmd.SysProcAttr.Pdeathsig = syscall.Signal(c.config.ParentDeathSignal)
  }
  return cmd, nil
}

这段代码就不多做解释了,看过本书前面的章节应该就能明白,这和前面创建容器初始化进程是相似的流程, 只是多加了一些环境变量和参数。容器创建其实就参考了runC实现。

最后,来看一下最终的start是如何实现的

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
func (c *linuxContainer) start(process *Process, isInit bool) error {
  // 创建初始化进程
  parent, err := c.newParentProcess(process, isInit)
  logrus.Infof("libcontainer start %++v", parent)
  // 下面的start真正开启了容器进程的启动
  if err := parent.start(); err != nil {
    // terminate the process to ensure that it properly is reaped.
    if err := parent.terminate(); err != nil {
      logrus.Warn(err)
    }
    return newSystemErrorWithCause(err, "starting container process")
  }
}

至此,就完成了容器的初始化进程启动。下面会再次调用runC的init方法完成容器初始化进程的启动。这个参数在factory_linux.go里面有体现

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// New returns a linux container factory based in the root directory and 
// configures the factory with the provided option funcs
func New(root string, options ...func(*LinuxFactory) error) (Factory, error) {
  if root != "" {
    if err := os.MkdirAll(root, 0700); err != nil {
      return nil, newGenericError(err, SystemError)
    }
  }
  l := *LinuxFactory {
    Root:         root,
    InitArgs:     []string{"/proc/self/exe", "init"},
    Validator:    validate.New(),
    CriuPath:     "criu",
  }
  return l, nil
}

在New函数中,可以看到熟悉的/proc/self/exe,后面的参数是init,其实架构和mydocker一样,也会重新运行runC的init方法来初始化容器的进程

代码读到这里,应该可以大概理解runC创建容器的整个过程了,如下:

  1. 读取配置文件
  2. 设置rootFileSystem
  3. 使用factory创建容器, 各个系统平台均有不同实现
  4. 创建容器的初始化进程process
  5. 设置容器的输出管道,主要是Go的pipes
  6. 执行Container.Start()启动物理的容器
  7. 回调init方法重新初始化进程
  8. runC父进程等待子进程初始化成功后退出

可以蛋刀,具体的执行流程设计3个概念:process、container、factory

factory用来创建容器,process负责进程之间的通信和启动容器。


本文发表于 0001-01-01,最后修改于 0001-01-01。

本站永久域名「 jiavvc.top 」,也可搜索「 后浪笔记一零二四 」找到我。


上一篇 « 下一篇 »

赞赏支持

请我吃鸡腿 =^_^=

i ysf

云闪付

i wechat

微信

推荐阅读

Big Image