Docker容器的网络命名空间为什么不可见?

Posted by hujin on April 6, 2023

背景

因为做过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