无论是虚拟化技术还是容器技术都是为了最大程度解决母机资源利用率的问题。虚拟化技术利用Hypervisor(运行在宿主机OS上)将底层硬件进行了虚拟,使得在每台VM看来,硬件都是独占的,并且由VM Guest OS直接操作(具备最高操作权限);而容器共享母机OS,每个容器只包含应用以及应用所依赖的库和二进制文件;Linux内核的namespace隔离特性,cgroups(资源控制),以及联合文件系统使得多个容器之间相互隔离,同时资源受到限制。总的来说:容器技术相比虚拟机更加轻量,同时也具备更高的执行效率
对于容器来说,最具有代表性的项目就是Docker。Docker自2013年由DotCloud开源后,便席卷整个容器技术圈。它通过设计和封装用户友好的操作接口,使得整个容器技术使用门槛大大降低;同时它也系统地构建了应用打包(Docker build),分发(Docker pull&push)标准和工具,使得整个容器生命周期管理更加容易和可实施。对于Docker来说,可以简单的认为它并没有创造新的技术,而是将内核的namespace(进程隔离),cgroups(进程资源控制),Capabilities,Apparmro以及seccomp(安全防护),以及联合文件系统进行了组合,并最终呈现给用户一个可操作和管理的容器引擎
这里为了研究容器技术,我在参考了阿里云三位同学编写的《自己动手写Docker》这本书后,基于mydocker项目开始编写自己的容器运行时,希望能更加贴近容器本质,并计划补充mydocker没有涉及的OCI,CRI等部分以及一些高级命令
下面我将依次介绍sample-container-runtime实现过程中的一些核心细节
在深入介绍namespace以及cgroups之前,我们先介绍联合文件系统。联合文件系统(UnionFS)用于将不同文件系统的文件和目录联合挂载到同一个文件系统。它使用branch把不同文件系统的文件和目录进行覆盖,形成一个单一一致的文件系统(对于同一路径,上层覆盖下层)视图,这些branch具备不同的读写权限,read-only or read-write;同时利用了写时拷贝(copy on write)技术将对只读层的写操作复制到了读写层。Cow是一种对可修改资源实现高效复制的资源管理技术,当一个资源是重复的且没有发生任何修改时,并不需要创建新的资源,该资源可被多个实例共享;只有当第一次写操作发生时,才会创建新的资源。通过CoW,可以显著减少未修改资源复制带来的消耗,但另一方面也增加了资源修改时的开销
AUFS重写了早期的UnionFS,并引入了一些新的功能,增加了可靠性和性能,同时也是Docker选用的第一个storage driver。sample-container-runtime采用AUFS作为联合文件系统实现,将容器镜像的多层内容呈现为统一的rootfs(根文件系统)
AUFS具备如下特性:
这里,我们将容器使用的镜像分为三个目录(参考Docker),如下:
$ make build
$ ./build/pkg/cmd/sample-container-runtime/sample-container-runtime run -ti --name container1 -v /root/tmp/from1:/to1 busybox sh
# on node
$ ls /var/lib/sample-container-runtime/
busybox busybox.tar mnt writeLayer
$ mount|grep aufs
none on /var/lib/sample-container-runtime/mnt/container1 type aufs (rw,relatime,si=b7a28d49e64d71ad)
$ cat /sys/fs/aufs/si_b7a28d49e64d71ad/*
/var/lib/sample-container-runtime/writeLayer/container1=rw
/var/lib/sample-container-runtime/busybox=ro
64
65
/var/lib/sample-container-runtime/writeLayer/container1/.aufs.xino
# container1
/ # mount
none on / type aufs (rw,relatime,si=b7a28d49e87289ad)
proc on /proc type proc (rw,nosuid,nodev,noexec,relatime)
tmpfs on /dev type tmpfs (rw,nosuid,mode=755)
/ # echo "hello, world" > tmpfile
/ # ls
bin dev etc home proc root sys tmp tmpfile usr var
# switch to node
$ ls -al /var/lib/sample-container-runtime/writeLayer/container1/
drwxr-xr-x 5 root root 4096 Nov 2 19:52 .
drwxr-xr-x 5 root root 4096 Nov 2 19:52 ..
-r--r--r-- 1 root root 0 Nov 2 19:52 .wh..wh.aufs
drwx------ 2 root root 4096 Nov 2 19:52 .wh..wh.orph
drwx------ 2 root root 4096 Nov 2 19:52 .wh..wh.plnk
drwx------ 2 root root 4096 Nov 2 19:52 root
-rw-r--r-- 1 root root 13 Nov 2 19:52 tmpfile
# container1
/ # echo "testline" >> tmp/work_dir_onion/install_exec.sh
# switch to node
$ ls -al /var/lib/sample-container-runtime/writeLayer/container1/
total 36
drwxr-xr-x 8 root root 4096 Nov 2 20:18 .
drwxr-xr-x 5 root root 4096 Nov 2 19:52 ..
-r--r--r-- 1 root root 0 Nov 2 19:52 .wh..wh.aufs
drwx------ 2 root root 4096 Nov 2 19:52 .wh..wh.orph
drwx------ 2 root root 4096 Nov 2 19:52 .wh..wh.plnk
drwxr-xr-x 2 root root 4096 Nov 2 20:03 bin
drwx------ 2 root root 4096 Nov 2 19:52 root
drwxrwxrwt 3 root root 4096 Nov 2 20:18 tmp
-rw-r--r-- 1 root root 13 Nov 2 19:52 tmpfile
drwxr-xr-x 3 root root 4096 Nov 2 20:01 usr
# ls -al /var/lib/sample-container-runtime/writeLayer/container1/
tmp
`-- work_dir_onion
`-- install_exec.sh
从运行可以看到添加文件到aufs mnt,文件实际添加到可写层;另外,修改可读层的文件会复制该文件到可写层,同时只读层该文件并没有修改
这里我们看一下实现:
// Create a AUFS filesystem as container root workspace
func NewWorkSpace(volume, imageName, containerName string) {
CreateReadOnlyLayer(imageName)
CreateWriteLayer(containerName)
CreateMountPoint(containerName, imageName)
if volume != "" {
volumeURLs := strings.Split(volume, ":")
length := len(volumeURLs)
if length == 2 && volumeURLs[0] != "" && volumeURLs[1] != "" {
MountVolume(volumeURLs, containerName)
log.Infof("NewWorkSpace volume urls %q", volumeURLs)
} else {
log.Infof("Volume parameter input is not correct.")
}
}
}
// Decompression tar image
func CreateReadOnlyLayer(imageName string) error {
unTarFolderUrl := RootUrl + "/" + imageName + "/"
imageUrl := RootUrl + "/" + imageName + ".tar"
exist, err := PathExists(unTarFolderUrl)
if err != nil {
log.Infof("Fail to judge whether dir %s exists. %v", unTarFolderUrl, err)
return err
}
if !exist {
if err := os.MkdirAll(unTarFolderUrl, 0622); err != nil {
log.Errorf("Mkdir %s error %v", unTarFolderUrl, err)
return err
}
if _, err := exec.Command("tar", "-xvf", imageUrl, "-C", unTarFolderUrl).CombinedOutput(); err != nil {
log.Errorf("Untar dir %s error %v", unTarFolderUrl, err)
return err
}
}
return nil
}
// Create read-write layer
func CreateWriteLayer(containerName string) {
writeURL := fmt.Sprintf(WriteLayerUrl, containerName)
if err := os.MkdirAll(writeURL, 0777); err != nil {
log.Infof("Mkdir write layer dir %s error. %v", writeURL, err)
}
}
// Create aufs mount point
func CreateMountPoint(containerName, imageName string) error {
mntUrl := fmt.Sprintf(MntUrl, containerName)
if err := os.MkdirAll(mntUrl, 0777); err != nil {
log.Errorf("Mkdir mountpoint dir %s error. %v", mntUrl, err)
return err
}
tmpWriteLayer := fmt.Sprintf(WriteLayerUrl, containerName)
tmpImageLocation := RootUrl + "/" + imageName
mntURL := fmt.Sprintf(MntUrl, containerName)
dirs := "dirs=" + tmpWriteLayer + ":" + tmpImageLocation
_, err := exec.Command("mount", "-t", "aufs", "-o", dirs, "none", mntURL).CombinedOutput()
if err != nil {
log.Errorf("Run command for creating mount point failed %v", err)
return err
}
return nil
}
核心命令如下:
mount -t aufs -o dirs=/var/lib/sample-container-runtime/writeLayer/container1:/var/lib/sample-container-runtime/busybox none . /var/lib/sample-container-runtime/mnt/container1
上述的aufs联合挂载点作为容器rootfs,这里利用了mount namespace,会在接下来的namespace章节-Mount namespaces介绍
通过上述操作,我们实现了容器重复利用只读层,并构建可写层运行容器的方法,而这实际上也是目前Docker采用的原理。通过联合文件系统可以使Docker镜像最大程度利用磁盘空间,同时也提高了Docker容器启动的效率
namespace提供了一种内核级别资源隔离的方法:
The purpose of each namespace is to wrap a particular global system resource in an abstraction that makes it appear to the processes within the namespace that they have their own isolated instance of the global resource.
Linux目前提供了6种namespace类型,每种namespace用途各不相同:
下面我将依次介绍各个namespace的应用实现(由易到难):
UTS namespace实现了进程hostname以及domain name的隔离,它允许我们给容器设置与母机不同的hostname以及domainname。通过给Cloneflags设置CLONE_NEWUTS来实现隔离,并在容器内部使用syscall.Sethostname()函数设置hostname,如下:
func NewParentProcess(tty bool, containerName, volume, imageName string, envSlice []string) (*exec.Cmd, *os.File) {
readPipe, writePipe, err := NewPipe()
if err != nil {
log.Errorf("New pipe error %v", err)
return nil, nil
}
initCmd, err := os.Readlink("/proc/self/exe")
if err != nil {
log.Errorf("get init process error %v", err)
return nil, nil
}
cmd := exec.Command(initCmd, "init")
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWPID | syscall.CLONE_NEWNS |
syscall.CLONE_NEWNET | syscall.CLONE_NEWIPC,
}
if tty {
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
} else {
dirURL := fmt.Sprintf(DefaultInfoLocation, containerName)
if err := os.MkdirAll(dirURL, 0622); err != nil {
log.Errorf("NewParentProcess mkdir %s error %v", dirURL, err)
return nil, nil
}
stdLogFilePath := dirURL + ContainerLogFile
stdLogFile, err := os.Create(stdLogFilePath)
if err != nil {
log.Errorf("NewParentProcess create file %s error %v", stdLogFilePath, err)
return nil, nil
}
cmd.Stdout = stdLogFile
}
cmd.ExtraFiles = []*os.File{
readPipe}
cmd.Env = append(os.Environ(), envSlice...)
NewWorkSpace(volume, imageName, containerName)
cmd.Dir = fmt.Sprintf(MntUrl, containerName)
return cmd, writePipe
}
...
var InitCommand = cli.Command{
Name: "init",
Usage: "Init container process run user's process in container. Do not call it outside",
Action: func(context *cli.Context) error {
log.Infof("init come on")
err := container.RunContainerInitProcess()
return err
},
}
func RunContainerInitProcess() error {
cmdArray := readUserCommand()
if cmdArray == nil || len(cmdArray) == 0 {
return fmt.Errorf("Run container get user command error, cmdArray is nil")
}
hostname := util.RandomSeq(10)
if err := syscall.Sethostname([]byte(hostname)); err != nil {
log.Errorf("set hostname error: %v", err)
return err
}
setUpMount()
path, err := exec.LookPath(cmdArray[0])
if err != nil {
log.Errorf("Exec loop path error %v", err)
return err
}
log.Infof("Find path %s", path)
if err := syscall.Exec(path, cmdArray[0:], append(os.Environ(), fmt.Sprintf("PS1=%s # ", hostname))); err != nil {
log.Errorf(err.Error())
}
return nil
}
运行如下:
# on container
$ ./build/pkg/cmd/sample-container-runtime/sample-container-runtime run -ti --name container1 busybox sh
{
"level":"info","msg":"createTty true","time":"2020-11-03T11:20:56+08:00"}
{
"level":"info","msg":"init come on","time":"2020-11-03T11:20:56+08:00"}
{
"level":"info","msg":"command all is sh","time":"2020-11-03T11:20:56+08:00"}
{
"level":"info","msg":"Current location is /var/lib/sample-container-runtime/mnt/container1","time":"2020-11-03T11:20:56+08:00"}
{
"level":"info","msg":"Find path /bin/sh","time":"2020-11-03T11:20:56+08:00"}
MbNtIFraOd # hostname
MbNtIFraOd
# on node
$ hostname
VM-xxx-centos
可以看到容器中hostname为MbNtIFraOd,而母机为VM-xxx-centos
IPC用于隔离进程某些IPC(进程间通信)资源,具体来说就是:System V IPC objects and (since Linux 2.6.30) POSIX message queues。其中System V IPC objects又包括:Shared Memory(共享内存), Semaphore(信号量) and Message Queues(消息队列)
这里我们通过给Cloneflags设置CLONE_NEWIPC来实现隔离,如下:
func NewParentProcess(tty bool, containerName, volume, imageName string, envSlice []string) (*exec.Cmd, *os.File) {
readPipe, writePipe, err := NewPipe()
if err != nil {
log.Errorf("New pipe error %v", err)
return nil, nil
}
initCmd, err := os.Readlink("/proc/self/exe")
if err != nil {
log.Errorf("get init process error %v", err)
return nil, nil
}
cmd := exec.Command(initCmd, "init")
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWPID | syscall.CLONE_NEWNS |
syscall.CLONE_NEWNET | syscall.CLONE_NEWIPC,
}
if tty {
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
} else {
dirURL := fmt.Sprintf(DefaultInfoLocation, containerName)
if err := os.MkdirAll(dirURL, 0622); err != nil {
log.Errorf("NewParentProcess mkdir %s error %v", dirURL, err)
return nil, nil
}
stdLogFilePath := dirURL + ContainerLogFile
stdLogFile, err := os.Create(stdLogFilePath)
if err != nil {
log.Errorf("NewParentProcess create file %s error %v", stdLogFilePath, err)
return nil, nil
}
cmd.Stdout = stdLogFile
}
cmd.ExtraFiles = []*os.File{
readPipe}
cmd.Env = append(os.Environ(), envSlice...)
NewWorkSpace(volume, imageName, containerName)
cmd.Dir = fmt.Sprintf(MntUrl, containerName)
return cmd, writePipe
}
验证如下:
# inside of container
gyZQRmcHMr # ipcs -a
------ Shared Memory Segments --------
key shmid owner perms bytes nattch status
------ Semaphore Arrays --------
key semid owner perms nsems
------ Message Queues --------
key msqid owner perms used-bytes messages
gyZQRmcHMr # readlink /proc/$$/ns/ipc
ipc:[4026532515]
# outside of container
$ ipcmk -Q
Message queue id: 0
$ ipcs -a
------ Message Queues --------
key msqid owner perms used-bytes messages
0x11df483b 0 root 644 0 0
------ Shared Memory Segments --------
key shmid owner perms bytes nattch status
0x00005feb 0 root 666 12000 3
...
------ Semaphore Arrays --------
key semid owner perms nsems
0x00008708 0 root 666 1
...
$ readlink /proc/$$/ns/ipc
ipc:[4026531839]
可以看到母机和容器中的IPC资源不同了,两者处于不同的ipc namespace
USER namespace用于隔离进程用户ID以及组ID资源,它允许我们设置进程在容器和母机中的用户和组ID映射,也就是说一个进程在容器中可以具有root最高权限,但是在母机上该进程实际上并不具备root用户权限,而只具备普通用户权限
每个进程通过如下文件路径存储映射关系(inside a USER namespace to a corresponding set of user IDs and group IDs outside the namespace):
这里,我们通过给syscall.SysProcAttr分别设置UidMappings(uid映射)以及GidMappings(gid映射)来实现容器进程USER namespace隔离:
func NewParentProcess(tty bool, containerName, volume, imageName string, envSlice []string) (*exec.Cmd, *os.File) {
readPipe, writePipe, err := NewPipe()
if err != nil {
log.Errorf("New pipe error %v", err)
return nil, nil
}
initCmd, err := os.Readlink("/proc/self/exe")
if err != nil {
log.Errorf("get init process error %v", err)
return nil, nil
}
cmd := exec.Command(initCmd, "init")
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWPID | syscall.CLONE_NEWNS |
syscall.CLONE_NEWNET | syscall.CLONE_NEWIPC | syscall.CLONE_NEWUSER,
UidMappings: []syscall.SysProcIDMap{
{
ContainerID: 0,
HostID: os.Getuid(),
Size: 1,
},
},
GidMappings: []syscall.SysProcIDMap{
{
ContainerID: 0,
HostID: os.Getgid(),
Size: 1,
},
},
}
if tty {
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
} else {
dirURL := fmt.Sprintf(DefaultInfoLocation, containerName)
if err := os.MkdirAll(dirURL, 0622); err != nil {
log.Errorf("NewParentProcess mkdir %s error %v", dirURL, err)
return nil, nil
}
stdLogFilePath := dirURL + ContainerLogFile
stdLogFile, err := os.Create(stdLogFilePath)
if err != nil {
log.Errorf("NewParentProcess create file %s error %v", stdLogFilePath, err)
return nil, nil
}
cmd.Stdout = stdLogFile
}
cmd.ExtraFiles = []*os.File{
readPipe}
cmd.Env = append(os.Environ(), envSlice...)
NewWorkSpace(volume, imageName, containerName)
cmd.Dir = fmt.Sprintf(MntUrl, containerName)
return cmd, writePipe
}
这里将容器init进程(1号进程)的uid和gid设置为0(root),且分别映射为母机当前的用户ID和组ID(非root)
Mount namespace用于隔离进程文件系统的挂载点视图,在不同namespace的进程中,看到的文件系统层次是不一样的,同时,在 Mount Namespace 中调用 mount()和 umount()仅仅只会影响当前Namespace内的文件系统,而对全局的文件系统是没有影响的(Mount Namespace是Linux第一个实现的Namespace类型,因此,它的系统调用参数是NEWNS ( New Namespace的缩写))
这里我将主要探讨如何实现使用mount namespace实现容器挂载联合文件系统作为它的rootfs,这也是上述讲解aufs时遗留的一个问题
通常来说我们需要在容器中按照如下步骤进行挂载:
/
with another (the rootfs-dir
in this case).另外,需要给Cloneflags设置syscall.CLONE_NEWNS实现mnt namespace隔离。核心代码如下:
func NewParentProcess(tty bool, containerName, volume, imageName string, envSlice []string) (*exec.Cmd, *os.File) {
readPipe, writePipe, err := NewPipe()
if err != nil {
log.Errorf("New pipe error %v", err)
return nil, nil
}
initCmd, err := os.Readlink("/proc/self/exe")
if err != nil {
log.Errorf("get init process error %v", err)
return nil, nil
}
cmd := exec.Command(initCmd, "init")
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWPID | syscall.CLONE_NEWNS |
syscall.CLONE_NEWNET | syscall.CLONE_NEWIPC,
}
...
return cmd, writePipe
}
...
func RunContainerInitProcess() error {
...
setUpMount()
...
return nil
}
/**
Init 挂载点
*/
func setUpMount() {
pwd, err := os.Getwd()
if err != nil {
log.Errorf("Get current location error %v", err)
return
}
log.Infof("Current location is %s", pwd)
pivotRoot(pwd)
...
}
func pivotRoot(root string) error {
// Remounts current root filesystem with MS_PRIVATE
if err := syscall.Mount("", "/", "", syscall.MS_REC|syscall.MS_PRIVATE, ""); err != nil {
return fmt.Errorf("syscall Mount current root failure: %v", err)
}
/**
为了使当前root的老 root 和新 root 不在同一个文件系统下,我们把root重新mount了一次
bind mount是把相同的内容换了一个挂载点的挂载方法
*/
if err := syscall.Mount(root, root, "bind", syscall.MS_BIND|syscall.MS_REC, ""); err != nil {
return fmt.Errorf("Mount rootfs to itself error: %v", err)
}
// 创建 rootfs/.pivot_root 存储 old_root
pivotDir := filepath.Join(root, ".pivot_root")
if err := os.Mkdir(pivotDir, 0777); err != nil {
return err
}
// pivot_root 到新的rootfs, 现在老的 old_root 是挂载在rootfs/.pivot_root
// 挂载点现在依然可以在mount命令中看到
if err := syscall.PivotRoot(root, pivotDir); err != nil {
return fmt.Errorf("pivot_root %v", err)
}
// 修改当前的工作目录到根目录
if err := syscall.Chdir("/"); err != nil {
return fmt.Errorf("chdir / %v", err)
}
pivotDir = filepath.Join("/", ".pivot_root")
// umount rootfs/.pivot_root
if err := syscall.Unmount(pivotDir, syscall.MNT_DETACH); err != nil {
return fmt.Errorf("unmount pivot_root dir %v", err)
}
// 删除临时文件夹
return os.Remove(pivotDir)
}
通过上述步骤,我们可以成功做到将容器的rootfs设置为aufs联合文件系统,如下:
# inside of container
JJMhAjPfRh # mount
none on / type aufs (rw,relatime,si=b7a28d49e33081ad)
JJMhAjPfRh # ls
bin dev etc home proc root sys tmp usr var
# outside of container
$ cat /sys/fs/aufs/si_b7a28d49e33081ad/*
/var/lib/sample-container-runtime/writeLayer/container1=rw
/var/lib/sample-container-runtime/busybox=ro
64
65
/var/lib/sample-container-runtime/writeLayer/container1/.aufs.xino
PID namespace用于隔离进程的PID,它会导致容器进程只能看到属于该namespace空间下的进程,同时不同PID namespace下的进程可以拥有相同的PID。当我们通过上述mnt namespace将联合文件系统作为容器的根文件系统后,由于没有/proc目录,我们通过ps命令看到的会是空返回(linux通过/proc目录存储操作系统所有进程的信息)。因此我们必须在运行容器指定进程前设置proc文件系统
具体来说我们需要给Cloneflags设置syscall.CLONE_NEWPID实现PID namespace隔离,同时在容器中设置proc文件系统(mount -t proc proc /proc),核心代码如下:
func NewParentProcess(tty bool, containerName, volume, imageName string, envSlice []string) (*exec.Cmd, *os.File) {
readPipe, writePipe, err := NewPipe()
if err != nil {
log.Errorf("New pipe error %v", err)
return nil, nil
}
initCmd, err := os.Readlink("/proc/self/exe")
if err != nil {
log.Errorf("get init process error %v", err)
return nil, nil
}
cmd := exec.Command(initCmd, "init")
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWUTS |