Docker Namespace资源隔离源码深度剖析-Docker商业环境实战

1,247 阅读6分钟

专注于大数据及容器云核心技术解密,可提供全栈的大数据+云原生平台咨询方案,请持续关注本套博客。如有任何学术交流,可随时联系。更多内容请关注《数据云技术社区》公众号。

1 Namespace 概述

  • Namespace是将内核的全局资源做封装,使得每个Namespace都有一份独立的资源,因此不同的进程在各自的Namespace内对同一种资源的使用不会互相干扰。实际上,Linux内核实现namespace的主要目的就是为了实现轻量级虚拟化(容器)服务。
  • 在同一个namespace下的进程可以感知彼此的变化,而对外界的进程一无所知。这样就可以让容器中的进程产生错觉,仿佛自己置身于一个独立的系统环境中,以此达到独立和隔离的目的。
  • namespace的API包括clone()、setns()以及unshare(),还有/proc下的部分文件。为了确定隔离的到底是哪种namespace,在使用这些API时,通常需要指定以下六个常数的一个或多个,通过|(位或)操作来实现。这六个参数分别是CLONE_NEWIPC、CLONE_NEWNS、CLONE_NEWNET、CLONE_NEWPID、CLONE_NEWUSER和CLONE_NEWUTS。
  • 通过clone()创建新进程的同时创建namespace
IPC:隔离System V IPC和POSIX消息队列。
Network:隔离网络资源。
Mount:隔离文件系统挂载点。每个容器能看到不同的文件系统层次结构。
PID:隔离进程ID。
UTS:隔离主机名和域名。
User:隔离用户ID和组ID。

int clone(int (*child_func)(void *), void *child_stack, int flags, void *arg);

参数child_func传入子进程运行的程序主函数。
参数child_stack传入子进程使用的栈空间
参数flags表示使用哪些CLONE_*标志位
参数args则可用于传入用户参数

clone()实际上是传统UNIX系统调用fork()的一种更通用的实现方式,它可以通过flags来控制使用多
少功能。一共有二十多种CLONE_*的flag(标志位)参数用来控制clone进程的方方面面(如是否与父
进程共享虚拟内存等等)。
  • 通过setns()加入一个已经存在的namespace
在进程都结束的情况下,也可以通过挂载的形式把namespace保留下来,保留namespace的目的自然是
为以后有进程加入做准备。通过setns()系统调用,你的进程从原先的namespace加入我们准备好的新
namespace,使用方法如下:

int setns(int fd, int nstype)
参数fd表示我们要加入的namespace的文件描述符。上文已经提到,它是一个指向/proc/[pid]/ns目录
的文件描述符,可以通过直接打开该目录下的链接或者打开一个挂载了该目录下链接的文件得到。

参数nstype让调用者可以去检查fd指向的namespace类型是否符合我们实际的要求。如果填0表示不检查。
  • 通过unshare()在原先进程上进行namespace隔离
后要提的系统调用是unshare(),它跟clone()很像,不同的是,unshare()运行在原先的进程上,
不需要启动一个新进程,使用方法如下:

int unshare(int flags);
调用unshare()的主要作用就是不启动一个新进程就可以起到隔离的效果,相当于跳出原先的
namespace进行操作。这样,你就可以在原进程进行一些需要隔离的操作。Linux中自带的
unshare命令,就是通过unshare()系统调用实现的。
  • 如下Docker源码,呈现了namespace的创建过程。

2 Namespace源码执行流程

2.1 容器对象创建阶段

  • 具体流程请参考《Docker源码解析》
startContainer() => createContainer() => loadFactory() => libcontainer.New() 

2.2 容器对象运行阶段(nsexec)

  • 整体流程如下
startContainer() => runner.run() => newProcess() => runner.container.Run(process) 
=> linuxContainer.strat() => linuxContainer.newParentProcess(process) 
=>linuxContainer.commandTemplate() => linuxContaine.newInitProcess() =>parent.start() 
=> initProcess.start()
  • linuxContainer.strat()
  • 首先创建newParentProcess,生成InitProcess,实现对container的process进行Namespace相关设置如uid/gid、pid、uts、ns、cgroup等。
func (c *linuxContainer) newParentProcess(p *Process, doInit bool) (parentProcess, error) {
	parentPipe, childPipe, err := newPipe()
	if err != nil {
		return nil, newSystemError(err)
	}
	cmd, err := c.commandTemplate(p, childPipe)
	if err != nil {
		return nil, newSystemError(err)
	}
	if !doInit {
		return c.newSetnsProcess(p, cmd, parentPipe, childPipe), nil
	}
	return c.newInitProcess(p, cmd, parentPipe, childPipe)
}
  • 创建容器的 init 进程时相关namespace 配置项(newInitProcess)
  • initProcess.start()。
  • InitProcess.start() 容器的初始化配置,此处 cmd.start() 调用实则是 runC init命令执行
  • InitProcess.start() 容器的初始化配置,此处 cmd.start() 调用实则是 runC init命令执行:
  • Init() 完成容器的相关初始化配置(网络/路由、rootfs、selinux、console、主机名、apparmor、Sysctl、seccomp、capability 等)

func (l *LinuxFactory) StartInitialization() (err error) {
  //...
    i, err := newContainerInit(it, pipe, consoleSocket, fifofd) 
  //...
  // newContainerInit()返回的initer实现对象的Init()方法调用 "linuxStandardInit.Init()"
  return i.Init()                    
}

func (l *linuxStandardInit) Init() error {
  //...
  // 配置network,
  //  配置路由
  // selinux配置
  // + 准备rootfs
  // 配置console
  // 完成rootfs设置
  // 主机名设置
  // 应用apparmor配置
  // Sysctl系统参数调节
  // path只读属性配置
  // 告诉runC进程,我们已经完成了初始化工作
  // 进程标签设置
  // seccomp配置
  // 设置正确的capability,用户以及工作目录
  // 确定用户指定的容器进程在容器文件系统中的路径
  // 关闭管道,告诉runC进程,我们已经完成了初始化工作
  // 在exec用户进程之前等待exec.fifo管道在另一端被打开
  // 我们通过/proc/self/fd/$fd打开它
  // ......
  // 向exec.fifo管道写数据,阻塞,直到用户调用`runc start`,读取管道中的数据
  // 此时当前进程已处于阻塞状态,等待信号执行后面代码
  //
    if _, err := unix.Write(fd, []byte("0")); err != nil {
        return newSystemErrorWithCause(err, "write 0 exec fifo")
    }
  // 关闭fifofd管道 fix CVE-2016-9962
  // 初始化Seccomp配置
  // 调用系统exec()命令,执行entrypoint
    if err := syscall.Exec(name, l.config.Args[0:], os.Environ()); err != nil {
        return newSystemErrorWithCause(err, "exec user process")
    }
    return nil
}


3 nsenter源码执行流程

  • Nsexec() 为 nsenter 主干执行逻辑代码,所有 namespaces 配置都在此 func 内执行完成,clone_parent就是实现Namespace创建的基本。
void nsexec()
{
	char *namespaces[] = { "ipc", "uts", "net", "pid", "mnt" };
	const int num = sizeof(namespaces) / sizeof(char *);
	jmp_buf env;
	char buf[PATH_MAX], *val;
	int i, tfd, child, len, pipenum, consolefd = -1;
	pid_t pid;
	char *console;

	val = getenv("_LIBCONTAINER_INITPID");
	if (val == NULL)
		return;

	pid = atoi(val);
	snprintf(buf, sizeof(buf), "%d", pid);
	if (strcmp(val, buf)) {
		pr_perror("Unable to parse _LIBCONTAINER_INITPID");
		exit(1);
	}

	val = getenv("_LIBCONTAINER_INITPIPE");
	if (val == NULL) {
		pr_perror("Child pipe not found");
		exit(1);
	}

	pipenum = atoi(val);
	snprintf(buf, sizeof(buf), "%d", pipenum);
	if (strcmp(val, buf)) {
		pr_perror("Unable to parse _LIBCONTAINER_INITPIPE");
		exit(1);
	}

	console = getenv("_LIBCONTAINER_CONSOLE_PATH");
	if (console != NULL) {
		consolefd = open(console, O_RDWR);
		if (consolefd < 0) {
			pr_perror("Failed to open console %s", console);
			exit(1);
		}
	}

	/* Check that the specified process exists */
	snprintf(buf, PATH_MAX - 1, "/proc/%d/ns", pid);
	tfd = open(buf, O_DIRECTORY | O_RDONLY);
	if (tfd == -1) {
		pr_perror("Failed to open \"%s\"", buf);
		exit(1);
	}

	for (i = 0; i < num; i++) {
		struct stat st;
		int fd;

		/* Symlinks on all namespaces exist for dead processes, but they can't be opened */
		if (fstatat(tfd, namespaces[i], &st, AT_SYMLINK_NOFOLLOW) == -1) {
			// Ignore nonexistent namespaces.
			if (errno == ENOENT)
				continue;
		}

		fd = openat(tfd, namespaces[i], O_RDONLY);
		if (fd == -1) {
			pr_perror("Failed to open ns file %s for ns %s", buf,
				  namespaces[i]);
			exit(1);
		}
		// Set the namespace.
		if (setns(fd, 0) == -1) {
			pr_perror("Failed to setns for %s", namespaces[i]);
			exit(1);
		}
		close(fd);
	}

	if (setjmp(env) == 1) {
		// Child

		if (setsid() == -1) {
			pr_perror("setsid failed");
			exit(1);
		}
		if (consolefd != -1) {
			if (ioctl(consolefd, TIOCSCTTY, 0) == -1) {
				pr_perror("ioctl TIOCSCTTY failed");
				exit(1);
			}
			if (dup3(consolefd, STDIN_FILENO, 0) != STDIN_FILENO) {
				pr_perror("Failed to dup 0");
				exit(1);
			}
			if (dup3(consolefd, STDOUT_FILENO, 0) != STDOUT_FILENO) {
				pr_perror("Failed to dup 1");
				exit(1);
			}
			if (dup3(consolefd, STDERR_FILENO, 0) != STDERR_FILENO) {
				pr_perror("Failed to dup 2");
				exit(1);
			}
		}
		// Finish executing, let the Go runtime take over.
		return;
	}
	// Parent

	// We must fork to actually enter the PID namespace, use CLONE_PARENT
	// so the child can have the right parent, and we don't need to forward
	// the child's exit code or resend its death signal.
	child = clone_parent(&env);
	if (child < 0) {
		pr_perror("Unable to fork");
		exit(1);
	}

	len = snprintf(buf, sizeof(buf), "{ \"pid\" : %d }\n", child);

	if (write(pipenum, buf, len) != len) {
		pr_perror("Unable to send a child pid");
		kill(child, SIGKILL);
		exit(1);
	}

	exit(0);
}

4 总结

Docker Namespace源码隔离过于高深,本文先浅析于此。

专注于大数据及容器云核心技术解密,可提供全栈的大数据+云原生平台咨询方案,请持续关注本套博客。如有任何学术交流,可随时联系。更多内容请关注《数据云技术社区》公众号。