Containers from Scratch从零开始实现容器
转载自 ericchiang-Containers from Scratch
2017/01/07,这篇文章是为了我在 CAT BarCmp 的演讲而写,演讲的起由是我的自我挑战——“在没有Docker或rkt的情况下介绍Docker”
容器(containers)通常被认为是廉价的虚拟机,仅仅是单个主机(host)上的隔离的进程组。这种隔离利用了linux内核中内置的几种底层技术:namespaces、cgroups、chroots以及许多你之前可能已经听过的术语。
所以,让我们玩得开心,并使用这些底层技术构建我们自己的容器。
Container file systems
容器镜像(images),即你从互联网上下载下来的东西,实际上只是压缩文件(tarball),容器中最不神奇的就是你可以与之交互的文件。
这篇文章里,我通过剥离Docker image构建了一个简单的tarball,它看起来像Debian文件系统,并将成为隔离进程的游乐场(playground)。
$ wget https://github.com/ericchiang/containers-from-scratch/releases/download/v0.1.0/rootfs.tar.gz
$ sha256sum rootfs.tar.gz
c79bfb46b9cf842055761a49161831aee8f4e667ad9e84ab57ab324a49bc828c rootfs.tar.gz
首先,解压文件并查看
$ # tar needs sudo to create /dev files and setup file ownership
$ sudo tar -zxf rootfs.tar.gz
$ ls rootfs
bin dev home lib64 mnt proc run srv tmp var
boot etc lib media opt root sbin sys usr
$ ls -al rootfs/bin/ls
-rwxr-xr-x. 1 root root 118280 Mar 14 2015 rootfs/bin/ls
生成的目录看起来非常像linux系统,一个bin
目录包含可执行文件,一个etc
目录包含系统配置,一个lib
目录包含共享库,等等。
实际上构建这个tarball是一个非常有趣的话题,但我们将略过这里,总而言之,我非常推荐我同事Brian Redbeard的精彩演讲"Minimal Containers".
chroot
我们将使用的第一个工具是chroot,它是一个有类似名字的syscall的轻薄包装器,可用于限制一个进程的文件系统视图。在本例中,我们将把进程限制在"rootfs"目录中,然后执行一个shell
一旦我们到了这里,我们就可以到处乱逛(poke around),运行命令,做一些经典的shell所做的事情
$ sudo chroot rootfs /bin/bash
root@localhost:/# ls /
bin dev home lib64 mnt proc run srv tmp var
boot etc lib media opt root sbin sys usr
root@localhost:/# which python
/usr/bin/python
root@localhost:/# /usr/local/bin/python -c 'print "Hello, container world!"'
Hello, container world!
root@localhost:/#
值得注意的是,这是因为所有的东西都拷贝进了tarball。当我们执行Python解释器,我们将执行 rootfs/usr/local/bin/python
,而不是宿主机的Python。该解释器依赖于特意打包进归档(archive)中的共享库(shared libraries)和设备文件(device files)。
我们还可以在chroot中运行应用程序,不只是shell,例如开启一个文件服务器
$ sudo chroot rootfs python -m SimpleHTTPServer
Serving HTTP on 0.0.0.0 port 8000 ...
Creating namespaces with unshare
怎么隔离这个 chrooted process
,让我们在宿主机的另外一个终端运行一个命令:
$ # outside of the chroot
$ top
不出所料,我们可以在chroot内部看到宿主机的 top
系统调用
$ sudo chroot rootfs /bin/bash
root@localhost:/# mount -t proc proc /proc
root@localhost:/# ps aux | grep top
1000 24753 0.1 0.0 156636 4404 ? S+ 22:28 0:00 top
root 24764 0.0 0.0 11132 948 ? S+ 22:29 0:00 grep top
更有甚者,我们的 chroot是以root运行的,因此它可以杀死 top
进程,xs
root@localhost:/# pkill top
就这种隔离程度??
是时候谈到namespaces了,namespace允许我们创建受限的系统视图,包括进程树、网络接口、挂载等
创建namespace非常简单,只需要带有一个参数的系统调用 unshare
,一个同名的命令行工具unshare
为我们很好的包装了这个系统调用,让我们可以手动设置namespace。在本例中,我们将为shell创建一个 PID namespace,然后像上面一样执行chroot。
$ sudo unshare -p -f --mount-proc=$PWD/rootfs/proc \
chroot rootfs /bin/bash
root@localhost:/# ps aux
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
root 1 0.0 0.0 20268 3240 ? S 22:34 0:00 /bin/bash
root 2 0.0 0.0 17504 2096 ? R+ 22:34 0:00 ps aux
root@localhost:/#
我们可以发现,我们的shell认为自己的PID是1,且不再能看到主机的进程树了。
Entering namespaces with nsenter
namespaces 一个强大的地方在于其可组合性,进程可以选择隔离一些namespace但是共享其他namespace。例如,两个进程可以具有独立的PID namespace,但共享同一个network namespace(例如Kubernetes Pods),这就引出了 setns 系统调用和nsenter命令行工具。
首先,让我们找到上一个例子中,运行在chroot中的shell
$ # From the host, not the chroot.
$ ps aux | grep /bin/bash | grep root
...
root 29840 0.0 0.0 20272 3064 pts/5 S+ 17:25 0:00 /bin/bash
内核将namespace以文件的形式暴露到 /proc/(PID)/ns
目录下。在本例中,/proc/29840/ns/pid
是shell进程的命名空间
$ sudo ls -l /proc/29840/ns
total 0
lrwxrwxrwx. 1 root root 0 Oct 15 17:31 ipc -> 'ipc:[4026531839]'
lrwxrwxrwx. 1 root root 0 Oct 15 17:31 mnt -> 'mnt:[4026532434]'
lrwxrwxrwx. 1 root root 0 Oct 15 17:31 net -> 'net:[4026531969]'
lrwxrwxrwx. 1 root root 0 Oct 15 17:31 pid -> 'pid:[4026532446]'
lrwxrwxrwx. 1 root root 0 Oct 15 17:31 user -> 'user:[4026531837]'
lrwxrwxrwx. 1 root root 0 Oct 15 17:31 uts -> 'uts:[4026531838]'
nsenter
命令行工具是 setns
系统调用的包装器,我们将提供namespace文件,然后运行 unshare
重新挂载 /proc
、运行chroot
重新设置chroot。这次,我们的shell不是创建一个新的namespace,而是连接一个已经存在的。
$ sudo nsenter --pid=/proc/29840/ns/pid \
unshare -f --mount-proc=$PWD/rootfs/proc \
chroot rootfs /bin/bash
root@localhost:/# ps aux
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
root 1 0.0 0.0 20272 3064 ? S+ 00:25 0:00 /bin/bash
root 5 0.0 0.0 20276 3248 ? S 00:29 0:00 /bin/bash
root 6 0.0 0.0 17504 1984 ? R+ 00:30 0:00 ps aux
我们已经成功的进入了namespace,当我们在第二个shell(PID=5)中执行 ps
的时候可以看到第一个shell(PID=1),这是因为两个shell共用的同一个PID namespace,但是都是与宿主机隔离的啦
Getting around chroot with mounts
当部署一个不可变的(immutable)的容器时,添加一个文件或目录进入chroot有时很重要,为了存储或者配合。例如,我们在宿主机上创建一些文件,然后通过 mount
以只读的方式暴露给chrooted shell.
首先,在宿主机上创建一个文件,它将以只读的方式挂载到chroot
$ sudo mkdir readonlyfiles
$ echo "hello" > readonlyfiles/hi.txt
接下来,在我们的容器中创建一个目标文件夹,然后使用 -o ro
参数挂载以使其只读。如果你从不了解mount,你可以将其视为一个符号链接
$ sudo mkdir -p rootfs/var/readonlyfiles
$ sudo mount --bind -o ro $PWD/readonlyfiles $PWD/rootfs/var/readonlyfiles
现在chrooted process可以看到挂载进来的文件了
$ sudo chroot rootfs /bin/bash
root@localhost:/# cat /var/readonlyfiles/hi.txt
hello
但是不能写入
root@localhost:/# echo "bye" > /var/readonlyfiles/hi.txt
bash: /var/readonlyfiles/hi.txt: Read-only file system
符合我们的预期!! 尽管这是一个非常简单的例子,但是很容易扩展到NFS或基于内存的文件系统,只需要切换mount的参数
使用 unmount
可以移除挂载(rm
不生效)
$ sudo umount $PWD/rootfs/var/readonlyfiles
cgroups
cgroups,是控制组(control groups)的简写,允许内核对内存和CPU等资源进行强制隔离。毕竟,隔离进程有什么意义呢?它们仍可以通过占用内存杀死邻居。
内核在 /sys/fs/cgroup
目录中暴露 cgroups,如果你的机器上没有,你需要挂载一个cgroup 才能进行接下来的操作。
$ ls /sys/fs/cgroup/
blkio cpuacct cpuset freezer memory net_cls,net_prio perf_event systemd
cpu cpu,cpuacct devices hugetlb net_cls net_prio pids
在这个例子中,我们将创建一个cgroup来限制进程的内存。创建一个cgroup非常简单,只需要创建一个目录。我们将创建一个名为“demo”的 memory cgroup,一旦创建,内核就会自动用cgroup配置文件自动填充这个目录。
$ sudo su
# mkdir /sys/fs/cgroup/memory/demo
# ls /sys/fs/cgroup/memory/demo/
cgroup.clone_children memory.memsw.failcnt
cgroup.event_control memory.memsw.limit_in_bytes
cgroup.procs memory.memsw.max_usage_in_bytes
memory.failcnt memory.memsw.usage_in_bytes
memory.force_empty memory.move_charge_at_immigrate
memory.kmem.failcnt memory.numa_stat
memory.kmem.limit_in_bytes memory.oom_control
memory.kmem.max_usage_in_bytes memory.pressure_level
memory.kmem.slabinfo memory.soft_limit_in_bytes
memory.kmem.tcp.failcnt memory.stat
memory.kmem.tcp.limit_in_bytes memory.swappiness
memory.kmem.tcp.max_usage_in_bytes memory.usage_in_bytes
memory.kmem.tcp.usage_in_bytes memory.use_hierarchy
memory.kmem.usage_in_bytes notify_on_release
memory.limit_in_bytes tasks
memory.max_usage_in_bytes
如果要调整,我们只需要向相应的文件写入值,例如我们限制cgroup只有100M内存和关闭swap
# echo "100000000" > /sys/fs/cgroup/memory/demo/memory.limit_in_bytes
# echo "0" > /sys/fs/cgroup/memory/demo/memory.swappiness
task
文件是特殊的,它包含分配给这个cgroup的进程列表。将我们自己的PID添加到cgroup中。
# echo $$ > /sys/fs/cgroup/memory/demo/tasks
最后,我们写一个消耗内存的程序
f = open("/dev/urandom", "r")
data = ""
i=0
while True:
data += f.read(10000000) # 10mb
i += 1
print "%dmb" % (i*10,)
如果cgroup正确发挥作用,这个程序将不会使你电脑奔溃
# python hungry.py
10mb
20mb
30mb
40mb
50mb
60mb
70mb
80mb
Killed
cgroups不能被移除直到 tasks
文件中的每个进程已经退出或者被再次赋给其他cgroup。退出shell并使用 rmdir
删除目录(不要使用 rm -r
)
# exit
exit
$ sudo rmdir /sys/fs/cgroup/memory/demo
Container security and capabilities
容器是一种用root直接运行从互联网上下载的任意代码的有效方式,这是因为容器只会带来较低的开销。容器比VM更容易被打破,因此,许多用于提高容器安全性的技术(如SELinux、seccomp)和能力都涉及到限制以root的身份运行的进程的能力。
这部分我们将探索 Linux capabilities.
看下面的GO程序,它试图在端口80上进行监听:
package main
import (
"fmt"
"net"
"os"
)
func main() {
if _, err := net.Listen("tcp", ":80"); err != nil {
fmt.Fprintln(os.Stdout, err)
os.Exit(2)
}
fmt.Println("success")
}
当我们编译后运行它会发生什么呢?
$ go build -o listen listen.go
$ ./listen
listen tcp :80: bind: permission denied
可以预见,程序会运行失败,我们没有监听80端口的权限。当然,我们可以使用 sudo
,但是我们仅仅想给二进制程序一个监听较低端口的权限,不想给全部的sudo
权限。
Capabilities是一系列离散的能力,包含root可以做的所有事情,这包括系统时钟、终止任意程序。在这里,CAP_NET_BIND_SERVICE
将允许可执行文件监听较低的端口。
我们可以使用 setcap
命令授权可执行文件 CAP_NET_BIND_SERVICE
$ sudo setcap cap_net_bind_service=+ep listen
$ getcap listen
listen = cap_net_bind_service+ep
$ ./listen
success
我们更感兴趣的是取消能力而不是赋予,首先,让我们看看root shell有什么功能:
$ sudo su
# capsh --print
Current: = cap_chown,cap_dac_override,cap_dac_read_search,cap_fowner,cap_fsetid,cap_kill,cap_setgid,cap_setuid,cap_setpcap,cap_linux_immutable,cap_net_bind_service,cap_net_broadcast,cap_net_admin,cap_net_raw,cap_ipc_lock,cap_ipc_owner,cap_sys_module,cap_sys_rawio,cap_sys_chroot,cap_sys_ptrace,cap_sys_pacct,cap_sys_admin,cap_sys_boot,cap_sys_nice,cap_sys_resource,cap_sys_time,cap_sys_tty_config,cap_mknod,cap_lease,cap_audit_write,cap_audit_control,cap_setfcap,cap_mac_override,cap_mac_admin,cap_syslog,cap_wake_alarm,cap_block_suspend,37+ep
Bounding set =cap_chown,cap_dac_override,cap_dac_read_search,cap_fowner,cap_fsetid,cap_kill,cap_setgid,cap_setuid,cap_setpcap,cap_linux_immutable,cap_net_bind_service,cap_net_broadcast,cap_net_admin,cap_net_raw,cap_ipc_lock,cap_ipc_owner,cap_sys_module,cap_sys_rawio,cap_sys_chroot,cap_sys_ptrace,cap_sys_pacct,cap_sys_admin,cap_sys_boot,cap_sys_nice,cap_sys_resource,cap_sys_time,cap_sys_tty_config,cap_mknod,cap_lease,cap_audit_write,cap_audit_control,cap_setfcap,cap_mac_override,cap_mac_admin,cap_syslog,cap_wake_alarm,cap_block_suspend,37
Securebits: 00/0x0/1'b0
secure-noroot: no (unlocked)
secure-no-suid-fixup: no (unlocked)
secure-keep-caps: no (unlocked)
uid=0(root)
gid=0(root)
groups=0(root)
耶!这有非常多的功能。
例如,我们将使用 capsh
去掉一些能力,包括 CAP_CHOWN
,如果工作符合预期,我们的shell尽管是root也不能修改文件的所有权。
$ sudo capsh --drop=cap_chown,cap_setpcap,cap_setfcap,cap_sys_admin --chroot=$PWD/rootfs --
root@localhost:/# whoami
root
root@localhost:/# chown nobody /bin/ls
chown: changing ownership of '/bin/ls': Operation not permitted
传统的观念仍然认为,当运行不受信任的代码时,VMs 是强制隔离的。但安全特性(例如capabilities)是重要的对于防止在容器中运行被hack的程序。
除了更复杂的工具像SELinux, seccomp和capabilities之外,在容器中运行的程序还受益于,程序在容器外运行时,相同的最佳实践(也就是说在容器外怎么用比较好,在容器内也同样使用)。
Conclusion
容器不是魔法,每个拥有Linux机器的人都可以玩容器,像Docker和rkt仅仅是对现代内核已有的一些东西的打包。不,你可能不应该去实现一个自己的container runtime,但是有一个对这些底层技术更好的理解将帮助我们更好地使用高层工具(尤其是Debug)
还有大量的话题我今天没有讲,网络和copy-on-write文件系统可能是最大的两个。然而,我希望这是一个良好的起点对于任何想动手实践的人,Happy hacking!