容器¶
主要作者
本文已完成
容器是近十几年来兴起的一种轻量级的虚拟化技术,在 Linux 内核支持的基础上实现了共享内核的虚拟化,让应用的部署与管理变得更加简单。
本部分假设读者了解基本的 Docker 使用。
容器技术的内核支持¶
命名空间¶
Linux 内核的命名空间功能是容器技术的重要基础。命名空间可以控制进程所能看到的系统资源,包括其他进程、网络、文件系统、用户等。可以阅读 namespaces(7) 了解相关的信息。
可以在 procfs 看到某个进程所处的命名空间;使用 lsns 可以列出所有当前命名空间可以「看到」的命名空间:
$ ls -lh /proc/self/ns/
total 0
lrwxrwxrwx 1 username username 0 Mar 24 21:04 cgroup -> 'cgroup:[4026531835]'
lrwxrwxrwx 1 username username 0 Mar 24 21:04 ipc -> 'ipc:[4026531839]'
lrwxrwxrwx 1 username username 0 Mar 24 21:04 mnt -> 'mnt:[4026531841]'
lrwxrwxrwx 1 username username 0 Mar 24 21:04 net -> 'net:[4026531840]'
lrwxrwxrwx 1 username username 0 Mar 24 21:04 pid -> 'pid:[4026531836]'
lrwxrwxrwx 1 username username 0 Mar 24 21:04 pid_for_children -> 'pid:[4026531836]'
lrwxrwxrwx 1 username username 0 Mar 24 21:04 time -> 'time:[4026531834]'
lrwxrwxrwx 1 username username 0 Mar 24 21:04 time_for_children -> 'time:[4026531834]'
lrwxrwxrwx 1 username username 0 Mar 24 21:04 user -> 'user:[4026531837]'
lrwxrwxrwx 1 username username 0 Mar 24 21:04 uts -> 'uts:[4026531838]'
$ lsns
NS TYPE NPROCS PID USER COMMAND
4026531834 time 294 2440 username /usr/bin/dbus-broker-launch --scope user
4026531835 cgroup 293 2440 username /usr/bin/dbus-broker-launch --scope user
4026531836 pid 248 2440 username /usr/bin/dbus-broker-launch --scope user
(以下省略)
使用 nsenter 命令可以进入某个命名空间:
$ sudo docker run -it --rm --name test ustclug/ubuntu:22.04
root@9213a075a2f4:/#
...
$ # 开启另一个终端
$ sudo docker top test
UID PID PPID C STIME TTY TIME CMD
root 117426 117406 0 21:09 pts/0 00:00:00 bash
$ sudo nsenter --target 117426 --uts bash # 进入 UTS 命名空间
[root@9213a075a2f4 example]# # 可以看到 hostname 已经改变
ustclug Docker image
本页的容器示例使用了 ustclug/mirrorimage 生成的容器镜像,默认配置了科大镜像站,帮助减少 apt 等操作之前还要跑 sed 的麻烦,支持包括 Ubuntu、Debian、Alpine 等多个发行版。
如果无法顺利访问 Docker Hub,也可以使用 ghcr.io/ustclug/ubuntu:22.04。
那么 PID 命名空间也是同理吗?
$ sudo nsenter --target 117426 --pid bash
# ps aux
fatal library error, lookup self
# echo $$
417
# ls -lh /proc/$$/
ls: cannot access '/proc/417/': No such file or directory
# # 如果使用 htop,仍然可以看到完整的进程列表
这是因为这里挂载的 procfs 是对应整个系统的,因此即使进入了新的 PID 命名空间,在 mount 命名空间不变的情况下,/proc 目录下的内容仍然是宿主机的。因此需要同时进入容器内对应进程的 mount 命名空间:
$ sudo nsenter --target 117426 --pid --mount bash
# ps aux
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
root 1 0.0 0.0 4624 3712 pts/0 Ss+ 13:09 0:00 bash
root 434 0.0 0.0 4624 3712 ? S 13:32 0:00 bash
root 437 0.0 0.0 7060 2944 ? R+ 13:32 0:00 ps aux
因此一般来讲,我们会希望同时进入进程所属所有的命名空间,以避免可能的不一致性问题。可以通过 -a 参数实现。
另一个与命名空间有关的实用命令是 unshare,取自同名的系统调用,可以创建新的命名空间。对于上面展示 PID 命名空间的例子,可以使用 unshare 命令创建一个新的 PID 命名空间与 mount 命名空间,并且在新的 mount 命名空间里面挂载新的 /proc:
$ sudo unshare --pid --fork --mount-proc bash
# ps aux
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
root 1 0.0 0.0 10876 4568 pts/17 S 21:42 0:00 bash
root 2 0.0 0.0 14020 4464 pts/17 R+ 21:42 0:00 ps aux
bind mount
对容器、沙盒等应用来说,在建立 mount 命名空间后一般都需要切换根目录到新位置(pivot_root(2))。但是很多时候,我们需要将主机的某个目录或者文件挂载进来,这就需要用到 bind mount 了。bind mount 可以以挂载点的形式将某个文件或者目录「挂载」到其他地方。使用 mount(8) 命令可以实现 bind mount:
# 将 /dir1 挂载到 /dir2
sudo mount --bind /dir1 /dir2
# 将 /dir1,包括 /dir1 下面的其他子挂载点递归挂载到 /dir2
sudo mount --rbind /dir1 /dir2
另一个关键的参数是挂载传播(mount propagation),它决定了某个挂载点内部子挂载点的变化是否会传播到它自己的其他「分身」(bind mount 或者其他的 mount namespace)上面。有四种不同的传播模式:
- shared:传播、接受变化,这一般是主机环境的默认值
- private:不传播、不接受变化
- slave:不传播变化,只接受它的父 mount namespace 里的变化(对应父 mount namespace 的挂载点需要是 shared)
- unbindable:不传播、不接受变化,也不允许被 bind mount
在这四项参数前面加上 r 就代表递归设置行为。一般来讲,容器都会选择 rprivate 传播模式,防止容器与主机之间互相影响。在特定需求情况下(例如这个 issue),可以视情况选择 rslave 或者 rshared 传播模式。Docker 支持相关的设置。
另外,有一种用户命名空间,允许非 root 用户创建新的用户命名空间(这也是 rootless 容器的基础),让我们简单试一试:
$ unshare --user --pid --fork --mount-proc bash
$ ps aux
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
nobody 1 0.0 0.0 10876 4312 pts/16 S 21:44 0:00 bash
nobody 2 0.0 0.0 14020 4276 pts/16 R+ 21:44 0:00 ps aux
$ exit
$ # 甚至可以修改映射,实现「假的」root
$ unshare --user --pid --fork --map-root-user --mount-proc bash
# ps aux
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
root 1 0.0 0.0 10876 4468 pts/16 S 21:46 0:00 bash
root 2 0.0 0.0 14020 4416 pts/16 R+ 21:46 0:00 ps aux
subuid 和 subgid
/etc/subuid 和 /etc/subgid 文件定义了用户命名空间允许的 UID 和 GID 的映射关系。一个 subuid 文件的例子如下:
这代表用户名为 user 的用户在创建新的用户命名空间的时候,可以将从 UID 100000 开始的 65536 个 UID 映射到新的用户命名空间中。一种常见的映射关系是,新命名空间的 root 用户(UID 0)映射到宿主机的当前用户,而 root 以外的用户则依次使用映射,例如用户命名空间中的 UID 1 就映射到宿主机的 UID 100000,以此类推。
命名空间的魔法
在了解命名空间的基础上,我们可以绕过容器运行时的一些限制,直接操作命名空间。
作为其中一个「花式操作」的例子,可以阅读这篇 USENIX ATC 2018 的论文:Cntr: Lightweight OS Containers(以及目前仍然在维护的代码仓库)。这篇工作实现了在不包含调试工具的容器中使用包含调试工具的镜像(或者 host 的调试工具)进行调试的功能。
Cgroups¶
Cgroups(cgroups(7))是 Linux 内核提供的限制与统计进程资源使用的机制。
Cgroups 以文件系统的形式暴露给用户态,一般挂载在 /sys/fs/cgroup/。
相比于传统的 setrlimit 等系统调用,cgroups 能够有效地管理一组进程(以及它们新建的子进程)的资源使用。
在使用 systemd 的系统中,cgroups 由 systemd 负责管理。
仔细观察 systemctl status 的输出,可以发现其就展示了一颗 cgroup 树(注意看 init.scope 上一行):
$ systemctl status
● example
State: running
Units: 271 loaded (incl. loaded aliases)
Jobs: 0 queued
Failed: 0 units
Since: Sun 2023-06-11 15:42:13 CST; 9 months 14 days ago
systemd: 252.19-1~deb12u1
CGroup: /
├─init.scope
│ └─1 /lib/systemd/systemd --system --deserialize=34
├─system.slice
│ ├─caddy.service
│ │ └─2398446 /usr/bin/caddy run --environ --config /etc/caddy/Caddyfile
│ ├─containerd.service
│ │ └─3923123 /usr/bin/containerd
│ ├─cron.service
│ │ └─649 /usr/sbin/cron -f
│ ├─dbus.service
│ │ └─650 /usr/bin/dbus-daemon --system --address=systemd: --nofork --nopidfile --systemd-activation --syslog-only
│ ├─docker.service
│ │ └─3926029 /usr/bin/dockerd -H fd:// --containerd=/run/containerd/containerd.sock
(省略)
也可以使用 systemd-cgtop 实时查看 cgroup 的使用情况。
Cgroups 有 v1 与 v2 两个版本。 较新的发行版默认仅支持 cgroups v2,稍老一些的会使用 systemd 的 "unified_cgroup_hierarchy" 特性,将 cgroups v1 与 v2 合并暴露给用户。目前大部分软件都已经支持 cgroups v2,因此下文讨论 cgroups 时,默认为 v2。
可以手工通过读写文件控制 cgroups:
$ sleep 1d &
[1] 1910225
$ sudo -i
# cd /sys/fs/cgroup
# mkdir test
# cd test
# echo 1910225 > cgroup.procs
# cat cgroup.procs
1910225
这样,我们就将刚刚创建的 sleep 1d 的进程移动到了 test cgroup 中。
(注意 cgroup 树包含了系统的所有进程,因此写入到 cgroup.procs 的实际语义是移动进程所属的 cgroup)。
由于 test 创建在根下面,因此其包含了所有的「控制器」(Controller)。
不同的控制器对应不同的系统资源,例如内存、CPU、IO 等。
可以通过 ls 确认:
# ls
cgroup.controllers cpu.max.burst cpu.weight.nice hugetlb.2MB.rsvd.max memory.low memory.zswap.current
cgroup.events cpu.pressure hugetlb.1GB.current io.bfq.weight memory.max memory.zswap.max
cgroup.freeze cpuset.cpus hugetlb.1GB.events io.latency memory.min misc.current
cgroup.kill cpuset.cpus.effective hugetlb.1GB.events.local io.low memory.numa_stat misc.events
cgroup.max.depth cpuset.cpus.exclusive hugetlb.1GB.max io.max memory.oom.group misc.max
cgroup.max.descendants cpuset.cpus.exclusive.effective hugetlb.1GB.numa_stat io.pressure memory.peak pids.current
cgroup.pressure cpuset.cpus.partition hugetlb.1GB.rsvd.current io.prio.class memory.pressure pids.events
cgroup.procs cpuset.mems hugetlb.1GB.rsvd.max io.stat memory.reclaim pids.max
cgroup.stat cpuset.mems.effective hugetlb.2MB.current io.weight memory.stat pids.peak
cgroup.subtree_control cpu.stat hugetlb.2MB.events irq.pressure memory.swap.current rdma.current
cgroup.threads cpu.stat.local hugetlb.2MB.events.local memory.current memory.swap.events rdma.max
cgroup.type cpu.uclamp.max hugetlb.2MB.max memory.events memory.swap.high
cpu.idle cpu.uclamp.min hugetlb.2MB.numa_stat memory.events.local memory.swap.max
cpu.max cpu.weight hugetlb.2MB.rsvd.current memory.high memory.swap.peak
控制器可以通过 cgroup.subtree_control 控制是否启用。
需要注意的是,根据 cgroup v2 的 "no internal processes" 规则,
除根节点以外,其他的 cgroup 不能同时本身既包含进程,又设置了 subtree_control。
尝试操作 subtree_control
请根据 cgroups 手册以及上文的说明,在 test cgroup 下创建一个新的 cgroup,并且使其仅包含内存控制器。
想一想:上面的 sleep 进程应该怎么操作?
实验完成后,杀掉 sleep 进程,并且使用 rmdir 删除 test cgroup:
为什么不使用 rm -r
思考这个问题:
/sys/fs/cgroup/test 并非我们传统意义上的「空目录」,为什么这里要用 rmdir,而不是用 rm -r 递归删除?
Cgroups 有命令行工具可以帮助管理,安装 cgroup-tools 后,可以使用 cgcreate、cgexec 等命令:
$ sudo cgcreate -g memory:test # 创建一个名为 test 的 cgroup
$ sudo cgset -r memory.max=16777216 test # 限制使用 16 MiB 内存
$ sudo cgexec -g memory:test python3 # 在 test 下运行 python3
Python 3.10.12 (main, Nov 20 2023, 15:14:05) [GCC 11.4.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> a = [0] * 4000000 # 尝试占用至少 32 MB 内存
Killed
$ sudo cgdelete memory:test # 清理现场
观察这些命令的行为
请使用系统调用分析工具 strace 观察 cgcreate、cgset、cgexec、cgdelete 的文件系统操作。
具体的控制器使用方法这里不再赘述。
Seccomp¶
Seccomp 是 Linux 内核提供的限制进程系统调用的机制。 如果没有 seccomp,即使使用上面提到的命名空间、cgroups,容器内的进程仍然可以执行任意的系统调用: root 权限的进程可以随意进行诸如关机、操作内核模块等危险操作,这通常是非预期的; 即使通过用户机制限制了权限,暴露所有的系统调用仍然大幅度增加了攻击面。
从程序员的角度,可以使用 libseccomp 库简化 seccomp 的使用。
执行 Python 3 解释器最少需要多少系统调用?
使用 libseccomp 编写程序,设置系统调用白名单限制。 尝试找出最小的系统调用集合,并且了解其中的每个系统调用的作用。
Capabilities¶
传统上,类 Unix 系统都根据用户来判断权限:root 用户什么都可以做,非 root 用户只能做有限的事情。但是这种模型在如今已经难以满足复杂的需求:例如 ping 等工具其实只需要原始套接字(raw socket)的权限,不需要完整的 root 权限——直接添加 SUID 权限会留下很大的攻击面;而容器环境中,我们一般也不希望 root 能做所有的事情。即使有 seccomp 限制系统调用,在一部分场景下也不够,例如 ioctl 这个调用是通用的——它既可以操作终端,也可以直接操作硬件设备。要在系统调用层面细化限制不是件容易事。
Linux 内核的能力(capabilities(7))则把 root 的权限拆分成了许多个独立的能力。例如 ping 需要的 CAP_NET_RAW 能力,绕过 Unix 传统权限控制的 CAP_DAC_OVERRIDE 能力等。如果没有某个能力,进程即使是 root 用户,也无法执行对应的操作;反之即使不是 root,只要有对应的能力,也可以执行对应的操作。
Capabilities 可以赋予给进程和可执行文件。例如在一部分较旧的系统上,ping 命令的可执行文件就被赋予了 CAP_NET_RAW 能力(getcap 等 capabilities 相关的工具在 libcap2-bin 包中):
Permitted、Effective 和 Inheritable 集合
上面输出中的 ep 代表这个程序的 CAP_NET_RAW 能力被设置到了 Permitted 和 Effective 集合中。
对进程(线程)来说,它可以申请使用在 Permitted 集合中的能力,在 Effective 集合中的能力则是当前生效的能力。除此之外,还有一个 Inheritable 集合,代表可以被子进程继承的能力。而对文件来说,相关的集合定义有一些细微的差别,详情可以参考手册。
为什么我的系统上,ping 既不是 SUID 程序,也没有 capabilities?
Linux 内核支持设置 net.ipv4.ping_group_range(这个选项也控制 IPv6 下对应行为),指定哪些用户组可以不依赖 capabilities 而对外发送 ICMP Echo Request,相比 capabilities 更加细化,且不再局限于具体的程序:
当前环境的 capabilities 则可以通过 capsh 查看(其中 Bounding 和 Ambient 集合的详细细节可参考手册):
$ capsh --print
Current: cap_wake_alarm=i
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,cap_audit_read,cap_perfmon,cap_bpf,cap_checkpoint_restore
Ambient set =
(以下省略)
# capsh --print # 是正常的 root 用户的话……
Current: =ep cap_wake_alarm+i
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,cap_audit_read,cap_perfmon,cap_bpf,cap_checkpoint_restore
Ambient set =
(以下省略)
试一下限制 capabilities
使用 capsh 限制 capabilities,尝试运行一些需要特定 capabilities 的命令,例如安装/卸载内核模块。
容器实现一般会限制掉大部分的 capabilities(除非用户需要特权容器)。例如 Docker 可参考其默认列表。
违背 Capabilities 设计初衷的 CAP_SYS_ADMIN
理论上来说,Capabilities 是一个好的设计:细化原先 root 的权限,减少攻击面。但是在实践上,许多重要的功能,例如 mount,都依赖于 CAP_SYS_ADMIN 这一能力。这就导致了 CAP_SYS_ADMIN 成为事实上的 "the new root"。
可阅读 LWN.net 的相关文章 了解更多。
除了以上提到的安全技术外,例如 Docker 等容器还会使用如 AppArmor、SELinux 等 MAC(Mandatory Access Control)机制进一步限制容器权限。这一部分会在高级内容的「DAC 与 MAC」部分进一步介绍。
Overlay 文件系统¶
Overlay 文件系统(OverlayFS)不是容器所必需的——比如说,一些容器运行时支持像 chroot 一样,直接从一个 rootfs 目录启动容器(例如 systemd-nspawn)。 但是对于 Docker 这样的容器运行时,其 image 的分层结构使得 OverlayFS 成为了一个非常重要的技术。 尽管对于 Docker 来说,其也支持其他的写时复制的存储驱动,例如 Btrfs 和 ZFS,但是 OverlayFS 仍然是最常见的选择——因为它不需要特殊的文件系统支持。
挂载 OverlayFS 需要三个目录:
- lowerdir: 只读的底层目录(支持多个)
- upperdir: 可读写的上层目录
- workdir: 需要为与上层目录处于同一文件系统的目录,用于处理文件系统的原子操作
最终合成的文件系统会将 lowerdir 与 upperdir 合并,对于相同的文件,upperdir 优先。 让我们试一试:
$ mkdir lower upper work merged
$ echo "lower" > lower/lower
$ echo "upper" > upper/upper
$ mkdir lower/dir upper/dir
$ echo "lower1" > lower/dir/file1
$ echo "upper1" > upper/dir/file1
$ echo "lower2" > lower/dir/file2
$ echo "upper2" > upper/dir/file2
$ echo "lower4" > lower/dir/file4
$ echo "upper3" > upper/dir/file3
$ sudo mount -t overlay overlay -o lowerdir=lower,upperdir=upper,workdir=work merged
$ tree merged
merged/
├── dir
│ ├── file1
│ ├── file2
│ ├── file3
│ └── file4
├── lower
└── upper
2 directories, 6 files
可以看到 merged 目录下的文件是合并后的结果,同时存在 lower 和 upper 目录的文件。
$ cat merged/lower
lower
$ cat merged/dir/file1
upper1
$ cat merged/dir/file2
upper2
$ cat merged/dir/file3
upper3
$ cat merged/dir/file4
lower4
上面只有 upper 不存在的 dir/file4 是 lower 的内容,因此可以印证 upper 优先。
$ echo "merged" > merged/merged
$ echo "modified-lower" > merged/lower
$ cat upper/merged
merged
$ cat upper/lower
modified-lower
$ cat lower/merged
cat: lower/merged: No such file or directory
$ cat lower/lower
lower
可以显然发现写入操作会被应用到 upperdir 中。
传统上,OverlayFS 最常见的用途是在 LiveCD/LiveUSB 上使用:在只读的底层文件系统上,挂载一个可写的上层文件系统,用于保存用户的数据。
而在容器(特别是 Docker)上,由于容器镜像的分层设计,OverlayFS 就成为了一个非常好的选择。
假设某个容器镜像有三层,每一层都做了一些修改。由于 OverlayFS 支持多个 lowerdir,
所以最后合成出来的 image 就是第一层基底 + 第二层的变化作为 lowerdir,第三层作为 upperdir,
这也可以从 docker image inspect 的结果印证:
$ sudo docker image inspect 201test
(省略)
"GraphDriver": {
"Data": {
"LowerDir": "/var/lib/docker/overlay2/9d15ee29579c96414c51ea2e693d2fe764da2e704a005e2d398025bf8c2b85b6/diff:/var/lib/docker/overlay2/38eb305239012877d40fc4f06620d0293d7632f188b986a0ff7f30a57b6feb32/diff",
"MergedDir": "/var/lib/docker/overlay2/a66d2956c278d83a86454659dba3b2f75b99b41ddb39c5227b02afde898efe55/merged",
"UpperDir": "/var/lib/docker/overlay2/a66d2956c278d83a86454659dba3b2f75b99b41ddb39c5227b02afde898efe55/diff",
"WorkDir": "/var/lib/docker/overlay2/a66d2956c278d83a86454659dba3b2f75b99b41ddb39c5227b02afde898efe55/work"
},
"Name": "overlay2"
},
(省略)
(当然,这里容器镜像不会,也没有必要挂载,因此如果尝试访问 merged 目录,会发现不存在)
使用容器镜像启动的容器则将镜像作为 lowerdir,在容器里面的写入操作则会被保存在 upperdir 中。
$ sudo docker run -it --rm --name test 201test
root@1522be2f7d29:/# echo 'test' > /test
root@1522be2f7d29:/# # 切换到另一个终端
$ sudo docker inspect test
(省略)
"GraphDriver": {
"Data": {
"LowerDir": "/var/lib/docker/overlay2/34e8198226f478c89021fd9a00a31570cdda57d4fcea66a0bb8506cf7b81dff5-init/diff:/var/lib/docker/overlay2/a66d2956c278d83a86454659dba3b2f75b99b41ddb39c5227b02afde898efe55/diff:/var/lib/docker/overlay2/9d15ee29579c96414c51ea2e693d2fe764da2e704a005e2d398025bf8c2b85b6/diff:/var/lib/docker/overlay2/38eb305239012877d40fc4f06620d0293d7632f188b986a0ff7f30a57b6feb32/diff",
"MergedDir": "/var/lib/docker/overlay2/34e8198226f478c89021fd9a00a31570cdda57d4fcea66a0bb8506cf7b81dff5/merged",
"UpperDir": "/var/lib/docker/overlay2/34e8198226f478c89021fd9a00a31570cdda57d4fcea66a0bb8506cf7b81dff5/diff",
"WorkDir": "/var/lib/docker/overlay2/34e8198226f478c89021fd9a00a31570cdda57d4fcea66a0bb8506cf7b81dff5/work"
},
"Name": "overlay2"
},
(省略)
$ sudo ls /var/lib/docker/overlay2/34e8198226f478c89021fd9a00a31570cdda57d4fcea66a0bb8506cf7b81dff5/diff/
test
解释 Dockerfile 编写中的实践
从 OverlayFS 的角度,解释以下 Dockerfile 存在的问题:
Docker¶
基础概念复习¶
Docker 是众多容器运行时中的一种(也是最流行的一种)。用户可以从 registry 获取 image, 获得的 image 可以直接创建 container 运行,也可以使用 Dockerfile 来定制 image。 除此之外,Docker 也提供了与存储(volume)、网络(network)等相关的功能。 Docker 的设计主要考虑了开发与部署的便利性。
Docker 采取 C-S 架构,server daemon(dockerd)暴露一个 UNIX socket(/run/docker.sock),
用户通过 docker 这个 CLI 工具,或者自行编写程序与其通信。
这个 daemon 的容器操作则是与 containerd 进行交互。
Podman
对比 Docker 的 C-S 架构,红帽主推的 Podman 则不再依赖于 daemon 进行容器管理。
最简单的创建容器的方法是:
加入 docker 用户组等价于提供 root 权限
在默认安装下,Docker socket 只有位于 docker 用户组的用户才能访问,对应的 server daemon 程序以 root 权限运行。 将用户加入 docker 用户组即授权了对应的用户与 Docker 的 UNIX socket,或者说与 dockerd 服务端,任意通信的权限。 用户可以通过创建特权容器、任意挂载宿主机目录等操作来实际做和 root 一模一样的事情。
2023 年的 Hackergame 有一道相关的题目:Docker for Everyone。
尽管 sudo 支持限制用户可以执行的命令,但是由于 Docker CLI 的复杂性,这种限制很难做到完备,极易被绕过。可参考 Hackergame 2024 的题目:Docker for Everyone Plus。
基于相同的理由,如果需要跨机器操作 Docker,也不应该用 -H tcp:// 的方式开启远程访问。
请阅读 https://docs.docker.com/engine/security/protect-access/ 了解安全配置远程访问 Docker 的方法。
以下所有块代码示例中均会使用 sudo。
保持环境整洁:给容器起名,并且为临时使用的容器加上 --rm
一个非常常见的问题是,很多人启动容器的时候直接这么做:
然后做了一些操作之后就直接退出了。这么做的后果,就是在 docker ps -a 的时候,发现一大堆已经处于退出状态的容器。
加上 --name 参数命名,可以帮助之后判断容器的用途;加上 --rm 参数则会在容器退出后自动删除容器。可以考虑为常用的命令添加 alias,例如:
这样执行 tmpctr ustclug/debian:12 就可以启动一个临时容器了。
另外创建容器时非常常见的需求:
-e KEY=VALUE/--env KEY=VALUE:设置环境变量-v HOST_PATH:CONTAINER_PATH:挂载宿主机目录- 相对路径需要自行加上
$(pwd),像这样:-v $(pwd)/data:/data
- 相对路径需要自行加上
-p HOST_PORT:CONTAINER_PORT:映射端口--restart=always/--restart=unless-stopped:设置容器启动、重启策略(可以使用docker update修改)--memory=512m --memory-swap=512m:限制容器内存使用
映射端口的安全性
默认情况下,Docker 会自行维护 iptables 规则,并且这样的规则不受 ufw 等工具的管理。 这会导致暴露的端口绕过了系统的防火墙。
如果不需要其他机器访问,使用 -p 127.0.0.1:xxxx:xxxx,而非 -p xxxx:xxxx。
作为一个真实的案例,某服务器这样启动了一个 MongoDB 数据库容器:
过了几个月,发现程序功能不正常,再一看才发现数据被加密勒索了——万幸的是里面没有重要的内容。
以及常见的容器管理命令:
docker ps:查看容器docker exec -it CONTAINER COMMAND:在容器内执行命令docker inspect CONTAINER:查看容器详细信息
与查看、清理 Docker 磁盘占用等操作:
docker system df:查看镜像、容器、volume 与构建缓存的磁盘占用docker system prune --volumes --all:清理不再使用的镜像、容器、volume、network 与全部构建缓存
导入与导出¶
Docker 支持导出容器与镜像,并以镜像的形式导入,格式均为 tar。可以通过管道的方式实现压缩:
$ # 镜像导入/导出
$ sudo docker image save hello-world:latest | zstd -o hello-world.tar.zst
/*stdin*\ : 15.11% ( 26.0 KiB => 3.93 KiB, hello-world.tar.zst)
$ sudo docker image rm hello-world:latest
$ zstd -d -c hello-world.tar.zst | sudo docker image load
e07ee1baac5f: Loading layer [==================================================>] 14.85kB/14.85kB
Loaded image: hello-world:latest
$ # 容器导出,并以镜像形式导入
$ sudo docker run -it --name test ustclug/debian:12 touch /test
$ sudo docker container export test | zstd -o test.tar.zst
/*stdin*\ : 33.93% ( 116 MiB => 39.2 MiB, test.tar.zst)
$ sudo docker rm test
$ zstd -d -c test.tar.zst | sudo docker import - test
sha256:21a35d8f910941f4913ada5f3600c04234d13860fe498ac5cb301ba1801aa82c
$ sudo docker run -it --rm test ls /test
/test
其中镜像导出(save)后仍然有层级结构,但是容器导出(export)后则是一个完整的文件系统。
也有一些工具,例如 dive,可以方便地查看镜像每一层的内容。
多阶段构建¶
在制作容器镜像的时候,一个常见的场景是:编译软件和实际运行的环境是不同的。 如果将两者写在不同的 Dockerfile 中,实际操作会很麻烦(需要先构建编译容器、运行容器、再构建运行环境容器); 如果写在同一个 Dockerfile 里面,并且需要清理掉实际运行时不需要的文件,也会非常非常麻烦:
# 那么可能只能这么写,否则编译环境仍然会残留在镜像中
RUN apt install -y some-dev-package another-dev-package ... && \
wget https://example.com/some-source.tar.gz && \
tar -zxvf some-source.tar.gz && \
./configure --some-option && make && make install && \
rm -rf /some-source.tar.gz /some-source && \
apt remove -y some-dev-package another-dev-package ... && \
Dockerfile 对多阶段(multi-stage)的支持很好地解决了这个问题,一个简单的例子如下:
FROM alpine:3.15 AS builder
RUN apk add --no-cache build-base
WORKDIR /tmp
ADD example.c .
RUN gcc -o example example.c
FROM alpine:3.15
COPY --from=builder /tmp/example /usr/local/bin/example
可以发现,这个 Dockerfile 有多个 FROM 代表了多个阶段。
第一阶段的 FROM 后面加上了 AS builder,这样就可以在第二阶段使用 COPY --from=builder 从第一阶段拷贝文件。
运行图形应用¶
在容器中运行图形程序也是相当常见的需求。 以下简单介绍在 Docker 中运行 X11 图形应用(即 X 客户端)的方法,假设主机环境已经配置好了 X 服务器。
X 客户端与服务器
X 服务器负责显示图形界面,而具体的图形界面程序,即 X 客户端,则需要连接到 X 服务器才能绘制出自己的窗口。
如果你正在使用 Linux 作为桌面环境,那么要么整个桌面环境就由 X 服务器渲染,要么在 Wayland 下 Xwayland 会作为 X 服务器提供兼容。如果正在使用 Windows 或 macOS,则需要各自安装 X 服务器实现。
对于 SSH 连接到远程服务器的场景,可以使用 ssh -X(或 ssh -Y (1))为远程的服务器上的 X 客户端暴露自己的 X 服务器。下面的例子假设了 X 服务器 socket 是一个本地的 UNIX socket 文件,但是这对 SSH X forwarding 的场景来说并不适用(SSH 会使用 TCP 转发 X 端口)。对应的,如果正在使用 SSH 测试下面的内容,那么在传递环境变量与 $HOME/.Xauthority 文件的同时,还需要设置容器与主机使用相同的网络(--network host)。
- 当使用
-X时,服务端会假设客户端是不可信任的,因此会限制一些操作;-Y选项则会放宽这些限制。详见 ssh_config(5) 对ForwardX11Trusted的介绍。
X 客户端连接到服务器,首先需要知道 X 服务器的地址。这是由 DISPLAY 环境变量指定的,一般是 :0,代表连接到 /tmp/.X11-unix/X0 这个 UNIX socket。此外,由于 X 的协议设计是「网络透明」的,因此 X 服务器理论上也可以以 TCP 的方式暴露出来(但是不建议这么做),客户端通过类似于 DISPLAY=host:port 的方式连接。
X 与抽象套接字(abstract socket)
目前,X server 会默认开启 abstract socket 支持,即使不共享 /tmp/.X11-unix,X 客户端也可以以此连接到 X 服务器。Abstract socket 只能通过网络命名空间隔离。详情见高级内容的「Linux 桌面与窗口系统」部分。
因此,首先需要传递 DISPLAY 环境变量,并且将 /tmp/.X11-unix 挂载到容器中:
为了测试,可以在容器里安装 x11-apps,然后运行 xeyes。如果配置正确,可以看到一双眼睛在跟随鼠标。
但是上面的配置是不够的:
root@6f640b929f0e:/# xeyes
Authorization required, but no authorization protocol specified
Error: Can't open display: :0
这是因为 X 服务器需要认证信息才能够连接,对应的认证信息就在名为 "Xauthority" 的文件中,对应 XAUTHORITY 环境变量:
如果这个环境变量不存在,那么就会使用默认值:当前用户的家目录下的 .Xauthority 文件。
避免直接关闭认证的做法
如果阅读网络上的一些教程,它们可能会建议直接关闭 X 服务器的认证,就像这样:
这在安全性上是非常糟糕的做法,因为这样的话就会允许所有能访问到 X 服务器的人/程序连接。
所以也需要将这一对环境变量和文件塞进来:
sudo docker run -it --rm -e "DISPLAY=$DISPLAY" -e "XAUTHORITY=$XAUTHORITY" -v /tmp/.X11-unix:/tmp/.X11-unix -v $XAUTHORITY:$XAUTHORITY ustclug/debian:12
这样就可以在容器中运行基本的图形应用了。 不过,如果实际需求是在类似沙盒的环境中运行图形应用,有一些更合适的选择,例如 bubblewrap 以及基于此的 Flatpak 等。
GPU
(以下内容不完全适用于使用 NVIDIA 专有驱动的 NVIDIA GPU)
在 Linux 下,GPU 设备文件位于 /dev/dri 目录下。每张显卡会暴露两个设备文件,其中 cardX 代表了完整的 GPU 设备(有写入权限相当于有控制 GPU 的完整权限),而 renderDXXX 代表了 GPU 的渲染设备。对于需要 GPU 加速渲染的场景,为其挂载 /dev/dri/renderDXXX 设备即可。
与此同时,容器内还需要安装对应的 GPU 用户态驱动。对于开源驱动来说,安装 Mesa 即可。
Registry¶
Registry 是存储与分发容器镜像的服务。在大部分时候,我们使用的 registry 是 Docker Hub。
区分 Docker 和 Docker Hub
Docker 是容器运行时,而 Docker Hub 是一个 registry 服务。除了 Docker Hub 以外,还有很多其他的 registry 服务, 这些服务提供的容器镜像也可以正常在 Docker 中使用。
镜像名称的格式是 registry/namespace/repository:tag,其中在 Docker 中,如果没有指定 registry,默认会使用 Docker Hub(docker.io);而如果没有指定 namespace,则默认会指定为 library,其代表 Docker Hub 中的「官方」镜像;如果没有指定 tag,则默认采用 latest。
Registry 服务大多允许用户上传自己的容器镜像。在对应的服务平台注册帐号,使用 docker login 登录之后,需要先使用 docker tag 为自己的镜像打上对应的标签:
然后再 docker push:
除了 Docker Hub 以外,另一个比较常见的 registry 服务是 GitHub Container Registry (ghcr)。它与 GitHub 的其他功能,如 Actions 有更好的集成(例如可以直接使用 ${{ secrets.GITHUB_TOKEN }} 来登录到 ghcr)。谷歌和红帽也提供了自己的 registry 服务。
Volume¶
Volume 是 Docker 提供的一种持久化存储的方式,可以用于保存数据、配置等。
上面介绍了使用 -v HOST_PATH:CONTAINER_PATH 的方式挂载宿主机的目录(这种方式也被称为 bind mount),不过如果将参数写成 -v VOLUME_NAME:CONTAINER_PATH 的形式,那么 Docker 就会自动创建一个以此命名的 volume,并且将其挂载到容器中。
$ sudo docker run -it --rm -v myvolume:/myvolume ustclug/debian:12
root@c273ee70fe7a:/# touch /myvolume/a
root@c273ee70fe7a:/# ls /myvolume/
a
Volume 在这里不会因为容器销毁被删除:
root@c273ee70fe7a:/# ^D
$ # 原来的容器没了,挂载相同的 volume 开个新的
$ sudo docker run -it --rm -v myvolume:/myvolume ustclug/debian:12
root@38e2da3a59f7:/# ls /myvolume/
a
可以查看 Docker 管理的所有 volume,它们在文件系统中的实际位置:
$ sudo docker volume ls
DRIVER VOLUME NAME
local 2aa17ad1c2ee9bf3b2933d241a5196bdaff5e144abcfbf4c1d161198f0f35912
(省略)
local myvolume
(省略)
$ sudo docker inspect myvolume
[
{
"CreatedAt": "2024-04-14T22:54:12+08:00",
"Driver": "local",
"Labels": null,
"Mountpoint": "/var/lib/docker/volumes/myvolume/_data",
"Name": "myvolume",
"Options": null,
"Scope": "local"
}
]
此外,在上面列出的 volume 里面,有一些没有名字,显示为哈希值的 volume。这些被称为「匿名 volume」。 例如,如果在创建容器的时候不指定 volume 名字,那么 Docker 就会自动创建一个匿名 volume:
$ sudo docker run -it --rm --name test -v /myvolume ustclug/debian:12
root@ec434eb92714:/# # 打开另一个终端
$ sudo docker inspect test
(省略)
"Mounts": [
{
"Type": "volume",
"Name": "e97304e5a0d8a981b4f0c62b776f6fcaed8d8a6a7263d8e8b7b2f1ea60018976",
"Source": "/var/lib/docker/volumes/e97304e5a0d8a981b4f0c62b776f6fcaed8d8a6a7263d8e8b7b2f1ea60018976/_data",
"Destination": "/myvolume",
"Driver": "local",
"Mode": "",
"RW": true,
"Propagation": ""
}
],
如果在 docker run 时添加了 --rm 参数,那么匿名 volume 会在容器销毁时被删除。
反之,手动 docker rm 一个容器时,它对应的匿名 volume 不会被删除。
同时,Dockerfile 中也可以使用 VOLUME 指令声明 volume。如果用户在运行容器的时候没有指定 volume,那么 Docker 就会自动创建一个匿名 volume。
一个例子是 mariadb 容器镜像:
网络¶
Docker 的网络隔离基于 Linux 的网络命名空间等特性。 默认情况下,Docker 会创建三种网络:
$ sudo docker network ls
NETWORK ID NAME DRIVER SCOPE
47bf1753e571 bridge bridge local
a490cc0dc175 host host local
4ad7868e3a47 none null local
其中 bridge 为容器默认使用的网络,host 为容器与宿主机共享网络,none 则是不使用网络(只保留本地回环)。
可以使用 --network 参数指定容器使用的网络。
Bridge 介绍¶
在计算机网络中,网桥(bridge)负责连接两个网络,提供在网络之间过滤与转发数据包等功能。 而在 Docker 中,bridge 网络也可以看作是连接容器网络和主机网络之间的桥。 连接到相同 bridge 的容器之间可以互相通信。
bridge 在 Linux 上的实现
首先,Docker 会创建一个虚拟的 docker0 网络设备作为网桥,这个设备默认对应了 IP 段 172.17.0.1/16,
创建的容器会被分配到这个网段中的一个 IP 地址。路由表也会将对这个网段的请求转发到 docker0 设备上:
$ ip a show docker0
20: docker0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default
link/ether 02:42:e0:cb:d8:81 brd ff:ff:ff:ff:ff:ff
inet 172.17.0.1/16 brd 172.17.0.255 scope global docker0
valid_lft forever preferred_lft forever
inet6 fe80::42:e0ff:fecb:d881/64 scope link proto kernel_ll
valid_lft forever preferred_lft forever
$ ip route get 172.17.0.2
172.17.0.2 dev docker0 src 172.17.0.1 uid 1000
cache
在默认网络配置下,如果创建了容器,可以发现增加了对应数量的 veth(虚拟以太网)设备:
$ # 开启了两个容器的情况下
$ ip a
(省略)
7: veth5669cd1@if6: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue master docker0 state UP group default
link/ether 96:2b:10:03:cc:36 brd ff:ff:ff:ff:ff:ff link-netnsid 1
inet6 fe80::942b:10ff:fe03:cc36/64 scope link
valid_lft forever preferred_lft forever
9: veth9219d7b@if8: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue master docker0 state UP group default
link/ether d2:b8:23:74:49:2a brd ff:ff:ff:ff:ff:ff link-netnsid 2
inet6 fe80::d0b8:23ff:fe74:492a/64 scope link
valid_lft forever preferred_lft forever
veth 设备可以看作是一根虚拟的网线,一端连接到容器内部(容器内部安装 iproute2 之后可以 ip a 看到 eth0 这个设备),另一端连接到 docker0 网桥。
但是仅仅有设备是不够的,Docker 还需要配置主机的 iptables 规则,否则尽管容器与主机之间能够正常通信,容器无法通过主机访问外部网络。
换句话讲,我们需要主机为容器扮演「路由器」的角色进行 NAT。
查看 iptables 的 nat 表:
$ sudo iptables -t nat -S
-P PREROUTING ACCEPT
-P INPUT ACCEPT
-P OUTPUT ACCEPT
-P POSTROUTING ACCEPT
-N DOCKER
-A PREROUTING -m addrtype --dst-type LOCAL -j DOCKER
-A OUTPUT ! -d 127.0.0.0/8 -m addrtype --dst-type LOCAL -j DOCKER
-A POSTROUTING -s 172.17.0.0/16 ! -o docker0 -j MASQUERADE
-A DOCKER -i docker0 -j RETURN
对于容器访问外部网络的数据包,会经过 POSTROUTING 链的 MASQUERADE 规则,将源地址替换为主机的地址。
同时,Docker 也会控制 iptables 处理端口映射等规则,例如如果启动这样一个容器:
那么 DOCKER 和 POSTROUTING 链就会变成这样:
$ sudo iptables -t nat -S
-P PREROUTING ACCEPT
-P INPUT ACCEPT
-P OUTPUT ACCEPT
-P POSTROUTING ACCEPT
-N DOCKER
-A PREROUTING -m addrtype --dst-type LOCAL -j DOCKER
-A OUTPUT ! -d 127.0.0.0/8 -m addrtype --dst-type LOCAL -j DOCKER
-A POSTROUTING -s 172.17.0.0/16 ! -o docker0 -j MASQUERADE
-A POSTROUTING -s 172.17.0.4/32 -d 172.17.0.4/32 -p tcp -m tcp --dport 80 -j MASQUERADE
-A DOCKER -i docker0 -j RETURN
-A DOCKER ! -i docker0 -p tcp -m tcp --dport 8080 -j DNAT --to-destination 172.17.0.4:80
对于外部到本机的访问,PREROUTING 链在跳转到 DOCKER 链之后,对于未进入 docker0 的 TCP 数据包,会根据 DNAT 规则将端口 8080 的数据包的目的地址修改为到该容器内部的 80 端口。
特别地,到本地回环 8080 端口的连接,会由用户态的 docker-proxy 程序负责「转发」到容器对应的端口上。
对该用户态程序的讨论,可以阅读 https://github.com/moby/moby/issues/11185。
可以通过 docker network create 创建自己的网络。对于新的 bridge 类型的网络,在主机上也会创建新的以 br- 开头的网桥设备。如果使用 docker compose 管理容器服务,那么其也会为对应的服务自动创建 bridge 类型的网络。有关用户创建的 bridge 网络相比于默认网络的优势(例如同网络容器间自动的 DNS 解析支持),可参考官方文档:Bridge network driver。
默认情况下,Docker 创建的网络会分配很大的 IP 段。
在创建了很多网络之后,可能会发现内网 IP 地址都被 Docker 占用了。我们建议修改 /etc/docker/daemon.json,将 default-address-pools 设置为一个较小的 IP 段:
{
"default-address-pools": [
{
"base": "172.17.1.0/24",
"size": 28
},
{
"base": "172.17.2.0/23",
"size": 28
}
],
}
此外,默认 docker0 的地址段也可以修改,对应 bip 选项:
防火墙配置¶
在 Linux 上,防火墙功能一般的实现方式是在 iptables 的 filter 表中添加规则。
而由于 Docker 自身支持为容器配置不同的网络,因此也需要操作 filter 表来保证容器之间的网络隔离。
Docker 会在 filter 表的 FORWARD 链中添加这样的规则:
-A FORWARD -j DOCKER-USER
-A FORWARD -j DOCKER-ISOLATION-STAGE-1
-A FORWARD -o docker0 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
-A FORWARD -o docker0 -j DOCKER
-A FORWARD -i docker0 ! -o docker0 -j ACCEPT
-A FORWARD -i docker0 -o docker0 -j ACCEPT
这些规则在 FORWARD 链最前面——即使自行添加了规则,Docker 服务重启之后它也会在最前面重新添加。
其中 DOCKER-USER 链允许用户自定义规则,其他以 DOCKER 开头的链则由 Docker 自行管理。
在有端口映射的情况下,DOCKER 链会直接允许对应的数据包,不经过之后的规则:
而例如在 Ubuntu/Debian 上比较常见的 ufw 工具,它的规则会在 Docker 的规则后面。 一个例子如下:
-A FORWARD -j DOCKER-USER
-A FORWARD -j DOCKER-ISOLATION-STAGE-1
-A FORWARD -o docker0 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
-A FORWARD -o docker0 -j DOCKER
-A FORWARD -i docker0 ! -o docker0 -j ACCEPT
-A FORWARD -i docker0 -o docker0 -j ACCEPT
-A FORWARD -o br-3e32bdc5bc2a -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
-A FORWARD -o br-3e32bdc5bc2a -j DOCKER
-A FORWARD -i br-3e32bdc5bc2a ! -o br-3e32bdc5bc2a -j ACCEPT
-A FORWARD -i br-3e32bdc5bc2a -o br-3e32bdc5bc2a -j ACCEPT
-A FORWARD -j ufw-before-logging-forward
-A FORWARD -j ufw-before-forward
-A FORWARD -j ufw-after-forward
-A FORWARD -j ufw-after-logging-forward
-A FORWARD -j ufw-reject-forward
-A FORWARD -j ufw-track-forward
于是这就导致了配置的「防火墙」对 Docker 形同虚设的问题。可以考虑以下几种解决方案:
- 自行维护
DOCKER-USER链。可参考官方文档中 Restrict external connections to containers 的部分。 - 使用 Docker 的
--network host选项,不做网络隔离,直接使用主机的网络。 - 设置端口映射只向
127.0.0.1开放,然后使用其他的程序(例如 Nginx)来对外提供服务(如果希望设置为默认选项,可以参考文档中 Setting the default bind address for containers 一节)。 - 不让 Docker 维护 iptables。这会导致一些与容器网络有关的问题,例如容器网络之间的隔离失效、容器内部无法正常访问外部网络等等。
IPv6¶
Docker 默认未开启 IPv6,并且在比较老的版本中,配置 IPv6 会比较麻烦。 一个重要的原因是:Docker 对 IPv4 的策略是配置 NAT 网络,但在 IPv6 的设计中,NAT 不是很「原教旨主义」(毕竟 IPv6 的地址多得用不完,为什么还要有状态的 NAT 呢?)。这就导致了在之前,Docker 中配置可用的 IPv6 就需要:
- 要么每个容器一个公网 IPv6 地址(否则容器无法连接外部的 IPv6 网络)。要这么做的前提是得知道自己能控制的 IPv6 段,并且容器打开的所有端口都会暴露在公网上。
- 使用第三方的方案帮忙做 IPv6 NAT,同时给容器分配 IPv6 的 ULA(Unique Local Address)地址段(目前可以分配 fd00::/8 内的地址段)。
不过好消息是,目前 Docker 添加了对 IPv6 NAT 的实验性支持,尽管默认的 bridge 网络的 IPv6 支持(基于 ip6tables)在 27.0 版本后才默认打开。如果正在使用 27.0 之前的版本,参考对应的文档1,一个配置 daemon.json 的例子如下:
这样新建的容器就能得到一个在 fd00::/80 内的 IPv6 地址,并且顺利访问外部的 IPv6 网络了:
root@14354a8c5349:/# 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
52: eth0@if53: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default
link/ether 02:42:ac:11:00:03 brd ff:ff:ff:ff:ff:ff link-netnsid 0
inet 172.17.0.3/24 brd 172.17.0.255 scope global eth0
valid_lft forever preferred_lft forever
inet6 fd00::242:ac11:3/80 scope global nodad
valid_lft forever preferred_lft forever
inet6 fe80::42:acff:fe11:3/64 scope link
valid_lft forever preferred_lft forever
root@14354a8c5349:/# ping6 mirrors.ustc.edu.cn
PING mirrors.ustc.edu.cn(2001:da8:d800:95::110 (2001:da8:d800:95::110)) 56 data bytes
64 bytes from 2001:da8:d800:95::110 (2001:da8:d800:95::110): icmp_seq=1 ttl=62 time=2.42 ms
ip6tables
注意在以上配置(旧版本手工启用,或者新版本默认)的情况下,系统的 ip6tables 配置会被 Docker 管理。在某些环境下,这可能会干扰系统管理员做的其他配置。如果你已经设置了 "iptables": false,那么很有可能你也会需要设置 "ip6tables": false。
VLAN¶
VLAN(虚拟局域网)用于将一个物理局域网划分为多个逻辑上的局域网,以实现网络隔离。Docker 支持 macvlan 和 ipvlan 两种 VLAN 驱动,前者允许每个容器拥有自己的 MAC 地址,后者则允许每个容器拥有自己的 IP 地址(MAC 地址共享)。这个功能适用于需要将多个容器直接在某个特定网络内提供服务的场景——一种场景是,内网使用 tinc 互联,希望能够使用内网的 IP 地址连接内部服务,而这些服务又在 Docker 容器中。此时可以使用 Docker 的 VLAN 功能,并且为容器分配不同的内网 IP 地址,实现内网通过对应的 IP 即可直接访问到容器服务的需求。
Bridge 与 macvlan
如果你曾经有过使用类似于 VMware Workstation / VMware Fusion 虚拟机软件的经验,可能会发现:软件中的 NAT 更像是 Docker 里面的 bridge,而「桥接」则更像是这里介绍的 macvlan。
Linux 下的 bridge 实际上是一个虚拟的交换机:在创建 bridge 之后,可以为这个 bridge 添加其他的设备作为 "slave"(设置其他设备的 "master" 为这个 bridge),然后 bridge 就像交换机一样转发数据包。同时,bridge 也支持设置一个 IP 地址,相当于在主机一端有一个自己的 "slave"。Docker 默认的 bridge 网络模式则是利用了这一点:bridge 的 IP 为容器的网关,主机一端的 veth 设备的 master 是 Docker 创建的 bridge 设备。这个 bridge 不对应到具体的物理设备(Docker 未提供相关的配置方式)。
而虚拟机软件的桥接则需要指定一个物理设备,这个设备会加入虚拟的交换机里面,虚拟机也会连接到这个交换机上。从外部来看,这种模式和 macvlan 的效果是一样的:有多个不同的 MAC 地址的设备连接到同一个物理网络上,但是具体实现是不同的。
Macvlan 与 IPvlan 功能也支持对接基于 IEEE 802.1Q 的 VLAN 配置,但是这里不做详细介绍。
Macvlan¶
由于每个容器都有不同于对应网络设备的 MAC 地址,因此 macvlan 模式要求网络设备支持混杂模式(promiscuous mode),即处理所有经过的数据包,即使数据包的 MAC 地址不是自己的。
以下以一台 Linux 虚拟机为例,对应的「物理」网络设备为 enp1s0:
$ ip a show enp1s0
2: enp1s0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UP group default qlen 1000
link/ether 52:54:00:d3:7d:6f brd ff:ff:ff:ff:ff:ff
inet 192.168.122.247/24 brd 192.168.122.255 scope global dynamic noprefixroute enp1s0
valid_lft 3577sec preferred_lft 3577sec
inet6 fe80::5054:ff:fed3:7d6f/64 scope link noprefixroute
valid_lft forever preferred_lft forever
我们创建一个 macvlan 网络,并且启动多个容器:
$ sudo docker network create -d macvlan --subnet=192.168.122.0/25 --gateway=192.168.122.1 -o parent=enp1s0 macvlan_test
047c9f91cb1a6962d45a916f60c612b0174be5425dcf75d55da1b964037d518f
$ sudo docker run -it --network macvlan_test --name ct1 -d --ip 192.168.122.10 ustclug/debian:12
83570467e991dea9fe9f221d7e4e6256f37d193a000b23777a4d37429cdd5e47
$ sudo docker run -it --network macvlan_test --name ct2 -d --ip 192.168.122.11 ustclug/debian:12
ea0ef56a73d940dad0b860099e2d8fe26ddcba1d424824e3f45bf4f492dd54f1
由于 macvlan 的实现原因,这两个 IP 在主机上无法 ping 通,但是容器之间,以及与虚拟机处在同一个子网的其他设备之间可以通信。
$ # 该虚拟机
$ ping 192.168.122.10
PING 192.168.122.10 (192.168.122.10) 56(84) bytes of data.
From 192.168.122.247 icmp_seq=1 Destination Host Unreachable
...
$ # ct1 内部——需要先安装 iputils-ping
$ sudo docker exec -it ct1 ping 192.168.122.11
PING 192.168.122.11 (192.168.122.11) 56(84) bytes of data.
64 bytes from 192.168.122.11: icmp_seq=1 ttl=64 time=0.095 ms
...
$ # 同子网其他设备(例如宿主机)
$ ping 192.168.122.10
PING 192.168.122.10 (192.168.122.10) 56(84) bytes of data.
64 bytes from 192.168.122.10: icmp_seq=1 ttl=64 time=0.284 ms
...
同时,也可以验证它们的 MAC 地址不同:
$ sudo arping 192.168.122.10
ARPING 192.168.122.10 from 192.168.122.1 virbr0
Unicast reply from 192.168.122.10 [02:42:C0:A8:7A:0A] 1.091ms
...
$ sudo arping 192.168.122.11
ARPING 192.168.122.11 from 192.168.122.1 virbr0
Unicast reply from 192.168.122.11 [02:42:C0:A8:7A:0B] 0.979ms
...
解决这个问题的一种方法是在主机上添加一个(和容器的 macvlan 一样的)新的 macvlan 接口,这样就可以互相通信了:
$ sudo ip link add macvlan-enp1s0 link enp1s0 type macvlan mode bridge
$ sudo ip addr add 192.168.122.9/25 dev macvlan-enp1s0
$ sudo ip link set macvlan-enp1s0 up
$ ping 192.168.122.10
PING 192.168.122.10 (192.168.122.10) 56(84) bytes of data.
64 bytes from 192.168.122.10: icmp_seq=1 ttl=64 time=0.172 ms
IPvlan¶
这里主要关注 IPvlan 的 L2 模式(也是 IPvlan 的默认模式),L3 模式与上文的场景不同,更加关注网络的隔离,和 bridge 网络类似(主机外部网络无法访问到其中的容器)。 同时由于不需要额外的 MAC 地址,IPvlan 可以避免混杂模式的开启。
和 macvlan 非常相似,仍然是创建网络与容器:
$ # 在执行命令之前,需要先清除上文的 macvlan 网络,否则网段会冲突,无法创建
$ # 清理之后就可以:
$ sudo docker network create -d ipvlan --subnet=192.168.122.0/25 --gateway=192.168.122.1 -o parent=enp1s0 ipvlan_test
1d7118ac1a4520b08d4420260700550bb1bcf2ff2badf6f2aeae830b7119502c
$ # 下面的内容和 macvlan 是几乎一致的,省略
主机无法连通容器 IP 的问题仍然存在,解决方法也几乎一致:
$ sudo ip link add ipvlan-enp1s0 link enp1s0 type ipvlan mode l2
$ # 后面省略……
$ ping 192.168.122.10
PING 192.168.122.10 (192.168.122.10) 56(84) bytes of data.
64 bytes from 192.168.122.10: icmp_seq=1 ttl=64 time=0.154 ms
同时,容器与主机共享相同的 MAC 地址:
$ sudo arping 192.168.122.10
ARPING 192.168.122.10 from 192.168.122.1 virbr0
Unicast reply from 192.168.122.10 [52:54:00:D3:7D:6F] 0.687ms
...
$ sudo arping 192.168.122.11
ARPING 192.168.122.11 from 192.168.122.1 virbr0
Unicast reply from 192.168.122.11 [52:54:00:D3:7D:6F] 0.721ms
...
$ sudo arping 192.168.122.247 # 虚拟机主机
ARPING 192.168.122.247 from 192.168.122.1 virbr0
Unicast reply from 192.168.122.247 [52:54:00:D3:7D:6F] 0.852ms
...
Docker Compose¶
Docker compose 是 Docker 官方提供的运行多个容器组成的服务的工具:用户编写 YAML 描述如何启动容器,然后使用 docker-compose 命令启动、停止、删除服务。
作为一个直观的例子,对于类似于下面这样需要大量设置环境变量与挂载点的的单容器启动命令:
docker run -it --rm \
-e "DISPLAY=$DISPLAY" \
-e "XAUTHORITY=$XAUTHORITY" \
-v /tmp/.X11-unix:/tmp/.X11-unix \
-v "$XAUTHORITY:$XAUTHORITY" \
-v /dev/dri/renderD128:/dev/dri/renderD128 \
-v /run/user/1000/pipewire-0:/run/pipewire/pipewire-0 \
-v /run/user/1000/pulse:/run/pulse/native \
local/example-desktop-1
可以发现这样写不直观,并且容易出错(对于这里的例子,把 -e 和 -v 写反了 Docker 启动容器不会报错)。而使用 Docker compose,就可以将这些参数写入一个 docker-compose.yml 文件:
version: "2"
services:
desktop:
image: local/example-desktop-1
environment:
- DISPLAY=$DISPLAY
- XAUTHORITY=$XAUTHORITY
volumes:
- /tmp/.X11-unix:/tmp/.X11-unix
- $XAUTHORITY:$XAUTHORITY
- /dev/dri/renderD128:/dev/dri/renderD128
- /run/user/1000/pipewire-0:/run/pipewire/pipewire-0
- /run/user/1000/pulse:/run/pulse/native
然后跑一下 docker compose up,容器就可以启动。相比于上述的命令来讲直观得多了。
版本¶
Docker compose 有 v1 和 v2 两个版本,而其配置文件(Compose file)则有 version 1(不再使用)、version 2、version 3,以及最新的 Compose Specification 四种版本,容易造成混乱。
Docker compose 的 v1 版本(基于 Python)已经于 2021 年 5 月停止维护。如果输出类似如下,那么就是 v1 版本的 compose:
$ docker-compose --version
docker-compose version 1.29.2, build unknown
docker-py version: 5.0.3
CPython version: 3.11.2
OpenSSL version: OpenSSL 3.0.11 19 Sep 2023
从 Docker 源(docker-ce)安装的 docker-compose-plugin 则是 v2 版本的(基于 Go 语言)。v2 版本的 compose 此时作为 Docker 的插件,推荐的运行方式是 docker compose(不含横线):
从 v1 迁移到 v2 的细节问题参见 Migrate to Compose V2(主要是容器名称与环境变量处理上存在差异)。
特别地,Ubuntu 在官方源中打包了 docker-compose-v2 这个包,Debian 13 官方源开始提供的 docker-compose 也是 v2 版本的。
对于 compose 文件,早期 version 2 与 version 3 的共存导致了一些混乱,因为后者是为了与 Docker Swarm(集群管理)兼容而设计的,丢弃了一些有意义的功能。
避免在非 swarm 集群场合使用 version 3 compose 文件格式
Version 3 格式令人诟病一点的是其抛弃了对资源限制的支持。最为糟糕的是,如果配置了资源限制,docker-compose 不会输出警告,而是直接忽略:
cpu_shares,cpu_quota,cpuset,mem_limit,memswap_limit: These have been replaced by the resources key underdeploy.deployconfiguration only takes effect when usingdocker stack deploy, and is ignored bydocker-compose.
测试用 docker-compose.yml 文件
version: '3.8'
services:
python-app:
image: python:3.10-slim
command: python -c "print('Init'); a = [0] * 4000000; print('Array created')"
mem_limit: 16m
memswap_limit: 16m
environment:
- PYTHONUNBUFFERED=1
如果运行的 compose 环境支持 Compose Specification,那么这个容器不会输出 Array created(在分配内存时即被杀死)。
如果不知道这一点,那么就只会在容器把机器资源耗尽之后才能发现问题。就目前的情况而言,如果仍然有使用旧版 docker-compose(不支持 Compose Specification 格式,即 1.27.0 以下的版本)的需求,建议使用 version 2 格式。相关讨论可以参考:https://github.com/docker/compose/issues/4513。
考虑到 Docker compose v1 已经不再维护,并且 Compose Specification 保持了对旧版 compose 文件的兼容性,因此下文仅考虑最新的 compose v2 与 Compose Specification 文件格式。
配置文件与基本使用¶
Compose Specification 规定了以下这些 "top-level" 元素:
- Version 和 name
- Services
- Network
- Volumes
- Configs
- Secrets
其中前四项是最常见的。同时 Compose Specification 已经不再需要写版本号(已有的会被忽略),而项目名称也是可选的(默认为当前目录名),所以一个最简单的 compose 文件可以只有 services 一项:
假设当前目录名为 helloworld,运行 docker compose up 之后,可以看到 compose 会创建一个名为 helloworld-hello-world-1 的容器,并且为容器创建 helloworld-hello-world-1 的 bridge 网络——由于 hello-world 的唯一功能是输出一段文字,所以容器会立即退出。
在测试完成后,使用 docker compose down 销毁环境(否则容器和网络会一直存在)。接下来的部分会分析一些使用 Docker compose 的例子。
查看当前所有 compose 环境
可以使用 docker compose ls 查看当前所有打开的 compose 环境。
案例 1:Hackergame 的 nc 类题目 Docker 容器环境¶
Hackergame nc 类题目的 Docker 容器资源限制、动态 flag、网页终端 提供了两个服务。其中 dynamic_flag 由 xinetd 暴露一个 TCP 端口,在客户端(nc)连接时,xinetd 会执行 front.py 脚本处理请求。脚本会要求用户输入 token,检查 token 有效性与连接频率,然后根据预先设置的规则生成 flag,创建并启动容器,由对应的题目容器与用户交互。题目容器内不需要做诸如验证 token、限制资源、处理网络连接等工作,只需要与用户使用标准输入输出交互即可。而 web_netcat 服务则是一个网页终端,用户可以通过浏览器连接到这个服务,然后在网页上输入命令与 dynamic_flag 交互。
dynamic_flag 的 docker-compose.yml 文件类似如下:
version: '2.4'
services:
front:
build: .
ports:
- ${port}:2333
restart: always
read_only: true
ipc: shareable
volumes:
- /var/run/docker.sock:/var/run/docker.sock
ulimits:
nofile:
soft: 65536
hard: 65536
environment:
- hackergame_conn_interval=${conn_interval}
- hackergame_token_timeout=${token_timeout}
- hackergame_challenge_timeout=${challenge_timeout}
- hackergame_pids_limit=${pids_limit}
- hackergame_mem_limit=${mem_limit}
- hackergame_flag_path=${flag_path}
- hackergame_flag_rule=${flag_rule}
- hackergame_challenge_docker_name=${challenge_docker_name}
- hackergame_read_only=${read_only}
- hackergame_flag_suid=${flag_suid}
- hackergame_challenge_network=${challenge_network}
- hackergame_shm_exec=${shm_exec}
- TZ=Asia/Shanghai
其中 build: . 表示使用当前目录下的 Dockerfile 构建镜像,其他的配置项都可以找到 docker run 的对应参数。配置中形如 ${port} 的部分被称为 Interpolation,在运行时会被替换——这里替换这些变量的值位于同一目录的 .env,目前最新版本使用的格式定义可参考文档说明。
web_netcat 的 docker-compose.yml 也类似:
version: '2.4'
services:
web:
build: .
ports:
- ${web_port}:3000
environment:
- nc_host=${nc_host}
- nc_port=${nc_port}
- nc_raw=${nc_raw}
restart: always
init: true
其中 init: true 表示 Docker 会在容器启动时使用基于 tini 的 docker-init 管理容器内进程。
容器里的 PID 1,与信号处理
在 Unix 系列的操作系统中,PID 1(init)是最重要的用户态程序,如果 init 退出,那么整个系统就会崩溃。在 Linux 的 PID 命名空间里面也是如此,如果其中 PID 为 1 的程序退出,那么 PID 命名空间里的其他程序都会收到内核发送的 SIGKILL 一起陪葬。对于容器来说,除了启动容器实际的服务程序以外,PID 1 至少还需要:
- 处理僵尸进程。当一个进程的父进程结束之后,这个进程的父进程就会变成 PID 1。当该进程结束之后,PID 1 需要妥善「收尸」,否则系统中会存在大量的僵尸进程占用资源。
- 传递信号。PID 1 需要将
SIGTERM、SIGINT等信号转发给实际的服务程序。
根据 tini Issue #8 的讨论,如果 bash 为 PID 1,那么处理僵尸进程的事情确实不需要操心,但是 bash 默认不会帮忙传递信号——这意味着如果执行 docker stop,那么 bash 自己吞掉信号之后什么都不会发生——这个命令会卡住比较长的时间,直到超时之后被强制杀死,无法做到优雅地退出(gracefully exit)。
特别地,实际的服务程序也需要恰当处理信号,特别是在作为 PID 1 的时候(因为 PID 1 进程不像其他进程,没有默认的信号 handler)。例如,Python 不会特殊处理信号,因此作为容器启动进程(PID 1)收到 SIGTERM(docker stop 发送的信号)时,什么都不会做:
$ sudo docker run -it --rm --name=python-signal python bash
root@ecdc8fe55f36:/# exec python -c "import time; time.sleep(10000000)" # 使用 `exec` 确保 PID 1 从 bash 变成 python
$ # 另一个终端
$ sudo docker stop python-signal
(卡住直到超时,SIGKILL)
如果在打包 Python 应用时没有注意的话,那么关闭容器的体验就会非常糟糕,并且强制退出也带来了潜在的数据丢失的风险。如果不希望启动容器额外设置 --init,或者需要在退出时做额外清理等操作的话,对于 Python 而言,可以这么解决:
用户需要运行的题目的示例则在 example 目录下,可以看一下这里的 docker-compose.yml 文件:
version: '2.4'
services:
challenge:
build: .
entrypoint: ["/bin/true"]
front:
extends:
file: ../dynamic_flag/docker-compose.yml
service: front
depends_on:
- challenge
web:
extends:
file: ../web_netcat/docker-compose.yml
service: web
其中 challenge 服务代表题目本身,这里修改 entrypoint,让运行时行为变成直接退出,只是为了能让 compose 创建出对应的容器镜像(选手在连接时由拥有 Docker socket 的 front 操作为每个选手创建、运行容器)。front 与 web 使用了 extends 指令来继承对应的 compose 配置。另外,depends_on 指令表示 front 服务依赖于 challenge 服务,即 challenge 服务启动后 front 服务才会启动。
在 extends 之后 interpolation 会优先使用当前目录的 .env 文件,因此 example/.env 文件中可以覆盖掉上述两个目录下 .env 的配置。
最终生成的配置可以使用 docker compose config 查看。
Example 最后的实际配置
$ docker compose config
WARN[0000] /example/hackergame-challenge-docker/example/docker-compose.yml: `version` is obsolete
name: example
services:
challenge:
build:
context: /example/hackergame-challenge-docker/example
dockerfile: Dockerfile
entrypoint:
- /bin/true
networks:
default: null
front:
build:
context: /example/hackergame-challenge-docker/dynamic_flag
dockerfile: Dockerfile
depends_on:
challenge:
condition: service_started
required: true
environment:
TZ: Asia/Shanghai
hackergame_challenge_docker_name: example_challenge
hackergame_challenge_network: ""
hackergame_challenge_timeout: "300"
hackergame_conn_interval: "10"
hackergame_flag_path: /flag1,/flag2
hackergame_flag_rule: f"flag{{this_is_an_example_{sha256('example1'+token)[:10]}}}",f"flag{{this_is_the_second_flag_{sha256('example2'+token)[:10]}}}"
hackergame_flag_suid: ""
hackergame_mem_limit: 256m
hackergame_pids_limit: "16"
hackergame_read_only: "1"
hackergame_shm_exec: "0"
hackergame_token_timeout: "30"
ipc: shareable
networks:
default: null
ports:
- mode: ingress
target: 2333
published: "10000"
protocol: tcp
read_only: true
restart: always
ulimits:
nofile:
soft: 65536
hard: 65536
volumes:
- type: bind
source: /var/run/docker.sock
target: /var/run/docker.sock
bind:
create_host_path: true
web:
build:
context: /example/hackergame-challenge-docker/web_netcat
dockerfile: Dockerfile
environment:
nc_host: front
nc_port: "2333"
nc_raw: "0"
init: true
networks:
default: null
ports:
- mode: ingress
target: 3000
published: "10001"
protocol: tcp
restart: always
networks:
default:
name: example_default
案例 2:Hackergame 比赛平台的 Docker compose 测试方案¶
(以下内容基于 https://github.com/ustclug/hackergame/pull/175/files)
Hackergame 比赛平台可以算是一个比较复杂的 Web 应用了:
- 平台使用 Django 框架,在生产环境中,需要使用 uWSGI 作为 WSGI 服务器。
- 平台需要使用 PostgreSQL 作为数据库。
- 由于 uWSGI 使用了 gevent,因此 Django 自带的数据库连接池无法正常工作,需要使用 pgBouncer 在 Django 与 PostgreSQL 之间建立连接池。
- 平台使用 Memcached 作为内存缓存数据库。
- 在 uWSGI 外是 Nginx 作为反向代理,为用户暴露服务。
对于这里的 docker-compose.yml,首先看 services 内部与数据库有关的三个服务:
memcached:
container_name: hackergame-memcached
image: memcached
restart: always
postgresql:
container_name: hackergame-postgresql
image: postgres:15
restart: always
environment:
- POSTGRES_USER=hackergame
- POSTGRES_PASSWORD=${DB_PASSWORD}
- POSTGRES_DB=hackergame
volumes:
- hackergame-postgresql:/var/lib/postgresql/data/
pgbouncer:
container_name: hackergame-pgbouncer
image: edoburu/pgbouncer:latest
restart: always
environment:
- DB_USER=hackergame
- DB_PASSWORD=${DB_PASSWORD}
- DB_HOST=postgresql
- POOL_MODE=transaction
# 坑: pg14+ 默认使用 scram-sha-256, 而 pgbouncer 默认是 md5
- AUTH_TYPE=scram-sha-256
depends_on:
- postgresql
除去案例 1 中已经介绍的配置,这里设置了容器名称与 volume。对于数据库而言,添加 volume 进行持久化是有必要的,否则容器重启后数据就会丢失。如果定义了 volume,还需要在最外层的 volumes 中定义这个 volume。该 compose 文件定义了三个使用的 volume:
docker compose down 与 volume
默认情况下,docker compose down 不会删除 volume。如果需要删除 volume,可以使用 docker compose down -v。
而这里额外设置 container_name(容器名)的目的是,在这些容器组成的内网中,Docker 提供的 DNS 就允许使用更短的主机名(服务名)做服务(容器)之间的互相通信,而用户管理容器时因为容器名都以 hackergame- 开头,可以方便地区分平台容器与其他的容器。
resolv.conf 配置,与 ping 主机名与容器名的输出
root@hackergame:/# cat /etc/resolv.conf
# Generated by Docker Engine.
# This file can be edited; Docker Engine will not make further changes once it
# has been modified.
nameserver 127.0.0.11
search example.com
options ndots:0
# Based on host file: '/etc/resolv.conf' (internal resolver)
# ExtServers: [192.168.0.1]
# Overrides: []
# Option ndots from: internal
root@hackergame:/# ping nginx
PING nginx (172.17.1.118) 56(84) bytes of data.
64 bytes from hackergame-nginx.hackergame_default (172.17.1.118): icmp_seq=1 ttl=64 time=0.184 ms
^C
root@hackergame:/# ping hackergame-nginx
PING hackergame-nginx (172.17.1.118) 56(84) bytes of data.
64 bytes from hackergame-nginx.hackergame_default (172.17.1.118): icmp_seq=1 ttl=64 time=0.233 ms
^C
映射端口的安全性(Compose)
如果阅读网络上某些 Docker compose 的配置,可能会发现他们会像这样将数据库的端口进行映射:
除非有确切的需求需要数据库从该 compose 文件管理的容器之外的地方访问,否则不应该这么设置,理由和基础概念部分中提到的一样。
由于 compose 会为其管理的服务创建专门的 bridge 网络,而 bridge 网络内部的容器可以互相直接使用主机名通信,因此不需要像这么暴露端口也可以正常工作。
Django 部分的配置如下:
hackergame:
container_name: &name hackergame
hostname: *name
build: .
restart: always
environment:
- DJANGO_SETTINGS_MODULE=conf.settings.docker
- DB_PASSWORD=${DB_PASSWORD}
# 调试用
- DEBUG=True
volumes:
- .:/opt/hackergame/:ro
# 存储静态网页与题目文件
- hackergame-static:/var/opt/hackergame/
# 很不幸,你可能还需要 bind 完整的题目目录进来(不然不方便导入)
depends_on:
- memcached
- pgbouncer
这里的 &name 和 *name 利用了 YAML 的 Anchor 与 Alias 功能。这里 &name hackergame 定义了一个名为 name,值为 hackergame 的 Anchor,而 *name 则表示使用 name 这个 Anchor 的值。
在这个例子中,使用这个特性或许有些小题大做,但是在一些复杂的配置中,可以使用 Anchor 定义一系列映射,类似这样:
然后在其他地方使用 *env 引用这个映射:
此外,可以发现在 volumes 的定义中,不再需要使用 $(pwd) 处理相对路径的问题:Docker compose 会帮我们搞定相对路径到绝对路径的转换。
Nginx 的 compose 配置没有新的特性,因此不再赘述。不过,另一点需要特别提及的是,在不使用 Docker compose 部署时,服务之间的通信使用了 UNIX socket,例如 uwsgi 暴露的 socket 配置如下:
一个重要的原因是,UNIX socket 是有用户所有者和权限的,但是在多个容器的场合下,保证所有容器的 /etc/passwd 映射的用户一致是比较困难的。而 TCP socket 则解决了这个问题。但是其安全性需要考虑,例如 uWSGI 的 TCP socket 默认是没有额外的鉴权的,而能够连接到这个 socket 的进程就可以执行任意命令,在某些不正确的设置下,即使在配置中将 uWSGI 降权运行,也可以以此获取到更高的权限。即使 uWSGI 的端口没有暴露到容器内网外部,如果有其他容器被攻破,那么攻击者也可以轻松横向移动到 uWSGI 所在容器。
Health check
在上面的例子中,我们限制了一些容器在其他容器启动之后再启动。但是在某些场景下,「容器启动」并不意味着「容器已经准备好接受请求」,在「启动」和「准备好」这个时间间隔中,启动需要对应服务的容器可能会失败。
Docker 提供了 Health check 功能,可以定义健康检查的命令,在容器启动后,Docker 会定期执行这个命令,根据返回值判断容器是否「健康」。
尝试编写一个 compose 文件,其中一个容器启动一个数据库(你可能需要自行定义 health check 命令),另一个容器需要在数据库准备好之后才能启动。
Rootless 容器¶
在 Docker 部分,我们提到将用户加入 docker 组就相当于给予了用户 root 权限,而传统的基于 SUID 的方式,如果 SUID 程序存在漏洞,那么也很容易被利用提权。那么是否有办法让普通用户创建容器,而不产生这样的安全风险呢?Rootless 容器基于非特权 user namespace 技术,可以让普通用户创建容器,而不需要 root 权限。在这样的容器中,用户「看起来」获得了 root 权限,并且能够在容器中做 root 能做的事情,但是实际上容器内的 root 用户对应的是宿主机上的一个普通用户。
Docker 与 Podman 均支持 rootless 容器,可以分别参考对应的配置文档(Docker、Podman)。
不过,非特权 user namespace 的安全性也存在争议。尽管较新的发行版一般都默认开启了非特权 user namespace,但是有观点认为,这一项特性在内核中的实现仍然有较多(未发现)的安全漏洞,因此在安全性要求较高的场合,可能需要谨慎使用。
Rootless 容器与安全的 Docker-in-Docker 设计
在一些场合下,我们不得不将 Docker socket 暴露给一些服务,但是又希望即使服务的安全边界被攻破,攻击者获得了 Docker socket 的访问权限,也无法影响宿主机。此时 Rootless 的 Docker-in-Docker(DinD)就是一种解决方案。详情可以参考 LUG Planet 的「Rootless Docker in Docker 在 Hackergame 中的实践」一文了解。
其他的容器实现¶
Docker 不是唯一的容器实现。OCI(Open Container Initiative)是 Linux Foundation 的项目,始于 2015 年,目标是为容器技术制定开放标准。目前有三个标准:
- Runtime Specification:容器运行时规范,runc 是其参考实现。上文的 Docker 使用 containerd 操作 runc 来运行容器。
- Image Specification:容器镜像规范,Docker 的镜像格式与之兼容。
- Distribution Specification:容器镜像分发规范,这和 registry 有关。
主流的容器实现一般都遵循 OCI 标准。以下介绍除了 Docker 以外的其他容器实现。
Podman¶
Podman 是红帽主推的容器方案,在 Fedora 和 RHEL 上自带。相比于 Docker,其最主要的特点是,没有 daemon,因此在一些操作上和 Docker 有显著的不同,例如:
- Podman 使用 rootless container 的方式让普通用户创建容器,而不是像 Docker 那样需要向用户授予与 root 等价的权限。
- 由于 Podman 没有 daemon,因此设置容器自动启动等依赖于 systemd 的用户服务等功能。
Podman 提供了与 Docker 兼容的命令行工具,但是在一些细节设置上仍然会出现不同的情况。
LXC¶
LXC 是一个 low level 的容器工具,提供了一些底层的 API 与命令行工具。在实际使用中,用户一般不会直接使用 LXC 的工具,而是使用 LXC 的高层次封装工具;开发者也可以基于 LXC 自行开发工具。Proxmox VE 的容器支持就是基于 LXC 的封装,而 LXD 则是 Canonical 开发的基于 LXC 的工具。
由于在 2023 年,Canonical 将 LXD 从 Linux Containers 项目中分离出来,因此出现了一个新社区 fork Incus。以上工具相比于 Docker 更加注重于系统级的容器,而不是应用级的容器。
Systemd-nspawn¶
Systemd-nspawn 是由 systemd 提供的轻量级容器工具,提供了与 systemd 的集成。以下是一个简单的使用例子,其中初始化了一个 Debian Bookworm 的 rootfs,并且启动了这个 "Debian":
$ sudo debootstrap bookworm debian https://mirrors.ustc.edu.cn/debian
W: Cannot check Release signature; keyring file not available /usr/share/keyrings/debian-archive-keyring.gpg
I: Retrieving InRelease
(以下省略)
$ cd debian
$ # 由于 debootstrap 创建的 root 没有密码,需要设置密码
$ sudo systemd-nspawn passwd root
Spawning container debian on /home/taoky/tmp/debian/debian.
Press Ctrl-] three times within 1s to kill container.
New password:
Retype new password:
passwd: password updated successfully
Container debian exited successfully.
$ # 引导容器
$ sudo systemd-nspawn --boot .
Spawning container debian on /home/taoky/tmp/debian/debian.
Press Ctrl-] three times within 1s to kill container.
systemd 252.22-1~deb12u1 running in system mode (+PAM +AUDIT +SELINUX +APPARMOR +IMA +SMACK +SECCOMP +GCRYPT -GNUTLS +OPENSSL +ACL +BLKID +CURL +ELFUTILS +FIDO2 +IDN2 -IDN +IPTC +KMOD +LIBCRYPTSETUP +LIBFDISK +PCRE2 -PWQUALITY +P11KIT +QRENCODE +TPM2 +BZIP2 +LZ4 +XZ +ZLIB +ZSTD -BPF_FRAMEWORK -XKBCOMMON +UTMP +SYSVINIT default-hierarchy=unified)
Detected virtualization systemd-nspawn.
Detected architecture x86-64.
Welcome to Debian GNU/Linux 12 (bookworm)!
(以下省略)
Singularity¶
Singularity 是在 HPC(高性能计算)领域非常常用的容器工具。相比于 Docker、Podman 等我们平时使用的容器工具,Singularity 更加注重于 HPC 场景下的需求:
- 其「容器」以单文件 SIF 的格式存储,方便在 HPC 集群中分发。
- HPC 场景下,服务器会有很多用户,这些用户都不应该有 root 权限,而 Singularity 能够使用 rootless 的方式让普通用户也能正常使用(在不支持 rootless 的环境下,会使用传统的 SUID 方案)。
- Singularity 相比于其他方案,默认与系统的集成程度更高——其默认不隔离 PID 命名空间、网络命名空间等,并允许容器直接访问 GPU 等资源。容器内的用户与执行它的用户的权限一致,以传统 UNIX 的用户作为安全性的边界。
目前,Singularity 有两个分支:一为 Linux Foundation 旗下的 Apptainer,二为 Sylabs 公司维护的 SingularityCE(Community Edition)。
基于虚拟机的容器技术¶
在我们的印象中,容器总是比虚拟机(hypervisor)更轻量级,但是对安全要求严苛的场合下,容器所依赖的 TCB(Trusted Computing Base)仍然太大了:你需要相信整个 kernel 与容器实现相关的部分都没有漏洞,而 KVM 等虚拟化技术的 TCB 就小很多,出现漏洞问题的可能性更小。
那么是否有办法结合两者的优势呢?SOSP 17 的论文 My VM is Lighter (and Safer) than your Container 实验证明了,如果整个虚拟化 stack 足够精简,那么虚拟机的开销也可以非常低,实现既轻量又安全的目标。
Kata Containers 就是这样一个以虚拟机作为容器的方案。它实现了对 OCI 的兼容,因此容器实现也可以使用 Kata 作为运行时,例如 Docker 就提供了对包括 Kata 在内的第三方运行时的支持。由 Amazon 开发的 Firecracker 也是轻量级虚拟机的方案,并且也提供了与 containerd 的集成。
基于容器技术的沙盒¶
以下介绍的「沙盒」不一定符合 OCI 标准,但是其也使用了与容器相同的内核技术。
Bubblewrap 是目前相对常用的底层沙盒工具之一,并且允许非 root 用户使用。以下是使用 bubblewrap 创建 shell 沙盒的例子:
set -euo pipefail
(exec bwrap --ro-bind /usr /usr \
--dir /tmp \
--dir /var \
--symlink ../tmp var/tmp \
--proc /proc \
--dev /dev \
--ro-bind /etc/resolv.conf /etc/resolv.conf \
--symlink usr/lib /lib \
--symlink usr/lib64 /lib64 \
--symlink usr/bin /bin \
--symlink usr/sbin /sbin \
--chdir / \
--unshare-all \
--share-net \
--die-with-parent \
--dir /run/user/$(id -u) \
--setenv XDG_RUNTIME_DIR "/run/user/`id -u`" \
--setenv PS1 "bwrap-demo$ " \
--file 11 /etc/passwd \
--file 12 /etc/group \
/bin/sh) \
11< <(getent passwd $UID 65534) \
12< <(getent group $(id -g) 65534)
这个「沙盒」只读绑定了主机的 /usr 目录,并且处理了一些 /etc 下的配置。
在实践中,更常见的面向用户的方案有 Flatpak 和 Snap 等。这些方案提供了应用沙盒与应用商店的功能,用户可以从它们的商店中安装应用,而这些应用会根据具体的需求以不同的配置隔离在沙盒中;而开发者也可以在公用的「运行时」上开发,保证自己的应用能够跨发行版顺利运行。
近年来,Linux 桌面社区也在推动桌面应用的沙盒化,其中非常重要的部分是 XDG Desktop Portal。Portal 的一个代表性例子是,应用需要显示文件选择对话框时,不直接访问文件系统,而是通过 Portal 请求文件选择器,Portal 会弹出一个文件选择器的窗口,用户选择文件后,Portal 会将文件的路径传递给应用。这样做的好处是,应用不需要直接访问文件系统,而是通过 Portal 与用户交互,Portal 可以根据用户的选择来限制应用的访问权限。
-
需要注意的是,文档中的 2001:db8:1::/64 这个地址隶属于 2001:db8::/32 这个专门用于文档和样例代码的地址段(类似于 example.com 的功能),不能用于实际的网络配置。 ↩