上接: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 执行 LinkAddrRoute 等操作。

到这里,整个网络路径就清楚了:

                    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 提供了 AddNetworkListDelNetworkList 等接口。(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 官方 libcniAddNetworkList() 就负责按配置顺序执行插件,并将前一个插件的结果传给后续插件。

容器退出后:

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 的核心生命周期模型。规范定义了 ADDDELCHECK 等操作;当前规范版本为 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 本身就把容器的生命周期定义为一组标准操作,例如 createstartstatekilldelete,并规定了 creatingcreatedrunningstopped 等状态。(https://opencontainers.github.io)

而真正的 runc 也提供了:

create
start
state
kill
delete
exec
pause
resume
...

其中 runc createrunc 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(),这就是为什么 createstart 必须分开。

我们现在的代码:

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 返回容器状态,其中包含 idstatuspid 等信息。(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 的客户端可以包括 ctrnerdctl、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 调用 runc CLI 的一个封装。

它并不是:

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、Runtimerunc
CRI-OCRI / 容器运行时管理专门服务 Kubernetescrun / runc
Docker Engine容器平台Docker CLI、镜像、容器管理runc
Mirantis Container Runtime容器平台 / CRIDocker Engine 的商业替代方案runc

containerd 的 Runtime v2 明确支持多个 runtime,并可以通过 BinaryName 指定实际 OCI runtime;官方示例就展示了把 io.containerd.runc.v2 配置为 crun、以及配置 gVisor、Kata 等不同 runtime。(containerd.io)

常见 OCI Runtime

OCI Runtime类型核心特点是否直接执行 Linux 容器
runcOCI RuntimeDocker/containerd 生态最常见
crunOCI RuntimeC 实现,体积小、启动快、资源开销低
youkiOCI RuntimeRust 实现,强调安全和现代工程设计
gVisor / runscOCI Runtime / 沙箱用用户态 Kernel 风格机制隔离容器⚠️ 不直接等价于普通 runc
Kata ContainersOCI Runtime / VM-based Sandbox使用轻量虚拟机强化隔离⚠️ 容器运行在 VM 级隔离环境
Nabla ContainersOCI 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 官方文档明确把这一过程分成:

  1. 将 Image 内容加载到 Content Store;
  2. 按顺序应用各个 layer,产生 committed snapshots;
  3. 在最终 layer 上创建一个 active snapshot;
  4. 最终这个 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-tcp listener,让 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 配置文档明确支持 addresstls_certtls_keytls_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_certtls_keytls_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 包括 crunrunc

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 作为容器实际执行层,并支持通过配置选择 crunrunc 等 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 解决资源限制,CapabilitySeccomp 解决权限,Mount Namespacerootfspivot_root 解决文件系统,vethbridgerouteNAT 解决网络。

最后再把这些东西重新拼起来,才发现所谓“容器”,本质上并没有什么魔法。

它归根到底,还是一个 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,不需要等待。

Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐