关注上方 云原生CTO 更多云原生干货等你来探索
专注于 云原生技术
分享
提供优质 云原生开发
视频技术培训
面试技巧
,及技术疑难问题 解答

云原生技术分享不仅仅局限于Go
、Rust
、Python
、Istio
、containerd
、CoreDNS
、Envoy
、etcd
、Fluentd
、Harbor
、Helm
、Jaeger
、Kubernetes
、Open Policy Agent
、Prometheus
、Rook
、TiKV
、TUF
、Vitess
、Argo
、Buildpacks
、CloudEvents
、CNI
、Contour
、Cortex
、CRI-O
、Falco
、Flux
、gRPC
、KubeEdge
、Linkerd
、NATS
、Notary
、OpenTracing
、Operator Framework
、SPIFFE
、SPIRE
和 Thanos
等


Rust 中的容器运行时——第 0 部分

从 DevOps
的角度来看,我只能说活着是多么美好!随着容器的兴起,我们在设置虚拟机、备份/恢复程序、动态配置方面遇到的所有痛苦,因为由于未优化的架构设置而导致使用量增加,等等。
现在一切都消失了,比如使用单个docker-compose
文件或使用简单快捷方式来运行程序,如helm install my-app
。
但是,容器本质上是什么,Docker
在底层是如何工作的?
这将是一个系列,用除了最适合这项工作的golang
语言之外,这里则使用Rust
来编写一个小型的容器运行时 - Rust
!
第 0
部分描述了 Linux
操作系统的系统特性,Docker
和其他工具利用这些特性来启动一个隔离和受保护的进程。或者换句话说,一个容器,整个系列都基于 Linux
容器,因此任何出现的容器一词都可以与“ Linux
容器”相关。
可以在此处找到这篇文章的完整源代码。
https://github.com/penumbra23/pura

容器
当被要求用尽可能少的词描述容器是什么时,大多数开发人员感到失望。用一个技术词来描述,它是一个分叉或克隆的过程。它有一个专用的 PID
,它由一个用户和一个组拥有,你可以用ps
命令列出它并向它发送信号(是的,信号 #9
也是)。
仅此而已,周围没有任何超级花哨的东西存在,只是一个老式的过程。
但是它是如何与系统的其余部分隔离的呢?
答案是命名空间
。
https://en.wikipedia.org/wiki/Linux_namespaces
命名空间为在不同命名空间组中运行的进程提供资源的逻辑隔离。命名空间有多种类型,例如,当前进程可以看到的所有挂载点的 MOUNT
命名空间,网络接口和流量规则的 NETWORK
命名空间,进程树的 PID
等等。
在不同 PID
命名空间中运行的两个进程看不到相同的进程树。单独的NETWORK
命名空间拥有自己的网络堆栈、路由表、防火墙和环回接口。绑定到各自环回设备的具有不同网络命名空间的两个进程绑定到单独的逻辑接口,以便流量不会在它们之间产生干扰。MOUNT
命名空间包含进程可以看到的挂载点列表。当第一次从挂载命名空间(CLONE_NEWNS
标志)克隆时,所有挂载点都从父命名空间复制到子命名空间。在子级中创建的任何其他挂载点都不会传播到父级挂载命名空间。此外,当子进程卸载任何挂载点时,它只会在其挂载命名空间内受到影响。

三个独立的 MNT、NET 和 PID 命名空间的示例
Root (“/”)
也不例外。对于不同的挂载命名空间,根挂载点不必是(大多数情况下不是)相同的目录。运行容器时,Docker
(特别是containerd
)为每个容器的根挂载点创建一个目录。在实际容器运行用户定义的进程之前,容器运行时负责将该目录挂载为容器的根目录。这样,每个容器都有自己的根挂载点,该挂载点与文件系统的其余部分以及在主机上运行的所有其他容器分开。
每个进程在主机上都有一个/proc/PID/ns
子目录,其中包含它们所属的每个命名空间(pid、net、user、cgroup
等)的符号链接。如果两个进程属于同一个命名空间,它们的符号链接将是相同的。
让我们看看一个简单的sleep
命令有哪些命名空间(当然在我的机器上):
PID 为 694 的睡眠进程的命名空间
在同一个 shell
中运行另一个进程并检查其命名空间会提供与上述相同的符号链接:

PID 为 697 的睡眠进程的命名空间
这两个进程在没有任何额外命令或设置的情况下运行,它们具有相同的命名空间符号链接,因此它们属于相同的命名空间。
Linux
提供了来自sched.h
库的UNSHARE
系统调用来更改进程的执行上下文并允许它创建和输入新的命名空间。在使用特定位掩码调用UNSHARE
之后,运行进程从根命名空间分离到它自己的一组命名空间。不幸的是,调用UNSHARE
并期望有一个隔离的容器是不够的(例如,一个“取消共享”PID
命名空间的父进程,仍然在根 PID
命名空间中运行,但之后创建的任何子进程都会进入新创建的 PID
命名空间)。通常在容器运行时调用UNSHARE
之后然后是一个fork/vfork
调用来创建实际的容器进程。
UNSHARE
: https://man7.org/linux/man-pages/man2/unshare.2.html
CLONE syscall
是一个更好的选择,并且在Rust
的 nix
包中的实现感觉更加健壮和细粒度。它提供了在UNSHARE
中指定命名空间标志、派生子进程并为子进程创建堆栈的能力。
SETNS
系统调用(NSENTER
命令)提供了通过文件描述符将给定命名空间更改为现有命名空间的选项。例如,要 fork
进入进程的挂载命名空间且 PID
为 15
的 shell
进程,请输入一个新的 shell
(具有 root
权限):
CLONE
: https://man7.org/linux/man-pages/man2/clone.2.html
Rust 的 nix 包中
: https://docs.rs/nix/0.22.1/nix/sched/fn.clone.html
nsenter --mount=/proc/15/ns/mnt /bin/sh
足够的理论!为了确认上面所说的一切,让我们跳到基于 Docker
的示例。我们将运行一个 alpine
容器,在主机系统上列出进程,检查它的命名空间,然后使用SETNS docker exec
进入它。让我们运行一个长时间运行的 sleep
容器:
运行“sleep 1000”
的 alpine
容器的 PID
(唯一运行相同命令的进程)
检查容器的 PID
后,让我们看看/proc/PID/ns
下的命名空间符号链接:

有趣的!一些命名空间与我们在终端中运行的上述 sleep
命令相同(特别是user
和cgroup
命名空间),但大多数命名空间不同,这证明 Docker
将容器分隔在不同的命名空间中。
现在,让我们尝试在容器内执行一个 shell
来检查文件系统:

NSENTER
命令在 alpine
容器的 MOUNT
命名空间内打开一个 shell
开始有意义了吗?嗯,差不多……
这里发生的事情是我们已经运行NSENTER
在容器进程的 mount
命名空间内启动一个shell
进程。容器的挂载命名空间与根命名空间不同,因为根目录列出的树结构与我的WSL
实例略有不同。此外,在我的 Debian
实例上,在容器内打印 Linux
发行版信息 ( /etc/os-release
) 会显示一个 Alpine Linux
发行版。所以我们得出结论:
docker exec -it <CONTAINER_ID> <CMD>
等于:
nsenter -a -t <CONTAINER_PID> <CMD>
*nsenter -a
使用-t arg
中指定的 PID
进入进程的所有命名空间
Docker
大多数读者已经熟悉 Docker
客户端-服务器模型,因此无需解释 CLI
如何在后台运行的dockerd
服务上调用命令。让我们打开前引擎盖,看看下面是什么。
在最后一个示例中,我们已经看到docker run
命令在单独的命名空间集中分叉一个进程。实际发生的是 Docker
(再次,更具体地说是 containerd
,但现在让我们保持简单)调用底层容器运行时来创建指定的命名空间,准备容器环境并在实际用户定义的命令开始之前执行一些所需的特殊命令.
但如果这是运行时的责任,那么我们使用 Docker
做什么呢?
Docker
在创建容器之前准备好一切,包括两个最重要的部分:
配置文件 容器根目录
这两部分(连同我们将在本系列中发现的其他内容)称为容器包。
该config.json
文件具有整个容器生命周期的完整布局,从容器开始到容器删除。它包含容器根目录的路径、需要非共享的命名空间列表、容器进程的资源限制、需要在特定时间点执行的钩子以及许多其他设置。所述容器的根目录是在安装命名空间部分中提到的目录。这是主机系统上某处的子目录,它将成为容器的根目录。用户定义的进程必须不知道在容器根目录之外有一个完全不同的世界,它基本上“笼子”(大多数文献将其称为“监狱”)容器根目录内的用户进程。
除了这两个最重要的事情之外,Docker
还做了其他准备工作(例如,从远程存储库中拉取镜像层,如果容器启用了网络,则设置网络接口,等等)。
OCI规范
标准化是任何软件集成的关键部分。从编写 REST API
或 gRPC
服务到设计低级网络协议,一切都始于一个定义良好的文档,描述需要(非)预期的行为以及实现需要履行的最小契约。
该开口容器倡议(OCI)
创建,并仍然保持,可以发现在OCI
运行规范在这里。这是一个仍在发展的规范,并添加了容器运行时可以或可能在启动容器进程时执行的新功能。
我不会进入规范的细节,因为它在文档中描述得非常好,但至少这里是它的简而言之。
符合 OCI
的容器运行时是一个 CLI
二进制文件,它实现了以下命令:
创建 <bundle_path> 开始 状态 杀死 <信号> 删除 与任何其他新兴规范一样, OCI
运行时规范描述了用于创建容器的最少功能。流行的容器运行时实现(如runc
或crun
) 具有附加参数,可帮助设置进程的PID
文件、容器状态的根文件夹或容器终端的套接字文件。
Runc :https://github.com/opencontainers/runc
Crun :https://github.com/containers/crun
但是不要担心这些部分。在本系列即将发布的文章中,我们将使用一些 Rust
代码更深入地研究它。
5.3 参考资料
Linux 命名空间手册页 [1]
OCI 运行时规范库 [2]
运行库 [3]
参考地址 [4]
参考资料
参考地址: https://man7.org/linux/man-pages/man7/namespaces.7.html
[2]参考地址: https://github.com/opencontainers/runtime-spec
[3]参考地址: https://github.com/opencontainers/runc/blob/master/man/runc.8.md
[4]参考地址: https://penumbra23.medium.com/container-runtime-in-rust-part-0-7af709415cda




