本文为容器化技术系列文章的第一篇。本文将介绍容器化中的隔离,限制的原理,并且实际编写一个类docker应用,并且实际执行docker run命令。
前置知识
初探容器化:隔离,限制与docker run 目录
隔离 Namepspaces 系统: Namespaces 系统是linux对于系统资源的一种抽象,它使得进程认为在同一namespaces系统下的进程之间能够互相感知,而对namespace外一无所知,从而实现隔离的效果。 linux最新版本中,根据不同的功能,实现了7个namespace系统,如下所示:
Namespace Constant Isolates Cgroup CLONE_NEWCGROUP Cgroup root directory IPC CLONE_NEWIPC System V IPC, POSIX message queues Network CLONE_NEWNET Network devices, stacks, ports, etc. Mount CLONE_NEWNS Mount points PID CLONE_NEWPID Process IDs User CLONE_NEWUSER User and group IDs UTS CLONE_NEWUTS Hostname 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 Credential *Credential Ptrace bool Setsid bool Setpgid bool Setctty bool Noctty bool Ctty int Foreground bool Pgid int Pdeathsig Signal Cloneflags uintptr Unshareflags uintptr UidMappings []SysProcIDMap GidMappings []SysProcIDMap GidMappingsEnableSetgroups bool AmbientCaps []uintptr }
在结构体中,我们着重关注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 mainimport ( "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实战
首先我们创建文件夹,并且把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 -2root @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 200 m --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 mainimport ( "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 }, } 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 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 subsystemtype 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{ &MemorySubSystem{}, } )
在这里可以看出,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 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 } } 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) } } func (s *MemorySubSystem) Remove (cgroupPath string ) error { if subsysCgroupPath, err := GetCgroupPath(s.Name(),cgroupPath,false ); err == nil { return os.Remove(subsysCgroupPath) } else { return err } } 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 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,由于各种原因存在一些拖延,但整体完成时间较长。本文主要也是我自己对于代码的一个复习于归纳。 下一篇文章不存在意外的话预计不会耗费多余一周的时间。敬请期待。