容器运行时深潜:我的迷你容器引擎进化史(runc → containerd)(2)
目录
- 容器终于起来了,但为什么没网?
- 一个能跑的容器,还远远不是完整的容器运行时
- 容器多了,我不想自己管理了
- 写在最后
上接:https://generalzy.blog.csdn.net/article/details/163213267?spm=1011.2415.3001.5331
容器终于起来了,但为什么没网?
前面的章节,我们已经把一个容器的核心部分逐渐搭了起来。
现在的 mini-runc 已经能够:
Namespace
↓
隔离进程、hostname、文件系统
rootfs
↓
提供容器自己的用户空间
Cgroup
↓
限制 CPU、Memory、PIDs
Capability / Seccomp
↓
限制容器进程的权限
从容器内部来看,它已经越来越像一台独立的 Linux 系统。
但我们现在进入容器:
./mini-runc
执行:
ip addr
却可能发现:
lo
仅仅只有一个回环接口。
再执行:
ping 8.8.8.8
大概率直接失败。
甚至:
curl https://example.com
也无法访问。
这时候问题来了:
文件系统有了,进程隔离有了,资源限制也有了,为什么容器还是没网?
答案是因为我们到目前为止,根本没有给容器配置网络。
Network Namespace:给容器创建独立的网络世界
Linux 同样提供了:
Network Namespace / NET Namespace
它负责隔离网络相关的资源,包括网络设备、IP 协议栈、路由表、防火墙规则、端口等。一个网络设备只能属于一个 Network Namespace。(man7.org)
所以可以把它理解成:
宿主机 Network Namespace
eth0
lo
路由表
iptables
socket
...
容器 Network Namespace
lo
路由表
socket
...
这意味着:
容器拥有自己的网络世界。
和前面的 Namespace 一样,Linux 提供了:
CLONE_NEWNET
因此我们现在可以给 mini-runc 再加一个:
syscall.CLONE_NEWNET
原来:
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWPID |
syscall.CLONE_NEWUTS |
syscall.CLONE_NEWNS,
}
修改成:
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWPID |
syscall.CLONE_NEWUTS |
syscall.CLONE_NEWNS |
syscall.CLONE_NEWNET,
}
于是容器进程现在同时拥有:
容器进程
│
├── PID Namespace
├── UTS Namespace
├── Mount Namespace
└── Network Namespace
但是,创建 Network Namespace 后并不会自动有网络,这是第一次实验时最容易产生误解的地方。
你可能以为:
CLONE_NEWNET
↓
创建网络 Namespace
↓
容器自动获得一个 eth0
实际上不是。
新的 Network Namespace 创建出来之后,里面并没有自动连接到宿主机网络。
通常只有一个特殊的回环设备:
lo
而且它默认还是 DOWN 状态。
可以进入容器之后执行:
ip link
可能看到:
1: lo: <LOOPBACK> ...
但是:
ip addr
并不会出现宿主机的:
eth0
ens33
enp...
之类的网络设备。
为什么?因为 Network Namespace 隔离的不仅仅是一个 IP。
它隔离的是整套网络栈:
Network Namespace
│
├── Network Device
├── IP Address
├── Routing Table
├── ARP / Neighbor Table
├── Firewall Rules
└── Socket / Port Namespace
所以创建:
CLONE_NEWNET
实际上相当于告诉 Linux Kernel:
给这个进程一套新的网络世界。
而这套网络世界一开始当然是“空”的。
可以把当前状态画成:
宿主机 Network Namespace
eth0
│
│
Internet
容器 Network Namespace
lo
│
└── 没有外部连接
它们之间甚至没有一根“网线”,所以此时容器执行:
ping 8.8.8.8
失败并不奇怪。
因为数据包根本没有离开容器的办法。
那 lo 是什么?
lo 是 Linux 的 loopback interface,即回环接口。
它用于让本机进程之间通过网络协议栈进行通信。
例如:
ping 127.0.0.1
数据包并不会真正经过物理网卡,而是在当前 Network Namespace 内部完成通信。
因此容器即使没有任何外部网络:
容器 Network Namespace
│
└── lo
在不依赖 mini-runc 的情况下,我们可以直接使用 Linux 的 ip 命令进行实验。
例如:
ip netns add test
这会创建一个命名的 Network Namespace。ip netns 是 Linux iproute2 提供的 Network Namespace 管理工具。(man7.org)
查看:
ip netns list
应该能看到:
test
然后:
ip netns exec test ip link
可以进入这个 Network Namespace 查看网络设备:
1: lo: <LOOPBACK> ...
可以看到:
它确实有一个独立的网络世界,但现在几乎什么都没有。
让 loopback 先工作起来,执行:
ip netns exec test ip link set lo up
然后:
ip netns exec test ip addr
应该可以看到:
lo:
127.0.0.1/8
现在:
ip netns exec test ping -c 3 127.0.0.1
应该就可以正常通信。
这说明:
Network Namespace
│
▼
loopback
│
▼
127.0.0.1
已经工作了。
但是还是不能访问互联网,如果执行:
ip netns exec test ping -c 3 8.8.8.8
仍然失败。
现在我们就遇到了容器网络最核心的问题:
如何把一个孤立的 Network Namespace 接到宿主机网络?
veth pair:连接容器与宿主机
我们需要一根“虚拟网线”。
Linux 提供的就是:
veth pair(Virtual Ethernet Pair)。
veth 是 Linux 提供的一对互相连接的虚拟 Ethernet 设备。向其中一端发送的数据包,会从另一端立即收到;最常见的用途就是把两个 Network Namespace 连接起来。(man7.org)
执行:
ip link add veth-host type veth peer name veth-container
Linux 会一次创建两个网络设备:
veth-host
veth-container
它们是一对。
可以把它想象成一根虚拟网线:
veth-host veth-container
│ │
│◄────── 虚拟网线 ────────────►│
如果往:
veth-host
发送一个 Ethernet frame,那么它会从:
veth-container
这一端出现。反过来也一样。(man7.org)
这和普通网卡最大的区别就是:
普通网卡:
网卡 → 物理网络
veth:
veth-host → veth-container
为什么正好需要两个端点?
我们的目标是:
宿主机
│
│
容器
而一个 Network Namespace 就像一个独立的网络世界。
所以:
宿主机 Namespace
│
└── veth-host
容器 Namespace
│
└── veth-container
然后:
veth-host ◄────────► veth-container
就相当于:
宿主机
│
网线
│
容器
我们先不修改 mini-runc,直接使用 Linux 原生命令实验。
上一节我们已经创建过一个 Network Namespace:
ip netns add test
确认:
ip netns list
然后创建 veth:
ip link add veth-host type veth peer name veth-container
查看:
ip link
应该能看到:
veth-host
veth-container
此时两个设备都还在当前宿主机的 Network Namespace 中。
我们现在要做的事情就是把一端移动到容器 Network Namespace:
宿主机:
veth-host
veth-container
变成:
宿主机:
veth-host
容器:
veth-container
Linux 的 ip link set ... netns ... 可以把一个网络设备移动到指定 Network Namespace。(man7.org)
执行:
ip link set veth-container netns test
然后查看宿主机:
ip link
现在应该只剩:
veth-host
而容器:
ip netns exec test ip link
应该可以看到:
veth-container
lo
于是整个结构变成:
宿主机 Network Namespace
│
│
veth-host
│
│
│
veth-container
│
▼
容器 Network Namespace
这时候,物理上的“连接”已经建立了,但网络还不能通信。
为什么?
因为我们还没有配置:
IP 地址
假设我们使用如下网段:
10.0.0.0/24
宿主机这一端:
10.0.0.1
容器这一端:
10.0.0.2
给宿主机端配置:
ip addr add 10.0.0.1/24 dev veth-host
然后启动:
ip link set veth-host up
容器端:
ip netns exec test ip addr add 10.0.0.2/24 dev veth-container
然后:
ip netns exec test ip link set veth-container up
最后别忘了:
ip netns exec test ip link set lo up
现在查看容器:
ip netns exec test ip addr
应该类似:
lo:
127.0.0.1/8
veth-container:
10.0.0.2/24
宿主机:
ip addr show veth-host
应该看到:
10.0.0.1/24
现在终于可以通信了,从容器向宿主机端:
ip netns exec test ping -c 3 10.0.0.1
应该可以得到:
64 bytes from 10.0.0.1
64 bytes from 10.0.0.1
64 bytes from 10.0.0.1
这时候数据包实际上走的是:
容器
10.0.0.2
│
▼
veth-container
│
│ veth pair
▼
veth-host
│
▼
宿主机
10.0.0.1
这已经是一个真正可以工作的网络连接了。
这时候再试:
ip netns exec test ping -c 3 8.8.8.8
大概率还是失败。
因为现在我们的网络拓扑只有:
容器
│
veth
│
宿主机
还没有:
宿主机
│
▼
互联网
从容器的角度看:
10.0.0.2
│
▼
10.0.0.1
│
X
没有下一跳
也就是说:
veth pair 只负责“把两个 Network Namespace 连起来”,它本身并不会自动提供路由,也不会自动提供互联网访问。
现在我们终于可以把网络部分加入之前的模型!
先安装:
go get github.com/vishvananda/netlink
go get github.com/vishvananda/netns
我们在 parent() 中创建容器进程以后,获取它的 Network Namespace:
func setupNetwork(pid int) error {
nsPath := fmt.Sprintf("/proc/%d/ns/net", pid)
nsFd, err := os.Open(nsPath)
if err != nil {
return fmt.Errorf("open network namespace: %w", err)
}
defer nsFd.Close()
// 后面在这里配置 veth / bridge / IP / route
return nil
}
创建 veth:
func createVeth(pid int) error {
vethHost := "veth-" + strconv.Itoa(pid)
vethContainer := "eth0"
veth := &netlink.Veth{
LinkAttrs: netlink.LinkAttrs{
Name: vethHost,
},
PeerName: vethContainer,
}
if err := netlink.LinkAdd(veth); err != nil {
return fmt.Errorf("create veth pair: %w", err)
}
// 将容器端移动到 child 的 Network Namespace。
if err := netlink.LinkSetNsPid(
vethContainer,
pid,
); err != nil {
return fmt.Errorf("move veth to netns: %w", err)
}
return nil
}
这里对应的 Linux 命令其实就是:
ip link add veth-host type veth peer name eth0
ip link set eth0 netns <PID>
LinkSetNsPid 就是把网卡移动到目标进程所属的 Network Namespace。
mini-runc
│
├── PID Namespace
├── UTS Namespace
├── Mount Namespace
├── Network Namespace
│
├── rootfs
├── pivot_root
├── Cgroup
├── Capability
├── Seccomp
│
└── execve()
创建 Network Namespace 只是第一步:
CLONE_NEWNET
↓
容器拥有独立网络世界
接下来还需要:
创建 veth pair
↓
宿主机一端
+
容器一端
↓
配置 IP
最终:
宿主机 Namespace
│
veth-host
│
│
veth-container
│
▼
容器 Namespace
│
10.0.0.2
但是,多个容器如果都这么直接连接宿主机:
host
/ | \
/ | \
c1-veth c2-veth c3-veth
网络管理很快就会变得混乱。
更合理的方式是:
bridge
/ | \
/ | \
c1 c2 c3
让多个容器通过一个 Linux Bridge 接入同一个二层网络。
bridge:把多个容器接入同一个二层网络
现实中的交换机大致是:
Switch
/ | \
/ | \
Host Host Host
它工作在二层,根据 MAC 地址转发 Ethernet Frame。
Linux Bridge 做的事情非常类似:
Linux Bridge
/ | \
/ | \
veth1 veth2 veth3
│ │ │
C1 C2 C3
每个容器连接到 Bridge 的一个端口。
Container A
│
veth-a
│
├─────────┐
│
bridge
│
┌─────────┤
│ │
veth-b veth-c
│ │
Container B Container C
这样多个容器就可以直接处于同一个二层网络中。
我们还是先不用修改 mini-runc,直接用 Linux 命令实验。
创建一个 Bridge:
ip link add br0 type bridge
查看:
ip link show br0
应该可以看到:
br0: <BROADCAST,MULTICAST> ...
然后启动:
ip link set br0 up
现在:
Linux Bridge
br0
│
└── 目前没有任何端口
还不能真正通信。
假设上一节我们已经有:
veth-host
veth-container
现在我们不再让:
veth-host
直接代表宿主机网络,而是把它接入 Bridge:
ip link set veth-host master br0
这里的:
master br0
意思是:
把
veth-host加入br0,作为 Bridge 的一个端口。
拓扑变成:
容器
│
veth-container
│
veth-host
│
bridge
br0
这时候你可能会问:
如果容器 A 发数据给容器 B,Linux Bridge 怎么知道应该把数据包送到谁?
因为 Ethernet Frame 里面有:
Source MAC
Destination MAC
Bridge 会维护一个类似:
MAC Address Table
例如:
MAC Address Port
02:42:ac:11:00:02 → veth-c1
02:42:ac:11:00:03 → veth-c2
02:42:ac:11:00:04 → veth-c3
当:
C1 → C2
发送 Ethernet Frame 时:
Destination MAC = C2
Bridge 查表:
C2 MAC
↓
veth-c2
然后把 Frame 转发过去。
所以它和现实交换机非常相似:
Bridge
│
├── MAC Table
│
├── veth-c1
├── veth-c2
└── veth-c3
现在真正做一个两个容器的实验,我们创建两个 Network Namespace:
ip netns add c1
ip netns add c2
创建 Bridge:
ip link add br0 type bridge
ip link set br0 up
创建容器 C1 的 veth
ip link add veth-c1 type veth peer name eth0 netns c1
这一次我们直接把其中一端:
eth0
放进 c1。
于是:
宿主机:
veth-c1
│
│ veth pair
│
▼
c1 Namespace:
eth0
把宿主机这一端接入 Bridge:
ip link set veth-c1 master br0
创建容器 C2 的 veth
ip link add veth-c2 type veth peer name eth0 netns c2
然后:
ip link set veth-c2 master br0
现在拓扑变成:
br0
/ \
/ \
veth-c1 veth-c2
│ │
│ │
eth0 eth0
│ │
C1 C2
我们使用如下网段,然后配置IP:
10.0.0.0/24
C1:
ip netns exec c1 ip addr add 10.0.0.2/24 dev eth0
C2:
ip netns exec c2 ip addr add 10.0.0.3/24 dev eth0
然后把接口启动:
ip netns exec c1 ip link set eth0 up
ip netns exec c2 ip link set eth0 up
别忘了:
ip netns exec c1 ip link set lo up
ip netns exec c2 ip link set lo up
现在两个容器应该可以直接通信,从 C1发送一个ping包:
ip netns exec c1 ping -c 3 10.0.0.3
应该看到:
64 bytes from 10.0.0.3
64 bytes from 10.0.0.3
64 bytes from 10.0.0.3
这时候数据包的路径是:
C1
│
eth0
│
veth-c1
│
▼
br0
│
veth-c2
│
eth0
│
▼
C2
整个过程:
没有经过路由器,也没有经过 NAT。
因为 C1 和 C2 都在:
10.0.0.0/24
这个二层网络中。
这就是 Bridge 最核心的作用:
把多个容器连接到同一个二层网络,让它们可以像接在同一台交换机上一样直接通信。
Bridge 最适合在宿主机上只创建一次。
const bridgeName = "br0"
func ensureBridge() error {
if _, err := netlink.LinkByName(bridgeName); err == nil {
return nil
}
bridge := &netlink.Bridge{
LinkAttrs: netlink.LinkAttrs{
Name: bridgeName,
},
}
if err := netlink.LinkAdd(bridge); err != nil {
return fmt.Errorf("create bridge: %w", err)
}
if err := netlink.LinkSetUp(bridge); err != nil {
return fmt.Errorf("set bridge up: %w", err)
}
return nil
}
然后把容器对应的宿主机 veth 接到 br0:
func attachVethToBridge(pid int) error {
vethName := "veth-" + strconv.Itoa(pid)
link, err := netlink.LinkByName(vethName)
if err != nil {
return fmt.Errorf("find veth: %w", err)
}
bridge, err := netlink.LinkByName(bridgeName)
if err != nil {
return fmt.Errorf("find bridge: %w", err)
}
if err := netlink.LinkSetMaster(link, bridge); err != nil {
return fmt.Errorf("attach veth to bridge: %w", err)
}
if err := netlink.LinkSetUp(link); err != nil {
return fmt.Errorf("set veth up: %w", err)
}
return nil
}
现在我们的网络结构开始变得完整:
Linux Host
│
br0
┌─────┼─────┐
│ │ │
veth veth veth
│ │ │
C1 C2 C3
而每个容器自己的 Network Namespace 里面:
C1
└── eth0
└── 10.0.0.2/24
C2
└── eth0
└── 10.0.0.3/24
C3
└── eth0
└── 10.0.0.4/24
现在:
C1 ↔ C2
C1 ↔ C3
C2 ↔ C3
都已经可以实现。
但我们还没有解决:
C1 → Internet
因为 Bridge 只能解决:
同一个二层网络中的通信。
它并不会自动帮我们把:
10.0.0.0/24
的数据包送到:
互联网
这就需要下一层:
Route
也就是:
告诉容器:离开自己的子网之后,数据包应该交给谁。
route:让数据包找到正确的方向
路由可以简单理解成:
根据目标 IP 地址,决定数据包下一步应该交给谁。
例如容器:
10.0.0.2/24
访问:
10.0.0.3
Linux 发现:
10.0.0.3
属于
10.0.0.0/24
于是直接通过自己的网卡发送:
C1
│
eth0
│
bridge
│
eth0
│
C2
但是如果目标是:
8.8.8.8
Linux 发现:
8.8.8.8
不属于
10.0.0.0/24
这时候它需要:
找一个默认网关,把数据包交给下一跳。
我们先看看上一节创建的 c1。
执行:
ip netns exec c1 ip route
可能只看到:
10.0.0.0/24 dev eth0 proto kernel scope link src 10.0.0.2
这句话的意思是:
目标:
10.0.0.0/24
设备:
eth0
源地址:
10.0.0.2
也就是说,目前 C1 只知道:
10.0.0.0/24这个网络从eth0走。
但是它不知道:
8.8.8.8
应该交给谁。
我们需要给容器增加:
default via 10.0.0.1 dev eth0
意思是:
所有找不到更具体路由的流量,都交给
10.0.0.1。
其中:
default
↓
所有没有匹配到更具体路由的目标
via 10.0.0.1
↓
下一跳 / 网关
dev eth0
↓
从 eth0 发出去
这就是默认网关。
宿主机这一端:
10.0.0.1
容器:
10.0.0.2
所以可以执行:
ip netns exec c1 ip route add default via 10.0.0.1
再查看:
ip netns exec c1 ip route
应该看到:
default via 10.0.0.1 dev eth0
10.0.0.0/24 dev eth0 proto kernel scope link src 10.0.0.2
现在容器就知道:
访问 10.0.0.0/24
↓
直接走 eth0
访问其他网络
↓
交给 10.0.0.1
但现在还不能访问互联网!!!
这里是网络实验中非常容易踩到的坑。
虽然我们给容器设置了:
default via 10.0.0.1
但:
ip netns exec c1 ping 8.8.8.8
仍然可能失败。
为什么?
因为路由只是告诉容器:
“把数据包交给 10.0.0.1”
但是宿主机收到这个数据包以后,还需要知道:
应该怎么把它转发到互联网?
当前数据包路径是:
C1
10.0.0.2
│
▼
veth
│
▼
br0
│
▼
宿主机
10.0.0.1
│
X
│
Internet
问题就在:
宿主机
↓
没有帮容器转发数据包
所以我们还缺一个步骤:
开启 Linux 的 IP forwarding。
Linux 默认情况下不会把收到的 IP 数据包随意从一个网络接口转发到另一个接口。
查看:
sysctl net.ipv4.ip_forward
如果得到:
net.ipv4.ip_forward = 0
说明没有开启 IPv4 转发。
临时开启:
sudo sysctl -w net.ipv4.ip_forward=1
再检查:
sysctl net.ipv4.ip_forward
应该:
net.ipv4.ip_forward = 1
现在数据包就可以经过宿主机进行三层转发:
容器
10.0.0.2
│
▼
10.0.0.1
│
│ IP forwarding
▼
宿主机外部网卡
│
▼
Internet
不要高兴的太早,即使开启了 IP forwarding:
ip netns exec c1 ping 8.8.8.8
仍然可能失败。
这是因为容器的数据包源地址是:
10.0.0.2
这是一个私有地址。
假设它最终到了互联网:
src = 10.0.0.2
dst = 8.8.8.8
互联网中的路由器并不知道:
10.0.0.0/24
应该怎么回到你的宿主机。
所以即使数据包能够出去:
容器
10.0.0.2
│
▼
Internet
返回的数据包也不知道:
10.0.0.2
在哪里?
这就需要:
NAT(Network Address Translation)。
把:
10.0.0.2
转换成宿主机的公网地址。
例如:
容器:
10.0.0.2:12345
│
│ NAT
▼
宿主机:
公网IP:54321
互联网看到的就不再是:
10.0.0.2
而是:
宿主机公网 IP
这样返回的数据包才能正确回到宿主机,再由 NAT 转发回容器。
先定义:
const (
containerCIDR = "10.0.0.0/24"
hostIP = "10.0.0.1/24"
)
假设根据 PID 简单计算容器 IP:
func containerIP(pid int) *net.IPNet {
last := byte(pid%200 + 2)
ip := net.IPv4(10, 0, 0, last)
return &net.IPNet{
IP: ip,
Mask: net.CIDRMask(24, 32),
}
}
然后在容器 Network Namespace 中配置:
func configureContainerNetwork(pid int) error {
nsPath := fmt.Sprintf("/proc/%d/ns/net", pid)
nsFd, err := unix.Open(
nsPath,
unix.O_RDONLY,
0,
)
if err != nil {
return fmt.Errorf("open netns: %w", err)
}
defer unix.Close(nsFd)
handle, err := netlink.NewHandleAt(netns.NsHandle(nsFd))
if err != nil {
return fmt.Errorf("create netlink handle: %w", err)
}
defer handle.Close()
link, err := handle.LinkByName("eth0")
if err != nil {
return fmt.Errorf("find eth0: %w", err)
}
addr := &netlink.Addr{
IPNet: containerIP(pid),
}
if err := handle.AddrAdd(link, addr); err != nil {
return fmt.Errorf("add container IP: %w", err)
}
if err := handle.LinkSetUp(link); err != nil {
return fmt.Errorf("set eth0 up: %w", err)
}
// 添加默认路由:
// default via 10.0.0.1 dev eth0
route := &netlink.Route{
LinkIndex: link.Attrs().Index,
Gw: net.IPv4(10, 0, 0, 1),
}
if err := handle.RouteAdd(route); err != nil {
return fmt.Errorf("add default route: %w", err)
}
// loopback
lo, err := handle.LinkByName("lo")
if err != nil {
return fmt.Errorf("find lo: %w", err)
}
if err := handle.LinkSetUp(lo); err != nil {
return fmt.Errorf("set lo up: %w", err)
}
return nil
}
这里netlink.NewHandleAt(...)非常关键。
它意味着:
不要把
ip配置到宿主机,而是在指定的 Network Namespace 中配置。
netlink 官方 API 明确支持为指定 Network Namespace 创建 Handle,并对该 Namespace 执行 Link、Addr、Route 等操作。
到这里,整个网络路径就清楚了:
Internet
▲
│
NAT
│
宿主机外部网卡
│
IP forwarding
│
10.0.0.1
│
br0
/ \
/ \
veth-c1 veth-c2
│ │
10.0.0.2 10.0.0.3
│ │
C1 C2
每一层解决的问题都不同:
Network Namespace
→ 创建独立网络世界
veth pair
→ 连接两个网络 Namespace
bridge
→ 把多个容器接入同一个二层网络
route
→ 决定数据包往哪里走
IP forwarding
→ 允许宿主机帮容器转发数据包
NAT
→ 让私有地址能够访问外部网络
这里再补一个容易产生误解的地方:Route 不只是“默认网关”
路由表里可以同时存在很多条规则:
10.0.0.0/24 dev eth0
172.16.0.0/16 dev eth1
192.168.1.0/24 dev eth2
default via 10.0.0.1
Linux 会根据目标 IP 与路由前缀的匹配程度选择路由。
例如:
目标:10.0.0.3
匹配:
10.0.0.0/24
default
优先选择:
10.0.0.0/24
而:
目标:8.8.8.8
匹配:
default
于是:
走默认网关
所以路由本质上是在回答:
“这个 IP 数据包,下一步应该从哪个接口、交给哪个下一跳?”
现在我们已经把容器网络需要的基本原语全部暴露出来了。
我们的 mini-runc 如果想自己配置网络,需要在创建:
CLONE_NEWNET
之后继续完成:
创建 Network Namespace
↓
创建 veth pair
↓
一端放入容器
↓
另一端连接 bridge
↓
给容器 eth0 配置 IP
↓
设置默认路由
↓
宿主机开启 IP forwarding
↓
配置 NAT
最终:
mini-runc
│
└── container
│
├── eth0
│ └── 10.0.0.2
│
├── default route
│ └── 10.0.0.1
│
└── Internet
到这里,容器就终于有了一条完整的网络路径。
但是如果让 mini-runc 每启动一个容器都手动执行:
创建 veth
创建 bridge
配置 IP
配置 route
配置 forwarding
配置 NAT
很快就会变成一堆复杂的网络脚本。
而 Docker、containerd、Kubernetes 并不会让每一个上层组件自己重新发明一套容器网络配置。
它们需要一种统一的网络插件接口。
NAT:让容器访问外部网络
NAT(Network Address Translation,网络地址转换)。
NAT 到底做了什么?
假设容器:
10.0.0.2:12345
访问:
8.8.8.8:53
容器发出的数据包:
src = 10.0.0.2:12345
dst = 8.8.8.8:53
经过宿主机 NAT 后:
src = 宿主机公网IP:54321
dst = 8.8.8.8:53
于是互联网看到的其实是:
宿主机公网 IP → 8.8.8.8
当响应回来:
8.8.8.8:53
↓
宿主机公网IP:54321
宿主机再根据 NAT 状态,把它转换回:
10.0.0.2:12345
于是:
8.8.8.8
│
▼
宿主机
│
▼
容器 10.0.0.2
这就是最常见的 SNAT / Masquerade。
NAT 本身解决不了转发问题。
先确认:
sysctl net.ipv4.ip_forward
应该看到:
net.ipv4.ip_forward = 1
如果是:
net.ipv4.ip_forward = 0
执行:
sudo sysctl -w net.ipv4.ip_forward=1
现在数据包才能按照:
容器
↓
bridge
↓
宿主机
↓
外部网卡
↓
Internet
进行转发。
我们需要知道:
哪个网卡连接着真正的外部网络?
执行:
ip route
通常会看到类似:
default via 192.168.1.1 dev eth0
10.0.0.0/24 dev br0 ...
192.168.1.0/24 dev eth0 ...
这里最重要的是:
default via 192.168.1.1 dev eth0
说明宿主机访问外部网络时,默认从:
eth0
出去。
你的机器也可能叫:
ens33
enp0s3
ens160
所以不要把 eth0 当成固定名称。
可以直接:
ip route get 8.8.8.8
例如:
8.8.8.8 via 192.168.1.1 dev eth0 src 192.168.1.100
这样就知道:
外部网卡 = eth0
我们现在假设:
容器网络:
10.0.0.0/24
Bridge:
br0
宿主机外部网卡:
eth0
现代 Linux 上推荐使用:
nftables
创建一个 NAT 表:
sudo nft add table ip nat
创建 POSTROUTING 链:
sudo nft 'add chain ip nat postrouting { type nat hook postrouting priority 100; }'
然后增加 Masquerade:
sudo nft add rule ip nat postrouting oifname "eth0" ip saddr 10.0.0.0/24 masquerade
这条规则的意思是:
如果:
出口网卡 = eth0
源地址 = 10.0.0.0/24
那么:
进行 masquerade
也就是:
10.0.0.x
↓
宿主机外部 IP
为什么这里叫 Masquerade?
masquerade
其实是一种特殊的 SNAT。
它特别适合宿主机这种:
外部 IP 可能通过 DHCP 动态变化的机器。
例如:
当前:
宿主机
192.168.1.100
过一段时间:
宿主机
192.168.1.101
Masquerade 会自动使用当前出口接口的地址进行源地址转换。
所以对于:
容器 → 宿主机 → Internet
这种场景非常常见。
这三个东西现在一定要分开:
Bridge
↓
解决二层网络连接
Route
↓
解决三层数据包往哪里走
NAT
↓
解决私有地址如何通过宿主机访问外部网络
可以把它们放在一起:
容器 10.0.0.2
│
▼
veth pair
│
▼
br0
│
▼
路由表
│
▼
10.0.0.1 / 宿主机
│
▼
NAT
│
▼
Internet
所以如果只有 Bridge:
C1 ↔ C2
可以。
如果加上 Route:
C1 → Host
可以。
如果再加上:
IP forwarding + NAT
才可以实现:
C1 → Internet
到这里,即使:
ping 8.8.8.8
能够成功:
64 bytes from 8.8.8.8
你执行:
ping google.com
也可能失败。
这时候不是 NAT 出问题了。
因为:
8.8.8.8
是 IP 地址。
而:
google.com
是域名。
容器还需要 DNS 配置。
例如:
cat /etc/resolv.conf
如果里面没有正确的 nameserver:
nameserver 8.8.8.8
那么:
域名
↓
DNS
↓
IP
这一层就无法完成。
这说明一个完整的容器网络其实还包括:
Network Namespace
veth
bridge
IP
route
forwarding
NAT
DNS
完整代码如下,先开启 forwarding:
func enableIPForwarding() error {
return os.WriteFile(
"/proc/sys/net/ipv4/ip_forward",
[]byte("1"),
0644,
)
}
然后用 Go 执行 nft:
func setupNAT() error {
commands := [][]string{
{"nft", "add", "table", "ip", "nat"},
{
"nft",
"add",
"chain",
"ip",
"nat",
"postrouting",
"{",
"type",
"nat",
"hook",
"postrouting",
"priority",
"100",
";",
"}",
},
{
"nft",
"add",
"rule",
"ip",
"nat",
"postrouting",
"ip",
"saddr",
"10.0.0.0/24",
"masquerade",
},
}
for _, args := range commands {
cmd := exec.Command(args[0], args[1:]...)
if output, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf(
"run %v: %w: %s",
args,
err,
output,
)
}
}
return nil
}
现在整个网络初始化流程终于可以完整地画出来了:
mini-runc
│
├── CLONE_NEWNET
│
├── 创建 veth pair
│
├── 一端放入 Network Namespace
│
├── 另一端接入 bridge
│
├── 配置容器 IP
│
├── 配置默认 route
│
├── 开启 IP forwarding
│
└── 配置 NAT
│
▼
Internet
这时候,容器网络终于从:
“一个孤立的 Network Namespace”
变成:
“可以访问外部网络的容器网络”
但是问题又来了,如果我们真的把这些操作全部塞进 mini-runc:
创建 veth
创建 bridge
配置 IP
配置 route
配置 forwarding
配置 NAT
那么很快就会发现:
容器网络的配置代码越来越复杂。
而且 Docker、containerd、Kubernetes 都需要做类似的事情。
Linux 已经提供了:
Network Namespace
veth
bridge
route
nftables
为什么还需要 CNI?
因为我们需要把:
“如何给一个容器配置网络”
变成一个标准化的插件接口。
CNI:把容器网络配置标准化
一个容器如果想正常联网,需要自己完成一大堆事情:
创建 Network Namespace
↓
创建 veth pair
↓
把一端移动到容器
↓
连接 bridge
↓
配置 IP
↓
配置 route
↓
配置 NAT
↓
...
只有一个容器的时候还好。
但如果同时运行:
container-1
container-2
container-3
...
container-100
让 Docker、containerd 或 Kubernetes 自己重复实现这一整套网络逻辑,很快就会变得复杂。
于是就有了:
CNI(Container Network Interface)。
CNI 并不是一种新的 Linux 网络技术。
它更准确地说是:
一套“容器运行时如何调用网络插件”的规范。
CNI 规范定义了网络配置格式、运行时与插件之间的执行协议、插件执行流程以及插件返回结果的格式。当前 CNI Specification 是 1.1.0。(GitHub)
CNI 的核心思想其实很简单:
容器运行时
│
│ “帮这个容器配置网络”
▼
CNI Plugin
│
├── veth
├── bridge
├── IP
├── route
└── ...
于是:
container runtime
不需要自己知道:
怎么创建 veth
怎么配置 bridge
怎么分配 IP
怎么设置 route
它只需要:
按照 CNI 规范调用插件。
而具体怎么把网络配置起来,由插件负责。
CNI 官方项目本身也明确将它定位为一套规范 + 库 + 网络插件,关注容器网络连接以及容器删除时的资源清理。(GitHub)
一个 CNI Plugin 本质上就是一个可执行程序。
例如:
bridge
host-local
loopback
这些插件分别负责不同的网络工作。
CNI Runtime 会找到对应的插件,然后执行它。
例如网络配置可能写成:
{
"cniVersion": "1.1.0",
"name": "mynet",
"plugins": [
{
"type": "bridge",
"bridge": "cni0",
"ipMasq": true,
"ipam": {
"type": "host-local",
"subnet": "10.88.0.0/16"
}
}
]
}
可以把 CNI 的基本工作流程理解成:
Runtime
│
│ CNI_COMMAND=ADD
│ CNI_CONTAINERID=...
│ CNI_NETNS=...
│ CNI_IFNAME=eth0
│
│ JSON 配置
▼
CNI Plugin
│
├── 创建 veth
├── 配置 interface
├── 配置 IP
├── 配置 route
└── ...
│
▼
返回 JSON Result
CNI 规范规定了 Runtime 调用插件时需要提供的一些环境变量,例如:
CNI_COMMAND
CNI_CONTAINERID
CNI_NETNS
CNI_IFNAME
CNI_PATH
其中 CNI_COMMAND 用来告诉插件当前是什么操作,CNI_NETNS 指向容器的网络 Namespace,CNI_IFNAME 指定容器内部的网络接口名称。(GitHub)
CNI_COMMAND
目前的 CNI 规范定义了几个核心操作:
ADD
DEL
CHECK
GC
VERSION
(GitHub)最重要的是前两个。
ADD
ADD
↓
把容器加入网络
例如:
创建 veth
↓
配置 eth0
↓
分配 IP
↓
配置 route
DEL
DEL
↓
把容器从网络中删除
也就是清理:
veth
IP
route
IPAM 分配
bridge 相关资源
...
所以一个非常重要的关系是:
ADD
↓
创建网络资源
DEL
↓
清理网络资源
CNI 规范要求 DEL 尽可能以 best-effort 的方式完成,即使部分资源已经不存在,也应该尽可能返回成功。(GitHub)
CNI 的一个很漂亮的设计是:
一个插件不一定负责整个网络。
例如:
bridge
↓
创建二层网络
host-local
↓
分配 IP
portmap
↓
配置端口映射
firewall
↓
配置防火墙
它们可以组成一个 plugin chain:
Runtime
│
▼
bridge
│
▼
host-local
│
▼
portmap
│
▼
...
前一个插件返回的 Result,可以作为后一个插件的 prevResult。
CNI 规范明确支持这种插件委托和链式执行机制。(GitHub)
这其实很符合 Unix 的设计思想:
每个组件只做好自己的一件事情,再通过标准接口组合起来。
现在你就会发现,CNI 其实解决的不是:
“Linux 怎么联网?”
这个问题我们前面已经解决了。
它解决的是:
“谁来负责这些 Linux 网络操作,以及 Runtime 和网络实现之间如何协作?”
所以我们的架构可以变成:
mini-runc
│
│ 创建容器
│ 创建 Network Namespace
▼
CNI
│
├── bridge plugin
├── host-local plugin
└── ...
│
▼
Linux Kernel
│
├── veth
├── bridge
├── route
└── nftables
这时候 mini-runc 就不用自己写:
createVeth()
createBridge()
allocateIP()
addRoute()
setupNAT()
而是变成:
mini-runc
↓
调用 CNI
↓
网络插件
↓
Linux Network Namespace
完整代码如下,首先需要 CNI reference plugins。
这些 plugin 是独立项目维护的,包括:
bridge
host-local
loopback
等...
官方 CNI 项目也明确说明,reference plugins 已经拆分到独立仓库维护。
在实验机上准备:
sudo mkdir -p /opt/cni/bin
然后安装/构建 CNI plugins,使得至少存在:
/opt/cni/bin/bridge
/opt/cni/bin/host-local
/opt/cni/bin/loopback
安装 Go CNI 库
go get github.com/containernetworking/cni
我们这里使用:
github.com/containernetworking/cni/libcni
它就是 CNI runtime 侧的 Go API,可以负责:
读取网络配置
↓
寻找 plugin
↓
构造 CNI 环境变量
↓
调用 plugin
↓
解析 plugin 返回结果
官方 libcni 提供了 AddNetworkList 和 DelNetworkList 等接口。(GitHub)
下面是在我们之前代码基础上,把网络部分改成 CNI 的完整版本。
package main
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"syscall"
"github.com/containernetworking/cni/libcni"
"golang.org/x/sys/unix"
)
const (
rootfs = "/workspace/myrunc/mini-runc/rootfs"
cgroupPath = "/sys/fs/cgroup/mini-runc"
// CNI plugin 所在目录
cniBinPath = "/opt/cni/bin"
// 保存 Network Namespace 的路径。
// 这样即使容器进程退出,Network Namespace 仍然可以被 CNI DEL 清理。
netnsDir = "/run/netns"
cniNetworkName = "mini-runc-net"
)
func main() {
if len(os.Args) > 1 && os.Args[1] == "child" {
child()
return
}
parent()
}
func parent() {
containerID := "mini-runc-" + strconv.Itoa(os.Getpid())
cmd := exec.Command("/proc/self/exe", "child")
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWPID |
syscall.CLONE_NEWUTS |
syscall.CLONE_NEWNS |
syscall.CLONE_NEWNET,
}
// 1. 创建容器进程
if err := cmd.Start(); err != nil {
fmt.Println("start container error:", err)
return
}
pid := cmd.Process.Pid
// 2. 创建 Cgroup
if err := setupCgroup(pid); err != nil {
fmt.Println("setup cgroup error:", err)
_ = cmd.Process.Kill()
_ = cmd.Wait()
return
}
// 3. 把容器 Network Namespace 保存成一个持久路径。
netnsPath, err := setupNetNS(pid, containerID)
if err != nil {
fmt.Println("setup netns error:", err)
_ = cmd.Process.Kill()
_ = cmd.Wait()
return
}
// 4. 调用 CNI ADD
if err := cniAdd(containerID, netnsPath); err != nil {
fmt.Println("CNI ADD error:", err)
cleanupNetNS(netnsPath)
_ = cmd.Process.Kill()
_ = cmd.Wait()
return
}
// 5. 等待容器退出
waitErr := cmd.Wait()
// 6. 容器退出以后调用 CNI DEL
if err := cniDel(containerID, netnsPath); err != nil {
fmt.Println("CNI DEL error:", err)
}
// 7. 清理 Network Namespace 挂载
cleanupNetNS(netnsPath)
// 8. 输出容器退出状态
if waitErr != nil {
fmt.Println("container exited:", waitErr)
}
}
这里是这一版 CNI 实现里非常重要的一部分。
如果只使用:
/proc/<PID>/ns/net
那么当容器进程退出并被 Wait() 回收之后:
/proc/<PID>
就不存在了。
但 CNI DEL 仍然需要知道:
刚才那个 Network Namespace 是哪个。
所以我们把 Network Namespace bind mount 到:
/run/netns/<containerID>
代码:
func setupNetNS(pid int, containerID string) (string, error) {
if err := os.MkdirAll(netnsDir, 0755); err != nil {
return "", fmt.Errorf("create netns dir: %w", err)
}
netnsPath := filepath.Join(netnsDir, containerID)
// 创建一个空文件作为 bind mount target。
file, err := os.OpenFile(
netnsPath,
os.O_CREATE|os.O_EXCL,
0600,
)
if err != nil {
return "", fmt.Errorf("create netns file: %w", err)
}
file.Close()
source := fmt.Sprintf("/proc/%d/ns/net", pid)
// 将进程的 Network Namespace bind mount 到一个持久路径。
if err := unix.Mount(
source,
netnsPath,
"",
unix.MS_BIND,
"",
); err != nil {
os.Remove(netnsPath)
return "", fmt.Errorf(
"bind mount network namespace: %w",
err,
)
}
return netnsPath, nil
}
于是:
/proc/12345/ns/net
│
│ bind mount
▼
/run/netns/mini-runc-12344
即使:
12345
这个进程退出,Network Namespace 仍然通过:
/run/netns/mini-runc-12344
保持存在。
清理 Network Namespace
func cleanupNetNS(netnsPath string) {
if netnsPath == "" {
return
}
_ = unix.Unmount(netnsPath, unix.MNT_DETACH)
_ = os.Remove(netnsPath)
}
所以整个生命周期是:
创建容器
↓
创建 Network Namespace
↓
/run/netns/xxx
↓
CNI ADD
↓
运行容器
↓
容器退出
↓
CNI DEL
↓
删除 /run/netns/xxx
现在定义 CNI 网络配置:
func cniConfig() []byte {
return []byte(`{
"cniVersion": "1.1.0",
"name": "mini-runc-net",
"plugins": [
{
"type": "bridge",
"bridge": "cni0",
"isGateway": true,
"ipMasq": true,
"ipam": {
"type": "host-local",
"ranges": [
[
{
"subnet": "10.88.0.0/16"
}
]
],
"routes": [
{
"dst": "0.0.0.0/0"
}
]
}
}
]
}`)
}
这里有几个很重要的字段:
type = bridge
↓
使用 bridge plugin
bridge = cni0
↓
Linux Bridge 名字
isGateway = true
↓
Bridge 作为容器网络的网关
ipMasq = true
↓
配置 IP masquerading
ipam.type = host-local
↓
使用 host-local 分配 IP
subnet = 10.88.0.0/16
↓
容器网络地址池
route 0.0.0.0/0
↓
默认路由
注意:
这里的配置不是我们自己定义的一套 JSON。
这是 CNI network configuration,runtime 会根据 CNI 规范把它转换成 plugin 的请求配置。CNI 要求 runtime 通过标准环境变量和 JSON stdin 调用 plugin。
现在真正调用 CNI:
func cniAdd(containerID, netnsPath string) error {
ctx := context.Background()
configBytes := cniConfig()
network, err := libcni.ConfListFromBytes(configBytes)
if err != nil {
return fmt.Errorf("parse CNI config: %w", err)
}
cni := libcni.NewCNIConfig(
[]string{cniBinPath},
nil,
)
runtimeConf := &libcni.RuntimeConf{
ContainerID: containerID,
NetNS: netnsPath,
IfName: "eth0",
}
result, err := cni.AddNetworkList(
ctx,
network,
runtimeConf,
)
if err != nil {
return fmt.Errorf("CNI ADD: %w", err)
}
fmt.Printf(
"CNI ADD result: %#v\n",
result,
)
return nil
}
这里真正关键的一行:
cni.AddNetworkList(...)
它会完成:
mini-runc
│
│ AddNetworkList
▼
libcni
│
│ 查找 bridge plugin
▼
/opt/cni/bin/bridge
│
├── 创建 veth
├── 创建/连接 cni0
├── 创建 eth0
├── 调用 host-local
├── 分配 IP
├── 配置 route
└── ipMasq
CNI 官方 libcni 的 AddNetworkList() 就负责按配置顺序执行插件,并将前一个插件的结果传给后续插件。
容器退出后:
func cniDel(containerID, netnsPath string) error {
ctx := context.Background()
configBytes := cniConfig()
network, err := libcni.ConfListFromBytes(configBytes)
if err != nil {
return fmt.Errorf("parse CNI config: %w", err)
}
cni := libcni.NewCNIConfig(
[]string{cniBinPath},
nil,
)
runtimeConf := &libcni.RuntimeConf{
ContainerID: containerID,
NetNS: netnsPath,
IfName: "eth0",
}
if err := cni.DelNetworkList(
ctx,
network,
runtimeConf,
); err != nil {
return fmt.Errorf("CNI DEL: %w", err)
}
return nil
}
于是:
CNI ADD
↓
创建网络资源
container running
CNI DEL
↓
释放网络资源
这正是 CNI 的核心生命周期模型。规范定义了 ADD、DEL、CHECK 等操作;当前规范版本为 1.1.0。(GitHub)
因此当前这一版的完整代码就是:
package main
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"syscall"
"github.com/containernetworking/cni/libcni"
"golang.org/x/sys/unix"
)
const (
rootfs = "/workspace/myrunc/mini-runc/rootfs"
cgroupPath = "/sys/fs/cgroup/mini-runc"
cniBinPath = "/opt/cni/bin"
netnsDir = "/run/netns"
cniNetworkName = "mini-runc-net"
)
func main() {
if len(os.Args) > 1 && os.Args[1] == "child" {
child()
return
}
parent()
}
func parent() {
containerID := "mini-runc-" + strconv.Itoa(os.Getpid())
cmd := exec.Command("/proc/self/exe", "child")
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWPID |
syscall.CLONE_NEWUTS |
syscall.CLONE_NEWNS |
syscall.CLONE_NEWNET,
}
// 创建容器进程。
if err := cmd.Start(); err != nil {
fmt.Println("start container error:", err)
return
}
pid := cmd.Process.Pid
// 创建/配置 Cgroup。
if err := setupCgroup(pid); err != nil {
fmt.Println("setup cgroup error:", err)
_ = cmd.Process.Kill()
_ = cmd.Wait()
return
}
// 保存 Network Namespace。
netnsPath, err := setupNetNS(pid, containerID)
if err != nil {
fmt.Println("setup netns error:", err)
_ = cmd.Process.Kill()
_ = cmd.Wait()
return
}
// CNI ADD。
if err := cniAdd(containerID, netnsPath); err != nil {
fmt.Println("CNI ADD error:", err)
cleanupNetNS(netnsPath)
_ = cmd.Process.Kill()
_ = cmd.Wait()
return
}
// 等待容器退出。
waitErr := cmd.Wait()
// CNI DEL。
if err := cniDel(containerID, netnsPath); err != nil {
fmt.Println("CNI DEL error:", err)
}
// 删除 Network Namespace。
cleanupNetNS(netnsPath)
if waitErr != nil {
fmt.Println("container exited:", waitErr)
}
}
func setupNetNS(pid int, containerID string) (string, error) {
if err := os.MkdirAll(netnsDir, 0755); err != nil {
return "", fmt.Errorf("create netns dir: %w", err)
}
netnsPath := filepath.Join(
netnsDir,
containerID,
)
file, err := os.OpenFile(
netnsPath,
os.O_CREATE|os.O_EXCL,
0600,
)
if err != nil {
return "", fmt.Errorf("create netns file: %w", err)
}
_ = file.Close()
source := fmt.Sprintf(
"/proc/%d/ns/net",
pid,
)
if err := unix.Mount(
source,
netnsPath,
"",
unix.MS_BIND,
"",
); err != nil {
_ = os.Remove(netnsPath)
return "", fmt.Errorf(
"bind mount network namespace: %w",
err,
)
}
return netnsPath, nil
}
func cleanupNetNS(netnsPath string) {
if netnsPath == "" {
return
}
_ = unix.Unmount(
netnsPath,
unix.MNT_DETACH,
)
_ = os.Remove(netnsPath)
}
func cniConfig() []byte {
return []byte(`{
"cniVersion": "1.1.0",
"name": "mini-runc-net",
"plugins": [
{
"type": "bridge",
"bridge": "cni0",
"isGateway": true,
"ipMasq": true,
"ipam": {
"type": "host-local",
"ranges": [
[
{
"subnet": "10.88.0.0/16"
}
]
],
"routes": [
{
"dst": "0.0.0.0/0"
}
]
}
}
]
}`)
}
func cniAdd(
containerID string,
netnsPath string,
) error {
ctx := context.Background()
network, err := libcni.ConfListFromBytes(
cniConfig(),
)
if err != nil {
return fmt.Errorf(
"parse CNI config: %w",
err,
)
}
cni := libcni.NewCNIConfig(
[]string{cniBinPath},
nil,
)
runtimeConf := &libcni.RuntimeConf{
ContainerID: containerID,
NetNS: netnsPath,
IfName: "eth0",
}
result, err := cni.AddNetworkList(
ctx,
network,
runtimeConf,
)
if err != nil {
return fmt.Errorf(
"CNI ADD: %w",
err,
)
}
fmt.Printf(
"CNI ADD result: %#v\n",
result,
)
return nil
}
func cniDel(
containerID string,
netnsPath string,
) error {
ctx := context.Background()
network, err := libcni.ConfListFromBytes(
cniConfig(),
)
if err != nil {
return fmt.Errorf(
"parse CNI config: %w",
err,
)
}
cni := libcni.NewCNIConfig(
[]string{cniBinPath},
nil,
)
runtimeConf := &libcni.RuntimeConf{
ContainerID: containerID,
NetNS: netnsPath,
IfName: "eth0",
}
if err := cni.DelNetworkList(
ctx,
network,
runtimeConf,
); err != nil {
return fmt.Errorf(
"CNI DEL: %w",
err,
)
}
return nil
}
func setupCgroup(pid int) error {
if err := os.MkdirAll(
cgroupPath,
0755,
); err != nil {
return fmt.Errorf(
"create cgroup: %w",
err,
)
}
if err := os.WriteFile(
filepath.Join(cgroupPath, "cpu.max"),
[]byte("50000 100000"),
0644,
); err != nil {
return fmt.Errorf(
"set cpu.max: %w",
err,
)
}
if err := os.WriteFile(
filepath.Join(cgroupPath, "memory.max"),
[]byte("104857600"),
0644,
); err != nil {
return fmt.Errorf(
"set memory.max: %w",
err,
)
}
if err := os.WriteFile(
filepath.Join(cgroupPath, "pids.max"),
[]byte("100"),
0644,
); err != nil {
return fmt.Errorf(
"set pids.max: %w",
err,
)
}
if err := os.WriteFile(
filepath.Join(cgroupPath, "cgroup.procs"),
[]byte(strconv.Itoa(pid)),
0644,
); err != nil {
return fmt.Errorf(
"add process to cgroup: %w",
err,
)
}
return nil
}
func child() {
// 这里继续使用前面章节已经完成的:
//
// hostname
// Mount Namespace
// rootfs
// pivot_root
// /proc
// Capability
// Seccomp
//
// 最后 execve("/bin/sh")
if err := syscall.Sethostname(
[]byte("mycontainer"),
); err != nil {
fmt.Println("set hostname error:", err)
return
}
if err := unix.Mount(
"",
"/",
"",
unix.MS_PRIVATE|unix.MS_REC,
"",
); err != nil {
fmt.Println("make mount private error:", err)
return
}
oldRoot := filepath.Join(
rootfs,
"old_root",
)
if err := unix.Mount(
rootfs,
rootfs,
"",
unix.MS_BIND|unix.MS_REC,
"",
); err != nil {
fmt.Println("bind mount rootfs error:", err)
return
}
if err := os.MkdirAll(
oldRoot,
0700,
); err != nil {
fmt.Println("mkdir old_root error:", err)
return
}
if err := unix.PivotRoot(
rootfs,
oldRoot,
); err != nil {
fmt.Println("pivot_root error:", err)
return
}
if err := os.Chdir("/"); err != nil {
fmt.Println("chdir error:", err)
return
}
if err := unix.Unmount(
"/old_root",
unix.MNT_DETACH,
); err != nil {
fmt.Println("unmount old root error:", err)
return
}
if err := os.RemoveAll(
"/old_root",
); err != nil {
fmt.Println("remove old root error:", err)
return
}
if err := unix.Mount(
"proc",
"/proc",
"proc",
0,
"",
); err != nil {
fmt.Println("mount proc error:", err)
return
}
env := []string{
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
"HOME=/root",
"TERM=xterm",
}
if err := syscall.Exec(
"/bin/sh",
[]string{"/bin/sh"},
env,
); err != nil {
fmt.Println("exec error:", err)
}
}
一个能跑的容器,还远远不是完整的容器运行时
到这里,我们已经亲手实现了一个具备不少核心能力的容器:
Namespace
↓
进程 / hostname / 文件系统 / 网络隔离
rootfs + pivot_root
↓
独立的容器文件系统
Cgroup
↓
CPU / Memory / PIDs 限制
Capability + Seccomp
↓
权限和系统调用限制
CNI
↓
容器网络
从 Linux Kernel 的角度来看,这已经是一个相当完整的容器进程了。
但如果现在拿我们的 mini-runc 和真正的 runc 对比,会发现它们依然有一个非常明显的区别:
我们现在更像是在“启动一个容器”,而不是在“管理一个容器”。
例如,我们当前基本是:
./mini-runc
命令结束之后,容器也就跟着结束。
但真正的容器运行时需要回答更多问题:
容器现在是什么状态?
容器的 PID 是多少?
容器能不能先创建出来,但暂时不启动?
能不能进入已经运行的容器?
能不能暂停容器?
能不能恢复容器?
容器退出以后,相关资源怎么清理?
同时运行十个、五十个容器怎么办?
这些问题已经不再是单纯的 Linux Namespace 或 Cgroup 原语,而是:
容器生命周期管理。
OCI Runtime Specification 本身就把容器的生命周期定义为一组标准操作,例如 create、start、state、kill、delete,并规定了 creating、created、running、stopped 等状态。(https://opencontainers.github.io)
而真正的 runc 也提供了:
create
start
state
kill
delete
exec
pause
resume
...
其中 runc create 和 runc start 的拆分尤其重要:create 负责把容器运行环境准备好,但不会运行用户指定的程序;start 才真正启动它。(GitHub)
容器生命周期:从 create 到 delete
先看一个最简单的生命周期:
create
↓
created
↓
start
↓
running
↓
stop / exit
↓
stopped
↓
delete
↓
不存在
这其实就是 OCI Runtime Specification 规定的核心生命周期模型。create 完成容器运行环境的创建,但用户指定的程序不能在此阶段运行;start 才触发用户程序执行;程序退出后进入 stopped,最后通过 delete 清理创建阶段产生的资源。(https://opencontainers.github.io)
这和我们现在的 mini-runc 有一个很大的不同。
我们现在基本是:
./mini-runc
│
├── 创建 Namespace
├── 创建 rootfs
├── 配置 Cgroup
├── 配置网络
└── execve("/bin/sh")
│
▼
running
所有事情都挤在了一次启动里。
而真正的 Runtime 会把它拆开:
create
│
├── Namespace
├── Mount
├── rootfs
├── Cgroup
├── Network
└── 保存容器状态
│
▼
created
此时:
用户程序
❌ 尚未执行
然后:
start
│
▼
execve()
│
▼
running
最后:
进程退出
│
▼
stopped
│
▼
delete
│
├── 删除 Cgroup
├── 清理 Network
├── 删除 runtime state
└── 释放其他资源
created 到底意味着什么?这里是这一节最值得理解的地方。
如果执行:
runc create mycontainer
此时容器已经存在,但:
/bin/sh
还没有执行。
OCI 的定义明确要求,create 阶段应该把 config.json 中要求的运行环境创建出来,但是不能在这个阶段运行 process 指定的用户程序。(https://opencontainers.github.io)
所以:
created
并不是:
“什么都没做。”
恰恰相反,很多事情已经完成了:
Namespace ✅
Mount ✅
rootfs ✅
Cgroup ✅
Network ✅
runtime state ✅
只是:
/bin/sh
还没有 execve(),这就是为什么 create 和 start 必须分开。
我们现在的代码:
main
│
└── parent
│
└── child
│
└── execve()
下一步就要把:
“创建环境”
和:
“启动用户程序”
拆成两个阶段。
可以先设计成:
mini-runc create mycontainer
得到:
container mycontainer
status: created
此时:
mini-runc start mycontainer
才真正进入:
execve("/bin/sh")
然后:
status: running
等 Shell 退出:
status: stopped
最后:
mini-runc delete mycontainer
才彻底清理。
因此,我们需要一个“容器状态”
之前我们的程序根本不需要记录容器:
ID
PID
状态
Namespace
Cgroup
Network
因为程序自己一路跑到底了,现在不行了。
我们至少需要保存:
type ContainerState struct {
ID string `json:"id"`
PID int `json:"pid"`
Status string `json:"status"`
Bundle string `json:"bundle"`
NetNS string `json:"netns"`
Cgroup string `json:"cgroup"`
}
例如:
{
"id": "mycontainer",
"pid": 12345,
"status": "created",
"bundle": "/workspace/mycontainer",
"netns": "/run/netns/mycontainer",
"cgroup": "/sys/fs/cgroup/mini-runc/mycontainer"
}
这些信息并不是 OCI 规定的完整内部实现方式,而是我们的 mini-runc 为了管理生命周期而保存的运行时状态。
状态为什么重要? 因为以后执行:
mini-runc state mycontainer
Runtime 必须能够回答:
ID: mycontainer
PID: 12345
Status: running
真正的 OCI Runtime Specification 也定义了 state 操作,要求 Runtime 返回容器状态,其中包含 id、status、pid 等信息。(https://opencontainers.github.io)
最后是 delete,当容器变成:
stopped
我们才能真正清理:
delete
│
├── 删除 Network Namespace
├── 删除 veth
├── 清理 CNI
├── 删除 Cgroup
├── 删除 runtime state
└── 释放其他资源
OCI 规范明确要求 delete 只能作用于已经停止的容器,并且应该删除 create 阶段创建的资源;对于并非该容器创建的共享资源,则不能随意删除。(https://opencontainers.github.io)
先把原来的“一把梭”改掉:
package main
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strconv"
"syscall"
"time"
)
const stateRoot = "/run/mini-runc"
type ContainerState struct {
ID string `json:"id"`
PID int `json:"pid"`
Status string `json:"status"`
Bundle string `json:"bundle"`
}
func main() {
if len(os.Args) < 2 {
usage()
return
}
switch os.Args[1] {
case "create":
if len(os.Args) != 4 {
fmt.Println("usage: mini-runc create <container-id> <bundle>")
return
}
id := os.Args[2]
bundle := os.Args[3]
if err := create(id, bundle); err != nil {
fmt.Println("create error:", err)
}
case "start":
if len(os.Args) != 3 {
fmt.Println("usage: mini-runc start <container-id>")
return
}
if err := start(os.Args[2]); err != nil {
fmt.Println("start error:", err)
}
case "state":
if len(os.Args) != 3 {
fmt.Println("usage: mini-runc state <container-id>")
return
}
if err := state(os.Args[2]); err != nil {
fmt.Println("state error:", err)
}
case "delete":
if len(os.Args) != 3 {
fmt.Println("usage: mini-runc delete <container-id>")
return
}
if err := deleteContainer(os.Args[2]); err != nil {
fmt.Println("delete error:", err)
}
case "child":
if len(os.Args) != 4 {
fmt.Println("invalid child arguments")
return
}
id := os.Args[2]
bundle := os.Args[3]
if err := child(id, bundle); err != nil {
fmt.Println("child error:", err)
os.Exit(1)
}
default:
usage()
}
}
func usage() {
fmt.Println(`usage:
mini-runc create <container-id> <bundle>
mini-runc start <container-id>
mini-runc state <container-id>
mini-runc delete <container-id>
`)
}
create:只创建,不启动
package main
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"syscall"
)
func create(id, bundle string) error {
stateDir := filepath.Join(stateRoot, id)
// 容器已经存在。
if _, err := os.Stat(stateDir); err == nil {
return fmt.Errorf("container %q already exists", id)
}
if err := os.MkdirAll(stateDir, 0755); err != nil {
return err
}
/*
这里只创建容器初始化进程。
注意:
真正的 /bin/sh 还没有执行。
child 进程启动以后会停在等待状态,
等待 parent 告诉它可以继续。
*/
cmd := exec.Command(
"/proc/self/exe",
"child",
id,
bundle,
)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWPID |
syscall.CLONE_NEWUTS |
syscall.CLONE_NEWNS |
syscall.CLONE_NEWNET,
}
if err := cmd.Start(); err != nil {
return err
}
containerState := ContainerState{
ID: id,
PID: cmd.Process.Pid,
Status: "created",
Bundle: bundle,
}
if err := saveState(containerState); err != nil {
_ = cmd.Process.Kill()
_ = cmd.Wait()
return err
}
/*
这里故意不调用 cmd.Wait()。
因为容器进程现在还处于 created 状态,
需要让它继续存活,等待 start。
*/
fmt.Printf(
"container %s created, pid=%d\n",
id,
cmd.Process.Pid,
)
return nil
}
这里需要引入:
import "os/exec"
child:创建环境以后等待 start,现在 child() 不再马上:
execve("/bin/sh")
而是:
创建 Namespace
↓
准备环境
↓
等待
↓
start
↓
execve()
最简单的实验方式就是使用一个 FIFO:
/run/mini-runc/<id>/start
父进程 start 时往里面写一个字节。
子进程收到以后才继续。
func child(id, bundle string) error {
stateDir := filepath.Join(stateRoot, id)
startFile := filepath.Join(stateDir, "start")
/*
这里本来应该继续放我们之前已经实现的:
hostname
Mount Namespace
rootfs
pivot_root
/proc
Capability
Seccomp
CNI
Cgroup
为了把生命周期讲清楚,这里先用一个最小版本展示
create/start 的进程模型。
*/
fmt.Printf(
"container %s initialized, waiting for start...\n",
id,
)
/*
等待 start。
这里简单使用普通文件作为演示:
start 命令写入文件后,child 继续执行。
*/
for {
if _, err := os.Stat(startFile); err == nil {
break
}
time.Sleep(100 * time.Millisecond)
}
fmt.Println("container start signal received")
/*
真正的 Runtime 会在这里完成最终的
execve(config.Process.Args[0], ...)
*/
return syscall.Exec(
"/bin/sh",
[]string{"/bin/sh"},
[]string{
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
"HOME=/root",
"TERM=xterm",
},
)
}
这里有一个非常重要的变化:
create
child
│
▼
waiting
start
│
▼
execve()
所以:
mini-runc create mycontainer /workspace/mycontainer
之后:
mini-runc state mycontainer
应该看到:
{
"id": "mycontainer",
"pid": 12345,
"status": "created",
"bundle": "/workspace/mycontainer"
}
此时/bin/sh 还没有启动。
start:真正启动容器
func start(id string) error {
containerState, err := loadState(id)
if err != nil {
return err
}
if containerState.Status != "created" {
return fmt.Errorf(
"container is not in created state: %s",
containerState.Status,
)
}
stateDir := filepath.Join(stateRoot, id)
startFile := filepath.Join(stateDir, "start")
// 给 child 发送启动信号。
if err := os.WriteFile(
startFile,
[]byte("start"),
0644,
); err != nil {
return err
}
containerState.Status = "running"
return saveState(containerState)
}
所以:
mini-runc start mycontainer
之后:
created
│
│ start
▼
running
state:查看容器状态
func state(id string) error {
containerState, err := loadState(id)
if err != nil {
return err
}
data, err := json.MarshalIndent(
containerState,
"",
" ",
)
if err != nil {
return err
}
fmt.Println(string(data))
return nil
}
运行:
mini-runc state mycontainer
得到:
{
"id": "mycontainer",
"pid": 12345,
"status": "running",
"bundle": "/workspace/mycontainer"
}
delete:删除容器状态
最开始我们先只做最简单的 delete:
func deleteContainer(id string) error {
containerState, err := loadState(id)
if err != nil {
return err
}
if containerState.Status == "running" {
return fmt.Errorf(
"cannot delete running container",
)
}
stateDir := filepath.Join(stateRoot, id)
if err := os.RemoveAll(stateDir); err != nil {
return err
}
fmt.Printf(
"container %s deleted\n",
id,
)
return nil
}
这就形成:
created
│
▼
running
│
▼
stopped
│
▼
delete
状态文件辅助函数
保存:
func saveState(state ContainerState) error {
stateDir := filepath.Join(
stateRoot,
state.ID,
)
if err := os.MkdirAll(
stateDir,
0755,
); err != nil {
return err
}
data, err := json.MarshalIndent(
state,
"",
" ",
)
if err != nil {
return err
}
return os.WriteFile(
filepath.Join(stateDir, "state.json"),
data,
0644,
)
}
读取:
func loadState(id string) (ContainerState, error) {
var state ContainerState
data, err := os.ReadFile(
filepath.Join(
stateRoot,
id,
"state.json",
),
)
if err != nil {
return state, err
}
if err := json.Unmarshal(data, &state); err != nil {
return state, err
}
return state, nil
}
这里有个现实问题,上面的代码为了把生命周期讲清楚,故意做得简单。
真正的 mini-runc 不能靠:
start 文件
↓
child 不断轮询
这种方式实现,真正的实现应该使用:
pipe
Unix socket
eventfd
或者其他 IPC
让:
create
创建好容器初始化进程以后:
parent
│
│ IPC
▼
child
然后:
start
↓
发送启动信号
↓
child
↓
execve()
不过这里有一个非常重要的收获:
我们已经把之前那种:
创建容器
+
运行程序
拆成了真正的:
create
↓
created
↓
start
↓
running
↓
stop
↓
stopped
↓
delete
这就是容器 Runtime 和一个普通“启动容器脚本”之间最本质的区别之一。
编译:
go build -o mini-runc .
创建:
sudo ./mini-runc create mycontainer /workspace/mycontainer
/workspace/mycontainer/
├── config.json
└── rootfs/
├── bin/
├── etc/
├── lib/
└── usr/
查看:
sudo ./mini-runc state mycontainer
此时:
status = created
然后:
sudo ./mini-runc start mycontainer
状态变成:
status = running
最终容器退出以后,我们还需要把状态更新为:
stopped
再:
sudo ./mini-runc delete mycontainer
完成生命周期。
日志:容器的 stdout / stderr 去哪里了?
容器里的程序执行
fmt.Println()、echo,这些输出到底去哪了?
我们之前的 mini-runc 其实一直没有专门处理这个问题。
因为最开始我们直接写的是:
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
所以容器的标准输入输出实际上直接继承了启动 mini-runc 的终端:
宿主机终端
│
▼
mini-runc
│
▼
容器进程
│
├── stdout ──→ 宿主机终端
└── stderr ──→ 宿主机终端
所以进入容器后看到的:
/ #
以及:
echo hello
产生的:
hello
本质上只是普通的 Unix 标准输入输出继承。
stdout 和 stderr 是什么?
Linux 进程启动后,通常会拥有三个标准文件描述符:
0 → stdin
1 → stdout
2 → stderr
可以理解成:
stdin
↓
程序从哪里读输入
stdout
↓
程序把正常输出写到哪里
stderr
↓
程序把错误输出写到哪里
例如:
echo hello
写入的是:
stdout = 1
而:
ls /not-exist
产生的错误通常写入:
stderr = 2
所以容器日志本质上并不神秘:
容器日志最底层就是容器进程写入 stdout 和 stderr 的数据。
这时候你可能会想到:
docker logs nginx
Docker 为什么能把容器输出重新显示出来?
因为 Docker 并不是让 stdout/stderr 直接消失,而是会建立自己的输出采集链路。
可以粗略理解成:
容器进程
│
├── stdout ──┐
│ │
└── stderr ──┤
▼
Runtime / container manager
│
▼
日志驱动
│
▼
Docker logs / 日志文件
具体实现会受到 Docker、containerd、shim 和 logging driver 的影响,但核心思想就是:
把容器的标准输出从“终端”接管出来,再交给上层保存、转发或展示。
我们现在不急着模拟 Docker 的完整日志驱动。
先做最简单的一件事:
把容器 stdout 和 stderr 重定向到两个文件。
例如:
/run/mini-runc/
└── mycontainer/
├── stdout.log
└── stderr.log
于是:
容器进程
│
├── stdout ──→ stdout.log
│
└── stderr ──→ stderr.log
创建日志目录
func setupLogs(id string) (string, string, error) {
dir := filepath.Join("/run/mini-runc", id)
if err := os.MkdirAll(dir, 0755); err != nil {
return "", "", err
}
stdoutPath := filepath.Join(dir, "stdout.log")
stderrPath := filepath.Join(dir, "stderr.log")
return stdoutPath, stderrPath, nil
}
创建 stdout / stderr 文件
func openLogs(id string) (*os.File, *os.File, error) {
stdoutPath, stderrPath, err := setupLogs(id)
if err != nil {
return nil, nil, err
}
stdout, err := os.OpenFile(
stdoutPath,
os.O_CREATE|os.O_WRONLY|os.O_APPEND,
0644,
)
if err != nil {
return nil, nil, err
}
stderr, err := os.OpenFile(
stderrPath,
os.O_CREATE|os.O_WRONLY|os.O_APPEND,
0644,
)
if err != nil {
_ = stdout.Close()
return nil, nil, err
}
return stdout, stderr, nil
}
然后:
stdout, stderr, err := openLogs(id)
if err != nil {
return err
}
defer stdout.Close()
defer stderr.Close()
启动容器时:
cmd.Stdout = stdout
cmd.Stderr = stderr
这样:
container
│
├── fd 1 → stdout.log
└── fd 2 → stderr.log
容器内执行:
echo "hello from container"
如果我们把 stdout 指向:
/run/mini-runc/mycontainer/stdout.log
那么:
cat /run/mini-runc/mycontainer/stdout.log
就会得到:
hello from container
错误输出也是一样:
ls /does-not-exist
产生的:
ls: /does-not-exist: No such file or directory
会进入:
stderr.log
但是还有一个问题:交互式 Shell
我们现在的容器是:
syscall.Exec(
"/bin/sh",
[]string{"/bin/sh"},
env,
)
这是一个交互式 Shell。
所以:
stdin
↓
终端
仍然需要保留。
因此我们可以:
cmd.Stdin = os.Stdin
cmd.Stdout = stdout
cmd.Stderr = stderr
这样:
mini-runc
│
┌───────┼────────┐
│ │ │
stdin stdout stderr
│ │ │
▼ ▼ ▼
终端 文件1 文件2
于是你仍然可以输入:
echo hello
但是 Shell 的输出不会再直接出现在宿主机终端,而是写到:
stdout.log
这里需要特别注意:
把 stdout/stderr 直接写入两个文件,只是我们 mini-runc 的教学实现。
更常见的运行时模型是:
容器进程
│
├── stdout
└── stderr
│
▼
pipe / FIFO
│
▼
runtime / shim
│
▼
log driver
为什么用 pipe?
因为 Runtime 可以实时读取:
容器输出
↓
pipe
↓
runtime
↓
立即处理
例如:
container
│
│ write(stdout)
▼
pipe
│
▼
containerd-shim
│
▼
log
而不是:
container
│
▼
一直写文件
这样上层就可以决定:
保存到文件
发送到 journald
发送到远程日志系统
输出给 docker logs
丢弃
为什么 Docker 能执行 docker logs?
从这个角度就很好理解了。
例如:
docker logs mycontainer
并不是 Docker 再去:
“进入容器里面执行 cat”
而是 Docker 的容器运行链路早就在运行过程中接管了 stdout/stderr。
可以粗略理解为:
nginx
│
│ stdout/stderr
▼
container runtime / shim
│
▼
日志存储
│
▼
docker logs
因此:
docker logs
本质上是:
读取容器运行期间被 Runtime 捕获的标准输出和标准错误。
既然我们已经有:
/run/mini-runc/<id>/
就可以顺手增加:
mini-runc logs mycontainer
代码可以非常简单:
func logs(id string) error {
path := filepath.Join(
"/run/mini-runc",
id,
"stdout.log",
)
data, err := os.ReadFile(path)
if err != nil {
return err
}
fmt.Print(string(data))
return nil
}
然后:
case "logs":
if len(os.Args) != 3 {
fmt.Println("usage: mini-runc logs <container-id>")
return
}
if err := logs(os.Args[2]); err != nil {
fmt.Println("logs error:", err)
}
这样:
mini-runc logs mycontainer
就可以读取:
/run/mini-runc/mycontainer/stdout.log
现在我们之前的:
/run/mini-runc/mycontainer/
已经开始变得丰富:
/run/mini-runc/mycontainer/
├── state.json
├── stdout.log
└── stderr.log
以后还可能出现:
├── console.sock
├── pid
├── config.json
└── ...
exec:如何进入一个已经运行的容器?
现在假设:
mini-runc start mycontainer
容器已经运行:
container
│
└── PID 12345
└── /bin/sh
此时我们又打开了一个新的终端。
问题来了:
如果我想再次进入这个已经运行的容器,应该怎么办?
最直觉的想法是:
ssh ...
但容器并不是一台独立的虚拟机。
同样,我们也不能简单地:
./mini-runc
重新启动一个容器。
我们真正需要的是:
让一个新的进程进入已经运行容器所处的 Namespace,然后执行一个新的 Shell。
这就是:
mini-runc exec mycontainer /bin/sh
要实现的事情。
exec 和 start 完全不是一回事这一点非常重要。
start
↓
启动容器的第一个进程
而:
exec
↓
在已经运行的容器中再启动一个进程
例如:
start:
container
│
└── PID 1
└── /bin/sh
执行:
mini-runc exec mycontainer /bin/sh
之后:
container
│
├── PID 1
│ └── /bin/sh
│
└── PID 8
└── /bin/sh
注意:
exec 不会创建一个新的容器。
它只是把一个新的进程放进原容器的 Namespace 中。
回顾前面的学习:
PID Namespace
UTS Namespace
Mount Namespace
Network Namespace
这些 Namespace 决定了进程看到的环境。
因此,如果一个新的 /bin/sh:
加入:
PID Namespace
UTS Namespace
Mount Namespace
Network Namespace
再:
execve("/bin/sh")
那么它看到的就是容器环境:
/
├── bin
├── etc
├── proc
├── usr
└── ...
而不是宿主机环境。
所以:
exec
↓
setns()
↓
加入容器 Namespace
↓
execve()
↓
新的容器进程
这就是整个 exec 的核心。
Linux 提供的:
setns()
系统调用就是干这个事情的。
setns() 可以把当前进程加入另一个 Namespace。
它的典型形式是:
setns(fd, nstype)
其中:
fd
↓
目标 Namespace
nstype
↓
Namespace 类型
Linux 会通过:
/proc/<PID>/ns/
暴露进程当前所在的 Namespace。
例如:
ls -l /proc/12345/ns
可能看到:
net -> net:[4026532000]
mnt -> mnt:[4026532001]
pid -> pid:[4026532002]
uts -> uts:[4026532003]
这些并不是普通文件。
它们是 Namespace 的内核对象引用。
所以我们可以:
打开 /proc/12345/ns/mnt
↓
得到 Namespace FD
↓
setns()
↓
当前进程进入这个 Mount Namespace
假设容器 PID 是:
12345
执行:
ls -l /proc/12345/ns
宿主机自己:
ls -l /proc/self/ns
然后比较:
宿主机:
mnt -> mnt:[4026531840]
容器:
mnt -> mnt:[4026532001]
不同的 inode 编号就说明:
这两个进程属于不同的 Namespace。
而 exec 要做的,就是让新的 Shell 从:
宿主机 Namespace
切换到:
容器 Namespace
Go 的:
golang.org/x/sys/unix
提供:
unix.Setns()
所以我们可以:
func enterNamespace(pid int) error {
base := fmt.Sprintf("/proc/%d/ns", pid)
namespaces := []struct {
name string
typ int
}{
{"mnt", unix.CLONE_NEWNS},
{"uts", unix.CLONE_NEWUTS},
{"net", unix.CLONE_NEWNET},
{"ipc", unix.CLONE_NEWIPC},
{"pid", unix.CLONE_NEWPID},
}
for _, ns := range namespaces {
path := filepath.Join(base, ns.name)
fd, err := unix.Open(
path,
unix.O_RDONLY,
0,
)
if err != nil {
return fmt.Errorf(
"open %s: %w",
path,
err,
)
}
if err := unix.Setns(fd, ns.typ); err != nil {
unix.Close(fd)
return fmt.Errorf(
"setns %s: %w",
path,
err,
)
}
unix.Close(fd)
}
return nil
}
不过这里马上会遇到一个非常重要的问题:
PID Namespace 不能像其他 Namespace 一样简单地使用
setns()后就认为当前进程自己 PID 改变了。
这也是 runc exec 很有意思的地方。
假设:
容器:
PID 1
└── nginx
宿主机看到:
PID 12345
我们执行:
setns("/proc/12345/ns/pid")
以后:
当前进程不会因此获得一个新的 PID Namespace PID。
PID Namespace 对一个进程的作用是在它创建子进程时体现出来。
所以通常流程是:
exec 进程
│
├── setns(mnt)
├── setns(uts)
├── setns(net)
└── ...
│
▼
clone()
│
▼
新进程进入 PID Namespace
│
▼
execve()
这也是为什么真正的 runc exec 实现比:
setns()
execve()
稍微复杂一点。
我们的 mini-runc 怎么实现?
可以采用非常经典的方式:
mini-runc exec
│
│ fork/clone
▼
exec child
│
├── setns(mnt)
├── setns(uts)
├── setns(net)
├── setns(ipc)
├── setns(pid)
│
└── execve("/bin/sh")
代码可以先设计成:
func execContainer(id string, command []string) error {
state, err := loadState(id)
if err != nil {
return err
}
if state.Status != "running" {
return fmt.Errorf(
"container is not running",
)
}
cmd := exec.Command(
"/proc/self/exe",
"exec-child",
strconv.Itoa(state.PID),
)
cmd.Args = append(
cmd.Args,
command...,
)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
然后:
func execChild(pid int, command []string) error {
if err := enterNamespaces(pid); err != nil {
return err
}
return syscall.Exec(
command[0],
command,
os.Environ(),
)
}
Mount Namespace 为什么特别重要?
假如我们只进入:
Network Namespace
那么:
ip addr
看到的确实可能是容器网络。
但是:
ls /
仍然是宿主机文件系统,所以setns(net)远远不够。
必须至少进入:
Mount Namespace
这样/才会变成容器的 rootfs。
因此一个最基本的 exec 可以理解成:
setns(mnt)
↓
文件系统变成容器的
setns(uts)
↓
hostname 变成容器的
setns(net)
↓
网络变成容器的
setns(pid)
↓
后续创建的进程进入容器 PID Namespace
execve()
↓
真正执行用户指定程序
所以:
mini-runc exec mycontainer /bin/sh
最终得到:
容器
│
├── PID 1 /bin/sh
│
└── PID 8 /bin/sh ← exec 创建的进程
两个 Shell:
看到相同的 rootfs
使用相同的 network
使用相同的 hostname
属于相同的 container
但它们是两个独立的 Linux 进程。
还有一个很重要的问题:
exec 创建出来的新进程是否应该进入容器的 Cgroup?
当然应该。
否则:
PID 1
↓
Cgroup A
↓
CPU = 50%
而:
exec /bin/sh
↓
不在 Cgroup A
↓
可以不受 CPU / Memory / PIDs 限制
那岂不是直接绕过了容器资源限制?
所以 exec 创建的新进程也必须进入:
container Cgroup
例如:
if err := addToCgroup(
state.Cgroup,
cmd.Process.Pid,
); err != nil {
return err
}
于是:
container
│
├── PID 1
│ └── Cgroup
│
└── PID 8
└── Cgroup
这时候你会发现一个很有意思的问题。
我们的容器原始进程在启动时已经:
Capability
Seccomp
都配置好了。
那么 exec 出来的进程怎么办?
答案是:
它应该继承容器的安全边界。
因此最终模型应该是:
exec
│
├── Namespace
├── Cgroup
├── Capability
├── Seccomp
│
└── execve()
否则就出现:
容器原进程
↓
权限受限
runc exec
↓
新进程
↓
权限更高
那显然是不合理的。
Seccomp filter 在适当的进程创建和 execve() 场景下会继续作用于子进程;Capability 则需要按照进程的 capability sets 和 execve() 规则正确继承/计算。
现在整个过程可以画成:
mini-runc exec mycontainer /bin/sh
│
▼
找到容器 PID
│
▼
创建 exec-child
│
┌───────┴────────┐
│ │
▼ │
setns() │
│ │
├── mount │
├── uts │
├── net │
├── ipc │
└── pid │
│
▼
加入 Cgroup
│
▼
Capability / Seccomp
│
▼
execve()
│
▼
/bin/sh
最终:
容器
│
├── PID 1 /bin/sh
│
└── PID 8 /bin/sh
它们共享:
Namespace
Cgroup
rootfs
Network
Security Policy
却仍然是两个独立的 Linux 进程。
这就是 docker exec / runc exec 的本质,所以:
docker exec -it container /bin/sh
并不是:
“重新创建一个容器。”
而是:
找到已经运行的容器,并创建一个新的进程,让这个进程进入容器的运行环境,然后执行指定程序。
pause / resume:如何冻结和恢复容器?
但现在还有一个很自然的问题:
如果我暂时不想让容器运行,又不想把它杀掉,该怎么办?
比如:
容器
├── nginx
├── worker
├── logger
└── ...
我希望:
pause
↓
所有进程暂时停止执行
resume
↓
所有进程继续运行
这就是容器的:
pause / resume
Linux Cgroup v2 提供了一个专门的控制器:
cgroup.freeze
它可以控制一个 Cgroup 中的进程是否被冻结。
查看:
cat /sys/fs/cgroup/mini-runc/mycontainer/cgroup.freeze
可能得到:
0
表示:
0 = 正常运行
写入:
echo 1 > /sys/fs/cgroup/mini-runc/mycontainer/cgroup.freeze
表示:
1 = 冻结
再次:
cat /sys/fs/cgroup/mini-runc/mycontainer/cgroup.freeze
应该:
1
此时这个 Cgroup 中的进程会被冻结。
Linux Kernel 文档对 Cgroup v2 的 cgroup.freeze 定义了 0/1 状态,用于控制该 cgroup 中进程的冻结与恢复。
为什么 pause 直接使用 Cgroup? 这其实非常漂亮。
我们前面一直把:
Namespace
↓
隔离
Cgroup
↓
资源限制
分开讲。
现在发现 Cgroup 还有一个能力:
Cgroup
├── CPU
├── Memory
├── PIDs
└── Freeze
所以:
Cgroup 不只是“资源限制器”,它还可以控制一组进程的执行状态。
而且这非常适合容器,因为一个容器通常不只有一个进程:
container
├── PID 1
├── PID 2
├── PID 3
└── PID N
我们不需要一个一个:
kill -STOP PID1
kill -STOP PID2
kill -STOP PID3
...
只需要:
echo 1 > cgroup.freeze
整个 Cgroup 都被冻结。
先做一个手工实验,找到我们的容器 Cgroup:
ls /sys/fs/cgroup/mini-runc
假设:
mycontainer
查看:
cat /sys/fs/cgroup/mini-runc/mycontainer/cgroup.freeze
应该:
0
然后:
echo 1 | sudo tee /sys/fs/cgroup/mini-runc/mycontainer/cgroup.freeze
现在:
容器进程
↓
全部被冻结
再恢复:
echo 0 | sudo tee /sys/fs/cgroup/mini-runc/mycontainer/cgroup.freeze
容器就继续运行。
为了让实验更加明显,可以在容器里运行:
while true; do
date +%s
sleep 1
done
比如:
1724060100
1724060101
1724060102
1724060103
此时:
echo 1 > /sys/fs/cgroup/mini-runc/mycontainer/cgroup.freeze
输出会停止。
过几秒:
echo 0 > /sys/fs/cgroup/mini-runc/mycontainer/cgroup.freeze
输出又继续:
1724060110
1724060111
1724060112
注意这里不是“暂停了整个终端”。
而是:
属于这个 Cgroup 的进程被冻结了。
现在我们的 Runtime 已经有:
state.Cgroup
因此 pause 非常简单。
func pauseContainer(id string) error {
state, err := loadState(id)
if err != nil {
return err
}
if state.Status != "running" {
return fmt.Errorf(
"container is not running",
)
}
freezePath := filepath.Join(
state.Cgroup,
"cgroup.freeze",
)
if err := os.WriteFile(
freezePath,
[]byte("1"),
0644,
); err != nil {
return fmt.Errorf(
"pause container: %w",
err,
)
}
state.Status = "paused"
return saveState(state)
}
于是:
mini-runc pause mycontainer
本质上就是:
mini-runc
│
▼
/sys/fs/cgroup/.../cgroup.freeze
│
│ write "1"
▼
Kernel
│
▼
冻结容器 Cgroup 中的进程
恢复更加简单:
func resumeContainer(id string) error {
state, err := loadState(id)
if err != nil {
return err
}
if state.Status != "paused" {
return fmt.Errorf(
"container is not paused",
)
}
freezePath := filepath.Join(
state.Cgroup,
"cgroup.freeze",
)
if err := os.WriteFile(
freezePath,
[]byte("0"),
0644,
); err != nil {
return fmt.Errorf(
"resume container: %w",
err,
)
}
state.Status = "running"
return saveState(state)
}
所以:
mini-runc resume mycontainer
对应:
cgroup.freeze
│
│ 0
▼
继续运行
给命令行增加 pause / resume,在 main() 中:
case "pause":
if len(os.Args) != 3 {
fmt.Println("usage: mini-runc pause <container-id>")
return
}
if err := pauseContainer(os.Args[2]); err != nil {
fmt.Println("pause error:", err)
}
case "resume":
if len(os.Args) != 3 {
fmt.Println("usage: mini-runc resume <container-id>")
return
}
if err := resumeContainer(os.Args[2]); err != nil {
fmt.Println("resume error:", err)
}
于是我们的命令就变成:
mini-runc pause mycontainer
以及:
mini-runc resume mycontainer
状态机也要跟着变化!!!之前:
created
↓
running
↓
stopped
现在变成:
created
│
│ start
▼
running
│
│ pause
▼
paused
│
│ resume
▼
running
│
│ exit
▼
stopped
这意味着我们的 state.json:
{
"id": "mycontainer",
"pid": 12345,
"status": "paused"
}
已经能够描述更多生命周期状态。
一个很重要的细节:pause 不会销毁容器,暂停以后:
Namespace ✅
rootfs ✅
Cgroup ✅
Network ✅
Process ✅
只是:
Cgroup
↓
freeze
所以容器:
仍然存在
仍然拥有 PID
仍然占据资源
仍然保留网络配置
只是里面的进程暂时不执行。
这和:
delete
完全不同。
多容器管理:从一个容器到容器集合
前面我们的 mini-runc 已经开始支持:
create
start
state
logs
exec
pause
resume
delete
但是到目前为止,我们其实一直在假设:
mycontainer
只有一个容器,而真正的 Runtime 不可能只有一个容器。
很快我们就会遇到:
container-a
container-b
container-c
...
于是问题变成:
Runtime 如何同时管理多个彼此独立的容器?
首先,每个容器必须有自己独立的状态,假设我们运行:
mini-runc create nginx /workspace/nginx
mini-runc create redis /workspace/redis
mini-runc create api /workspace/api
Runtime 至少需要保存:
/run/mini-runc/
├── nginx/
│ └── state.json
├── redis/
│ └── state.json
└── api/
└── state.json
例如:
{
"id": "nginx",
"pid": 12345,
"status": "running"
}
和:
{
"id": "redis",
"pid": 12368,
"status": "running"
}
完全是两份独立状态,这样 Runtime 才能知道:
nginx → PID 12345
redis → PID 12368
api → PID 12372
其次,容器 ID 就变成了 Runtime 的主键,我们调用:
mini-runc state nginx
实际上是在/run/mini-runc/nginx/state.json中查找状态。
而:
mini-runc state redis
则读取:
/run/mini-runc/redis/state.json
所以容器 ID 本质上就是:
Runtime 管理容器时使用的唯一标识。
因此创建容器时必须保证:
container ID 唯一
例如:
func containerExists(id string) bool {
_, err := os.Stat(
filepath.Join(stateRoot, id),
)
return err == nil
}
创建时:
if containerExists(id) {
return fmt.Errorf("container %q already exists", id)
}
这样:
mini-runc create nginx
mini-runc create nginx
第二次就应该失败。
Cgroup 也必须一容器一个,前面我们为了实验写过:
const cgroupPath = "/sys/fs/cgroup/mini-runc"
但现在有多个容器,这样就不行了。
否则:
Cgroup
└── mini-runc
├── nginx
└── redis
两个容器会共享同一个资源限制,正确的方式应该是:
/sys/fs/cgroup/
└── mini-runc/
├── nginx/
│ ├── cpu.max
│ ├── memory.max
│ ├── pids.max
│ └── cgroup.procs
│
└── redis/
├── cpu.max
├── memory.max
├── pids.max
└── cgroup.procs
代码:
func containerCgroupPath(id string) string {
return filepath.Join(
"/sys/fs/cgroup/mini-runc",
id,
)
}
于是:
nginx
↓
/sys/fs/cgroup/mini-runc/nginx
redis
↓
/sys/fs/cgroup/mini-runc/redis
每个容器拥有自己的资源边界。
Network Namespace 也是一容器一个,前面我们已经把 Network Namespace 保存到了:
/run/netns/
现在自然变成:
/run/netns/
├── nginx
├── redis
└── api
于是:
nginx
↓
/run/netns/nginx
redis
↓
/run/netns/redis
这样执行:
ip netns exec nginx ip addr
和:
ip netns exec redis ip addr
看到的是两个完全不同的网络 Namespace。
到这里,每个容器都有自己的一整套运行时资源:
mini-runc
│
┌─────────────┼─────────────┐
│ │ │
nginx redis api
│ │ │
┌───┴───┐ ┌───┴───┐ ┌───┴───┐
│ │ │ │ │ │
rootfs cgroup rootfs cgroup rootfs cgroup
│ │ │ │ │ │
netns state netns state netns state
而有些资源则是共享的,例如:
所有容器
│
▼
bridge
br0
/ | \
c1 c2 c3
所以要特别区分:
容器独享资源
├── PID Namespace
├── Mount Namespace
├── UTS Namespace
├── Network Namespace
├── Cgroup
├── rootfs
└── state
多个容器共享资源
├── bridge
└── CNI 网络
这也是前面 delete 为什么不能“顺手把 bridge 删除掉”的原因。
因为:
删除 nginx
并不意味着:
删除 br0
否则:
redis
api
也会跟着失去网络。
既然已经有多个容器,自然需要:
mini-runc list
例如:
ID PID STATUS
nginx 12345 running
redis 12368 running
api 12372 paused
实现其实非常简单:
func listContainers() error {
entries, err := os.ReadDir(stateRoot)
if err != nil {
return err
}
fmt.Printf(
"%-16s %-8s %-10s\n",
"ID",
"PID",
"STATUS",
)
for _, entry := range entries {
if !entry.IsDir() {
continue
}
state, err := loadState(entry.Name())
if err != nil {
fmt.Printf(
"%-16s %-8s %-10s\n",
entry.Name(),
"-",
"unknown",
)
continue
}
fmt.Printf(
"%-16s %-8d %-10s\n",
state.ID,
state.PID,
state.Status,
)
}
return nil
}
然后在 main():
case "list":
if err := listContainers(); err != nil {
fmt.Println("list error:", err)
}
这样:
mini-runc list
就能看到所有由当前 mini-runc 管理的容器。
到这里你会发现:
mini-runc
其实开始越来越像一个数据库。
例如:
Container ID
│
▼
┌────────────────────────────┐
│ state.json │
│ │
│ PID │
│ status │
│ bundle │
│ cgroup │
│ netns │
│ rootfs │
└────────────────────────────┘
Runtime 通过:
container ID
找到:
进程
Namespace
Cgroup
Network
Logs
于是:
mini-runc exec nginx /bin/sh
其实就是:
nginx
↓
state.json
↓
PID
↓
/proc/<PID>/ns/*
↓
setns()
↓
execve()
而:
mini-runc pause nginx
则是:
nginx
↓
state.json
↓
Cgroup
↓
cgroup.freeze = 1
这就是 Runtime 所谓的“管理”。
随着功能越来越多,我们之前的:
type ContainerState struct {
ID string `json:"id"`
PID int `json:"pid"`
Status string `json:"status"`
Bundle string `json:"bundle"`
}
已经不够用了,可以扩展成:
type ContainerState struct {
ID string `json:"id"`
PID int `json:"pid"`
Status string `json:"status"`
Bundle string `json:"bundle"`
RootFS string `json:"rootfs"`
Cgroup string `json:"cgroup"`
NetNS string `json:"netns"`
Created int64 `json:"created"`
}
例如:
{
"id": "nginx",
"pid": 12345,
"status": "running",
"bundle": "/workspace/nginx",
"rootfs": "/workspace/nginx/rootfs",
"cgroup": "/sys/fs/cgroup/mini-runc/nginx",
"netns": "/run/netns/nginx",
"created": 1755600123
}
这时候 Runtime 已经可以根据一份 state 找到这个容器的大部分关键资源。
容器删除也变成了“按容器清理”
例如:
mini-runc delete nginx
不能直接:
rm -rf /run/mini-runc
而应该精确清理:
nginx
│
├── Cgroup
│ └── /sys/fs/cgroup/mini-runc/nginx
│
├── Network Namespace
│ └── /run/netns/nginx
│
├── Logs
│ └── /run/mini-runc/nginx/
│
└── State
└── /run/mini-runc/nginx/state.json
但:
redis
api
br0
CNI 网络
都必须保持不动。
这就是多容器管理最核心的思想:
每一个容器都拥有自己的资源边界,但 Runtime 同时还要知道哪些资源是共享的。
容器多了,我不想自己管理了
到目前为止,我们已经把 mini-runc 一步一步从一个简单的进程启动器,做成了一个具备基本 Runtime 能力的程序:
Namespace
↓
rootfs
↓
pivot_root
↓
Cgroup
↓
Capability
↓
Seccomp
↓
Network / CNI
↓
create / start / exec / pause / resume / delete
最开始的时候,我们只需要:
./mini-runc
它创建一个容器,进入 Shell,然后结束。
后来容器有了生命周期:
create
start
exec
pause
resume
delete
又有了多个容器:
nginx
redis
api
worker
...
于是我们很快会发现:
问题已经不再是“怎么创建一个容器”,而是“谁来长期管理这些容器”。
如果继续坚持让每一个操作都启动一次:
mini-runc start nginx
mini-runc exec nginx /bin/sh
mini-runc pause nginx
mini-runc resume nginx
mini-runc delete nginx
那么每执行一次命令,就会:
CLI
↓
读取 state
↓
找到 PID
↓
操作 Namespace / Cgroup / Network
↓
退出
这在实验阶段完全没问题。
但当容器数量越来越多,就开始显得笨重。
于是,一个新的架构自然出现了:
CLI
│
│ RPC / IPC
▼
daemon
│
├── container A
├── container B
├── container C
└── ...
这就是我们这一章要研究的问题:
为什么容器运行时最终会从一个 CLI 程序,演化成一个长期运行的 daemon?
为什么需要 daemon:从 CLI 到长期运行的管理进程
先看我们现在的 mini-runc。
执行:
mini-runc start nginx
它本身就是一个临时进程:
shell
│
└── mini-runc
│
└── 操作 nginx
命令执行结束以后:
shell
│
└── mini-runc ← 退出
但:
nginx
还在继续运行。
这其实没有问题,因为容器进程本身并不需要 mini-runc 一直活着。
但是问题出现在“管理”上。
假设同时有:
nginx
redis
mysql
api
worker
...
现在你想知道:
哪些容器还活着?
CLI 只能重新读取:
state.json
再去检查:
/proc/<pid>
如果容器崩溃了、PID 被回收了、state 文件过期了,就需要重新判断。
于是我们开始需要一个长期存在的管理进程:
mini-runcd
│
├── nginx
├── redis
├── mysql
└── api
CLI 不再直接管理容器,而是:
mini-runc CLI
│
│ IPC / RPC
▼
mini-runcd
│
├── 查询状态
├── 创建容器
├── 停止容器
├── 删除容器
├── 监听退出
└── 管理资源
这就是 daemon 最基本的意义:
把“容器长期运行所需要的管理逻辑”从一次性 CLI 进程中剥离出来。
这样以后:
CLI
更像一个客户端。
例如:
mini-runc list
实际上变成:
mini-runc CLI
│
│ “请告诉我所有容器”
▼
mini-runcd
│
└── 查询容器状态
而:
mini-runc pause nginx
则变成:
mini-runc CLI
│
│ “冻结 nginx”
▼
mini-runcd
│
└── 写 cgroup.freeze
因此:
CLI
= 用户入口
daemon
= 长期管理者
为什么 daemon 更适合管理容器?
最重要的原因是:
daemon 可以一直活着。
例如:
mini-runcd
│
├── 监听容器退出
├── 保存状态
├── 管理日志
├── 管理 exec
├── 管理网络
└── 回收资源
假设容器进程突然退出:
nginx
│
▼
exit
daemon 可以立即收到通知:
SIGCHLD / wait4
│
▼
mini-runcd
│
├── 更新状态
├── 处理日志
└── 清理相关资源
如果没有 daemon,就只能等下一次:
mini-runc state nginx
执行时才发现:
“哦,原来 nginx 已经死了。”
这就是被动查询和主动管理的区别。
这里有一个容易产生的误解。
daemon 的存在:
不是为了当容器进程的父进程。
它真正负责的是:
生命周期
状态
事件
资源
IPC
容器本身仍然是:
Linux Process
例如:
mini-runcd
│
└── container process
但是 daemon 不需要和容器进程永久保持某种简单的父子关系。
更重要的是:
daemon 需要知道容器什么时候发生了变化,并对这些变化作出响应。
这也是后面 containerd / shim 架构产生的重要原因之一。
这时候最适合做一个非常简单的实验。
先写:
mini-runcd
它启动以后:
监听 Unix Socket
例如:
/run/mini-runc/mini-runc.sock
CLI:
mini-runc list
不再直接读取:
state.json
而是:
mini-runc CLI
│
│ Unix Socket
▼
mini-runcd
│
▼
state
为什么使用 Unix Socket?
因为:
mini-runc
和:
mini-runcd
都运行在同一台 Linux 主机上。
没必要为了本地管理功能专门开放:
TCP 127.0.0.1:xxxx
直接使用 Unix Domain Socket 就足够了:
/run/mini-runc/mini-runc.sock
通信模型:
CLI
│
│ connect()
▼
Unix Socket
│
▼
daemon
这和普通网络通信很类似,只不过通信发生在本机。
例如:
package main
import (
"fmt"
"net"
"os"
)
const socketPath = "/run/mini-runc/mini-runc.sock"
func main() {
_ = os.Remove(socketPath)
if err := os.MkdirAll("/run/mini-runc", 0755); err != nil {
panic(err)
}
listener, err := net.Listen("unix", socketPath)
if err != nil {
panic(err)
}
defer listener.Close()
fmt.Println("mini-runcd started")
for {
conn, err := listener.Accept()
if err != nil {
continue
}
go handle(conn)
}
}
func handle(conn net.Conn) {
defer conn.Close()
_, _ = conn.Write([]byte("hello from mini-runcd\n"))
}
启动:
sudo ./mini-runcd
然后另一个终端:
socat - UNIX-CONNECT:/run/mini-runc/mini-runc.sock
可以看到:
hello from mini-runcd
这虽然还没有真正管理容器,但已经建立了:
CLI
│
▼
Unix Socket
│
▼
daemon
这就是容器运行时管理架构最基本的雏形。
containerd:容器运行时管理器
容器一多,就不能再靠一次次启动 CLI 程序手工管理。
于是我们得到了:
CLI
│
│ IPC
▼
daemon
│
├── container A
├── container B
└── container C
如果继续往这个方向发展,我们的 daemon 最终就会需要负责越来越多的事情:
容器生命周期
镜像
rootfs
snapshot
日志
事件
网络
Cgroup
Runtime
问题也就来了:
为什么不把这些通用的容器管理能力抽出来,做成一个专门的 daemon?
这就是 containerd 出现的位置。
containerd 官方对自己的定位,就是一个长期运行的容器管理 daemon,负责主机上的容器完整生命周期,并协调镜像、存储、容器执行等工作;它本身更偏向被其他上层系统嵌入使用,而不是直接作为面向普通用户的最终 CLI。(containerd)
最容易产生的误解是:
Docker
↓
containerd
↓
runc
然后把 containerd 理解成:
“另一个负责启动容器的 runc。”
其实不是,更准确地说:
containerd 是一个更高层的容器管理器,而 runc 是一个更底层的 OCI Runtime。
containerd 自己并不直接完成 Linux Namespace、Cgroup、pivot_root、execve 这些底层容器创建动作。Runtime v2 架构中,containerd 会准备容器需要的 filesystem/config,然后通过 runtime 接口请求底层 runtime 创建、启动、停止容器。(GitHub)
可以把它画成:
containerd
│
┌──────────────┼──────────────┐
│ │ │
Image Snapshot Container
Store Store Lifecycle
│ │ │
└──────────────┼──────────────┘
│
▼
Runtime
│
▼
runc
│
▼
Linux Kernel
所以:
containerd
= 管理
runc
= 执行
这两个词非常值得记住。
这是我们马上会碰到的一个关键问题。
直觉上可能会想:
containerd
│
└── exec runc
事实上,Runtime v2 并不是这么直接做的。
containerd 官方架构明确规定:containerd 不直接启动底层 runtime,而是调用一个 runtime shim;shim 再去调用真正的 runtime engine。对于默认的 runc Runtime,典型结构就是:
containerd
│
▼
containerd-shim-runc-v2
│
▼
runc
于是:
containerd
│
│
▼
containerd-shim-runc-v2
│
│ fork/exec
▼
runc
│
▼
Container Process
containerd 到底负责什么?
现在可以结合我们自己的 mini-runc 来对照,我们前面已经实现了:
mini-runc
├── create
├── start
├── state
├── logs
├── exec
├── pause
├── resume
└── delete
但 containerd 要管理的东西更多。
官方文档描述的 Runtime v2 架构中,containerd 会负责例如:
镜像内容
filesystem snapshot
container metadata
runtime 配置
container lifecycle coordination
然后把真正的“启动容器”工作交给下层 runtime。
Docker Image
│
▼
containerd
│
├── Content Store
├── Snapshotter
│
▼
Container rootfs
│
▼
Runtime config
│
▼
containerd-shim-runc-v2
│
▼
runc
│
▼
Linux Container
containerd 和我们的 mini-runc 有什么区别?
我们的:
mini-runc
│
└── 直接操作 Linux Kernel
containerd:
containerd
│
└── 协调各种组件
│
├── snapshotter
├── content store
├── metadata
├── CNI / network
└── runtime shim
于是:
mini-runc
= Runtime
containerd
= Container Manager
这也是为什么 containerd 官方特别强调,它更适合作为更大系统的一部分,而不是直接面向终端用户使用。(containerd)
现在回头看上一节的 daemon 就很容易理解了,containerd 是一个长期运行的 daemon:
systemd
│
└── containerd
│
├── container A
├── container B
├── container C
└── ...
而:
ctr
或者:
Docker
Kubernetes
nerdctl
都可以作为 client 去请求 containerd。
官方 Runtime v2 文档也明确列出了 containerd 的客户端可以包括 ctr、nerdctl、Kubernetes、Docker/Moby 等。(GitHub)
为什么还需要 shim?
既然已经有一个长期运行的 containerd daemon,为什么不让 containerd 自己直接管理每一个容器进程?
Runtime v2 的设计明确指出,shim 负责作为 containerd 与底层 runtime engine 之间的桥梁。shim 会监听 containerd 发来的命令,并调用 runtime engine;对于 io.containerd.runc.v2,shim 会调用 runc。(GitHub)
而且 shim 可以长期存在,并负责容器相关进程的生命周期和回收工作。官方文档还明确描述了 containerd-shim-runc-v2 作为 sub-reaper 的职责。(GitHub)
Docker / Kubernetes / nerdctl
│
▼
containerd
│
ttrpc / Unix socket
│
▼
containerd-shim-runc-v2
│
│ fork/exec
▼
runc
│
▼
Linux Kernel
│
▼
Container Process
containerd-shim:为什么容器不能直接挂在 containerd 上?
这里最关键的是解耦。
containerd 是一个通用的容器管理器,它不应该把自己绑定到:
runc
这一种 Runtime。
今天可以是:
runc
明天也可以是:
gVisor
或者:
Kata Containers
再或者其他实现 OCI Runtime 的 engine。
shim 提供统一接口:
create
start
kill
delete
exec
state
...
至于下面到底调用:
runc
gVisor
Kata
...
由具体 shim 决定。
官方文档明确指出,这种设计可以让同一个 Runtime API 对接不同的 runtime engine。(GitHub)
为什么 containerd 重启以后,容器还能继续?
这其实是理解 shim 最有价值的地方。
假设:
containerd
│
▼
shim
│
▼
nginx
现在:
containerd
×
崩溃
如果容器直接挂在 containerd 上:
containerd
│
└── nginx
containerd 崩溃以后,容器管理关系就会变得非常麻烦。
而 Runtime v2 的设计让 shim 独立于 containerd:
containerd ×
shim
│
└── nginx
containerd 重新启动之后:
containerd
│
│ reconnect
▼
shim
│
└── nginx
containerd Runtime v2 的设计明确支持重新连接已经存在的 shim;containerd 启动或者重启后,可以根据 shim 的地址重新建立通信。(GitHub)
所以:
shim 把“容器运行状态”从 containerd 的进程生命周期中剥离了出来。
这非常重要。
shim 还有一个非常重要的职责:回收子进程
我们之前一直讨论:
“容器进程既然是别人的子进程,那谁来回收它?”
答案在 containerd Runtime v2 中就是 shim 的重要职责之一。
containerd-shim-runc-v2 会把自己设置成 sub-reaper,负责回收容器相关的 orphaned processes。官方文档对此有专门说明。(GitHub)
可以粗略理解成:
shim
│
└── container
│
├── worker
├── child
└── orphan
当某些父进程退出以后产生 orphan:
orphan
↓
需要重新 parent / reap
shim 可以承担这个回收角色,而不是让 containerd 自己处理大量底层 Linux 进程管理细节。
为什么一个 shim 可以对应多个容器?
这里还有个容易误解的地方:
一个 container
↓
一个 shim
并不是 Runtime v2 的硬性要求。
官方设计允许:
1 shim : 1 container
也允许:
1 shim : N containers
具体取决于 shim 的设计。
containerd-shim-runc-v2 实际上可以根据场景决定如何分组,例如 Kubernetes Pod 中相关容器可以由同一个 shim 进行处理。(GitHub)
所以不要把架构死记成:
containerd
├── shim
│ └── container A
├── shim
│ └── container B
└── shim
└── container C
更准确的是:
containerd
│
├── shim-A
│ ├── container A
│ └── container B
│
└── shim-B
└── container C
具体如何分组,是 shim 的实现选择。(GitHub)
CRI:Kubernetes 是怎么调用 containerd 的?
Kubernetes 并不直接负责创建 Linux Container。真正运行容器的是节点上的 Container Runtime。
Kubernetes 中负责与容器运行时通信的是:
kubelet
而 kubelet 与容器运行时之间的标准接口,就是:
CRI(Container Runtime Interface)。
CRI 是 Kubernetes 定义的容器运行时接口,kubelet 通过 gRPC 调用 Runtime Service 和 Image Service。Kubernetes 1.26 及以后要求运行时支持 CRI v1 API。(Kubernetes)
所以现在完整的链路变成:
Kubernetes
│
kubelet
│
CRI / gRPC
│
▼
containerd
│
CRI Plugin
│
▼
containerd-shim-runc-v2
│
▼
runc
│
▼
Linux Kernel
这时候你应该能发现:
Kubernetes 根本不需要知道 runc 是什么。
它只需要知道:
“我有一个符合 CRI 的 Runtime。”
CRI 最核心的是两个 Service:
RuntimeService
ImageService
Kubernetes 官方 CRI API 就是围绕这两个服务设计的。(Kubernetes)
可以简单理解为:
RuntimeService
↓
管理 Pod Sandbox 和 Container
ImageService
↓
管理 Container Image
RuntimeService 负责容器和 Pod Sandbox 的生命周期。
例如:
RunPodSandbox
StopPodSandbox
RemovePodSandbox
PodSandboxStatus
CreateContainer
StartContainer
StopContainer
RemoveContainer
ContainerStatus
ExecSync
Exec
Attach
PortForward
这些名字是不是很熟?
因为我们前面自己写 mini-runc 时已经实现过:
create
start
exec
pause
resume
delete
CRI 只是把这些能力进一步标准化成了 API。
另外一组 API 是:
ImageService
例如:
PullImage
ListImages
ImageStatus
RemoveImage
也就是说:
Kubelet
│
│ PullImage
▼
containerd CRI plugin
│
▼
containerd image store
containerd 再去处理:
registry
↓
content store
↓
snapshotter
Kubernetes 为什么需要 Pod Sandbox?
这里是 CRI 最有意思的设计之一,Kubernetes 的最小部署单位其实不是 Container,而是:
Pod
一个 Pod 可以有多个容器:
Pod
├── container A
├── container B
└── container C
这些容器通常共享:
Network Namespace
IPC Namespace
以及 Pod 级别的网络地址,所以 Kubernetes 需要先创建一个:
Pod Sandbox
可以粗略理解为:
先建立 Pod 的隔离环境,再把应用容器放进去。
containerd 的 CRI plugin 文档对这个过程有非常清楚的描述:kubelet 请求创建 Pod 后,CRI plugin 创建 Pod 的 Network Namespace,并通过 CNI 配置网络,然后创建并启动一个特殊的 pause container 作为 sandbox;应用容器随后加入这个 Pod 的 Namespace 和 Cgroup。(GitHub)
于是:
Pod
│
Pod Sandbox
│
┌─────────┼─────────┐
│ │ │
App A App B App C
pause container 是干什么的?
这里的pause container 不是我们前面讲的 cgroup.freeze。
这是两个完全不同的概念。
Kubernetes 的 pause container 是一个非常小的容器,它的主要作用是:
作为 Pod Sandbox,持有 Pod 的 Namespace,使 Pod 中的其他容器可以共享这些 Namespace。
所以:
Pod Sandbox
│
├── Network Namespace
│
└── ...
│
├── App Container A
├── App Container B
└── App Container C
这也是为什么 Kubernetes Pod 中多个容器可以使用同一个 Pod IP,并通过 localhost 访问同一 Pod 中的其他容器。
假设我们提交:
apiVersion: v1
kind: Pod
metadata:
name: nginx
spec:
containers:
- name: nginx
image: nginx
Kubernetes 最终会让 kubelet 去完成类似这样的事情:
kubelet
│
│ RunPodSandbox
▼
containerd CRI Plugin
│
├── 创建 Pod Network Namespace
│
├── 调用 CNI
│
└── 启动 pause container
│
▼
Pod Sandbox
然后:
kubelet
│
│ PullImage nginx
▼
containerd CRI Plugin
│
▼
containerd
│
▼
registry / image store
接着:
kubelet
│
│ CreateContainer
▼
containerd CRI Plugin
│
▼
containerd
│
▼
snapshot / filesystem
最后:
kubelet
│
│ StartContainer
▼
containerd
│
▼
containerd-shim-runc-v2
│
▼
runc
│
▼
nginx
这整个流程与 containerd 官方 CRI plugin 文档描述的 Pod 创建流程一致。(GitHub)
go-runc:containerd 是怎么调用 runc 的?
containerd-shim-runc-v2 到底是怎么从 Go 程序里调用
runc的?
答案其实比想象中简单:
它并不是把 runc 当成一个 Go package 直接调用,而是通过一个 Go 封装库,把
runc当成外部进程来调用。
containerd 官方源码中,containerd-shim-runc-v2 的 task service 当前就直接依赖:
github.com/containerd/go-runc
并通过它调用 runc。(GitHub)
于是这一层关系可以画成:
containerd-shim-runc-v2
│
│ Go
▼
go-runc
│
│ exec
▼
runc
│
▼
Linux Kernel
go-runc 可以理解成:
用 Go 调用
runcCLI 的一个封装。
它并不是:
runc 的 Go 源码
也不是:
libcontainer
而是:
Go Program
│
▼
go-runc
│
▼
os/exec
│
▼
runc binary
这样,上层 Go 程序就不用自己到处写:
exec.Command(
"runc",
"create",
...
)
而是通过 Go API 调用。
为什么 containerd 不直接 exec.Command("runc")?
其实当然可以,最简单的方式就是:
cmd := exec.Command(
"runc",
"state",
"mycontainer",
)
output, err := cmd.CombinedOutput()
这完全合法。
但 containerd 是一个大型工程,需要处理:
runc path
runc root
bundle
stdin/stdout/stderr
状态解析
错误处理
signal
checkpoint
restore
systemd cgroup
runtime 参数
...
如果所有地方都自己拼:
runc create ...
runc start ...
runc kill ...
runc state ...
runc delete ...
整个代码库会很快失控。
所以把它抽成一个统一的 Go API:
go-runc
就合理多了。
一个最简单的例子
go get github.com/containerd/go-runc
然后:
package main
import (
"fmt"
"github.com/containerd/go-runc"
)
func main() {
r := &runc.Runc{
Command: "runc",
}
fmt.Println(r)
}
这里的核心:
&runc.Runc{
Command: "runc",
}
本质上就是告诉这个 Go 对象:
以后你要执行的 runtime engine 是哪个
runc二进制。
containerd 的配置也确实允许通过 BinaryName 指定 OCI runtime binary,例如:
[plugins."io.containerd.grpc.v1.cri".containerd.runtimes.runc.options]
BinaryName = "/usr/bin/runc"
这种配置最终由 io.containerd.runc.v2 shim 使用。(GitHub)
go-runc 最终还是会启动 runc
比如我们要执行:
runc state mycontainer
Go 代码本质上还是:
go-runc
│
▼
exec.Command(...)
│
▼
/usr/bin/runc state mycontainer
所以:
go-runc 没有取代 runc,它只是把“调用 runc”这件事情封装成 Go API。
现在我们可以把全过程串起来。
假设 containerd 收到:
CreateTask
shim 收到请求之后:
containerd-shim-runc-v2
│
▼
go-runc
│
▼
runc create <id>
runc 再根据 OCI Bundle:
config.json
rootfs/
执行:
Namespace
Mount
Cgroup
Capability
Seccomp
...
最终创建容器进程。
这就是 Runtime v2 文档所描述的 shim → runtime engine 关系:shim 负责通过独立进程调用 OCI runtime engine,例如 runc。(GitHub)
甚至可以替换 Runtime,这也是这个设计漂亮的地方。
containerd 并不死死绑定:
runc
只要某个 runtime 能够兼容 OCI Runtime 接口,就可以替换。
例如:
containerd
│
▼
shim
│
├── runc
├── crun
├── gVisor
└── Kata
containerd 的配置甚至可以明确指定不同 runtime handler,以及各自的 BinaryName。(GitHub)
OCI 到底是什么?OCI、OCI Image 和 OCI Runtime 是什么关系?
前面我们一直在讨论:
Docker
containerd
runc
OCI
OCI Runtime
Image
rootfs
这些名字很容易混在一起。
尤其是:
OCI 是不是就是 OCI Runtime?
答案是:不是。
OCI 全称是:
Open Container Initiative
它首先是一个开放的容器标准组织和规范体系,而不是某一个具体的软件。
简单理解:
OCI
│
├── Image Specification
│
└── Runtime Specification
也就是说,OCI 至少有两条非常重要的标准线:
Image Specification
↓
规定“容器镜像应该长什么样”
Runtime Specification
↓
规定“容器应该怎么被运行”
OCI Image Specification 解决的是:
一个容器镜像到底是什么?
例如一个 Image 大致包含:
Image
├── Manifest
├── Config
└── Layers
├── Layer 1
├── Layer 2
└── Layer 3
这里的 Layer 本质上是文件系统变化。
例如:
Layer 1
└── Alpine 基础系统
Layer 2
└── nginx
Layer 3
└── nginx.conf
所以:
OCI Image Specification
↓
定义 Image 的格式、Manifest、Config、Layer 等
它解决的是:
“镜像怎么存、怎么传、怎么描述?”
另一边是:
OCI Runtime Specification
它解决的是:
已经有了容器的 rootfs 和运行配置以后,Runtime 应该怎么启动这个容器?
比如我们前面亲手实现的:
config.json
↓
Namespace
↓
Mount
↓
rootfs
↓
pivot_root
↓
Cgroup
↓
Capability
↓
Seccomp
↓
execve()
这些事情就属于 Runtime 这一层。
OCI Runtime Spec 定义了 Runtime 需要遵循的生命周期和配置模型,例如:
create
start
state
kill
delete
以及:
config.json
rootfs
process
mounts
linux namespaces
linux resources
...
于是:
OCI Runtime Specification
↓
规定“容器应该怎样运行”
runc 是 OCI Runtime Specification 的一个实现。
也就是说:
OCI Runtime Specification
│
│ 实现
▼
runc
当然不只有 runc,例如还有:
OCI Runtime Specification
│
├── runc
├── crun
├── youki
└── 其他 OCI Runtime
所以:
OCI Runtime
≠
runc
而是:
OCI Runtime
= 遵循 OCI Runtime Specification 的 Runtime
这时候就要修正一个非常容易出现的理解:
containerd 不是 OCI Runtime 的“上一层规范”。
containerd 是一个容器管理系统/daemon。
它负责:
镜像
内容存储
Snapshot
容器生命周期
Metadata
事件
Runtime 管理
所以:
containerd
↓
调用 OCI Runtime
↓
runc
可以理解成:
containerd
= 管理容器
runc
= 运行容器
因此它们属于不同层次。
现在就可以把这两条线接起来。
用户拿到一个:
nginx:latest
这是:
Image
而 runc 并不能直接理解:
nginx:latest
它最终需要的是:
rootfs
+
config.json
所以中间要经过 containerd:
Image
│
▼
containerd
│
├── Content Store
├── Snapshotter
│
▼
rootfs
+
config.json
│
▼
OCI Runtime Bundle
│
▼
runc
OCI Image 和 OCI Runtime 是上下游关系
可以把它画成:
OCI
│
┌───────┴────────┐
│ │
▼ ▼
Image Specification Runtime Specification
│ │
▼ ▼
Image Runtime
│ │
│ ┌──┴──┐
│ │ │
│ runc crun
│
▼
containerd
│
Snapshotter
│
▼
rootfs
│
▼
OCI Runtime Bundle
│
▼
runc
所以 OCI Image 和 OCI Runtime 其实是:
同一个 OCI 体系中的两条不同标准线。
一条解决:
“镜像是什么?”
另一条解决:
“容器怎么运行?”
再往上看:
Docker
↓
containerd
↓
runc
所以最后记住四句话就够了:
OCI 是容器标准体系,不是一个具体软件。
OCI Image Specification 定义镜像怎么描述。
OCI Runtime Specification 定义容器怎么运行。
runc 是 OCI Runtime Specification 的一个实现。
最后给出一个OCI产品和OCI Runtime的对照表
常见 CRI / 容器管理运行时
| 产品 | 所属层 | 主要用途 | 常见底层 OCI Runtime | 可否切换 Runtime |
|---|---|---|---|---|
| containerd | 容器管理 / CRI | 通用容器管理、镜像、Snapshot、Runtime | runc | ✅ |
| CRI-O | CRI / 容器运行时管理 | 专门服务 Kubernetes | crun / runc | ✅ |
| Docker Engine | 容器平台 | Docker CLI、镜像、容器管理 | runc | ✅ |
| Mirantis Container Runtime | 容器平台 / CRI | Docker Engine 的商业替代方案 | runc | ✅ |
containerd 的 Runtime v2 明确支持多个 runtime,并可以通过 BinaryName 指定实际 OCI runtime;官方示例就展示了把 io.containerd.runc.v2 配置为 crun、以及配置 gVisor、Kata 等不同 runtime。(containerd.io)
常见 OCI Runtime
| OCI Runtime | 类型 | 核心特点 | 是否直接执行 Linux 容器 |
|---|---|---|---|
| runc | OCI Runtime | Docker/containerd 生态最常见 | ✅ |
| crun | OCI Runtime | C 实现,体积小、启动快、资源开销低 | ✅ |
| youki | OCI Runtime | Rust 实现,强调安全和现代工程设计 | ✅ |
| gVisor / runsc | OCI Runtime / 沙箱 | 用用户态 Kernel 风格机制隔离容器 | ⚠️ 不直接等价于普通 runc |
| Kata Containers | OCI Runtime / VM-based Sandbox | 使用轻量虚拟机强化隔离 | ⚠️ 容器运行在 VM 级隔离环境 |
| Nabla Containers | OCI Runtime / 沙箱 | 强隔离、缩小系统调用暴露面 | ⚠️ 沙箱型 Runtime |
OCI 官方目前明确列举了 runc、crun、youki、gVisor、Kata Containers 等作为 OCI Runtime Specification 的实现。
Image 到 rootfs:容器镜像是如何变成容器文件系统的
前面我们一直在使用:
rootfs/
├── bin/
├── etc/
├── lib/
├── usr/
└── ...
然后让 mini-runc:
rootfs
↓
pivot_root()
↓
容器 /
但这里其实一直缺了一块非常重要的内容:
这个
rootfs到底是从哪里来的?
我们之前为了学习 runc,直接准备了一个 Alpine rootfs。
Alpine minirootfs
↓
rootfs/
↓
mini-runc
这适合学习 Runtime。
但真实世界里,用户输入的通常不是:
/workspace/myrunc/rootfs
而是:
nginx:latest
ubuntu:24.04
redis:latest
也就是说:
Runtime 最终需要的 rootfs,必须先从 Image 变出来。
而这正是 containerd、snapshotter 等组件开始发挥作用的地方。
OCI Image 是由 manifest、配置以及一个或多个 filesystem layers 组成的;这些 layer 描述的是文件系统变更,而不是简单的一整个最终目录。(opencontainers.org; specs.opencontainers.org) (https://opencontainers.github.io)
可以简单理解成:
Image
│
├── Manifest
├── Config
└── Layers
├── Layer 1
├── Layer 2
├── Layer 3
└── ...
而:
rootfs
是:
把这些 Layer 按顺序应用之后
最终得到的文件系统视图
OCI Image Specification 明确规定,镜像 rootfs 的 diff_ids 按顺序引用各层内容;将这些 layer 应用到一个初始空目录后,才能得到最终的 filesystem layout。(https://opencontainers.github.io)
一个 Layer 本质上可以理解成:
一组文件系统变更。
例如第一层:
Layer 1
/bin/sh
/bin/busybox
/etc/passwd
/usr/...
第二层:
Layer 2
/usr/bin/nginx
/etc/nginx/nginx.conf
第三层:
Layer 3
/app/server
/app/config.yaml
它们不是三个完整 rootfs,而是三组变化。
OCI Layer Specification 定义了 layer 是一种 filesystem changeset,可以记录文件的新增、修改、删除等变化;一个或多个 layer 按顺序应用后才能得到完整文件系统。(https://opencontainers.github.io)
于是:
Layer 1
↓
Layer 2
↓
Layer 3
↓
最终 rootfs
为什么要设计成 Layer?
最直接的原因就是:
避免重复存储。
比如:
ubuntu
↓
很多程序共用相同基础文件
如果每个 Image 都保存完整 rootfs:
Image A
↓
500 MB
Image B
↓
500 MB
Image C
↓
500 MB
大量内容其实是重复的。
有了 Layer:
Base Layer
│
┌────┼────┐
│ │ │
A B C
多个镜像就可以复用相同的基础 layer。
这也是容器镜像为什么可以做到:
docker pull
时只下载本机没有的 layer。
那这些 Layer 存在哪里?
containerd 会把镜像相关内容保存到自己的 Content Store,并通过 Snapshotter 把这些 layer 准备成可以挂载的文件系统。containerd 官方文档直接把这个过程称为从 content 到 snapshot,再到最终可挂载 filesystem 的 content flow。(containerd)
典型路径:
/var/lib/containerd/

其中你之前已经看到过:
io.containerd.content.v1.content/
以及:
io.containerd.snapshotter.v1.overlayfs/
这两个目录分别代表两个不同阶段:
Content Store
↓
保存镜像内容
Snapshotter
↓
把这些内容组织成文件系统 snapshot
现在可以把整个过程画出来:
Docker / OCI Registry
│
▼
Image
│
├── Manifest
├── Config
└── Layers
│
▼
containerd Content Store
│
▼
Snapshotter
│
▼
Filesystem Snapshot
│
▼
rootfs
│
▼
OCI Runtime Bundle
│
▼
runc
│
▼
Container
OCI 官方文档也明确描述了这个方向:OCI Image 下载后,需要将其 unpack 成 OCI Runtime Bundle,Runtime 再运行这个 Bundle。(开放容器倡议)
containerd 到底做了什么?
以redis:latest为例,containerd 首先需要把 Image 的内容保存下来。
也就是:
Registry
↓
Manifest
↓
Config
↓
Layer blobs
↓
Content Store
然后进行 unpacking。
也就是:
Layer 1
↓
Layer 2
↓
Layer 3
↓
Layer 4
按照正确顺序应用。
containerd 官方文档明确把这一过程分成:
- 将 Image 内容加载到 Content Store;
- 按顺序应用各个 layer,产生 committed snapshots;
- 在最终 layer 上创建一个 active snapshot;
- 最终这个 active snapshot 就可以作为容器的 root filesystem。(containerd)
为什么还需要 active snapshot?
假设nginx Image本身的 Layer 应该是只读的,但容器启动以后:
echo hello > /tmp/test
显然不能修改原始 Image Layer。
所以通常是:
Image Layers
│
│ Read Only
▼
Base Snapshot
│
▼
Active Snapshot
│
│ Read / Write
▼
Container
这也是 OverlayFS 等 snapshotter 非常有价值的地方。
OverlayFS 在这里干了什么?
假设:
Lower Layers
├── Layer 1
├── Layer 2
└── Layer 3
Upper Layer
└── Container changes
OverlayFS 可以把它们组合成一个统一的文件系统视图:
Container /
│
┌─────────┴─────────┐
│ │
Upper Layer Lower Layers
可写 只读
于是容器看到的是:
/
├── bin/
├── etc/
├── usr/
├── var/
└── ...
但实际上这些文件可能来自不同的 Layer。
之前执行:
mount | grep overlay
看到:
overlay ...
lowerdir=...
upperdir=...
workdir=...
就是在观察这个机制,之前在机器上看到的:
/var/lib/containerd/io.containerd.snapshotter.v1.overlayfs/
正是 containerd OverlayFS snapshotter 的存储位置之一。(containerd)
到了这里,containerd 已经得到一个可以挂载的 filesystem。
接下来,它需要准备:
OCI Runtime Bundle
Bundle 大致是:
bundle/
├── config.json
└── rootfs/
├── bin/
├── etc/
├── lib/
├── usr/
└── ...
然后:
runc
│
├── 读取 config.json
├── 使用 rootfs
├── 创建 Namespace
├── 配置 Cgroup
├── 配置 Capability
├── 配置 Seccomp
└── execve()
这就和我们第二章自己做的事情重新接上了。
这时候再看我们之前准备的:
/workspace/myrunc/mini-runc/rootfs
就非常容易理解了。
我们相当于跳过了整个 Image → Snapshot → Bundle 的过程:
我们的实验
Alpine rootfs
│
▼
rootfs/
│
▼
mini-runc
│
▼
container
而真实世界:
Docker / Kubernetes
Image
│
├── Manifest
├── Config
└── Layers
│
▼
containerd
│
├── Content Store
│
▼
Snapshotter
│
▼
active snapshot
│
▼
rootfs
│
▼
OCI Bundle
│
▼
runc
│
▼
container
所以我们以前的 rootfs/ 并不是错。
只是我们为了专注研究 Runtime,人为把 Image 和 Storage 这一整层跳过去了。
不用 Docker:用 Go 直接连接 containerd
如果我的 Go 程序本身就需要运行容器,还一定要经过 Docker 吗?
答案是:
不需要。
containerd 本身就提供了官方 Go Client,可以直接连接正在运行的 containerd daemon。当前 containerd 2.x 的 Go Client 位于:
github.com/containerd/containerd/v2/client
containerd 官方文档也直接提供了通过 Go Client 连接 /run/containerd/containerd.sock、拉取镜像、创建 Container、创建 Task 并启动进程的示例。(GitHub)
在你的 AlmaLinux 上:
ls -l /run/containerd/containerd.sock
如果 containerd 正在运行,通常可以看到:
srw-rw---- 1 root root ... /run/containerd/containerd.sock
这个文件不是普通的文件。
它是:
Unix Domain Socket。
我们的 Go 程序最终就是通过它和 containerd daemon 通信。
也就是说:
Go 程序
│
│ gRPC
▼
/run/containerd/containerd.sock
│
▼
containerd
containerd 的官方 Go Client 就是干这个事情的。
例如:
mkdir containerd-demo
cd containerd-demo
go mod init containerd-demo
然后:
go get github.com/containerd/containerd/v2
containerd 2.x 已经把 Client 放到了:
github.com/containerd/containerd/v2/client
这是 containerd 2.0 之后的重要 API 变化。
先什么都不做,只连接 containerd:
package main
import (
"context"
"fmt"
containerd "github.com/containerd/containerd/v2/client"
)
func main() {
ctx := context.Background()
client, err := containerd.New(
"/run/containerd/containerd.sock",
)
if err != nil {
panic(err)
}
defer client.Close()
fmt.Println("connected to containerd")
}
运行:
sudo go run .
如果看到:
connected to containerd
说明:
Go
│
▼
containerd Client
│
▼
Unix Socket
│
▼
containerd daemon
已经连通。
containerd 为什么还有 Namespace?
这里又会出现一个之前已经见过的:
Namespace
不过这次不是 Linux PID Namespace。
这是:
containerd 自己的逻辑 Namespace。
containerd 官方文档明确说明,containerd Namespace 用来隔离不同消费者之间的资源,多个用户可以共享同一个 containerd daemon,同时避免容器和镜像相互冲突。(GitHub)
例如:
containerd
│
├── namespace: moby
│ ├── images
│ └── containers
│
├── namespace: k8s.io
│ ├── images
│ └── containers
│
└── namespace: test
├── images
└── containers
所以我们的 Go 程序应该明确指定一个 containerd Namespace:
import (
"context"
"github.com/containerd/containerd/v2/pkg/namespaces"
)
ctx := namespaces.WithNamespace(
context.Background(),
"mini-demo",
)
这样后面的操作都会落在:
mini-demo
这个 containerd Namespace 下面。
现在我们就可以不通过 Docker:
docker pull redis:latest
而让 Go 直接告诉 containerd:
“帮我拉 redis 镜像。”
代码:
image, err := client.Pull(
ctx,
"docker.io/library/redis:latest",
containerd.WithPullUnpack,
)
if err != nil {
return err
}
这里containerd.WithPullUnpack很重要!
它不只是把镜像内容下载到 Content Store,还会把镜像 unpack 到配置好的 Snapshotter 中,供后面的 Container 使用。containerd 官方文档就是这样使用 Pull(..., WithPullUnpack) 的。
创建 Container,这里开始出现一个非常容易混淆的地方。
containerd 中:
Container
并不等于:
正在运行的 Linux 进程
containerd 官方文档明确把 Container 定义为一个metadata object,它可以关联:
OCI Runtime Spec
Image
Root filesystem
其他 metadata
而真正可以执行的对象叫:
Task
也就是说:
Container
↓
“容器应该是什么样?”
Task
↓
“这个容器现在真正跑起来的进程”
创建 Container:
container, err := client.NewContainer(
ctx,
"redis-demo",
containerd.WithNewSnapshot(
"redis-demo-rootfs",
image,
),
containerd.WithNewSpec(
oci.WithImageConfig(image),
),
)
if err != nil {
return err
}
defer container.Delete(
ctx,
containerd.WithSnapshotCleanup,
)
这里:
image
↓
WithNewSnapshot
↓
创建这个容器自己的 filesystem snapshot
然后:
WithNewSpec
↓
根据 Image Config
生成 OCI Runtime Spec
也就是说,containerd 已经开始替我们完成以前手工做的:
Image
↓
rootfs
↓
OCI config
官方文档也直接使用 WithNewSnapshot + WithNewSpec(oci.WithImageConfig(image)) 创建 Container。
到这里:
container
已经创建出来了,但:
/bin/sh
或者:
redis-server
还没有真正启动,这和我们刚才讲的:
create
↓
created
非常类似,真正的运行对象是:
Task
创建 Task
task, err := container.NewTask(
ctx,
cio.NewCreator(
cio.WithStdio,
),
)
if err != nil {
return err
}
defer task.Delete(ctx)
containerd 官方文档明确说明:
Task 是把 Container 转换成实际可运行进程的对象。
创建 Task 后,它已经有了 PID,可以用于后续网络等操作;调用 task.Start() 后,容器中的进程才真正开始执行。
启动:
if err := task.Start(ctx); err != nil {
return err
}
把前面的东西合起来,可以先写一个非常小的 containerd Client:
package main
import (
"context"
"fmt"
"log"
containerd "github.com/containerd/containerd/v2/client"
"github.com/containerd/containerd/v2/pkg/cio"
"github.com/containerd/containerd/v2/pkg/namespaces"
"github.com/containerd/containerd/v2/pkg/oci"
)
const (
socketPath = "/run/containerd/containerd.sock"
namespace = "mini-demo"
imageRef = "docker.io/library/redis:latest"
containerID = "redis-demo"
snapshotID = "redis-demo-rootfs"
)
func main() {
ctx := namespaces.WithNamespace(
context.Background(),
namespace,
)
// 1. 连接 containerd
client, err := containerd.New(socketPath)
if err != nil {
log.Fatal(err)
}
defer client.Close()
fmt.Println("connected to containerd")
// 2. 拉取并解包镜像
fmt.Println("pulling image...")
image, err := client.Pull(
ctx,
imageRef,
containerd.WithPullUnpack,
)
if err != nil {
log.Fatal(err)
}
fmt.Println("image pulled:", image.Name())
// 3. 创建 Container
container, err := client.NewContainer(
ctx,
containerID,
// 为这个 Container 创建自己的 writable snapshot。
containerd.WithNewSnapshot(
snapshotID,
image,
),
// 根据 Image Config 生成 OCI Runtime Spec。
containerd.WithNewSpec(
oci.WithImageConfig(image),
),
)
if err != nil {
log.Fatal(err)
}
defer func() {
if err := container.Delete(
ctx,
containerd.WithSnapshotCleanup,
); err != nil {
log.Println("container cleanup error:", err)
}
}()
fmt.Println("container created")
// 4. 创建 Task,也就是实际运行的容器进程。
task, err := container.NewTask(
ctx,
cio.NewCreator(cio.WithStdio),
)
if err != nil {
log.Fatal(err)
}
defer func() {
if _, err := task.Delete(ctx); err != nil {
log.Println("task cleanup error:", err)
}
}()
fmt.Printf(
"task created, pid=%d\n",
task.Pid(),
)
// 5. 启动容器。
if err := task.Start(ctx); err != nil {
log.Fatal(err)
}
fmt.Println("container started")
// 6. 等待容器退出。
exitCh, err := task.Wait(ctx)
if err != nil {
log.Fatal(err)
}
status := <-exitCh
fmt.Printf(
"container exited, code=%d\n",
status.ExitCode(),
)
}
containerd Client 和 Docker Client 的区别
如果你的应用只是需要创建、启动、停止、管理容器,并不需要 Docker Engine 提供的整套高层体验,那么完全可以直接使用 containerd。
containerd 官方定位本身就是为了让其他平台通过 Client Package 集成容器能力。
但这里有个细节,containerd 的确有能力管理和导入/导出镜像内容,甚至提供了一些底层 API 来操作 content、snapshot、image metadata。
但它不等于:
“containerd 自带一个 Dockerfile builder”。
例如:
docker build .
背后现在主要是 BuildKit 在做构建,而不是 containerd 自己解析 Dockerfile。
你可以把整个链条理解成:
Dockerfile
│
▼
BuildKit
│
├── 执行构建步骤
├── 生成 Layers
└── 生成 Image
│
▼
containerd
│
├── Content Store
├── Image Metadata
└── Snapshotter
│
▼
rootfs
│
▼
runc
containerd 负责消费和管理镜像,但镜像“怎么被构建出来”通常属于 BuildKit、Buildah、Kaniko 等构建工具的职责。
一个有趣的构想:用 Go 管理多台 containerd
学到这里,其实已经可以做一个非常有意思的小实验了。
假设我手里有:
1 台小资源主机
3 台大资源主机
三台大主机分别运行:
containerd
而小主机只运行一个我自己写的 Go 程序。
那么这个 Go 程序能不能统一管理三台机器上的容器?
答案是:
完全可以。
架构可以变成:
小资源主机
┌─────────────────┐
│ mini-k8s │
│ Go Controller │
└────────┬────────┘
│
┌────────────┼────────────┐
│ │ │
▼ ▼ ▼
Node A Node B Node C
containerd containerd containerd
│ │ │
runc runc runc
│ │ │
Container Container Container
于是我甚至可以让 Controller 提供这样的接口:
CreateContainer(image, cpu, memory)
DeleteContainer(id)
StartContainer(id)
StopContainer(id)
ExecContainer(id, command)
ListContainers()
Controller 自己决定:
Node A
Node B
Node C
到底把容器放在哪里,这已经开始有点像一个极简版 Kubernetes 了。
而实现这个想法,其实有两条路线。
方案一:直接把 containerd Socket 暴露出来
这是最简单、也最“暴力”的方法。
正常情况下,containerd 的 Unix Socket 是:
/run/containerd/containerd.sock
它只存在于本机:
Go 程序
│
▼
containerd.sock
│
▼
containerd
但如果把 containerd 的监听端从 Unix Socket 扩展成 TCP,那么就可以变成:
小主机
Go Controller
│
│ TCP
▼
Node A:xxxx
│
▼
containerd
然后在 Go 程序里保存:
type Node struct {
Name string
Addr string
}
var nodes = []Node{
{
Name: "node-a",
Addr: "192.168.1.101:xxxx",
},
{
Name: "node-b",
Addr: "192.168.1.102:xxxx",
},
{
Name: "node-c",
Addr: "192.168.1.103:xxxx",
},
}
Controller 想在 Node B 上创建容器:
Controller
│
│ containerd API
▼
192.168.1.102:xxxx
│
▼
containerd
│
▼
runc
甚至可以把:
client, err := containerd.New(...)
进一步抽象成:
func getNodeClient(node Node) (*containerd.Client, error) {
// 根据 Node.Addr 创建到远程 containerd 的连接
}
然后:
CreateContainer("node-b", ...)
就可以直接操作指定节点。
这种方案为什么“不安全”?
一旦这个 TCP 接口没有做好强认证、加密和访问控制,拿到访问权限的人就可能进一步控制这台 Node 上的容器,甚至间接影响整个主机。
所以这种方案:
可以拿来做纯内网实验,但不要把它直接暴露到公网。
比如实验环境:
10.0.0.1 Controller
10.0.0.2 Node A
10.0.0.3 Node B
10.0.0.4 Node C
全部处于可信的实验内网中:
Controller
│
├── 10.0.0.2:containerd
├── 10.0.0.3:containerd
└── 10.0.0.4:containerd
这样作为学习和实验是非常有意思的。
containerd 默认通过
/run/containerd/containerd.sock提供 Unix Domain Socket 接口。如果只是做内网实验,可以使用socat将这个 Socket 转发到 TCP 端口,例如TCP-LISTEN:50051 → UNIX-CONNECT:/run/containerd/containerd.sock。这种方式非常简单,但socat本身只是数据转发,不负责 containerd 级别的认证和加密。更推荐的方式是直接配置 containerd 的grpc-tcplistener,让 containerd 原生监听 TCP,并进一步配置 TLS。这样网络通信路径更加直接,也更符合实际的生产架构。
使用:
socat \
TCP-LISTEN:50051,reuseaddr,fork \
UNIX-CONNECT:/run/containerd/containerd.sock
可以快速把 containerd 的 Unix Socket 转成 TCP,但如果是长期运行的环境,更推荐直接让 containerd 自己监听 TCP。
containerd 的默认配置文件是:
/etc/containerd/config.toml
如果文件不存在,可以直接生成默认配置:
containerd config default | sudo tee /etc/containerd/config.toml
containerd 官方文档也说明,/etc/containerd/config.toml 是默认配置文件路径;当前 containerd 4 版配置中,TCP gRPC listener 对应:
io.containerd.server.v1.grpc-tcp
并且只有设置了 address 后才会启动 TCP listener。
修改 containerd 配置,编辑:
sudo vim /etc/containerd/config.toml
在配置中加入:
[plugins."io.containerd.server.v1.grpc"]
address = "/run/containerd/containerd.sock"
[plugins."io.containerd.server.v1.grpc-tcp"]
address = "192.168.1.101:50051"
其中:
192.168.1.101
替换成当前这台 Node 的实际内网 IP,于是 containerd 同时提供两个入口:
Unix Socket:
/run/containerd/containerd.sock
↓
本机程序
TCP:
192.168.1.101:50051
↓
远程程序
当前 containerd 配置文档明确支持 address、tls_cert、tls_key、tls_ca 等 TCP gRPC 配置项。
修改之后:
sudo systemctl restart containerd
检查状态:
sudo systemctl status containerd
然后检查 TCP 端口:
sudo ss -lntp | grep 50051
正常情况下应该看到:
LISTEN 0 4096 192.168.1.101:50051 0.0.0.0:*
也可以从 Controller 主机测试:
nc -vz 192.168.1.101 50051
如果能够建立 TCP 连接,说明 containerd 已经开始监听这个地址。
实验的时候可能很容易写成:
address = "0.0.0.0:50051"
这代表:
监听本机所有网络接口。
对于我们的实验环境,更推荐:
address = "192.168.1.101:50051"
只监听内部管理网卡。因为 containerd 的这个 gRPC 接口不是一个普通的 Web API,它属于高权限的容器管理接口。
containerd 的 TCP gRPC server 本身支持:
[plugins."io.containerd.server.v1.grpc-tcp"]
address = "192.168.1.101:50051"
tls_cert = "/etc/containerd/tls/server.crt"
tls_key = "/etc/containerd/tls/server.key"
tls_ca = "/etc/containerd/tls/ca.crt"
这样:
Go Controller
│
│ TLS / gRPC
▼
192.168.1.101:50051
│
▼
containerd
就比简单的裸 TCP 安全得多。containerd 当前配置规范直接提供 tls_cert、tls_key 和 tls_ca 配置项。
方案二:每台 Node 部署一个 Agent
更合理的生产式架构,则是:
Controller
│
gRPC / HTTP
│
┌─────────────┼─────────────┐
│ │ │
▼ ▼ ▼
node-agent node-agent node-agent
│ │ │
containerd containerd containerd
│ │ │
runc runc runc
也就是说:
不要把 containerd 本身暴露给网络。
每台 Node 只提供一个你自己定义的 Agent:
mini-agent
例如:
Node A
├── mini-agent
└── containerd
Node B
├── mini-agent
└── containerd
Node C
├── mini-agent
└── containerd
Controller:
mini-controller
通过 gRPC:
CreateContainer
DeleteContainer
StartContainer
StopContainer
ExecContainer
ListContainers
调用 Agent,Agent 再通过 containerd Go Client:
mini-agent
│
▼
containerd Go Client
│
▼
/run/containerd/containerd.sock
这样 containerd 的 Unix Socket 仍然只存在于:
Node A
Node B
Node C
从外部根本看不到。
然后,“小 Kubernetes”就真的出现了,最开始:
Controller
↓
Node Agent
↓
containerd
再往后可以给 Controller 加:
Node Registry
↓
记录有哪些 Node
Heartbeat
↓
检测 Node 是否在线
Resource Collector
↓
CPU / Memory / Disk
Scheduler
↓
决定容器跑在哪台机器
Reconciler
↓
发现容器死了就重新启动
最终:
mini-k8s
│
┌─────────┼─────────┐
│ │ │
Scheduler Controller API
│ │
└────┬────┘
│
┌──────┼──────┐
▼ ▼ ▼
Agent-A Agent-B Agent-C
│ │ │
containerd containerd containerd
│ │ │
runc runc runc
Podman:不需要 daemon 的容器引擎
前面我们花了很长时间研究:
CLI
↓
daemon
↓
containerd
↓
shim
↓
runc
于是很容易产生一个错觉:
容器是不是一定需要一个长期运行的 daemon?
答案是:
不是。
Podman 就是一个很好的反例。
Podman 是一个 daemonless container engine,它可以直接由用户通过 CLI 或 API 管理容器,而不要求存在一个类似 Docker Engine 那样的中央 daemon。Podman 同样使用 OCI 镜像和 OCI Runtime,常见底层 Runtime 包括 crun 和 runc。
podman
│
├── image/storage
├── network
└── OCI Runtime
│
┌──┴──┐
runc crun
│
▼
Container
没有一个必须长期存在的:
podmand
来承载所有容器。
所以:
podman run nginx
Podman 本身就可以完成从:
Image
↓
Storage
↓
OCI Config
↓
Runtime
↓
Container
这一整套流程。
这里特别容易产生误解。
Daemonless 并不是“没有后台进程”。
而是:
Podman 不要求有一个集中式、长期运行的 daemon 作为所有容器的父级管理入口。
每次执行 Podman 命令时,它可以直接进入自己的容器管理流程。Podman 只是没有把“所有容器的中央管理”强制集中到一个常驻 daemon 中。
**那 Podman 是不是直接调用 runc?**可以这么理解,但不要理解成:
Podman
↓
runc
中间什么都没有,Podman 自己仍然需要处理:
Image
Storage
Network
Container metadata
OCI config
然后再调用 OCI Runtime:
Podman
│
├── containers/storage
│
├── Netavark / network
│
└── OCI Runtime
├── crun
└── runc
Podman 官方文档把 OCI Runtime 作为容器实际执行层,并支持通过配置选择 crun、runc 等 Runtime。
所以:
Podman
= 容器引擎
runc / crun
= OCI Runtime
两者不是一个层级。
为什么 Podman 默认喜欢 crun?
在现代 Linux 容器生态里,Podman 经常使用:
crun
作为默认或常见 OCI Runtime。
crun 是一个用 C 编写的 OCI Runtime,相比 runc 更轻量,启动速度和资源占用也比较低;它也是 Podman / Red Hat 容器生态中常见的 Runtime。
Podman 仍然可以提供 API 服务
既然 Podman 是 daemonless,是不是意味着:
Podman 就完全不能被远程调用?
不是。
Podman 提供了自己的 API 服务,可以通过:
podman system service
启动 API 服务。
Go / REST Client
│
▼
Podman API Service
│
▼
Podman
│
▼
crun / runc
所以:
Daemonless ≠ 完全拒绝 daemon/API 服务。
更准确地说:
Podman 的核心容器管理模型不依赖一个常驻中央 daemon;需要 API 服务时,可以按需启动服务。
这也是它和 Docker Engine 思路上的重要区别。
Podman 还有一个非常有代表性的特点:
Rootless Container。
也就是说,普通用户:
podman run nginx
可以不使用:
sudo
就运行容器。
那 Kubernetes 为什么不用 Podman?
Kubernetes kubelet 需要的是:
CRI
也就是:
kubelet
│
▼
CRI
而 Podman 本身并不是 Kubernetes 最常见的 CRI 实现。
真正与 Kubernetes 直接对应的是:
Kubernetes
│
▼
CRI
│
├── containerd
└── CRI-O
而 Podman 和 CRI-O 虽然都来自 Red Hat / Containers 生态,但两者职责不同:
Podman
→ 面向用户的容器引擎
CRI-O
→ 面向 Kubernetes 的 CRI Runtime
两者底层都可以使用:
crun
runc
这也是为什么:
Podman
↓
crun
CRI-O
↓
crun
看起来会很像,但它们仍然属于不同层次。
更准确一点说,Podman 更偏向:
面向开发者和运维人员的容器引擎,适合单机或小规模容器管理。
它的典型使用方式就是:
用户
↓
podman CLI
↓
Podman
├── 镜像管理
├── 容器生命周期
├── 存储
├── 网络
└── OCI Runtime
↓
crun / runc
它的优势正好在于:
不需要中央 daemon
支持 rootless
CLI 体验接近 Docker
可以直接运行 OCI Image
所以对于:
个人开发机
测试机
CI 环境
单机服务器
小规模容器部署
非常合适,而到了:
几十台 / 几百台节点
大量容器
跨节点调度
自动故障恢复
服务发现
滚动更新
这时候 Podman 本身就不再是主要解决方案了,通常会进入:
Kubernetes
↓
CRI
↓
containerd / CRI-O
↓
runc / crun
不过有一点值得纠正:不能简单说 Podman “只能用于小规模”。它也可以管理不少容器,而且 Podman 还有 Pod、REST API、systemd 等能力。更准确的说法是:
Podman 的核心定位是单机、daemonless 的容器引擎,而不是像 Kubernetes 那样设计成跨节点的容器编排平台。
Podman
→ 单机容器引擎
containerd
→ 容器管理基础设施 / daemon
Kubernetes
→ 多节点容器编排平台
一个有趣的构想:用 Go 管理多台 Podman
如果我有一台小资源主机和三台大资源主机,能不能只在小主机上运行一个 Go 程序,然后统一管理三台大主机上的 Podman?
整体结构可以设计成:
小资源主机
┌──────────────────┐
│ mini-controller │
│ Go 程序 │
└────────┬─────────┘
│
Podman Remote API
┌─────────┼─────────┐
│ │ │
▼ ▼ ▼
Node A Node B Node C
Podman Podman Podman
│ │ │
crun crun crun
│ │ │
Container Container Container
Controller 只需要维护一个 Node 列表:
type Node struct {
Name string
Addr string
}
var nodes = []Node{
{
Name: "node-a",
Addr: "http://192.168.1.101:8080",
},
{
Name: "node-b",
Addr: "http://192.168.1.102:8080",
},
{
Name: "node-c",
Addr: "http://192.168.1.103:8080",
},
}
然后 Controller 就可以自己决定:
启动 nginx
↓
Node A 资源不足
↓
Node B 资源充足
↓
部署到 Node B
这时候,一个最基础的 Scheduler 就已经出现了。
Podman 本身就是面向用户的容器引擎,并提供容器、镜像、Pod、网络等管理能力。更重要的是,它提供了官方 Go Bindings:
go.podman.io/podman/v6/pkg/bindings
因此我们不需要:
Go
↓
exec.Command("podman", ...)
而可以直接使用 Go API 操作 Podman。
例如:
ctx := context.Background()
conn, err := bindings.NewConnection(
ctx,
"unix:///run/podman/podman.sock",
)
if err != nil {
log.Fatal(err)
}
之后就可以通过 bindings 操作:
Pull
Create
Start
Stop
Exec
Remove
等资源。
Podman 虽然是 daemonless 的,但并不意味着它不能提供 API 服务。
可以使用:
podman system service
启动 Podman API,例如 Node A:
podman system service --time=0 tcp:0.0.0.0:8080
Controller:
Go Controller
│
│ HTTP/API
▼
Node A:8080
│
▼
Podman
Node B、Node C 也可以采用相同的方式。
不过正式环境不应该直接把 Podman API 裸露在公网,更合理的做法是:
Controller
│
TLS / SSH
│
▼
Podman Service
如果只是自己的内网实验,那可以把事情做得非常简单:
Controller
│
├── HTTP → 192.168.1.101:8080
├── HTTP → 192.168.1.102:8080
└── HTTP → 192.168.1.103:8080
然后 Controller 自己实现:
CreateContainer(node, image)
StartContainer(node, id)
StopContainer(node, id)
ExecContainer(node, id, cmd)
RemoveContainer(node, id)
ListContainers(node)
这样,一个最简单的多节点容器管理系统就出来了。
写在最后
大三实习求职的时候,我其实就学过一次 Docker。
那时候学 Docker,目标非常明确:会用。
Dockerfile 要会写,多阶段构建要会,尽量使用微基础镜像压缩制品体积、加快服务上线要懂,docker build -t ... 要熟,甚至在公司里,我也基于 Docker 写过不少快速搭建开发环境的脚本。
这些东西都没有错。
甚至可以说,这才是 Docker 在日常开发中最直接的价值。
只是那时候的我,关注的是:
Docker 怎么用?
而这一次,我开始问的是:
Docker 到底是怎么做到的?
于是顺着 docker 一路往下挖,先看到了 containerd,再看到 runc,再看到 OCI,最后一路落到了 Linux Kernel。
Namespace 解决隔离,Cgroup 解决资源限制,Capability 和 Seccomp 解决权限,Mount Namespace、rootfs 和 pivot_root 解决文件系统,veth、bridge、route 和 NAT 解决网络。
最后再把这些东西重新拼起来,才发现所谓“容器”,本质上并没有什么魔法。
它归根到底,还是一个 Linux 进程。
只是这个进程被放进了一个经过精心构造的世界里:
Namespace
→ 让它看到一个不同的世界
Cgroup
→ 限制它能消耗多少资源
Capability
→ 限制它拥有多少特权
Seccomp
→ 限制它能向 Kernel 提出什么请求
rootfs
→ 给它准备一个独立的文件系统
Network
→ 给它准备一个独立的网络世界
再往上,则是:
runc
↓
containerd
↓
CRI
↓
Kubernetes
这一层层越来越复杂的东西,本质上也不是凭空出现的。
它们只是在解决越来越高层的问题:
Linux Kernel
→ 怎么隔离、限制、运行一个进程
runc
→ 怎么把这些 Linux 原语组织成一个 OCI Container
containerd
→ 怎么管理大量容器、镜像和运行时
Kubernetes
→ 怎么管理一群机器上的大量容器
这也是我这次重新学习 Docker 最大的收获。
以前我看到的是:
docker build
docker run
docker exec
现在再看到这些命令,脑子里已经能够继续往下展开:
docker
↓
containerd
↓
shim
↓
runc
↓
OCI
↓
Namespace / Cgroup / Mount / Seccomp
↓
Linux Kernel
这时候,工具就不再只是工具了。
它背后的系统终于开始变得清晰。
当然,AI 时代完全可以把这些东西直接问出来。你甚至不需要记住 runc 的某个参数,也不需要背 Cgroup 的某个文件名。
但我越来越觉得,知道答案和拥有模型,是两回事。
AI 可以告诉你:
docker exec本质上和setns()有关。
但如果这些东西真正成为了你脑子里的知识,那么下次看到一个陌生 Runtime,你甚至不需要先问 AI,就能自己推导:
它怎么创建 Namespace?
怎么准备 rootfs?
怎么限制资源?
怎么配置网络?
怎么进入已经运行的容器?
知识真正进入大脑以后,检索就变成了辅助,而不是依赖。
有人说,AI 时代不需要学习了,因为不会的东西问 AI 就行。
我倒觉得恰恰相反。
越是 AI 能快速告诉你答案的时代,越应该把那些能够形成认知体系、能够让你自己推导答案的东西真正学进去。
毕竟 AI 还需要几十秒把答案检索、组织、生成出来。
而那些已经进入你脑子的知识,就在那里。
随时可以调用。
不需要网络,不需要 API,不需要等待。
更多推荐
所有评论(0)