初探容器化:隔离,限制与docker run

  本文为容器化技术系列文章的第一篇。本文将介绍容器化中的隔离,限制的原理,并且实际编写一个类docker应用,并且实际执行docker run命令。

前置知识

  • Linux
  • Go

初探容器化:隔离,限制与docker run

目录

隔离

Namepspaces 系统:

  Namespaces 系统是linux对于系统资源的一种抽象,它使得进程认为在同一namespaces系统下的进程之间能够互相感知,而对namespace外一无所知,从而实现隔离的效果。
  linux最新版本中,根据不同的功能,实现了7个namespace系统,如下所示:

NamespaceConstantIsolates
CgroupCLONE_NEWCGROUPCgroup root directory
IPCCLONE_NEWIPCSystem V IPC, POSIX message queues
NetworkCLONE_NEWNETNetwork devices, stacks, ports, etc.
MountCLONE_NEWNSMount points
PIDCLONE_NEWPIDProcess IDs
UserCLONE_NEWUSERUser and group IDs
UTSCLONE_NEWUTSHostname and NIS domain name

  详情可见man namespaces。具体命名空间数量由linux版本决定,在这里,由于只实现简易版本,我们只使用到IPC Network Mount PID User UTS这几个namespace。在内核版本3.8之后,这几个namespace都可用。

  在man文档中,给了我们几种系统调用的方法,分别是:
  clone:创建新进程,根据flag数,为每一个flag创建一个namespace,将进程封入进去。
  setns:将调用的进程封装进已经存在的namespace里面。
  unshare:将正在调用的进程封装进由flags确立的新的namespaces里面。
  ioctl:查看namespaces的信息。
  文档中给出了这些信息,但我们不一定需要使用裸露的api,go为我们封装了它。

Go的系统调用:

  在go的syscall库中,为我们封装了系统调用。我们可以通过设置SysProcAttr结构体,来进行系统调用。该结构体定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
type SysProcAttr struct {
Chroot string // Chroot.
Credential *Credential // Credential.
Ptrace bool // Enable tracing.
Setsid bool // Create session.
Setpgid bool // Set process group ID to Pgid, or, if Pgid == 0, to new pid.
Setctty bool // Set controlling terminal to fd Ctty (only meaningful if Setsid is set)
Noctty bool // Detach fd 0 from controlling terminal
Ctty int // Controlling TTY fd
Foreground bool // Place child's process group in foreground. (Implies Setpgid. Uses Ctty as fd of controlling TTY)
Pgid int // Child's process group ID if Setpgid.
Pdeathsig Signal // Signal that the process will get when its parent dies (Linux only)
Cloneflags uintptr // Flags for clone calls (Linux only)
Unshareflags uintptr // Flags for unshare calls (Linux only)
UidMappings []SysProcIDMap // User ID mappings for user namespaces.
GidMappings []SysProcIDMap // Group ID mappings for user namespaces. // GidMappingsEnableSetgroups enabling setgroups syscall. // If false, then setgroups syscall will be disabled for the child process. // This parameter is no-op if GidMappings == nil. Otherwise for unprivileged // users this should be set to false for mappings work.
GidMappingsEnableSetgroups bool
AmbientCaps []uintptr // Ambient capabilities (Linux only)
}

  在结构体中,我们着重关注Cloneflags,Unshareflags,UidMappings,GidMappings,这些在namespaces系统中,可能会用得到。

  下面是一个小demo,用于展示如何使用SysProcAttr去创建进程并且封入新的namespace里面。

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
package main

import (
"syscall"
"os/exec"
"os"
"fmt"
)

func main(){
cmd:=exec.Command("sh")
fmt.Println("start namespace demo")
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWUTS|syscall.CLONE_NEWIPC|syscall.CLONE_NEWPID|syscall.CLONE_NEWNS|syscall.CLONE_NEWUSER,
UidMappings: []syscall.SysProcIDMap{
{
ContainerID: syscall.Getuid(),
HostID: syscall.Getuid(),
Size: 1,
},
},
GidMappings: []syscall.SysProcIDMap{
{
ContainerID: syscall.Getgid(),
HostID: syscall.Getgid(),
Size: 1,
},
}
}
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr

if err:=cmd.Run();err!=nil {
log.Fatal(err)
}
fmt.Println("end namespace demo")
}

  在demo,创建了子进程,启动了一个sh,并且封入了各个namespace中。在这个demo中,可以尝试各种指令来尝试隔离性,如ps -ef。

限制

Cgroups技术

  Namespace技术保证了各个进程的隔离。而若要添加诸如内存大小的限制,我们需要学习cgroups技术。
  Control cgroups,通常称为cgroups,是linux内核提供的特性,它能使进程被组织进hierarchical groups,可以用来限制和监视各种资源的使用。内核的cgroup接口通过伪文件系统cgroupfs来调用。分组操作在cgroup内核中实现,而资源跟踪和限制则是通过资源类型子系统来实现。
  cgroup 是绑定到通过 cgroup 文件系统定义的一组限制或参数的进程的集合。
  subsystems 是修改cgroup进程的行为的内核组建。各式各样的子系统能够用来执行一些操作,诸如限制cpu时间或者内存。subsytems也被成为资源控制器。
  控制器的cgroup按层次结构排列。 通过在cgroup文件系统中创建,删除和重命名子目录来定义此层次结构。 在层次结构的每个级别,都可以定义属性(例如,限制)。 cgroup提供的限制,控制和计费通常在定义属性的cgroup之下的整个子层次结构中都有效,例如,后代不能超过在层次结构中较高级别上放置在cgroup上的限制cgroups。

Cgroups实战

  • 环境需要
    • stress程序 用于模拟系统负载较高场景

  首先我们创建文件夹,并且把cgroup挂载进去。执行一下命令:

1
2
3
4
5
root@wqy:/home/wqy# mkdir cgroup-test
root@wqy:/home/wqy# sudo mount -t cgroup -o none,name=cgroup-test cgroup-test ./cgroup-test
root@wqy:/home/wqy# ls ./cgroup-test/
cgroup.clone_children cgroup.sane_behavior release_agent
cgroup.procs notify_on_release tasks

  可以看到,在我们挂在完成后,文件内创建了一些默认的文件。其中cgroup.clone_childred代表子cgroup是否继承父cgroup的cpuset cgroup.procs代表树中当前节点所有进程组id notify_on_release代表最后一个进程退出后是否执行release_agent release_agent是个路径 用于退出后自动清理 tasks表示cgroup下的进程id。
  我们可以在此文件夹中增加新的cgroup来进行测试。执行以下指令:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
root@wqy:/home/wqy/cgroup-test# sudo mkdir cgroup-1
root@wqy:/home/wqy/cgroup-test# sudo mkdir cgroup-2
root@wqy:/home/wqy/cgroup-test# tree
.
├── cgroup-1
│   ├── cgroup.clone_children
│   ├── cgroup.procs
│   ├── notify_on_release
│   └── tasks
├── cgroup-2
│   ├── cgroup.clone_children
│   ├── cgroup.procs
│   ├── notify_on_release
│   └── tasks
├── cgroup.clone_children
├── cgroup.procs
├── cgroup.sane_behavior
├── notify_on_release
├── release_agent
└── tasks

2 directories, 14 files

  可以看到,在添加子cgroup后,子cgroup继承了属性。
  接下来,我们为进程加入限制。我们将创建一个stress程序,占用200m内存,观察限制前与限制后是否有区别。

1
stress --vm-bytes 200m --vm-keep -m 1

  执行以上指令,在本机的top中可以看到,%MEM项目为2.6,即200M。接下来,加上限制。

1
2
3
4
5
6
7
8
root@wqy:/home/wqy/cgroup-test# mount |grep memory
cgroup on /sys/fs/cgroup/memory type cgroup (rw,nosuid,nodev,noexec,relatime,memory)
root@wqy:/home/wqy/cgroup-test# cd /sys/fs/cgroup/memory
root@wqy:/sys/fs/cgroup/memory# sudo mkdir test-limit-memory
root@wqy:/sys/fs/cgroup/memory# cd test-limit-memory/
root@wqy:/sys/fs/cgroup/memory/test-limit-memory# sudo sh -c "echo "100m" >memory.limit_in_bytes"
root@wqy:/sys/fs/cgroup/memory/test-limit-memory# sudo sh -c "echo $$ > tasks"
root@wqy:/sys/fs/cgroup/memory# stress --vm-bytes 200m --vm-keep -m 1

  在上述命令中,我们通过mount和grep找到了memory所在的挂载点,在挂载点创建了子cgroup:test-limit-memory,将内存限制写入了限制文件,将本进程写入了task,最后执行了指令。通过top命令可以看到 %MEM项目为1.3,即100M。我们成功实现了限制内存使用。

  在完成了namespace隔离demo和cgroup限制demo后,我们即将用go语言,将这些整合在一起,实现简单的隔离限制程序。

docker_run

  该部分代码仓库:github:mydockerpractice 具体为main.go subsystem cgroupmanager container四个部分。

基本结构

  在实现简易的docker run之前,我们需要理清思路:我们即将要编写的文件,how it works。

  • 功能
    • 实现命令行解析参数 包括命令种类如run 资源限制如-m
    • 在指定run命令后 使用namespace创建子进程 并且执行命令行给予的程序
    • 在指定限制之后 能使用cgroup加入限制

  解析文件可以由第三方库来实现,这里使用的是github.com/urfave/cli,在上述vendor文件夹中可以找到。

实战docker_run

  首先,我们来确认主函数功能:启动cli,绑定各种参数,进行解析。剩下事务交给其他模块来做。我们可以得到以下主函数:

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
package main

import (
"github.com/urfave/cli"
"fmt"
"os"
"strings"
"io/ioutil"
_ "github.com/Sirupsen/logrus"
container "mydocker/container"
subsystem "mydocker/subsystem"
cgroupmanager "mydocker/cgroupmanager"
)

const usage = `mydocker is a simple container.`

func main(){
app := cli.NewApp()
app.Name = "mydocker"
app.Usage = usage
app.Commands = []cli.Command{
initCommand,
runCommand,
}

if err:= app.Run(os.Args); err!=nil {
fmt.Println(err)
}
}

  可以看到,我们定义了两个启动命令init和run,分别是初始化容器和启动程序。我们将在下面介绍两个指令的功能。
   runCommand以及Run函数:runCommand解析参数并且传递给Run函数。Run函数使用NewParentProcess创建了带namespace的进程,创建了cgroup管理程序,配置了限制,应用到进程上,并且将程序指令写入pipe,便于namespace的进程调用。

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
var runCommand = cli.Command{
Name : "run",
Usage : "Create a container",
Flags : []cli.Flag{
cli.BoolFlag{
Name : "ti",
Usage: "enable tty",
},
cli.StringFlag{
Name : "m",
Usage : "limit the memory",
},
},

Action: func(context *cli.Context) error {
fmt.Println("start runCommand")
if len(context.Args()) < 1 {
return fmt.Errorf("Missing container command")
}
var cmdArray []string
for _, arg := range context.Args() {
cmdArray = append(cmdArray, arg)
}
tty := context.Bool("ti")
resConf := &subsystem.ResourceConfig{
MemoryLimit: context.String("m"),
}
Run(tty, cmdArray, resConf)
return nil
},
}

/*
Run command
start a parentprocess with namespace
start a CgroupManager
set the config
store the command
*/
func Run(tty bool, commandArray []string, resConf *subsystem.ResourceConfig){
parent, writePipe := container.NewParentProcess(tty)
if err:= parent.Start(); err!=nil {
fmt.Println(err)
return
}
cgroupManager := cgroupmanager.NewCgroupManager("mydocker-cgroup")
defer cgroupManager.Destroy()
cgroupManager.Set(resConf)
cgroupManager.Apply(parent.Process.Pid)
sendInitCommand(commandArray, writePipe)
parent.Wait()
}

  注意上述NewParentProcess,这是完成程序隔离的重要函数。在下面我们会讲解它。

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
/*
NewParentProcess function:
creates a child precess with namespaces,
run itself in the process,
create a pipe and file for the Run fucntion to write
*/
func NewParentProcess(tty bool) (*exec.Cmd, *os.File){
fmt.Println("New parent")
readPipe, writePipe, err := NewPipe()
if err!=nil {
fmt.Printf("pipe error %v\n",err)
return nil,nil
}
cmd := exec.Command("/proc/self/exe","init")
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWPID |
syscall.CLONE_NEWNS | syscall.CLONE_NEWIPC | syscall.CLONE_NEWNET |syscall.CLONE_NEWUSER,
UidMappings: []syscall.SysProcIDMap{
{
ContainerID: syscall.Getuid(),
HostID: syscall.Getuid(),
Size: 1,
},
},
GidMappings: []syscall.SysProcIDMap{
{
ContainerID: syscall.Getgid(),
HostID: syscall.Getgid(),
Size: 1,
},
},
}
if tty {
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
}
cmd.ExtraFiles = []*os.File{readPipe}
return cmd, writePipe
}

  这便是NewParentProcess的全貌。它启动了一个子cmd,套上了namespace隔离,并且最终要的一点,它使用了以init参数exec /proc/self/exe 这相当与它在子进程中以init参数调用它自己,重新启动。这会覆盖掉当前的数据,堆栈,PID等,从而实现了使用指定的run进程覆盖掉init进程的办法。就这样,我们便得到了一个守护进程以及一个被隔离的容器进程。
  该函数返回了writePipe,便是方便将指令传达给子进程。它使用了ExtraFiles属性,意味着在创建子进程时候会额外携带该句柄。在操作系统中,几乎总是分配最小的可用句柄。由于0 1 2为标准输入输出错误句柄,这样在子进程中,就可以直接打开3号句柄获取指令。(虽然硬编码非常不优雅)
  在隔离之后,我们还要为其加上限制。这一部分功能是由CgroupManager来完成。这里只实现了内存的限制,我们来从内存,观察CgroupManager是如何工作的。

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
type CgroupManager struct {
Path string
Resource *subsystem.ResourceConfig
}
func NewCgroupManager(path string) *CgroupManager{
return &CgroupManager{
Path: path,
}
}

func (c *CgroupManager) Apply(pid int) error {
for _, subSysIns := range(subsystem.SubsystemIns) {
subSysIns.Apply(c.Path, pid)
}
return nil
}

func (c *CgroupManager) Set(res *subsystem.ResourceConfig) error {
for _, subSysIns := range(subsystem.SubsystemIns) {
subSysIns.Set(c.Path, res)
}
return nil
}

func (c *CgroupManager) Destroy() error{
for _, subSysIns := range(subsystem.SubsystemIns) {
if err:= subSysIns.Remove(c.Path); err!= nil {
fmt.Printf("error in remove cgroup %v\n",err)
}
}
return nil
}

  可以看到,CgroupManager包含了路径以及资源限制的配置ResouceConfig。而在Run函数中可以看到,CgroupManager实现了Apply,Set,Destroy等方法,这些方法本质上是将ResouceConfig传递给subsystem文件中的SubsystemIns,使用SubsystemIns的多态方法进行调用。
  接下来我们来观察subsystem文件声明,来观察它是如何限制资源的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package subsystem

type ResourceConfig struct {
MemoryLimit string
CPUShare string
CPUSet string
}

type Subsystem interface {
Name() string
Set(path string, res *ResourceConfig) error
Apply(path string, pid int) error
Remove(path string) error
}

var (
SubsystemIns = []Subsystem{
//&CPUSetSubSystem{},
&MemorySubSystem{},
//&CPUSubSystem{},
}
)

  在这里可以看出,SubsystemIns是一个Subsystem数组,其中包括MemorySubSystem。Subsystem接口规定了实现的方法,Name,Set,Apply,Remove。
  接下来让我们以MemorySubSystem为例子,观察具体的限制是如何实现的。

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
/*
Set function
write the memory limit into the cgroup path
*/
func (s *MemorySubSystem) Set(cgroupPath string, res *ResourceConfig) error {
if subsysCgroupPath, err := GetCgroupPath(s.Name(),cgroupPath,true); err == nil{
if res.MemoryLimit != "" {
if err := ioutil.WriteFile(path.Join(subsysCgroupPath, "memory.limit_in_bytes"), []byte(res.MemoryLimit),0644); err != nil {
return fmt.Errorf("set cgroup memory failed %v",err)
}
}
return nil
} else {
return err
}
}

/*
Apply function
Join a process into a cgroup
*/
func (s *MemorySubSystem) Apply(cgroupPath string, pid int) error {
if subsysCgroupPath, err := GetCgroupPath(s.Name(),cgroupPath,false); err == nil {
if err := ioutil.WriteFile(path.Join(subsysCgroupPath, "tasks"),[]byte(strconv.Itoa(pid)),0644); err!=nil {
return fmt.Errorf("set cgroup proc fail %v",err)
}
return nil
} else {
return fmt.Errorf("get cgroup %s error: %v",cgroupPath,err)
}
}

/*
Remove function
remove the cgroup path
*/
func (s *MemorySubSystem) Remove(cgroupPath string) error {
if subsysCgroupPath, err := GetCgroupPath(s.Name(),cgroupPath,false); err == nil {
return os.Remove(subsysCgroupPath)
} else {
return err
}
}

/*
Name function
get its name
*/
func (s *MemorySubSystem) Name() string{
return "memory"
}

  此处代码有注释,不过多赘述。本质上和之前的demo一样,写入限制文件,将进程放进去,和移除该进程限制所在的文件夹。在这里GetCgroupPath作用是根据mountinfo找到cgroup限制的文件夹,并创建子目录。具体代码可以在仓库内查看。
  就此,限制系统demo便完成了。接下来我们来观察init指令执行后,该进程会如何运行。

1
2
3
4
5
6
7
8
9
10
11
var initCommand = cli.Command {
Name : "init",
Usage : "Init the process",

Action: func(context *cli.Context) error {
fmt.Println("start initCommand")
cmd := readUserCommand()
err := container.RunContainerInitProcess(cmd[0],cmd[1:])
return err
},
}

  回到主进程。在创建子进程并且以init为参数执行了/proc/self/exe后,相对应接收的便是init指令。上面可以看到,init指令读取了之前的参数,并且初始化了容器进程。读取指令就像之前所说一样,可以采用fd=3的硬编码打开读取。
  接下来我们来看看RunContainerInitProcess该如何运行。

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
/*
RunContainerInitProcess function:
mounts the essential environment,
read the command stored by the NewParentProcess,
run the commands
*/
func RunContainerInitProcess(command string, args []string) error{
fmt.Printf("the command is %s\n",command)
fmt.Println("mount start")
err :=syscall.Mount("","/","",syscall.MS_PRIVATE|syscall.MS_REC,"")
if err!=nil {
fmt.Println(err)
return err
}
defaultMountFlags := syscall.MS_NOEXEC | syscall.MS_NOSUID | syscall.MS_NODEV
err = syscall.Mount("proc", "/proc","proc", uintptr(defaultMountFlags),"")
if err!=nil {
fmt.Println(err)
return err
}
command,err = exec.LookPath(command)
if(err!=nil){
fmt.Printf("error in finding %s \n",command)
fmt.Println(err)
return err
}
argv := append([]string{command},args...)
if err := syscall.Exec(command, argv, os.Environ()) ; err!=nil {
fmt.Printf("exec error is %v\n",err)
return err
}
return nil
}

  可以看到,该函数接收两个参数,command和args,首先实现了chroot防止proc不释放,然后将proc挂载了进来,便于在容器内实现各种操作。之后,使用LookPath解析了指令,并且exec了程序。至此,我们实现了资源的限制,资源的隔离,并且在完成这些配置后运行了程序。一个简单的docker run项目便如此完成了。

下一步做什么

  在docker中,除去隔离与限制,最显著的特点便是它的image镜像功能。这能大大减少部署环境的难度。在下一篇文章中,我们将探讨如何实现这一功能。

总结与后记

  本篇文章讲解了如何实现一个隔离并且进行限制的小docker程序。在这其中主要熟悉了各种系统调用的作用与使用方法,以及如何进行实现。由于篇幅问题,本文省略了一部分工具代码,具体代码还需要参考仓库内。在仓库中有各种demo的实现。
  本文对系统调用的知识主要来自对man文档的翻译,可能存在欠缺。因此有不足的地方欢迎指正。
  本篇文章起于11.12,完成于11.16,由于各种原因存在一些拖延,但整体完成时间较长。本文主要也是我自己对于代码的一个复习于归纳。
  下一篇文章不存在意外的话预计不会耗费多余一周的时间。敬请期待。

Author

王钦砚

Posted on

2020-11-12

Licensed under

CC BY-NC-SA 4.0

Your browser is out-of-date!

Update your browser to view this website correctly.&npsb;Update my browser now

×