众所周知,在Linux上使用容器有个天大的好处,就是可以把不同的系统打包运行,虽然你的宿主机可能就是CentOS,但是如果使用了容器技术,你可以同时运行alpline,Debian,Ubuntu等其他的你喜欢的发行版,关键是这些容器共享宿主的资源,因此,每个容器是非常轻量级的,不同虚拟机,这是如何做到的?

这里先定下今天的目标,通过使用pivot_root系统调用以及Mount命名空间。首先我们要回顾写前面几篇系列文的代码,罗马不是一天建成的,我们的demo也经过几次迭代,才有今天的基础。

抛出问题

首先回顾下上篇文章写的程序,分析下执行结果

buntu@VM-0-7-ubuntu:~/workspace/gons$ mount | wc
     58     348   11624
ubuntu@VM-0-7-ubuntu:~/workspace/gons$ go build ns-proc.go
ubuntu@VM-0-7-ubuntu:~/workspace/gons$ ./ns-proc
arg0=./ns-proc,
arg0=initFuncName,

>> namespace setup code goes here <<

-[namespace-process]-# mount | wc
     58     348   11624
-[namespace-process]-# exit

在开始编译之前,先检查下宿主机上的挂载情况,鉴于挂载的有点多,也不好直接看,所以直接就看统计数据就好

然后开始编译并执行demo,可以从名字看出当前是在宿主上还是demo容器上。在demo进程初始化完成,运行了和宿主机上同样的操作mount | wc从输出结果来看,再和宿主机的结果对比,发现结果是一致的,呃呃这隐约感觉到不妥😳,为什么这么说呢,因为我们的代码中可是指定了CLONE_NEWNS这个flag,根据文档,

> Namespace Constant Isolates > Mount CLONE_NEWNS Mount points

我们已经显式指定了Mount命名空间

这一点都不容器,据我了解,容器应该是隔离环境的,通常不应该和宿主机的共享挂载才对。容器固然知道越少东西越好,一般我们都是通过指定-v参数docker才会把我们要挂载的目录给挂载都容器内的,但是现在看到demo并不是这样子。这是为什么❓❓❓

真相只有一个

细细翻阅Mount命名空间相关文档 >When a process creates a new mount namespace using clone(2) or unshare(2) with the CLONE_NEWNS flag, the mount point list for the new namespace is a copy of the caller's mount point list. Subsequent modifications to the mount point list (mount(2) and umount(2)) in either mount namespace will not (by default) affect the mount point list seen in the other namespace (but see the following discussion of shared subtrees).

按照这种说法,那就是新命名空间里的的其实会把原调用者的mount list直接复制,难怪看上去挂载点是一毛一样的。那么现在的问题就变成了,如何控制让我们的容器进程不要直接复制原的来mount list,我们不需要容器进程知道宿主机这么多的细节。

关于pivot_root

直接看手册文档pivot_root

  int pivot_root(const char *new_root, const char *put_old);

pivot_root将当前进程的根文件系统移动到put_old目录,并使new_root成为新的根文件系统。 那就可以通过pivot_root移动挂载点然后umount掉来清理他们。

理论上,我们在Ubuntu宿主机某个地方把一个CentOS整个文件系统保存起来,然后我们创建新的命名空间的同时,通过调用pivot_root把根目录指向这个CentOS文件系统。那么这个新启动的进程就会傻乎乎的认为自己真的是在CentOS环境下😆,当然,计算资源还是共享宿主的。

此时,reexec就变得很有用了,因为我们必须要在新的命名空间创建之后才能调用pivot_root,否则的话我们就会把宿主机的根目录给干掉了,这就不太好了😿。另外我们肯定是希望在新进程(/bin/sh)启动之前就把这个文件系统给准备好,这样进程一启动就以为自己真的在某个我们指定的操系统中,不得不说在linux世界中万物皆文件在这里体现出来了。

撸起袖子

说到Linux下的pivot_root,其实在go中有相应的实现,就在syscall这个包当中,它的定义是这样的: func PivotRoot(newroot string, putold string) (err error) 可以看到和Linux手册中看到的pivot_root是一样的,将当前进程的根文件系统移动到putold目录,并使newroot成为新的根文件系统。

有几个点是值得注意的:

  • 两个参数都表示目录
  • 指定的目录必须要存在
  • 两者都不能是当前的根目录
  • putold目录没有挂载其他的文件系统
  • putold目录应该还是在newroot之下(子目录)

文件系统

新文件系统是个关键点。这个文件系统,就代表新进程所在的根文件系统,对应这个新进程而言,它能感知的就是相应的操作系统。你提供的是centos的文件系统,那新进程启动后就感知到自己是在运行在centos环境下。同理,如果提供的是一个简单的busybox的文件系统,新进程感知的就是一个简单busybox环境。

这让我们不禁想起dockerfile,这里暂且埋下了docker的文件系统AUFS,AUFS是一种Union File System(联合文件系统),如果以后有机会再详细探讨下🤦‍♂️。其实我们在使用dockerfile进行build的过程就是通过叠加不同的文件层,最后组装成一个我们期望的文件系统,当这个构建的镜像启动后,他就能根据所在的文件系统知道自己所处的运行环境。

这里使用了从alpine官网下载的文件系统 alpine-minirootfs-3.9.3-x86_64.tar.gz

ubuntu@VM-0-7-ubuntu:~$  mkdir -p /tmp/ns-proc/rootfs
ubuntu@VM-0-7-ubuntu:~$ tar -C /tmp/ns-proc/rootfs/ -xf  alpine-minirootfs-3.9.3-x86_64.tar.gz
ubuntu@VM-0-7-ubuntu:~$ ls /tmp/ns-proc/rootfs/
bin  dev  etc  home  lib  media  mnt  opt  proc  root  run  sbin  srv  sys  tmp  usr  var
ubuntu@VM-0-7-ubuntu:~$

从上面可以看到,暂且把下载的系统放在/tmp/ns-proc/rootfs/

golang的pivotRoot使用

为了区分之前的代码,关于文件系统这分布新创建了个文件container-fs.go,主要包含了我们等下要用到的pivotRoot

package main

import (
	"fmt"
	"os"
	"path/filepath"
	"syscall"
)

func checkRootfs(rootfsPath string) {
	if _, err := os.Stat(rootfsPath); os.IsNotExist(err) {
		fmt.Println("rootfsPath %s is not found you may need to download it",rootfsPath)
		os.Exit(1)
	}
}

//implement pivot_root by syscall
func pivotRoot(newroot string) error {

	preRoot := "/.pivot_root"
	putold := filepath.Join(newroot,preRoot) //putold:/tmp/ns-proc/rootfs/.pivot_root


	// pivot_root requirement that newroot and putold must not be on the same filesystem as the current root
	//current root is / and new root is /tmp/ns-proc/rootfs and putold is /tmp/ns-proc/rootfs/.pivot_root
	//thus we bind mount newroot to itself to make it different
	//try to comment here you can see the error
	if err := syscall.Mount(newroot, newroot, "", syscall.MS_BIND|syscall.MS_REC, ""); err != nil {
		return err
	}

	// create putold directory, equal to mkdir -p xxx
	if err := os.MkdirAll(putold, 0700); err != nil {
		return err
	}

	// call pivot_root
	if err := syscall.PivotRoot(newroot, putold); err != nil {
		return err
	}

	// ensure current working directory is set to new root
	if err := os.Chdir("/"); err != nil {
		return err
	}

	// umount putold, which now lives at /.pivot_root
	putold = preRoot
	if err := syscall.Unmount(putold, syscall.MNT_DETACH); err != nil {
		return err
	}

	// remove putold
	if err := os.RemoveAll(putold); err != nil {
		return err
	}

	return nil
}



回到main函数中,尝试调用这段代码。上文中,我们就写了一句 fmt.Printf("\n>> namespace setup code goes here <<\n\n") 就假装自己完成初始化了,现在这段可以用上真正的pivotRoot初始化了。

main.go 部分代码:

...

func init() {

	fmt.Printf("arg0=%s,\n",os.Args[0])

	reexec.Register("initFuncName", func() {
		fmt.Printf("\n>> namespace setup code goes here <<\n\n")
		newRoot := os.Args[1]
		fmt.Printf("newRoot:%s",newRoot)
		if err := pivotRoot(newRoot); err != nil {
			fmt.Printf("Error running pivot_root - %s\n", err)
			os.Exit(1)
		}

		nsRun() //calling clone() to create new process goes here
	})

	if reexec.Init() {
		os.Exit(0)
	}
}



func main() {
	var rootfsPath string
	// ...
	cmd := reexec.Command("initFuncName", rootfsPath)
}

上面代码,我们在main函数中定义了根文件系统位置rootfsPath, 然后把他作为参数传递给reexec.Command, 后面通过newRoot := os.Args[1]取出来。

这里的执行顺序应该为: (init1)创建命名空间---映射---(init2)mount---启动/bin/sh

pivotRoot这个函数是放在init注册的initFuncName匿名函数中,并且是在启动sh进程的nsRun()之前。即pivotRoot会在sh进程启动前完成挂载点的初始化。这样,在/bin/sh进程启动之时就已经准备好了新的文件系统。

现在来验证下,重新编译并执行

ubuntu@VM-0-7-ubuntu:~/workspace/gons$ ls
container-fs.go  go.mod  go.sum  ns-proc.go  README
ubuntu@VM-0-7-ubuntu:~/workspace/gons$ go build
ubuntu@VM-0-7-ubuntu:~/workspace/gons$ ls
container-fs.go  go.mod  gons  go.sum  ns-proc.go  README
ubuntu@VM-0-7-ubuntu:~/workspace/gons$ ./gons
arg0=./gons,
arg0=initFuncName,

>> namespace setup code goes here <<

newRoot:/tmp/ns-proc/rootfs
-[namespace-process]-# cat /etc/issue
Welcome to Alpine Linux 3.9
Kernel \r on an \m (\l)

-[namespace-process]-# mount
mount: no /proc/mounts
-[namespace-process]-#

在容器进程里面执行cat /etc/issue可以看到现在是处于Alpine Linux 3.9文件系统下,因此这个容器进程是在运行Alpine镜像的。至少这部分是如我们所愿的。但是,目前为止还有一个十分严重的问题。mount命令用不了,我们不知道现在的挂载情况

/proc到底是什么

直接查看man page文档

>The proc filesystem is a pseudo-filesystem which provides an interface to kernel data structures. It is commonly mounted at /proc. Typically, it is mounted automatically by the system, but it can also be mounted manually using a command such as: mount -t proc proc /proc Most of the files in the proc filesystem are read-only, but some files are writable, allowing kernel variables to be changed.

/proc是个很特殊的目录,它是个虚拟文件系统 上面是内核信息,以及系统内存的部分映射。用户可以通过这些文件查看有关系统硬件及当前正在运行进程的信息。可以看到新的文件系统/tmp/ns-proc/rootfs/并没有/proc,因为它的信息是动态的。除非我们把宿主机上的/proc给挂载进来,那就拥有了这些信息了。

稍微修改下container-fs.go加入挂载宿主/proc

func mountProc(newroot string) error {
	source := "proc"
	target := filepath.Join(newroot, "/proc")
	fstype := "proc"
	flags := 0
	data := ""

	os.MkdirAll(target, 0755)
	if err := syscall.Mount(
		source,
		target,
		fstype,
		uintptr(flags),
		data,
	); err != nil {
		return err
	}

	return nil
}

在调用pivotRoot之前,先把宿主上的/proc挂载到[newroot]/proc下。然后调用pivotRoot之后就有了映射进来的/proc

当然,我们注册到内存重的匿名函数也要相应的修改,加入调用mountProc.

func init() {

	fmt.Printf("arg0=%s,\n",os.Args[0])

	reexec.Register("initFuncName", func() {
		fmt.Printf("\n>> namespace setup code goes here <<\n\n")

		newRoot := os.Args[1]

		if err := mountProc(newRoot); err != nil {
			fmt.Printf("Error mounting /proc - %s\n", err)
			os.Exit(1)
		}

		fmt.Printf("newRoot:%s \n",newRoot)
		if err := pivotRoot(newRoot); err != nil {
			fmt.Printf("Error running pivot_root - %s\n", err)
			os.Exit(1)
		}

		nsRun() //calling clone() to create new process goes here
	})

	if reexec.Init() {
		os.Exit(0)
	}
}

效果演示

现在用最新代码重新编译演示:

ubuntu@VM-0-7-ubuntu:~/workspace/gons$ ls
container-fs.go  go.mod  go.sum  ns-proc.go  README
ubuntu@VM-0-7-ubuntu:~/workspace/gons$ go build
ubuntu@VM-0-7-ubuntu:~/workspace/gons$ ls
container-fs.go  go.mod  gons  go.sum  ns-proc.go  README
ubuntu@VM-0-7-ubuntu:~/workspace/gons$ ./gons
arg0=./gons,
arg0=initFuncName,

>> namespace setup code goes here <<

newRoot:/tmp/ns-proc/rootfs
-[namespace-process]-# cat /etc/issue
Welcome to Alpine Linux 3.9
Kernel \r on an \m (\l)

-[namespace-process]-# mount
/dev/vda1 on / type ext3 (rw,noatime,data=ordered)
proc on /proc type proc (rw,nodev,relatime)
-[namespace-process]-# mount | wc
        2        12        95
-[namespace-process]-# exit
ubuntu@VM-0-7-ubuntu:~/workspace/gons$ mount | wc
     61     366   11831

可喜可贺,可以看到在新容器进程里面,文件系统是换成了Alpine,并且mount结果也出来了,只有和自己相关的挂载结果。这才是个纯净的新系统。

值得提示一点,在本例中,其实还用到了CLONE_NEWPID这个命名空间。有什么用处呢🧐? 刚才其实只是把宿主机上的/proc挂载进来,因此如果此时在新进程里执行诸如ps这类命令,理论上看到的是宿主机上的进程信息。这显然不符合我们的需求,但是加上CLONE_NEWPID这个flag后就可以,新启动的容器进程执行ps就只显示只和当前进程相关命名空间下的信息,其他不相干的都会过滤掉。 效果如下:

ubuntu@VM-0-7-ubuntu:~/workspace/gons$ go build
ubuntu@VM-0-7-ubuntu:~/workspace/gons$ ./gons
arg0=./gons,
arg0=initFuncName,

>> namespace setup code goes here <<

newRoot:/tmp/ns-proc/rootfs
-[namespace-process]-# ps
PID   USER     TIME  COMMAND
    1 root      0:00 {exe} initFuncName /tmp/ns-proc/rootfs
    4 root      0:00 sh
    5 root      0:00 ps
-[namespace-process]-#

本文涉及的代码也越来越多,所以就不发全部代码。相关的源码保存在git上,可以自行获取研究,获取地址gitee托管 ns-proc/mount-5

接下来做什么

这是系列文章第五篇,在介绍了几个namespace之后包括CLONE_NEWUTS,CLONE_NEWNS,CLONE_NEWUSER,CLONE_NEWPID等,我们的演示容器已经逐步完善。 好像还缺了点东西🐒,关于网络部分似乎还没涉及到。但这篇也已经足够长了,所以剩下的不如下次再研究了。