本文为容器化技术系列文章的第一篇。本文将介绍容器化中的隔离,限制的原理,并且实际编写一个类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,由于各种原因存在一些拖延,但整体完成时间较长。本文主要也是我自己对于代码的一个复习于归纳。   下一篇文章不存在意外的话预计不会耗费多余一周的时间。敬请期待。