100 行代码创建一个容器

容器占用资源监控和故障隔离,训练性能不一致

关键技术一:命名空间

  1. PID:PID命名空间为某个进程及其子进程提供了系统中进程的一个子集的视图。你可以将其想象为一个映射表。当PID命名空间中的某个进程向kernel请求一个进程列表时,kernel将会检查这个映射表。如果该进程已经存在于表中,那么kernel就会返回其映射ID,而不是真实的ID。而如果该进程不存在于映射表中,那么kernel会假设该进程完全不存在。在PID命名空间中创建的第一个进程的pid为1(因此其主机ID的映射值为1),该命名空间在容器中会表现为一个隔离的进程树。
  2. MNT:在某种意义上说,mount命名空间是最重要的一个命名空间,它为其中所包含的进程提供了一个独有的mount表。这也意味着当这些进程对目录进行挂载或取消挂载时不会影响其他命名空间(包括主机命名空间)。更重要的是,我们将看到,通过与pivot_root这个系统调用的结合,它让某个进程能够拥有一个独有的文件系统。因此,只需交换容器的文件系统,进程就会认为它正运行在某个Ubuntu、BusyBox或Alpine中。
  3. NET:network命名空间为使用它的进程赋予了独立的网络栈。通常来说,只有在主network命名空间(也就是当机器启动时会自动启动的进程所在的命名空间)中才会被分配真实的物理网卡。但我们可以创建虚拟的网络设备对,即互联的网卡,其中一端属于某个network命名空间,而另一端则属于另一个network命名空间,通过这种方式在两个network命名空间之间创建了一个虚拟的连接。这种方式有些类似于在同一台主机中让多个IP栈进行通信。通过一定的路由逻辑,每个容器就能够保持自己独立的网络栈,同时能够与外界进行通信。
  4. UTS:UTS(UNIX Time-sharing System)命名空间为其中的进程提供了系统主机名与域名的独有视图。当进入某个UTS命名空间之后,对于主机名与域名的修改不会影响其他进程。
  5. IPC:IPC(Interprocess Communication)命名空间能够隔离各种进程间的通信机制,例如消息队列等等。可参考命名空间的相关文档,以了解更多细节。
  6. USER:user命名空间最近刚刚得到支持,从安全性的角度来看,它可能是最强大的一种命名空间了。user命名空间能够将某个进程所看到的uid映射为主机中一个不同的uid(以及gid)集合。这一特性非常实用,通过使用user命名空间,我们就能够将容器的root user ID(比如0)映射为主机中一个任意的(并且未赋予特权的)uid。这就意味着我们可以让某个容器认为它具有对root的访问权,而同时又无需为其赋予任何root命名空间中的权限(我们甚至可以为其访问特定于容器的资源赋予类似于root的权限)。容器可随意以uid 0运行进程(这通常意味着该用户具备root权限),而kernel会在内部将该uid映射为某个未赋予特权的真实uid。大多数容器系统都不会将容器中的任何一个uid映射为调用命名空间中的uid 0,换句话说,在容器中不存在任何一个具有root权限的uid。

关键技术二: cgroups

Cgroups全称Control Groups,是Linux内核提供的物理资源隔离机制,通过这种机制,可以实现对Linux进程或者进程组的资源限制、隔离和统计功能。比如可以通过cgroup限制特定进程的资源使用,比如使用特定数目的cpu核数和特定大小的内存,如果资源超限的情况下,会被暂停或者杀掉。Cgroup是于2.6内核由Google公司主导引入的,它是Linux内核实现资源虚拟化的技术基石,LXC(Linux Containers)和docker容器所用到的资源隔离技术,正是Cgroup。

cgroup 内容很多,具体可以参见这里

关键技术三: 分层文件系统

命名空间与CGroups负责容器化的隔离与资源共享,他们实现了容器的主体功能以及安全保障。而分层文件系统使我们能够高效地移动完整的机器镜像,它保证了容器的持续运作。从本质上来看,分层文件系统的作用是使为每个容器创建一份root文件系统的拷贝的调用过程进行优化。有多种不同的方式可以实现这一目标。Btrfs在文件系统层使用了写时拷贝(copy-on-write)技术,而Aufs则使用了“union mounts”这种挂载机制。由于可以通过多种方式实现这一步骤,因此本文选择了一种非常简单的方式:我们将真正地创建一个拷贝。虽然这种方式很慢,但确实能够完成任务。

code

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
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")
}
}
# step 1 ‘/proc/self/exe’,这是一个特殊文件,它包含了当前可执行文件的一个内存镜像。换句话说,我们将重新调用这个程序本身,将‘child’作为第一个参数进行传递
func parent() {
cmd := exec.Command("/proc/self/exe", append([]string{"child"}, os.Args[2:]...)...)
# step 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() {
# step3 第2步中已经将该进程运行在了新的mnt命名空间,此处进行rootfs 的切换
must(syscall.Mount("rootfs", "rootfs", "", syscall.MS_BIND, ""))
must(os.MkdirAll("rootfs/oldrootfs", 0700))
must(syscall.PivotRoot("rootfs", "rootfs/oldrootfs"))

# 当‘pivotroot’调用 结束之后,容器中的‘/’目录将指向rootfs目录
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)
}
}