我出于兴趣决定自己写一个容器运行时,尽管还很早,却发现 Docker 很奇怪。容器并不真实存在:它不像虚拟机(VM),只是受到特殊限制的进程,宿主机的内核仍在运行它,只要找对地方,甚至能在 /proc 中看到这个进程。Linux 也没有一个单独称为容器的功能,容器其实是由 5 项内核机制拼出来的。
正文
我出于好玩决定自己写一个容器运行时,虽然才刚起步,但我已经发现 Docker 真的很“奇怪”。下面是我学到的几件关于 Docker 的怪事。
容器并不真实
和虚拟机不同,容器只是受到特殊限制的进程。你的内核仍然在运行这个进程!而且如果你知道去哪儿看,甚至可以在 /proc 里找到那个进程。
容器真的并不真实
其实也没有哪个 Linux 特性单独叫“容器”。相反,容器其实就是 5 个不同的 Linux 内核特性披着一件风衣伪装成的。
Namespaces 用来隐藏宿主机和容器能看到的东西,cgroups 限制容器的资源使用,还有一大堆我不太懂的网络魔法,用来隔离容器的网络。
镜像只是文件夹
镜像其实就是文件夹:Docker 把它放在某个地方,然后调用 chroot(或 pivot_root),把这个文件夹变成容器进程的新根目录。所以如果你运行 docker run -it --rm ubuntu /bin/bash,Docker 会:
- 从 Dockerhub 下载 ubuntu 镜像。
- 把这个镜像放到某个地方。
- 运行
/some/weird/docker/path/bin/bash
对容器运行时而言,Go 是一门挺糟糕的语言
Docker 的容器运行时 runc 是用 Go 写的,而 Go 的一些设计选择让它不太适合写容器运行时。Go 的运行时原生就是多线程的。这确实是个很酷的特性,但也给 runc 带来了一些挑战。
容器运行时需要调用的许多 Linux 系统调用都必须在单线程环境下完成,所以 runc 有一个疯狂的 C 模块,它会在 Go 运行时初始化之前运行,以便执行这些系统调用。
Go 也没有 fork!fork 只会复制主线程,所以在 Go 这样的运行时里简直是个容易伤到自己的陷阱。但容器运行时确实需要 fork,先设置一堆限制,然后 exec 你在 docker run 里真正指定的进程。为了绕过这一点,runc 会 exec /proc/self/exe,并带上一个特殊的命令行参数,把自己标识为子进程,以此近似实现 fork。
Docker 需要 root 权限!
如果你像我一样在 macOS 上用 Docker,这点很容易忽略。容器运行时使用的很多 Linux 内核 API 都需要(或者过去需要)以 root 身份运行,所以 Docker 守护进程通常以 root 运行。我只能想象,做容器安全一定是非常难的工作。
这点现在似乎好多了。最大的例外似乎是网络。如果你运行的是 rootless 容器运行时,看起来它必须在用户空间重新实现整个网络栈,才能真正提供同样的功能!
学习很有趣!
我刚起步写自己的容器运行时,就已经觉得学到了很多关于 Docker 的知识。给自己常用的工具写一个自己的版本,是了解它的绝佳方式!
评论
讨论气氛友好、偏技术怀旧与科普,Docker 创造者 @shykes 也现身回忆早年设计,并表示愿意回答疑问;真正分歧不在 Docker 是否复杂,而在它的实现应被理解为长期演进的自然结果,还是对既有内核能力的“勉强拼凑”。另一个延伸争议是 Docker 的 root 加守护进程模型,是否仍不如无 root 的 Podman 用 fork/exec 方式来得干净。
认为把容器拆回基础组件来学习很有价值
- 文章和评论都在鼓励从命名空间、cgroup、chroot/pivot_root 等底层机制理解容器,而不是把 Docker 当黑盒;评论还顺手推荐了多个从零上手的学习资源(较多人) > 对技术人员来说,bocker 能把文章里的观点讲得非常清楚(@tyingq)
认为 Docker 的“奇怪”只是长期演进的自然结果
- 文章列举的很多设计,在了解容器和内核能力多年演化后并不荒诞,反而顺理成章;这种“见怪不怪”也带点自嘲(少数人) > 当你知道这些东西是怎么一点点演化过来的,列表里的每一点都会显得很自然(@j16sdiz)
认为 Docker 的真正价值是包装内核特性、提升开发体验
- 即便现在更偏好 Podman,也不能否认 Docker 把多个零散且难用的 Linux 内核特性做成轻量、好用的产品,这是它的核心卖点(少数人) > Docker 的卖点,是把原本笨重难用的操作系统能力变成轻量的开发体验(@slowin)
认为无 root 的 Podman 用 fork/exec 方式更合理
- 相比 Docker 的守护进程和 root 套接字模型,Podman 以无 root 方式用 fork/exec 启动容器,被一些人视为更干净的方案(较多人) > 无 root 的 Podman 用 fork/exec 启动容器,不需要 root,也没有守护进程和套接字(@suprjami)
为 Docker 用 Go 编写和依赖内核运行容器的说法辩护
- 被吐槽“用 Go 写运行时很糟”并不公平,crun、youki 在很多实际场景并没有明显更快;而且“容器只能由宿主机内核运行”也忽略了 Kata Containers、gVisor 等运行时(少数人) > 说系统内核仍然必须运行容器,其实忽略了 Kata Containers 和 gVisor 这类运行时(@miladyincontrol)
有价值的补充
- 早期 Docker 的线程模型埋过真实故障:goroutine 会在线程之间跳转,而 fork 时继承的是线程维度的 namespace 与 cgroup 配置;网络命名空间偶尔清理不掉,会让复用线程的网络出问题,甚至导致 Docker 拉不到镜像(@gzapp 的技术回顾)
- 术语上,“文件夹”只是目录的图形化比喻,并不等于目录本身;因此“镜像只是文件夹”这种说法严格来说混淆了图形界面展示和实际目录(@assimpleaspossi 的挑刺)
- 学习资源上,LWN 关于 Linux 内核命名空间的系列文章被认为讲得很系统;Liz Rice 的 “Containers From Scratch” 演讲也是从零理解容器的经典材料(@suprjami、@QGQBGdeZREunxLe 补充)