Docker 原理篇(五)Docker namespace

前言

此篇博文是笔者所总结的 Docker 系列之一;

本文为作者的原创作品,转载需注明出处;

Linux Namespace 概述

Linux 内核从版本 2.4.19 开始陆续引入了 namespace 的概念;

Linux 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. 它的目的是将某个特定的全局系统资源(global system resource)通过抽象方法使得在 namespace 中的进程看起来他们自己就是该全局资源的一个实例。

六中 Namespace 隔离措施

namespace 内核版本引入 被隔离的全局资源 在容器下的隔离效果
Mount namespaces Linux 2.4.19 文件系统挂接点 每个容器能看到不同的文件系统层次结构
UTS namespaces Linux 2.6.19 nodename 和 domainname 每个容器可以有自己的 hostname 和 domainame
IPC namespaces Linux 2.6.19 特定的进程间通信资源,包括 System V IPC 和 POSIX message queues 每个容器有其自己的 System V IPC 和 POSIX 消息队列文件系统,因此,只有在同一个 IPC namespace 的进程之间才能互相通信
PID namespaces Linux 2.6.24 进程 ID 数字空间 (process ID number space) 每个 PID namespace 中的进程可以有其独立的 PID; 每个容器可以有其 PID 为 1 的root 进程;容器中的 PID 与 host 上的 PID 有一一映射的关系
Network namespaces 始于Linux 2.6.24 完成于 Linux 2.6.29 网络相关的系统资源 每个容器用有其独立的网络设备,IP 地址,IP 路由表,/proc/net 目录,端口号等等。
User namespaces 始于 Linux 2.6.23 完成于 Linux 3.8) 用户和组 ID 空间 在 user namespace 中的进程的用户和组 ID 可以和在 host 上不同;

Docker 中的 Namespace 使用

PID Namesampe

案例

创建一个镜像,默认让容器执行do echo 'hello world' | nc -l -p 8888命令

1
2
3
4
FROM ubuntu:14.04
MAINTAINER comedshang <comedshang@unknow.com>
EXPOSE 8888
CMD while true; do echo 'hello world' | nc -l -p 8888; done

使用如上Dockerfile创建exposed_port镜像,然后执行,容器命名为exposed_port_c

1
mac@ubuntu:~/docker7$ docker run --name exposed_port_c exposed_port

让我们来观察HostContainer之间的 PID 的映射的情况;

  • 首先我们来观察Container的情况
    进入正在执行的exposed_port Container中的bash环境,然后查看其当前进程的情况

    1
    2
    3
    4
    5
    6
    7
    mac@ubuntu:~/docker10$ docker exec -it exposed_port_c bash
    root@729834d39b5e:/# ps -ef
    UID PID PPID C STIME TTY TIME CMD
    root 1 0 0 04:20 ? 00:00:00 /bin/sh -c while true; do echo 'hello world' | nc -l -p 8888; done
    root 6 1 0 04:20 ? 00:00:00 nc -l -p 8888
    root 7 0 1 04:25 ? 00:00:00 bash
    root 21 7 0 04:25 ? 00:00:00 ps -ef

    可以看到,容器中主要运行的有两个进程,

    1. PID 1, PPID 0
      /bin/sh -c while true; do echo 'hello world' | nc -l -p 8888; done
      这是容器的 init 进程,因为执行的是/bin/sh,所以我们简称“容器 /bin/sh 进程”
    2. PID 6, PPID 1
      nc -l -p 8888,我们简称“容器 nc 进程”
  • 再次我们来观察Host的情况

    1. 我们在 Host 中来找“容器 /bin/sh 进程”

      1
      2
      mac@ubuntu:~/docker7$ ps -ef | grep "/bin/sh -c while true; do echo 'hello world' | nc -l -p 8888; done"
      root 11483 11468 0 12:20 ? 00:00:00 /bin/sh -c while true; do echo 'hello world' | nc -l -p 8888; done

      可以看到,Host上启动了一个 PID = 11483 的进程来执行“容器 /bin/sh 进程”,所以,这里我们可以看到 Host 与 Container 进程之间的映射关系是Host PID(11483) <-> Container PID(1)

    2. 我们在 Host 中来找“容器 nc进程”

      1
      2
      mac@ubuntu:~/docker7$ ps -ef | grep "nc -l -p 8888$"
      root 11514 11483 0 12:20 ? 00:00:00 nc -l -p 8888

      (注意,这里使用正则表达式$来避免重复搜索到“容器 /bin/sh 进程”。)
      可以看到,Host上启动了一个 PID = 11514 的进程来执行“容器nc进程”,所以,Host 与 Container 之间的映射关系是Host PID(11514) <-> Container PID(6)

Docker 是如何实现的

  • Docker Engine
    管理镜像,并交由 Containerd 执行。
  • Containerd
    一个守护进程,通过调用 通过 Container-shim 和 runC 管理着容器的生命周期,开始、停止、暂停和销毁。由于容器启动以后(既 Containerd Deamon 启动以后),不需要依赖 Docker Engine,所以,Docker Engine 升级的时候,无需关闭当前正在执行的容器。
  • Containerd-shim
    容器中的 init 进程在 Host 上所对应的进程的父进程就是 Containerd-shim
    继续使用上述的用例,容器中的 init 进程对应 Host PID 11483, 其 PPID = 11468,我们查看 11486 是什么,

    1
    2
    mac@ubuntu:~/docker7$ ps -ef | grep 11468
    root 11468 20408 0 12:20 ? 00:00:00 docker-containerd-shim 729834d39b5e2a7f9336f3fb4ba63075c3d32d8bba65f07fd5b945043e5c333a /var/run/docker/libcontainerd/729834d39b5e2a7f9336f3fb4ba63075c3d32d8bba65f07fd5b945043e5c333a docker-runc

    果不其然,是 docker-containerd-shim 进程,看来每一个容器的 init 进程都由一个 docker-containerd-shim 父进程管理其生命周期。

  • runC
    一个轻量级工具,就是用来运行容器的。

UTS Namespace

每个容器可以有自己的 hostname 和 domainame,且与 host 不同

  • Host

    1
    2
    mac@ubuntu:~/docker7$ hostname
    ubuntu
  • Containerd

    1
    2
    3
    mac@ubuntu:~/docker7$ docker exec -it exposed_port_c bash
    root@729834d39b5e:/# hostname
    729834d39b5e

User Namespace

风险

如果基础镜像使用的是 root 用户构建,那么其 Docker 容器在执行过程中会继承基础镜像中的 root 用户执行;但需要特别注意的是,这里,容器执行所使用的 root 用户和 host 主机上的 root 用户,是同一个用户。那么,如果使用了Volumn,将 host 主机上的某个系统目录挂载到了容器的某个目录上,那么容器就可以对 host 主机上的系统目录进行更改,从而发起攻击;

后续会介绍,Docker 是如何使用 User Namespace 来规避这种风险的,但在 Docker 1.10 版本之前,Docker 是不支持 user namespace,所以,旧的版本是非常容易被黑客所利用的..

下面,我们来重现这种风险的过程,注:我本地 Docker 版本是 1.12.3,没有开启 User Namespace 映射。

启动容器 web31,并将 host 的 /bin 挂载到了容器的 /host/bin 中

1
mac@ubuntu:~$ docker run -d -v /bin:/host/bin --name web34 training/webapp python app.py

1
2
3
4
5
6
7
8
9
10
11
12
mac@ubuntu:~$ docker inspect web34
......
"Mounts": [
{
"Source": "/bin",
"Destination": "/host/bin",
"Mode": "",
"RW": true,
"Propagation": "rprivate"
}
],
......

我们来试图通过修改容器中的 /host/bin 中的文件已达到对 host /bin 目录中的文件修改,

1
2
3
4
5
6
7
8
9
10
mac@ubuntu:~$ docker exec -it web34 bash
root@9e08cdf0df18:/opt/webapp# cd /host/bin
root@9e08cdf0df18:/host/bin# ls
bash bzegrep dd grep loginctl netcat ntfswipe setfacl systemd-notify whiptail
btrfs bzexe df gunzip lowntfs-3g netstat open setfont systemd-sysusers ypdomainname
btrfs-calc-size bzfgrep dir gzexe ls networkctl openvt setupcon
....... # 可以看到,host /bin 目录被挂载到了容器的 /host/bin 目录中了
# 接着,生成一个 hackfile,然后退出
root@9e08cdf0df18:/host/bin# touch hackfile
root@9e08cdf0df18:/host/bin# exit

返回 host,查看 /bin 目录下的情况,

1
2
mac@ubuntu:~$ ls /bin/hackfile
/bin/hackfile

可以看到,在容器中成功的在 host /bin 目录下生成了 hackfile 已达到了攻击的目的。所以,容器和主机共享一个 root 账户是非常非常危险的,下面,我们来看看,Docker 是如何通过 User Namespace 来避免这种情况的。

启用 Host 用户与 Container 用户映射

启用 Host 用户与 Container 用户的映射,步骤如下,

  1. 修改 /etc/default/docker 文件,添加行 DOCKER_OPTS=”–userns-remap=default”
  2. 重启 Docker,sudo service docker start

记录一个,如果你的 Unbuntu 是 16+ 版本,Docker 1.12.5 版本的,上述的配置默认是不会生效的。我们需要继续做如下的修改,

  • 修改 /lib/systemd/system/docker.service,做如下修改
    • 添加一行 EnvironmentFile=-/etc/default/docker (- 代表ignore error)
    • 将ExecStart=/usr/bin/docker daemon -H fd:// 改成 ExecStart=/usr/bin/docker daemon -H fd:// $DOCKER_OPTS
  • 重启 Docker
    • systemctl daemon-reload
    • sudo service docker restart

查看 dockerd 进程,发现参数--userns-remap=default成功添加在了启动 CMD 中

1
2
3
root@ubuntu:~# ps -ef | grep dockerd
root 14225 1 1 15:43 ? 00:00:00 /usr/bin/dockerd -H fd:// --userns-remap=default
root 14309 12300 0 15:43 pts/0 00:00:00 grep --color=auto dockerd


好了,User 映射启动了,再次使用上述的用例来进行测试,记得将之前的 web34 Container 停止后删除。

1
mac@ubuntu:~$ docker run -d -v /bin:/host/bin --name web34 training/webapp

再次 hack,

1
2
3
4
root@ubuntu:~# docker exec -it web34 bash
root@4129f02954e7:/opt/webapp# cd /host/bin
root@4129f02954e7:/host/bin# touch new_hackfle
touch: cannot touch 'new_hackfle': Permission denied

是的,这次你得到了Permission denied的错误提示,表示你当前没有权限更改 host /bin 目录上的文件了。

我们来看看到底发生了什么?

1
2
root@4129f02954e7:/host/bin# id
uid=0(root) gid=0(root) groups=0(root)

可见,容器中,依然使用的是 root 账户,uid=0;那么 host 是如何去映射 Container 中的 root 用户的呢?

1
2
3
root@ubuntu:~# ps -ef | grep python
231072 14768 14737 0 16:15 ? 00:00:00 python app.py
root 14819 12300 0 16:15 pts/0 00:00:00 grep --color=auto python

host 主机上,对应容器的 init 进程的进程(14768)用户,已经不再是 host 的 root 用户了,而是由 host 的另一个用户231072所替代了。我们看看,host 是如何将用户 231072 与 Container 中的 root 用户进行映射的?

1
2
root@ubuntu:~# cat /proc/14768/uid_map
0 231072 65536

可见,host 将容器的 root 用户( UID = 0 )映射为了 host 的 231072 用户,所以,当容器试图再次进行 hack 的 host /bin 的时候,实际上是使用的用户231072在执行,所以返回Permission Denied

其实归纳起来,就是这样的关系 host 231072 <--> 容器 root 用户;

Network Namespace

参考章节docker-namespace-network