背景
因为做过openstack的虚拟网络,习惯使用netns去管理linux的网络命名空间,也一直理解容器网络应该是类似的。 但是实际在使用时(runtime使用docker)发现每个非hostnetwork的容器创建后并不会查看到一个network namespace(通过ip netns ls查看); 在使用的runtime是containerd的时候呢,这个namespace又出现了,有点迷糊了,今天就专门看了这里面的原因
容器网络命名空间以及问题
我们将研究Docker容器的网络名称空间文件的问题。具体来说,我们将了解为什么ip netns ls命令看不到网络名称空间文件。 Docker容器的最基础层是Linux cgroup和名称空间机制。这两种机制协同工作,在Docker容器中提供我们所利用的进程和资源隔离。 其中cgroups限制一个进程可以使用的资源,名称空间控制进程间资源的可见性。命名空间中一个类型就是网络命名空间(network namespace)。
network namespace实质上虚拟化并隔离了进程的网络堆栈。也就是说不同的进程可以有自己独特的防火墙配置、私有IP地址和路由规则。 通过网络命名空间,我们可以为每个Docker容器提供一个与主机网络隔离的网络堆栈。
在Linux中,管理网络名称空间的主要工具之一是ip netns。这个命令行工具是ip工具的扩展。它允许我们在不同的网络名称空间上执行ip兼容的命令。
每当我们创建Docker容器时,守护进程都会为容器进程创建名称空间对应文件。然后,它会将这些文件放在目录/proc/{pid}/ns下,其中pid是容器的进程id。
让我们看一个例子:
1
2
3
4
$ sudo docker run --rm -d ubuntu:latest sleep infinity
2545fdac9b41e463a29b4a61c201b789d567f88d54b6973bdcca9e69ba35ba92
$ sudo docker inspect -f '' 2545fdac9b41e463a29b4a61c201b789d567f88d54b6973bdcca9e69ba35ba92
3357
在上面的命令中,首先创建一个运行ubuntu:latest映像的Docker容器。然后,我们通过运行sleep infinity来保持容器运行。 最后,我们运行docker inspect命令来获取容器的进程id。
现在,查看/proc/3357/ns目录,我们可以看到创建了所有不同种类的名称空间:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
$ sudo ls -la /proc/3357/ns
total 0
dr-x--x--x 2 root root 0 Feb 5 04:24 .
dr-xr-xr-x 9 root root 0 Feb 5 04:24 ..
lrwxrwxrwx 1 root root 0 Feb 5 04:25 cgroup -> 'cgroup:[4026531835]'
lrwxrwxrwx 1 root root 0 Feb 5 04:25 ipc -> 'ipc:[4026532720]'
lrwxrwxrwx 1 root root 0 Feb 5 04:25 mnt -> 'mnt:[4026532718]'
lrwxrwxrwx 1 root root 0 Feb 5 04:24 net -> 'net:[4026532723]'
lrwxrwxrwx 1 root root 0 Feb 5 04:25 pid -> 'pid:[4026532721]'
lrwxrwxrwx 1 root root 0 Feb 5 04:25 pid_for_children -> 'pid:[4026532721]'
lrwxrwxrwx 1 root root 0 Feb 5 04:25 time -> 'time:[4026531834]'
lrwxrwxrwx 1 root root 0 Feb 5 04:25 time_for_children -> 'time:[4026531834]'
lrwxrwxrwx 1 root root 0 Feb 5 04:25 user -> 'user:[4026531837]'
lrwxrwxrwx 1 root root 0 Feb 5 04:25 uts -> 'uts:[4026532719]'
从名称空间对应文件列表中,我们可以看到这个进程的net文件的存在。因为net文件对应于一个Linux网络名称空间,所以理论上我们现在可以列出所有network namespace, 然而事实并非如此。现在运行ip netns ls将显示0个结果:
1
2
$ ip netns ls
$
作为对比让我们手动创建一个网络名称空间。然后,验证它是否在我们运行ip netns时出现:
1
2
3
4
$ sudo ip netns add netA
$ ip netns ls
netA
$
正如我们所看到的,它按照预期显示netA。那么为什么不显示docker run创建的network namespace呢?
不可见的原因:丢失的文件引用
为了理解这个问题,我们需要知道ip netns ls命令其实是在/var/run/netns目录中查找网络名称空间文件。但是,Docker守护进程在创建后并不会在/var/run/netns目录中创建网络名称空间文件的引用。 因此,ip netns ls无法解析网络命名空间文件。
解决这种不一致现象的方法是在/var/run/netns目录中为net文件创建一个文件引用。具体来说,我们可以将net名称空间文件绑定挂载到我们在/var/run/netns目录中创建的一个空文件上。
首先,我们在目录中创建一个空文件,并用名称空间文件所关联的容器id来命名它:
1
2
$ mkdir -p /var/run/netns
$ touch /var/run/netns/$container_id
其中$container_id是一个环境变量,是创建的Docker容器的id
随后,我们可以运行mount -o bind命令来绑定挂载net文件:
1
$ mount -o bind /proc/3357/ns/net /var/run/netns/$container_id
3357是上面获取的容器的pid
现在,再次运行相同的ip netns ls命令,我们就能看到docker容器的网络命名空间了:
1
2
3
4
$ ip netns ls
ip netns ls
2545fdac9b41e463a29b4a61c201b789d567f88d54b6973bdcca9e69ba35ba92
netA
建立了对网络名称空间文件的文件引用,我们就可以使用ip netns exec运行任何ip命令。例如,我们可以使用ip addr list命令查看网络命名空间上的接口:
1
2
3
4
5
6
7
8
9
$ ip netns exec $container_id ip addr list
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
inet 127.0.0.1/8 scope host lo
valid_lft forever preferred_lft forever
4: eth0@if5: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default
link/ether 02:42:ac:11:00:02 brd ff:ff:ff:ff:ff:ff link-netnsid 0
inet 172.17.0.2/16 brd 172.17.255.255 scope global eth0
valid_lft forever preferred_lft forever
containerd中如何实现网络命名空间的挂载的
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
[root@node1 ~]# kubectl cluster-info dump |grep containerRuntimeVersion
"f:containerRuntimeVersion": {},
"containerRuntimeVersion": "containerd://1.6.6",
"f:containerRuntimeVersion": {},
"containerRuntimeVersion": "containerd://1.6.6",
"f:containerRuntimeVersion": {},
"containerRuntimeVersion": "containerd://1.6.6",
[root@node1 ~]# ip netns
cni-028380d7-7dcf-44f5-35a4-607654492671 (id: 1)
cni-c37c9aee-54d5-baf1-a606-1c1865b83f6e (id: 0)
cni-63d627ff-481c-20ac-7abe-42ce95db7d28 (id: 21)
cni-1e075915-b90a-e7db-936d-569b71f45c2b (id: 20)
cni-61035ce0-1910-0de3-d59e-837321c2e5e4 (id: 19)
cni-05895113-0f27-8966-8db3-fb45bdd196fe (id: 18)
cni-ac9ba39f-68a4-4fb4-3c14-9d0b893d5687 (id: 17)
cni-ed0a2a49-bd79-775e-b2b1-70b0a662466b (id: 16)
cni-27ad6830-a146-ec01-425c-b6e846cb0207 (id: 15)
cni-cac98bd4-2a73-014f-5ef3-fe634ecdb9b6 (id: 14)
前面背景中提到的问题原因找到了,但是我们发现在使用containerd作为runtime时,是可以通过ip netns查看到容器的网络命名空间的,这是怎么做的呢? 这里可以简单看下containerd的代码就清楚了:
containerd/pkg/cri/sbserver/sandbox_run.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// RunPodSandbox creates and starts a pod-level sandbox. Runtimes should ensure
// the sandbox is in ready state.
func (c *criService) RunPodSandbox(ctx context.Context, r *runtime.RunPodSandboxRequest) (_ *runtime.RunPodSandboxResponse, retErr error) {
...
// Create initial internal sandbox object.
sandbox := sandboxstore.NewSandbox(
sandboxstore.Metadata{
ID: id,
Name: name,
Config: config,
RuntimeHandler: r.GetRuntimeHandler(),
},
sandboxstore.Status{
State: sandboxstore.StateUnknown,
},
)
if _, err := c.client.SandboxStore().Create(ctx, sandboxInfo); err != nil {
return nil, fmt.Errorf("failed to save sandbox metadata: %w", err)
}
...
// Setup the network namespace if host networking wasn't requested.
if !hostNetwork(config) {
netStart := time.Now()
// If it is not in host network namespace then create a namespace and set the sandbox
// handle. NetNSPath in sandbox metadata and NetNS is non empty only for non host network
// namespaces. If the pod is in host network namespace then both are empty and should not
// be used.
var netnsMountDir = "/var/run/netns"
if c.config.NetNSMountsUnderStateDir {
netnsMountDir = filepath.Join(c.config.StateDir, "netns")
}
sandbox.NetNS, err = netns.NewNetNS(netnsMountDir)
if err != nil {
return nil, fmt.Errorf("failed to create network namespace for sandbox %q: %w", id, err)
}
// Update network namespace in the store, which is used to generate the container's spec
sandbox.NetNSPath = sandbox.NetNS.GetPath()
...
可以看到在创建容器时会创建一个sandbox容器用来共享网络命名空间,那就认为只有这个容器会对应一个网络命名空间,这里我们重点看NewNetNS方法的调用,最终调用newNS方法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
func newNS(baseDir string, pid uint32) (nsPath string, err error) {
b := make([]byte, 16)
_, err = rand.Read(b)
if err != nil {
return "", fmt.Errorf("failed to generate random netns name: %w", err)
}
// Create the directory for mounting network namespaces
// This needs to be a shared mountpoint in case it is mounted in to
// other namespaces (containers)
if err := os.MkdirAll(baseDir, 0755); err != nil {
return "", err
}
// create an empty file at the mount point and fail if it already exists
nsName := fmt.Sprintf("cni-%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:])
nsPath = path.Join(baseDir, nsName)
mountPointFd, err := os.OpenFile(nsPath, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0666)
if err != nil {
return "", err
}
mountPointFd.Close()
defer func() {
// Ensure the mount point is cleaned up on errors
if err != nil {
os.RemoveAll(nsPath)
}
}()
if pid != 0 {
procNsPath := getNetNSPathFromPID(pid)
// bind mount the netns onto the mount point. This causes the namespace
// to persist, even when there are no threads in the ns.
if err = unix.Mount(procNsPath, nsPath, "none", unix.MS_BIND, ""); err != nil {
return "", fmt.Errorf("failed to bind mount ns src: %v at %s: %w", procNsPath, nsPath, err)
}
return nsPath, nil
}
var wg sync.WaitGroup
wg.Add(1)
// do namespace work in a dedicated goroutine, so that we can safely
// Lock/Unlock OSThread without upsetting the lock/unlock state of
// the caller of this function
go (func() {
defer wg.Done()
runtime.LockOSThread()
// Don't unlock. By not unlocking, golang will kill the OS thread when the
// goroutine is done (for go1.10+)
var origNS cnins.NetNS
origNS, err = cnins.GetNS(getCurrentThreadNetNSPath())
if err != nil {
return
}
defer origNS.Close()
// create a new netns on the current thread
err = unix.Unshare(unix.CLONE_NEWNET)
if err != nil {
return
}
// Put this thread back to the orig ns, since it might get reused (pre go1.10)
defer origNS.Set()
// bind mount the netns from the current thread (from /proc) onto the
// mount point. This causes the namespace to persist, even when there
// are no threads in the ns.
err = unix.Mount(getCurrentThreadNetNSPath(), nsPath, "none", unix.MS_BIND, "")
if err != nil {
err = fmt.Errorf("failed to bind mount ns at %s: %w", nsPath, err)
}
})()
wg.Wait()
if err != nil {
return "", fmt.Errorf("failed to create namespace: %w", err)
}
return nsPath, nil
}
- 这里baseDir是/var/run/netns,nsName是随机生成的36位字符串, pid是0
- getCurrentThreadNetNSPath方法直接返回的是容器的网络命名空间路径:/proc/{pid}/task/{pid}/ns/net
- 最终将proc中网络命名空间mount到nsPath对应位置,和上面我们手动操作的情况类似
总结
本次我们从简单介绍Linux名称空间和cgroup开始。然后演示了当我们运行ip netns ls时,docker run创建的网络名称空间文件不显示的问题。 随后解释了这是因为文件引用不是在/var/run/netns创建的,而ip netns ls命令只在这个目录中查找网络名称空间。
随后,我们以一个简单的修正结束了本文,即将文件绑定挂载到/var/run/netns,这样就可以通过ip netns ls找到它。 最后通过查看containerd的代码发现它也是采用类似方式自动将文件mount到/var/run/netns目录下的,这样就比较方便的通过ip netns来管理容器的网络命名空间了, 这里有个不太方便的地方是查询容器和这个network namespace的对应关系,从代码里也看到是随机生成的uuid