Docker容器是如何隔离的?

 

chroot

chroot是Linux上一个比较古老的系统调用,其作用是将用户的根目录设定到制定目录下,限制用户的文件访问权限。 早期有很多人利用chroot和busybox搭建一个”隔离”的沙盒迷你系统,这个沙盒和现在容器的理念十分接近。 不过chroot虽然在存储上限制了用户的访问行为,但是和现代的容器隔离相比,这种简单粗暴的方式无疑是显得”原始”了点。 Linux后来引入了namespace用于管理和隔离进程。

Namespace介绍

什么是Namespace?

namespace是Linux内核提供的进程隔离机制,用于将进程(组)的资源进行隔离。 涉及namespace的系统调用有3个:

clone() -> 创建一个新的进程, 按照制定参数创建隔离级别
setns() -> 使进程加入到某个namespace
unshare() -> 使进程脱离某个namespace

至今为止,Linux Namespace拓展的所有类别如下:

Name 说明 调用参数 内核发布版本
Mount Namespace 隔离各进程的挂载点视图 CLONE_NEWNS Linux 2.4.19
UTS Namespace 隔离nodename、domainname等 CLONE_NEWUTS Linux 2.6.19
IPC Namespace 隔离进程间通讯方式 CLONE_NEWIPC Linux 2.6.19
PID Namespace 隔离进程id CLONE_NEWPID Linux 2.6.24
Network Namespace 隔离容器网络 CLONE_NEWNET 2.6.24 ~ 2.6.29
User Namespace 隔离用户和用户组Id CLONE_NEWUSER 2.6.23 ~ 3.8

查看进程Namespace信息

我们都知道,linux/proc路径下存放的是当前系统进程的运行时信息,进程的namespace信息就在/proc/[pid]/ns路径下。

root@slave:~ $ll /proc/1/ns
total 0
lrwxrwxrwx 1 root root 0 Mar 16 01:03 cgroup -> 'cgroup:[4026531835]'
lrwxrwxrwx 1 root root 0 Mar  8 23:23 ipc -> 'ipc:[4026532558]'
lrwxrwxrwx 1 root root 0 Mar  8 23:23 mnt -> 'mnt:[4026532629]'
lrwxrwxrwx 1 root root 0 Jan 27 20:31 net -> 'net:[4026532561]'
lrwxrwxrwx 1 root root 0 Jan 27 20:31 pid -> 'pid:[4026532631]'
lrwxrwxrwx 1 root root 0 Mar 16 01:03 pid_for_children -> 'pid:[4026532631]'
lrwxrwxrwx 1 root root 0 Mar 16 01:03 user -> 'user:[4026531837]'
lrwxrwxrwx 1 root root 0 Mar  8 23:23 uts -> 'uts:[4026532630]'
  • 输出信息的格式是 ns_type:[ns_id],即Namespace的类型和Namespace的inode number。
  • 若两个进程某个Namespace的inode number相关,则说明他们属于同一个namespace。
  • 大部分情况下,若Namespace中所有进程都退出了,该Namespace会被删除。

Namespace系统调用详解

UTS (Unix Time-Sharing) Namespace

UTS Namespace主要用于隔离hostname和domainName。

  • hostname: 主机名,用于表示主机。
  • NIS domain name: 主机加入NIS网络使用的标识名。 对于hostname和NIS具体的细节我们这里不多表述,不熟悉且感兴趣的同学可自行查阅资料。

我们用一段代码来新建一个bash进程并创建一个Namespace。

package main

import (
	"log"
	"os"
	"os/exec"
	"syscall"
)

func main() {
	cmd := exec.Command("bash")
        // 这里我们先只创建UTS Namespace
	cmd.SysProcAttr = &syscall.SysProcAttr{
		Cloneflags: syscall.CLONE_NEWUTS,
	}

	cmd.Stdin = os.Stdin
	cmd.Stdout = os.Stdout
	cmd.Stderr = os.Stderr

	if err := cmd.Run(); err != nil {
		log.Fatal(err.Error())
	}
}

编译运行测试

root@master:~ $ echo `hostname`,`domainname`
master,(none)
root@master:~ $ ./namespace
root@master:~ $ echo `hostname`,`domainname`
master,(none)
root@master:~ $ hostname -b test_hostname
root@master:~ $ domainname -b test_domainname
root@master:~ $ echo `hostname`,`domainname`
test_hostname,test_domainname
root@master:~ $ exit
exit
root@master:~ $ echo `hostname`,`domainname`
master,(none)

在新建的bash进程中,修改hostname和domainname并不会影响到全局设置。当我们推出bash进程后,我们看到的仍是全局的主机名。

IPC(InterProcess) Namespace

要创建IPC Namespace, 只需要在clone flag参数上加上IPC的标记:

Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWIPC,

进入namespace, 测试ipc隔离:

root@master:~ $ ./namespace
root@master:~ $ ipcs -q
------ Message Queues --------
key        msqid      owner      perms      used-bytes   messages
root@master:~ $ ipcmk -Q
Message queue id: 0
root@master:~ $ ipcs -q
------ Message Queues --------
key        msqid      owner      perms      used-bytes   messages
0xd63f036f 0          root       644        0            0

root@master:~ $ exit
exit
root@master:~ $ ipcs -q
------ Message Queues --------
key        msqid      owner      perms      used-bytes   messages

在namespace内创建的mq只在当前namespace内可见。

Mount Namespace

mount namespace用于隔离进程所见的挂载视图。在不同的Namespace中,调用mount和umount仅仅影响当前namespace下的挂载,不会影响到全局系统。

说到这,你们是不是想起来文章开头所说的chroot,mount namespace也可以实现这一功能,而且更加灵活和安全。 Mount Namespace是Linux第一个实现的Namespace,他的系统调用参数是NEWNS(New Namespace)。 当时的社区工作者们没有意识到后续会有其他类型的namespace加入到Linux内核中,左右直接就用NEWNS来表示这个Namespace了。

同样需要加上系统调用参数

Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWIPC | syscall.CLONE_NEWPID | syscall.CLONE_NEWNS,

全局的mount namespace number:

root@master:~ $ readlink /proc/$$/ns/mnt
mnt:[4026531840]

我们先创建几个目录:

root@master:~ $ tree -L 2
.
├── info
│   ├── data1
│   └── data2
├── mount
│   └── info
└── namespace

将data1挂载到: mount --bind /root/info/data1 /root/mount/info

好,我们现在运行程序创建新的namespace:

root@master:~ $ readlink /proc/$$/ns/mnt
mnt:[4026532212]

可以看到在fork出的bash进程下,mnt namespace的inode number发生了变化。

现在我们运行程序新建namespace,修改/data/mount/info的挂载:

root@master:~ $ ls mount/info/
test1
root@master:~ $ umount /root/mount/info/
root@master:~ $ mount --bind /root/info/data2 /root/mount/info/
root@master:~ $ ls mount/info/
test2
root@master:~ $ exit
exit
root@master:~ $ ls /root/mount/info/
test2

这里是不是发现有点不对劲呢?在新namespace下的mount改动在全局下也生效了。 要了解发生了什么我们还得再说到mount namespace的事件传播机制,linux下一共定义了4种类型:

  • MS_SHARED: 同一个peer group成员共享mount事件
  • MS_PRIVATE: 私有传播, 不发送和接受其他的mount事件
  • MS_SLAVE: mount group种的master事件会传播到slave,反之则不行
  • MS_UNBINDABLE: 不发送、接受mount事件,且不可被mount --bind

而默认的mnt事件传播机制就是shared,我们验证一下:

root@master:~ $ findmnt -o TARGET,PROPAGATION /
TARGET PROPAGATION
/      shared

这里我们将mount类型改为private: mount --make-private /,再试一次上述的挂载试验。 这时新bash下的mount操作就不会被传播到外部了,同步我们修改的mount propagation类型也只在新的namespace下生效。

如果用go进行操作的话,在创建mount namespace后需要修改一下传播模式:

func mount() {
  // 将namespace内rootfs下传播模式改为private
  mountFlags := syscall.MS_REC | syscall.MS_PRIVATE
  err := syscall.Mount("", "/", "", uintptr(config.MountFlagsPrivate), "")
    if err != nil {
      return err
    }
}

PID Namespace

PID Namespace对进程id(pid)进行隔离,添加系统调用参数后: Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWPID, 运行进程,我们就会发现新的bash进程的pid变成了1:

root@master:~ $ ./namespace
root@master:~ $ echo $$
1

新创建的bash进程的进程id变成了1,而全局下Linux中pid为1的应该是上帝进程(systemd)。 我们打开另一个bash窗口,我们可以追踪到该bash进程的真实pid:

root@master:~ $ pstree -pl
systemd(1)─┬─sshd(728)─┬─sshd(7427)───bash(7468)───namespace(7606)─┬─bash(7611)
                       │                                           ├─{namespace}(7607)
                       │                                           ├─{namespace}(7608)
                       │                                           ├─{namespace}(7609)
                       │                                           └─{namespace}(7610)
                       └─sshd(7621)───bash(7661)───pstree(7675)

在linux中,进程之间具有父子关系,而当运行中的父子进程中父进程退出了,那么在namespace中子进程会被namespace中的init进程接管,而非全局的init进程。 当namespace的init进程退出,那么他会向当前namespace中所有其他进程发送终止Signal。结果就是namespace的所有进程退出,namespace被销毁。

User Namespace

User Namespace用于隔离linux user的权限相关资源,这是所有namespace中实现最复杂的一个。 user namespace支持嵌套(内核限制32层),所有user namespace父子关系最上层都是系统默认的user namespace。

当一个进程调用unshareclone创建一个新的user namespace时,当前进程所在的用户空间就成为了父空间,所有的user namespace形成树形结构。 每个其他类型的namespace,都有一个自己所属的user namespace。

struct uts_namespace {
  struct kref kref;
  struct new_utsname name;
  struct user_namespace *user_ns;
  struct ns_common ns;
};

比如我们看uts namespace的结构中,有一个指针指向了一个user namespace,其他的namespace也类似,都会和一个user namespace绑定。

接下来,我们通过unshare创建一个bash进程和user namespace。

root@ecs-slave2:~ $unshare --user /bin/bash
nobody@ecs-slave2:~ $whoami
nobody
nobody@ecs-slave2:~ $id
uid=65534(nobody) gid=65534(nogroup) groups=65534(nogroup)
nobody@ecs-slave2:~ $exit
exit
root@ecs-slave2:~ $id
uid=0(root) gid=0(root) groups=0(root)

当我们新建了一个user namespace后,shell显示的用户名变成了nobody,用户(组)也变成了65534,这是没有指定用户映射时的系统默认值。

虽然显示用户是nobody,但是仍然可以访问root用户的目录:

root@master:/root$ unshare --user /bin/bash
nobody@master:/root$ ls /root
test

我们添加一个临时账号做同样的操作,却发现权限不够:

root@master:~ $ su test
test@master:/root$ unshare --user /bin/bash
nobody@master:/root$ ls /root
ls: cannot open directory '/root': Permission denied

说明账号之间存在某一种映射关系

那账号之间是如何映射的呢?

在创建了新的user namespace后,可以通过往/proc/{pid}/uid_map/proc/{pid}/gid_map制定用户的映射。 文件的格式为:

    inside-ns-id-bigin   outside-ns-id-beigin   length

通过指定子namespace和父namespace的起始userid,将整段父空间中的用户映射到子空间中。需要注意的是,该文件只能被写一次,之后会被设置为不可写入状态。

Network Namespace

Docker利用network namespace隔离网络设备、ip、端口号等,所以每一个容器都可以拥有独立的网络设备、端口号等,然后通过NAT与外部通讯。

root@master:~ $ unshare --uts --net /bin/bash
root@master:~ $ ifconfig
root@master:~ $ ip link set lo up
root@master:~ $ ifconfig
lo: flags=73<UP,LOOPBACK,RUNNING>  mtu 65536
        inet 127.0.0.1  netmask 255.0.0.0
        inet6 ::1  prefixlen 128  scopeid 0x10<host>
        loop  txqueuelen 1000  (Local Loopback)
        RX packets 0  bytes 0 (0.0 B)
        RX errors 0  dropped 0  overruns 0  frame 0
        TX packets 0  bytes 0 (0.0 B)
        TX errors 0  dropped 0 overruns 0  carrier 0  collisions 0

在新的namespace中,网络配置被重置,在新空间中修改也不会影响外部配置。新创建的namespace不具备任何的联网功能,在后续的文章中,我们会介绍如何让network namespace与外界进行通讯。

References

  1. Chroot简介
  2. chroot和busybox
  3. MOUNT NAMESPACE IN GOLANG