0x00 前言
容器是指一种系统级的虚拟化技术,想比于KVM等内核级的虚拟化技术,具有更加轻量的特点。随着Docker技术以及k8s容器编排引擎的流行,容器在云原生时代扮演着绝对重要的角色。但事实上,容器技术自从Unix时代就已经出现,并且存在着多种容器方案,不同的容器方案之间既有相同点,也有不同点。
0x01 最早的容器化技术——chroot
最早的容器是1979年由UNIX实现的Chroot Jail。通过chroot技术,实现了独立的系统目录结构,从而支持运行指定的操作系统。
$ wget https://mirrors.tuna.tsinghua.edu.cn/ubuntu-cdimage/ubuntu-base/releases/20.04.3/release/ubuntu-base-20.04.3-base-amd64.tar.gz
$ tar zxvf ubuntu-base-20.04.3-base-amd64.tar.gz
$ sudo mount -o bind /proc proc
$ sudo mount -o bind /dev dev
$ sudo mount -o bind /etc/resolv.conf etc/resolv.conf
$ sudo chroot .
# cat /etc/lsb-release
DISTRIB_ID=Ubuntu
DISTRIB_RELEASE=20.04
DISTRIB_CODENAME=focal
DISTRIB_DESCRIPTION="Ubuntu 20.04.3 LTS"
此时,就进入了Ubuntu 2004
的环境,不过这是一个只进行了文件系统隔离的容器,没有做进程、网络、资源等隔离,安全性较差,对于本地使用倒是足够了。
相对于Docker,这种方式在容器内的操作会保存到文件系统,因此退出以后再次进入还会保留之前的状态。可以使用这种方式来管理多个非临时容器环境(例如本地的多个测试环境)。
获取系统镜像压缩包
除了直接使用操作系统提供的压缩包,还可以通过docker获取,这种方式会更简单通用。
$ sudo docker run -it ubuntu:20.04 bash
root@762063502a65:/# apt update
root@762063502a65:/# apt install curl
$ sudo docker container export 762063502a65 -o ubuntu20.04.tar
$ tar xvf ubuntu20.04.tar
这里可以先对镜像做一些操作,然后再导出成压缩包。
proot
proot是一款用户空间的chroot
、mount
、binfmt_miscbinfmt_misc
实现。它可以在非root场景下代替chroot
,因此在Android等不方便root的设备上应用比较广泛。
proot利用ptrace
去拦截子进程中的系统调用,从而达到替换系统调用结果的目的。但这种方式也会产生一定的性能损耗。
$ proot -r ./ubuntu-20.04.3 -b /proc:/proc cat /etc/lsb-release
proot info: default working directory is now "/"
DISTRIB_ID=Ubuntu
DISTRIB_RELEASE=20.04
DISTRIB_CODENAME=focal
DISTRIB_DESCRIPTION="Ubuntu 20.04.3 LTS"
0x02 Linux资源隔离方案——Namespace
chroot容器只是实现了容器最基本的功能,支持了文件系统的隔离,但是其它资源,像进程、用户、网络这些都是与宿主机共享的。
为了支持资源隔离,Linux提供了Namespace(命名空间)
解决方案。PID, IPC, Network等系统资源不再是全局性的,而是属于某个特定的Namespace。不同命名空间下的进程只能访问当前命名空间内的资源,无法访问其它命名空间里的资源,从而做到了资源隔离。
可以使用unshare
命令创建新的命名空间,基本原理是在创建子进程的时候加入了对应的命名空间标记位,源代码地址为:https://github.com/util-linux/util-linux/blob/master/sys-utils/unshare.c。下面是该命令的使用帮助:
$ unshare --help
Usage:
unshare [options] [<program> [<argument>...]]
Run a program with some namespaces unshared from the parent.
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
-C, --cgroup[=<file>] unshare cgroup namespace
-T, --time[=<file>] unshare time namespace
-f, --fork fork before launching <program>
--map-user=<uid>|<name> map current user to uid (implies --user)
--map-group=<gid>|<name> map current group to gid (implies --user)
-r, --map-root-user map current user to root (implies --user)
-c, --map-current-user map current user to itself (implies --user)
--map-auto map users and groups automatically (implies --user)
--map-users=<outeruid>,<inneruid>,<count>
map count users from outeruid to inneruid (implies --user)
--map-groups=<outergid>,<innergid>,<count>
map count groups from outergid to innergid (implies --user)
--kill-child[=<signame>] when dying, kill the forked child (implies --fork)
defaults to SIGKILL
--mount-proc[=<dir>] mount proc filesystem first (implies --mount)
--propagation slave|shared|private|unchanged
modify mount propagation in mount namespace
--setgroups allow|deny control the setgroups syscall in user namespaces
--keep-caps retain capabilities granted in user namespaces
-R, --root=<dir> run the command with root directory set to <dir>
-w, --wd=<dir> change working directory to <dir>
-S, --setuid <uid> set uid in entered namespace
-G, --setgid <gid> set gid in entered namespace
--monotonic <offset> set clock monotonic offset (seconds) in time namespaces
--boottime <offset> set clock boottime offset (seconds) in time namespaces
-h, --help display this help
-V, --version display version
For more details see unshare(1).
最新版本的使用文档可以参考:https://man7.org/linux/man-pages/man1/unshare.1.html。
PID命名空间
$ sudo unshare --fork --pid --mount-proc bash
# ps aux
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
root 1 0.0 0.0 3952 3020 pts/0 S 22:47 0:00 bash
root 2 0.0 0.0 5644 2636 pts/0 R+ 22:47 0:00 ps aux
可以看到,在新的shell中,已经无法看到父命名空间中的进程了。如果不加--fork
参数,看到的进程列表会是当前系统中的全部进程。这是因为新的PID命名空间只在子进程中生效。
用户命名空间
$ ps aux | grep bash
root 303493 0.0 0.0 3952 3032 pts/3 S+ 07:53 0:00 bash
在全局进程列表中可以看到,命名空间中的进程实际上也是以root权限执行的,存在一定的安全风险。
$ unshare --fork --pid --mount-proc --user bash
$ whoami
nobody
使用--user
参数可以创建用户命名空间,并以普通用户权限进入命名空间中。但我们实际操作中更希望容器里面的用户是root
权限,此时可以使用--map-root-user
参数。
$ unshare --fork --pid --mount-proc --user --map-root-user bash
# id
uid=0(root) gid=0(root) groups=0(root),65534(nogroup)
# reboot
Failed to connect to bus: No data available
Failed to open initctl fifo: Permission denied
Failed to talk to init daemon.
使用--map-root-user
参数可以将全局的普通用户映射为容器中的root
用户,但并不是真正的全局root用户,因此无法访问那些只有全局root用户才能访问的资源。
Mount命名空间
前面的例子使用的都是宿主机上的操作系统,如果想使用不同的操作系统,可以通过Mount Namespace
来实现。
假设现在ubuntu-20.04
目录中已经有一个完整的Ubuntu系统了。
$ sudo unshare -R ubuntu-20.04 bash
root@drunkdream-LB0:/# cat /etc/os-release
NAME="Ubuntu"
VERSION="20.04.4 LTS (Focal Fossa)"
ID=ubuntu
ID_LIKE=debian
PRETTY_NAME="Ubuntu 20.04.4 LTS"
VERSION_ID="20.04"
HOME_URL="https://www.ubuntu.com/"
SUPPORT_URL="https://help.ubuntu.com/"
BUG_REPORT_URL="https://bugs.launchpad.net/ubuntu/"
PRIVACY_POLICY_URL="https://www.ubuntu.com/legal/terms-and-policies/privacy-policy"
VERSION_CODENAME=focal
UBUNTU_CODENAME=focal
通过-R dir
/ --root=dir
参数可以指定系统根目录。低版本的unshare可能不支持这个参数,可以使用sudo unshare sh -c 'chroot ubuntu-20.04 && bash'
命令代替。
网络命名空间
使用ip netns
命令可以进行网络命名空间的管理。
创建命名空间
$ sudo ip netns add netns1
获取命名空间列表
$ sudo ip netns ls netns1 $ ls -l /var/run/netns/netns1 -r--r--r-- 1 root root 0 May 21 15:30 /var/run/netns/netns1 $ sudo ./unshare -R ubuntu-20.04 --net=/var/run/netns/netns1 bash
网络命名空间会存储在
/var/run/netns
目录下。在网路命名空间中执行命令
$ sudo ip netns exec netns1 ip a 1: lo: <LOOPBACK> mtu 65536 qdisc noop state DOWN group default qlen 1000 link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
启用网络设备
$ sudo ip netns exec netns1 ip link set lo up
$ sudo ip netns exec netns1 ip a
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
inet 127.0.0.1/8 scope host lo
valid_lft forever preferred_lft forever
inet6 ::1/128 scope host
valid_lft forever preferred_lft forever
- 创建veth-pair
veth-pair是一对虚拟设备接口,和
tap/tun
设备不同的是,它都是成对出现的,一端连着协议栈,一端彼此相连着。
$ sudo ip link add veth1 type veth peer name veth2
$ sudo ip link set veth2 netns netns1
$ sudo ip netns exec netns1 ip link set veth2 up
$ sudo ip netns exec netns1 ip addr add 192.168.10.2/24 dev veth2
$ sudo ip link set veth1 up
$ sudo ip addr add 192.168.10.1/24 dev veth1
- 配置路由转发
$ sudo ip netns exec netns1 ip route add default via 192.168.10.1 dev veth2
$ sudo iptables -A FORWARD -o veth1 -j ACCEPT
$ sudo iptables -A FORWARD -i veth1 -j ACCEPT
$ sudo iptables -t nat -A POSTROUTING -s 192.168.10.0/24 -o eth0 -j MASQUERADE
eth0
需要替换成本机的实际网络接口
此时,容器内就可以访问外网了。
- 与unshare相结合
unshare也提供了--net=/var/run/netns/netns1
参数来创建网络命名空间,但是实际测试下来发现有些问题。所以比较保险的方案还是将sudo ip netns exec netns1
与unshare
结合起来使用。
$ sudo ip netns exec netns1 unshare -R ubuntu-20.04 ip a
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
inet 127.0.0.1/8 scope host lo
valid_lft forever preferred_lft forever
inet6 ::1/128 scope host
valid_lft forever preferred_lft forever
29: veth2@if30: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default qlen 1000
link/ether fe:2c:29:36:5c:9a brd ff:ff:ff:ff:ff:ff link-netnsid 0
inet 192.168.10.2/24 scope global veth2
valid_lft forever preferred_lft forever
inet6 fe80::fc2c:29ff:fe36:5c9a/64 scope link
valid_lft forever preferred_lft forever
0x03 打造一个相对完整的容器
从上面的介绍来看,如果不需要用到网络虚拟化,是可以使用普通用户权限的。
使用普通用户权限创建不支持网络命名空间的容器
新版本unshare
$ unshare --fork --pid --mount-proc --mount --uts --ipc -R . --map-root-user bash bash-4.2# id uid=0(root) gid=0(root) groups=0(root),65534(nogroup)
旧版本unshare
$ unshare --fork --pid --mount-proc --mount --uts --ipc --user --map-root-user sh -c 'mount -o bind /proc proc && chroot .' bash-4.2# id uid=0(root) gid=0(root) groups=0(root),65534(nogroup)
使用root权限创建支持网络命名空间的容器
$ sudo ip netns exec netns1 unshare --fork --pid --mount-proc --mount --uts --ipc -R . --map-root-user bash
旧版本unshare方式不再赘述
在Ubuntu里使用apt
时如果遇到下面这个错误:
root@drunkdream-LB0:/# apt update
E: setgroups 65534 failed - setgroups (1: Operation not permitted)
E: setegid 65534 failed - setegid (22: Invalid argument)
E: seteuid 100 failed - seteuid (22: Invalid argument)
E: setgroups 0 failed - setgroups (1: Operation not permitted)
Reading package lists... Done
W: chown to _apt:root of directory /var/lib/apt/lists/partial failed - SetupAPTPartialDirectory (22: Invalid argument)
W: chown to _apt:root of directory /var/lib/apt/lists/auxfiles failed - SetupAPTPartialDirectory (22: Invalid argument)
E: setgroups 65534 failed - setgroups (1: Operation not permitted)
E: setegid 65534 failed - setegid (22: Invalid argument)
E: seteuid 100 failed - seteuid (22: Invalid argument)
E: setgroups 0 failed - setgroups (1: Operation not permitted)
E: Method gave invalid 400 URI Failure message: Failed to setgroups - setgroups (1: Operation not permitted)
E: Method gave invalid 400 URI Failure message: Failed to setgroups - setgroups (1: Operation not permitted)
E: Method http has died unexpectedly!
E: Sub-process http returned an error code (112)
可以通过在进入容器后使用以下命令解决:
# apt-config dump | grep Sandbox::User
APT::Sandbox::User "_apt";
# cat <<EOF > /etc/apt/apt.conf.d/sandbox-disable
APT::Sandbox::User "root";
EOF
# apt-config dump | grep Sandbox::User
APT::Sandbox::User "root";
0x04 Linux容器技术——LXC
lxc是一种系统级容器,在使用体验上与传统的虚拟机差异不是很大。
lxc模板文件位于/usr/share/lxc/templates
路径下,包含了绝大多数的Linux发行版。
$ ls /usr/share/lxc/templates
lxc-alpine lxc-centos lxc-fedora lxc-oci lxc-plamo lxc-sparclinux lxc-voidlinux
lxc-altlinux lxc-cirros lxc-fedora-legacy lxc-openmandriva lxc-pld lxc-sshd
lxc-archlinux lxc-debian lxc-gentoo lxc-opensuse lxc-sabayon lxc-ubuntu
lxc-busybox lxc-download lxc-local lxc-oracle lxc-slackware lxc-ubuntu-cloud
创建lxc容器
$ sudo lxc-create -t ubuntu -n ubuntu-xenial
修改lx容器网络类型
默认的lxc容器网络是empty
类型,只有lo
网卡,不能访问外网。可以通过修改/var/lib/lxc/ubuntu-xenial/config
文件中的lxc.net.0.type = empty
为lxc.net.0.type = none
,来使用主机网络。
启动lxc容器
$ sudo lxc-start ubuntu-xenial
进入lxc容器
$ sudo lxc-attach ubuntu-xenial bash
root@ubuntu-xenial:/# cat /etc/lsb-release
DISTRIB_ID=Ubuntu
DISTRIB_RELEASE=16.04
DISTRIB_CODENAME=xenial
DISTRIB_DESCRIPTION="Ubuntu 16.04.7 LTS"
root@ubuntu-xenial:/# ps aux
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
root 1 0.0 0.0 37236 5288 ? Ss 23:51 0:00 /sbin/init
root 36 0.0 0.0 35276 5440 ? Ss 23:51 0:00 /lib/systemd/systemd-journald
root 85 0.0 0.0 30720 2880 ? Ss 23:51 0:00 /usr/sbin/cron -f
syslog 87 0.0 0.0 256396 3196 ? Ssl 23:51 0:00 /usr/sbin/rsyslogd -n
root 124 0.0 0.0 17492 2180 console Ss+ 23:51 0:00 /sbin/agetty --noclear --keep-baud console 115200 38400 9600
root 125 0.0 0.0 17492 2244 pts/2 Ss+ 23:51 0:00 /sbin/agetty --noclear --keep-baud pts/2 115200 38400 9600 vt
root 126 0.0 0.0 17492 2172 pts/3 Ss+ 23:51 0:00 /sbin/agetty --noclear --keep-baud pts/3 115200 38400 9600 vt
root 127 0.0 0.0 17492 2176 pts/1 Ss+ 23:51 0:00 /sbin/agetty --noclear --keep-baud pts/1 115200 38400 9600 vt
root 128 0.0 0.0 17492 2236 pts/0 Ss+ 23:51 0:00 /sbin/agetty --noclear --keep-baud pts/0 115200 38400 9600 vt
root 129 0.0 0.0 65516 5412 ? Ss 23:51 0:00 /usr/sbin/sshd -D
root 137 0.0 0.0 18256 3256 ? Ss 23:53 0:00 bash
root 149 0.0 0.0 34428 2796 ? R+ 23:54 0:00 ps aux
可以看出,想比于docker,lxc容器更接近于完整的Linux系统。
停止lxc容器
$ sudo lxc-stop ubuntu-xenial
删除lxc容器
$ sudo lxc-destroy ubuntu-xenial
0x05 容器技术的集大成者——Docker
Docker是一种应用级容器,倡导每个容器只包含一个主进程。同时,它提供了打包容器镜像的支持,从而大大降低了容器分发的成本,使得基于容器镜像的应用部署逐渐成为主流。可以说,Docker最大的贡献就是创造性地发明了容器镜像技术,并通过推动OCI(Open Container Initiative)标准,统一了镜像规范和运行时规范。
常见的Docker操作命令可以参考:https://www.drunkdream.com/2019/04/04/docker-command/。
OCI 对容器 runtime 的标准主要是指定容器的运行状态,和 runtime 需要提供的命令。下面是容器状态转换图:
RunC
是Docker提供的一个OCI运行时实现, 是从Docker的libcontainer中迁移而来,实现了容器启停、资源隔离等功能,可以作为容器运行时标准的参考实现。
除了RunC
,还有Kata
、gVisor
等OCI实现。
containerd
是一个上层的容器运行时,与RunC
等底层容器运行时相比,它还包含了镜像管理等能力。
K8S也从1.23版本开始弃用Docker,改用containerd作为容器运行时。下图是改用containerd之后的基本架构。
0x06 容器技术的未来
RunC
作为传统的容器运行时技术,由于与宿主机共享内核,一直存在着隔离性差的缺点。因此,以提升隔离性为目标的Kata
、gVisor
等新的容器运行时技术被开发出来。
Kata
Kata Containers开源项目于2017年底正式启动,其目标是将虚拟机(VM)的安全优势与容器的高速及可管理性相结合,为用户带来出色的容器解决方案。Kata的目标是开发面向云原生的虚拟化
技术,也就是通过创建轻量级的虚拟机以提供像容器一样快同时又像虚拟机一样安全的容器。
gVisor
gVisor是Google开发的一款轻量级容器运行时,提供了更高的隔离性。与其它运行时使用虚拟机提升隔离性不同,它实现了一个用户层内核
,通过拦截系统调用提供了强隔离边界。因此,gVisor更加轻量,资源占用更少。但对于少量应用程序可能存在兼容性问题。
0x07 参考文献
- https://josephmuia.ca/2018-05-16-net-namespaces-veth-nat/
- https://www.l6bj.com/post/cloudnative/docker/02-namespace/
- https://www.waynerv.com/posts/container-fundamentals-filesystem-isolation-and-sharing/
- https://zhuanlan.zhihu.com/p/372698959
- https://zhuanlan.zhihu.com/p/370869886
- https://zhuanlan.zhihu.com/p/369510683
- https://www.modb.pro/db/248305
- https://www.cnblogs.com/bakari/p/10613710.html
- https://zhuanlan.zhihu.com/p/441238885