Docker Technology using Linux Namespaces

Docker 是目前广泛应用的容器技术,它将容器内部的环境与主机进行隔离,使得只需分发容器边可运行多种应用程序并且免除了复杂的软件环境配置。在 Docker 中,主要通过 Linux Namespaces 功能来完成对容器和主机多种资源隔离,本文详述了 Linux Namespace 功能,并且演示了如何使勇 unshare 程序手工创建一个隔离环境。

1. Docker 简述

Docker 产生的背景

通常被开发的软件都将会运行在不同的平台和环境上,开发环境、测试环境、生产环境等等。在软件部署的过程中可能会需要配置各种各样的依赖库,数据库,Web服务器等等。即便软件本身没有任何的问题,正确的配置各个平台和环境也不是一件容易的事。

而随着互联网时代的高速发展,特别是面向普通的大众的软件产品,以其需求的不明确性和不稳定性,传统的软件开发模式变不在适用,如瀑布模型等。取而代之的则是所谓的敏捷开发,需要快速的交付能力和应对的需求的快速变更能力。实现最小原型,获取产品的即时反馈,并进行即时迭代。这就要求部署的更加平凡,这对于传统运维来讲是巨大的挑战的。开发的目的是交付新特性、修复Bug。而运维的目的是维持系统稳定性和可靠性。而对于快速部署这一需求的到来,激化了开发和运维之间的矛盾。因此,真正促使 Docker 技术的出现的原因正是开发和运维所面临的挑战。而 Docker 主要是解决目前在软件部署过程中开发环境和运维环境的一致性问题。使得开发环境与运维环境达到了很好的衔接,在部署应用上线时,不需要花费时间去处理环境的不兼容问题。

同时 Docker 也是实现 DevOps 的最佳解决方案。

什么是 Docker

一张图说明 Docker

  • 对软件和其依赖环境的标准化打包

  • 应用之间相互隔离

  • 共享一个OS Kernel

  • 可以运行在很多主流操作系统上

Docker 中 容器,镜像,仓库的概念及其操作(略)

* DevOps

DevOps 一词的来自于 Development 和 Operations 的组合,突出重视软件开发人员和运维人员的沟通合作,通过自动化流程来使得软件构建、测试、发布更加快捷、频繁和可靠。但是 DevOps 至今缺乏一个明确的定义,不同角色所理解的 DevOps 并不同样。

从技术的角度来看,DevOps 更多的是一组技术实践,具体来讲就是 DevOps 工具链。工具的具体选择则需要根据具体情况而定。

涉及到 DevOps 通常有以下话题:

  • 高频部署
  • 持续交付
  • 云计算/虚拟化技术
  • 基础设施即代码
  • Docker
  • 自动化运维

3. Docker 的技术支撑

Docker 的出现一定是因为开发和运维阶段确实需要一种虚拟化技术,解决开发和生产环境一致的问题。虽然这个需求推动着虚拟化技术的产生,但是如果没有合适的底层技术支撑,那么将任然都不到一个完美的产品。

3.1 Docker vs Hypervisor

在了解 Docker 的技术支撑之前,先了解一下 Docker 和虚拟机之间的区别。

同样,虚拟化技术也同样可以解决环境差异问题。在同一台物理机上安装多个虚拟机,应用则可以部署在每个独立的虚拟机中。但是相比较于容器技术轻量级,每一个虚拟机都需要一定资源来运行和维护自身的操作系统。当虚拟机数量增多,势必操作系统自身消耗的资源将大大增加。

容器技术与虚拟化技术并不冲突。如果将容器比作集装箱,那么虚拟化就相当于大货轮。将容器和虚拟机结合在一起使用,也是目前的主流做法。

3.2 Docker 中的关键技术

docker-core-techs1

3.2.1 Namespaces

命名空间 (namespaces) 是 Linux 提供的用于分离进程树、网络接口、挂载点以及进程间通信等资源的方法。在日常使用 Linux 桌面版时,通常没有运行多个完全分离的进程的需要。但是在服务器上运行多个服务,这些服务其实会相互影响的。每一个服务都能看到其他服务的进程,也可以访问宿主机器上的任意文件。在很多时候,这样是有风险的。在这种情况下,一旦服务器上的某一个服务被入侵,那么入侵者就能够访问当前机器上的所有服务和文件。此时就需求一种能够使得运行在同一台机器上的不同服务能够完全隔离的方法,就像运行在多台不同的机器上一样。而 Docker 就是通过 Linux 的 Namespaces 对不同的容器实现了隔离。

/proc/[pid]/ns 目录

每一个进程都有一个 /proc/[pid]/ns 目录,其中每一个文件都对应着一个 namespace。这些文件都是以连接文件的形式存在,并连接到对应的 namespace 类型和 inode 。如果两个进程在同一个命名空间,那么他们的 namespace 类型和 inode 数值将相同。这些值都可以使用 setns() 系统调用来设置。

1
2
3
4
5
6
7
8
9
10
11
=>  ~ ls -l /proc/$$/ns
total 0
lrwxrwxrwx 1 panderan panderan 0 12月 3 18:10 cgroup -> 'cgroup:[4026531835]'
lrwxrwxrwx 1 panderan panderan 0 12月 3 18:10 ipc -> 'ipc:[4026531839]'
lrwxrwxrwx 1 panderan panderan 0 12月 3 18:10 mnt -> 'mnt:[4026531840]'
lrwxrwxrwx 1 panderan panderan 0 12月 3 18:10 net -> 'net:[4026531993]'
lrwxrwxrwx 1 panderan panderan 0 12月 3 18:10 pid -> 'pid:[4026531836]'
lrwxrwxrwx 1 panderan panderan 0 12月 3 18:10 pid_for_children -> 'pid:[4026531836]'
lrwxrwxrwx 1 panderan panderan 0 12月 3 18:10 user -> 'user:[4026531837]'
lrwxrwxrwx 1 panderan panderan 0 12月 3 18:10 uts -> 'uts:[4026531838]'

Namespace Type

namespace 引入的相关内核版本 被隔离的全局系统资源 在容器语境下的隔离效果
mount since linux 3.8 文件系统挂接点 每个容器能看到不同的文件系统层次结构。
uts since linux 3.0 nodename 和 domainname 每个容器可以有自己的 hostname 和 domainame。
IPC since linux 3.0 特定的进程间通信资源 每个容器有其自己的 System V IPC 和 POSIX 消息队列文件系统,在同一个 IPC namespace 的进程之间才能互相通信。
PID since linux 3.8 进程 ID 数字空间 每个 PID namespace 中的进程可以有其独立的 PID, 每个容器可以有其 PID 为 1 的root 进程。
Network since linux 3.0 网络相关的系统资源 每个容器用有其独立的网络设备,IP 地址,IP 路由表,/proc/net 目录,端口号等等。
User since liux 3.8 用户和组 ID 空间 在 user namespace 中的进程的用户和组 ID 可以和在 host 上不同; 每个 container 可以有不同的 user 和 group id;一个 host 上的非特权用户可以成为 user namespace 中的特权用户;

由此表可以看到为什么 Docker 会要求 Kernel 版本大于等于 3.8。

在 linux 中会有一个 unshare 工具,这个工具可以运行一些程序从当前进行中 namespace 进行隔离。其 man page 如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
NAME
unshare - run program with some namespaces unshared from parent
SYNOPSIS
unshare [options] [program [arguments]]
Options:
-m, --mount[=<file>] unshare mounts namespace
-u, --uts[=<file>] unshare UTS namespace (hostname etc)
-i, --ipc[=<file>] unshare System V IPC namespace
-n, --net[=<file>] unshare network namespace
-p, --pid[=<file>] unshare pid namespace
-U, --user[=<file>] unshare user namespace
-f, --fork fork before launching <program>
--mount-proc[=<dir>] mount proc filesystem first (implies --mount)
-r, --map-root-user map current user to root (implies --user)
--propagation slave|shared|private|unchanged
modify mount propagation in mount namespace
-s, --setgroups allow|deny control the setgroups syscall in user namespaces

-h, --help display this help and exit
-V, --version output version information and exit

CLONE_NEWUTS

在 clone() 函数创建子进程时,CLONE_NEWUTS 标志置位会将使得主机名资源被隔离。两个 UTS 空间的任何改变都不相互影响。这对于虚拟化来讲非常有用,虚拟机管理程序可以给每一个虚拟机创建一个 UTS 空间。

NEWUTS

CLONE_NEWUSER

通过在 clone() 函数中使用 CLONE_NEWUSER 标志,一个单独的 user namespace 就会被创建出来。在新的 user namespace 中会有一个虚拟的用户和用户组的集合。这些用户和用户组,从 uid/gid 0 开始,可以被映射到该 namespace 之外。

上图中,原 user namespace 中 uid:1000 和 gid:1000 被映射到新的 user namespace 中 uid:0 和 gid:0 。尽管在新的 user namespace 中是 root 身份(uid:0,gid:0),依然不能访问 /root 目录。

CLONE_NEWNS

Mount namespace 用来隔离文件系统的挂载点, 使得不同的 mount namespace 拥有自己独立的挂载点信息,不同的 namespace 之间不会相互影响,当前进程所在 mount namespace 里的所有挂载信息可以在 /proc/[pid]/mounts、/proc/[pid]/mountinfo 和 /proc/[pid]/mountstats 里面找到。每个 mount namespace 都拥有一份自己的挂载点列表,当用 clone 或者 unshare 函数创建新的 mount namespace 时,新创建的 namespace 将拷贝一份老 namespace 里的挂载点列表,但从这之后,他们就没有关系了,通过 mount 和 umount 增加和删除各自 namespace 里面的挂载点都不会相互影响。

CLONE_NEWPID

PID namespaces 用来隔离进程的 ID 空间,使得不同 pid namespace 里的进程 ID 可以重复且相互之间不影响。 PID namespace 可以嵌套,也就是说有父子关系,在当前 namespace 里面创建的所有新的 namespace 都是当前 namespace 的子 namespace。父 namespace 里面可以看到所有子孙后代 namespace 里的进程信息,而子namespace 里看不到祖先或者兄弟 namespace 里的进程信息。

到这里,Docker 容器中进程隔离已经显而易见了,当创建并启动 Docker 容器后 docker start … && docker exec … 后,容器中进程如下图所示。

CLONE_NEWIPC

IPC namespace用来隔离 System V IPC objects 和POSIX message queues。其中System V IPC objects包含Message queues、Semaphore sets 和 Shared memory segments.

进入 Docker 环境,除了 docker attach、docker exec 命令,也可用以下命令进行。$PID 为 docker container 的运行进程。

1
$ nsenter --target $PID --mount --uts --ipc --net --pid --user /bin/bash

CLONE_NEWNET

network namespace 用来隔离网络设备, IP地址, 端口等。每个 namespace 将会有自己独立的网络栈,路由表,防火墙规则,socket 等。每个新的 network namespace 默认有一个本地环回接口,除了 lo 接口外,所有的其他网络设备(物理/虚拟网络接口,网桥等)只能属于一个 network namespace。每个 socket 也只能属于一个network namespace。当新的 network namespace 被创建时,lo 接口默认是关闭的,需要自己手动启动起。

Linux container 中用到一个叫做 veth 的东西,这是一种新的设备,专门为 container 所建。veth 从名字上来看是 Virtual ETHernet 的缩写,它的作用很简单,就是要把从一个 network namespace 发出的数据包转发到另一个 namespace。veth 设备是成对的,一个是 container 之中,另一个在 container 之外,即在真实机器上能看到的。 VETH设备总是成对出现。创建并配置正确后,向其一端输入数据,VETH会改变数据的方向并将其送入内核网络子系统,完成数据的注入,而在另一端则能读到此数据。veth工作在L2数据链路层,veth-pair设备在转发数据包过程中并不串改数据包内容。

显然,仅有veth-pair设备,容器是无法访问网络的。因为容器发出的数据包,实质上直接进入了veth1设备的协议栈里。如果容器需要访问网络,需要使用bridge等技术,将veth1接收到的数据包通过某种方式转发出去 。

在 Docker 中的网络实现基本与此相同。

3.2.2 Control Groups

3.2.3 Union Filesystem

4. 实验

4.1 利用 Linux 中的 Namespace 手工建立容器环境

Start Terminal 1 (In Container)

  • 创建相关目录
1
2
root@panderan /h/deranpan# mkdir -p /opt/container_root/root/old_root /opt/container_root/data
root@panderan /h/deranpan# cd /opt/container_root
  • wget 下载 ubuntu 16.04 的根文件
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    root@panderan /o/container_root# wget https://raw.githubusercontent.com/tianon/docker-brew-ubuntu-core/dist-amd64/xenial/ubuntu-xenial-core-cloudimg-amd64-root.tar.gz
    --2018-12-05 18:14:53-- https://raw.githubusercontent.com/tianon/docker-brew-ubuntu-core/dist-amd64/xenial/ubuntu-xenial-core-cloudimg-amd64-root.tar.gz
    Resolving raw.githubusercontent.com (raw.githubusercontent.com)... 151.101.108.133
    Connecting to raw.githubusercontent.com (raw.githubusercontent.com)|151.101.108.133|:443... connected.
    HTTP request sent, awaiting response... 200 OK
    Length: 42625208 (41M) [application/octet-stream]
    Saving to: ‘ubuntu-xenial-core-cloudimg-amd64-root.tar.gz’

    ubuntu-xenial-core-cloudimg- 100%[===========================================>] 40.65M 876KB/s in 2m 23s

    2018-12-05 18:17:18 (290 KB/s) - ‘ubuntu-xenial-core-cloudimg-amd64-root.tar.gz’ saved [42625208/42625208]
  • 解压根文件系统到 /opt/container_root/root 目录下
    1
    2
    root@panderan /o/container_root# cd root && tar -xf ../ubuntu-xenial-core-cloudimg-amd64-root.tar.gz
    root@panderan /o/c/root# cd ..
1
2
3
4
5
6
7
8
9
10
### 利用 unshare 创建命名空间隔离的 bash 进程,进程号为 3664
root@panderan /o/container_root# unshare --mount --uts --ipc --net --pid --user --fork --propagation private /bin/bash
nobody@panderan:/opt/container_root$ ps
PID TTY TIME CMD
2281 pts/1 00:00:00 zsh
2518 pts/1 00:00:00 su
2519 pts/1 00:00:00 zsh
3663 pts/1 00:00:00 unshare
3664 pts/1 00:00:00 bash
3675 pts/1 00:00:00 ps

Start Terminal 2 (Host)

  • 映射 uid 和 gid ,将 host 中的所有 uid,gid 都映射到 Container 中
1
2
3
root@panderan /h/deranpan# cd /proc/3664 
root@panderan /p/3664# echo "0 0 4294967295" > gid_map
root@panderan /p/3664# echo "0 0 4294967295" > uid_map

Switch to Terminal 1 (In Container)

  • 跟新 bash 可见用户名已经从 nobody 变为 root,并设置新的 hostname 为 container
1
2
3
4
5
nobody@panderan:/opt/container_root$ exec bash
root@panderan:/opt/container_root# hostname container
root@panderan:/opt/container_root# exec bash
root@container:/opt/container_root#

Switch to Terminal 2 (Host)

  • 创建一对 veth 设备,并使用 NAT 使得 Container 能够访问外网。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
root@panderan /p/3664# ip link add veth0 type veth peer name veth1
root@panderan /p/3664# ip link set veth1 netns 3664
root@panderan /p/3664# ip addr add dev veth0 192.168.8.1/24
root@panderan /p/3664# ip link set veth0 up
root@panderan /p/3664# ifconfig veth 0
SIOCSIFADDR: No such device
veth: ERROR while getting interface flags: No such device
root@panderan /p/3664# ifconfig veth0
veth0 Link encap:Ethernet HWaddr de:b0:75:84:2c:cb
inet addr:192.168.8.1 Bcast:0.0.0.0 Mask:255.255.255.0
UP BROADCAST MULTICAST MTU:1500 Metric:1
RX packets:0 errors:0 dropped:0 overruns:0 frame:0
TX packets:0 errors:0 dropped:0 overruns:0 carrier:0
collisions:0 txqueuelen:1000
RX bytes:0 (0.0 B) TX bytes:0 (0.0 B)
root@panderan /p/3664# echo 1 > /proc/sys/net/ipv4/ip_forward
root@panderan /p/3664# iptables -P FORWARD DROP
root@panderan /p/3664# iptables -F FORWARD
root@panderan /p/3664# iptables -t nat -F
root@panderan /p/3664# iptables -t nat -A POSTROUTING -s 192.168.8.0/24 -o ens33 -j MASQUERADE
root@panderan /p/3664# iptables -A FORWARD -i ens33 -o veth0 -j ACCEPT
root@panderan /p/3664# iptables -A FORWARD -o ens33 -i veth0 -j ACCEPT

Switch to Terminal 1 (In Container)

  • 设置 Container 中的网络设备,并添加默认路由
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
root@container:/opt/container_root# ip link addr add dev veth1 192.168.8.2/24
Command "addr" is unknown, try "ip link help".
root@container:/opt/container_root# ip addr add dev veth1 192.168.8.2/24
root@container:/opt/container_root# ip link set veth1 up
root@container:/opt/container_root# ip route add default via 192.168.8.1
root@container:/opt/container_root# ifconfig veth1
veth1 Link encap:Ethernet HWaddr 4e:41:2c:4c:21:e1
inet addr:192.168.8.2 Bcast:0.0.0.0 Mask:255.255.255.0
inet6 addr: fe80::4c41:2cff:fe4c:21e1/64 Scope:Link
UP BROADCAST RUNNING MULTICAST MTU:1500 Metric:1
RX packets:30 errors:0 dropped:0 overruns:0 frame:0
TX packets:8 errors:0 dropped:0 overruns:0 carrier:0
collisions:0 txqueuelen:1000
RX bytes:4034 (4.0 KB) TX bytes:648 (648.0 B)

root@container:/opt/container_root# route -n
Kernel IP routing table
Destination Gateway Genmask Flags Metric Ref Use Iface
0.0.0.0 192.168.8.1 0.0.0.0 UG 0 0 0 veth1
192.168.8.0 0.0.0.0 255.255.255.0 U 0 0 0 veth1
root@container:/opt/container_root#

  • 测试网络
1
2
3
4
5
6
7
8
9
10
11
root@container:/opt/container_root# ping 114.114.114.114
PING 114.114.114.114 (114.114.114.114) 56(84) bytes of data.
64 bytes from 114.114.114.114: icmp_seq=1 ttl=68 time=39.4 ms
64 bytes from 114.114.114.114: icmp_seq=2 ttl=90 time=40.0 ms
64 bytes from 114.114.114.114: icmp_seq=3 ttl=90 time=39.6 ms
64 bytes from 114.114.114.114: icmp_seq=4 ttl=63 time=38.5 ms
^C
--- 114.114.114.114 ping statistics ---
4 packets transmitted, 4 received, 0% packet loss, time 3005ms
rtt min/avg/max/mdev = 38.590/39.412/40.009/0.536 ms
root@container:/opt/container_root#

Continue at Terminal 1 (In Container)

  • pivot_root 替换根目录时,新根目录和原根目录不能在一个挂载点。
1
root@container:/opt/container_root# mount --bind root/ root/
  • 挂载数据卷,这样切换根目录后任然可以和主机共享文件
1
2
root@container:/opt/container_root# mkdir root/data
root@container:/opt/container_root# mount --bind ./data root/data
  • 切换根文件系统,挂载 proc sys dev 目录,并 umount 原根目录
1
2
3
4
5
6
7
8
9
10
root@container:/opt/container_root# mkdir root/old_root
root@container:/opt/container_root# cd root/
root@container:/opt/container_root/root# pivot_root . old_root/
root@container:/opt/container_root/root# exec bash
root@container:/# mount -t proc proc /proc
root@container:/# mount -t sysfs sysfs /sys
root@container:/# mount -t tmpfs tmpfs /dev
root@container:/# umount -l old_root/
root@container:/#

  • 查看进程,已与容器外部隔离;data 下也没有数据。
1
2
3
4
5
6
root@container:/# ps -ef
UID PID PPID C STIME TTY TIME CMD
root 1 0 0 10:25 ? 00:00:00 bash
root 70 1 0 11:01 ? 00:00:00 ps -ef
root@container:/# ls /data
root@container:/#

Switch to Terminal 2 (Host)

  • 将 sources.list 文件拷贝至主机中的 data 目录中。
1
2
3
4
5
6
7
8
root@panderan /o/container_root# cat ~/sources.list 
deb http://mirrors.163.com/ubuntu/ xenial main restricted universe multiverse
deb http://mirrors.163.com/ubuntu/ xenial-security main restricted universe multiverse
deb http://mirrors.163.com/ubuntu/ xenial-updates main restricted universe multiverse
deb http://mirrors.163.com/ubuntu/ xenial-proposed main restricted universe multiverse
deb http://mirrors.163.com/ubuntu/ xenial-backports main restricted universe multiverse
root@panderan /o/container_root# cp ~/sources.list /opt/container_root/data

Switch to Terminal 1 (In Container)

  • 将 sources.list 拷贝到 etc 下,并使用 apt 安装 vim 程序。
1
2
3
4
5
6
7
8
9
10
11
12
13
root@container:/# ls /data 
sources.list
root@container:/# cp /data/sources.list /etc/apt/sources.list
root@container:/# echo "nameserver 114.114.114.114" > /etc/resolv.conf
root@container:/# apt update
...
Fetched 18.6 MB in 26s (704 kB/s)
Reading package lists... Done
root@container:/#
root@container:/# apt install vim
...
Processing triggers for libc-bin (2.23-0ubuntu10) ...
root@container:/# vim

Switch to Terminal 2 (Host)

  • 观察进程树
    unshare 创建了子进程bash,bash 又运行刚刚下载安装的 vim
1
2
3
4
5
6
7
8
9
10
11
12
root@panderan /o/container_root# pstree -pl | tail -n 15 | head -n 10
|-systemd-timesyn(631)---{sd-resolve}(676)
|-systemd-udevd(356)
|-thermald(860)---{thermald}(939)
|-tmux(2280)-+-zsh(2281)---su(2518)---zsh(2519)---unshare(3663)---bash(3664)---vim(7015)
| `-zsh(2368)---su(3740)---zsh(3741)-+-head(7445)
| |-pstree(7443)
| `-tail(7444)
|-upowerd(1448)-+-{gdbus}(1452)
| `-{gmain}(1451)
|-vmtoolsd(594)

Start Terminal 3(Get into Container,another bash in container)

  • 利用 nsenter 进入容器,并利用 apt 安装 psmisc 后使用 pstree 观察容器内进程树。
1
2
3
4
5
6
7
8
9
10
11
12
13
root@panderan /h/deranpan# nsenter --target 3664 --mount --uts --ipc --net --pid --user bash
root@container:/# apt install psmisc
...
root@container:/# pstree -pl 0
?()-+-bash(1)---vim(529)
`-bash(598)---pstree(603)
root@container:/# ps -ef
UID PID PPID C STIME TTY TIME CMD
root 1 0 0 10:25 ? 00:00:00 bash
root 529 1 0 11:09 ? 00:00:00 vim
root 598 0 0 11:22 ? 00:00:00 bash
root 604 598 0 11:23 ? 00:00:00 ps -ef

至此,手工创建一个容器到此结束。这个过程简单的利用 Linux 中的 Namespace 隔离机制创建了一个隔离的容器环境。

参考资料

  1. Docker 核心技术与实现原理
  2. 容器技术概述
  3. 精益创业
  4. 聊聊不一样的 DevOps(上)
  5. 容器技术概述
  6. DevOps简介
  7. Dev 与 Ops 互怼 | 科普一下 DevOps
  8. Linux内核-容器之namespace
  9. Docker Internals
  10. http://www.cnblogs.com/sammyliu/p/5878973.html
  11. Linux Namespace系列