Golang 版本导致的容器运行时问题
问题现场
用户反馈安装了某个 containerd 版本的节点无法正常拉起容器,业务场景是在 K8S Pod 里面运行一个 Docker,在容器里面通过 docker 命令再启动新的容器。
报错信息如下:
$ docker run -it ubuntu /bin/bash
docker: Error response from daemon: failed to create task for container: failed to create shim task: OCI runtime create failed: runc create failed: unable to start container process: error during container init: error mounting "proc" to rootfs at "/proc": mount proc:/proc (via /proc/self/fd/10), flags: 0xe: operation not permitted: unknown.
ERRO[0000] error waiting for container: context canceled
排查过程
从报错信息flags: 0xe: operation not permitted可以知道这个问题的直接原因是挂载 proc文件系统的时候没有权限,导致这一现象的根本原因还需要进一步定位。
Containerd
在用户反馈的信息中,比较关键的一点是某个 containerd 版本开始存在问题,很容易联想到可能和 Containerd 引入的改动有关系。
经过分析,出现问题的 containerd 版本与上一个版本相比,仅新增了两个 Commit,改动内容非常少。通过仔细分析这些引入的改动,发现它们与上述报错信息并无关联,并且新增的特性不会默认打开。因此,基本确定和 containerd 引入的改动无关。
Pod 配置
既然 Containerd 的改动不会有影响,是不是用户 Pod 配置的不同导致呢?
经过对比 Pod yaml,我们发现用户在两个不同 containerd 版本的节点上的 Pod 没有影响权限的配置项。
- 都是用了特权容器,配置相同 - securityContext:
 privileged: true
 runAsNonRoot: false
 
- 都挂载了节点上的以下目录(排除 ConfigMap,PVC 等内容): - volumes:
 -hostPath:
 path:/usr
 type:Directory
 name:host-usr
 -hostPath:
 path:/var/lib/containerd
 type:Directory
 name:containerd-image
 -hostPath:
 path:/run/containerd
 type:Directory
 name:containerd-dir
 -hostPath:
 path:/var/lib/lxc/lxcfs/proc/cpuinfo
 type:File
 name:lxcfs-proc-cpuinfo
 -hostPath:
 path:/var/lib/lxc/lxcfs/proc/meminfo
 type:File
 name:lxcfs-proc-meminfo
 -hostPath:
 path:/var/lib/lxc/lxcfs/sys/devices/system/cpu/online
 type:File
 name:system-cpu-online
 
由于暂时无法确定根因,提供了回退 Containerd 版本的脚本:
TOS_URL=https://xxx/containerd-$VERSION.tar.gz
function downgrade() {
        wget $TOS_URL -o containerd.tar.gz
        tar -zxvf containerd.tar.gz -C /usr/
        systemctl restart containerd
}
downgrade
Runc
既然 Containerd 的改动不会影响权限,这个报错信息是什么地方导致的呢?
这个报错信息来自 runc,分析 runc 报错的具体位置:
// prepareRootfs sets up the devices, mount points, and filesystems for use
// inside a new mount namespace. It doesn't set anything as ro. You must call
// finalizeRootfs after this function to finish setting up the rootfs.
func prepareRootfs(pipe io.ReadWriter, iConfig *initConfig, mountFds []int) (err error) {
    config := iConfig.Config
    if err := prepareRoot(config); err != nil {
        return fmt.Errorf("error preparing rootfs:%w", err)
    }
    if mountFds != nil && len(mountFds) != len(config.Mounts) {
        return fmt.Errorf("malformed mountFds slice. Expected size: %v, got: %v. Slice: %v", len(config.Mounts), len(mountFds), mountFds)
    }
    mountConfig := &mountConfig{
        root:            config.Rootfs,
        label:           config.MountLabel,
        cgroup2Path:     iConfig.Cgroup2Path,
        rootlessCgroups: iConfig.RootlessCgroups,
        cgroupns:        config.Namespaces.Contains(configs.NEWCGROUP),
    }
    setupDev := needsSetupDev(config)
    for i, m := range config.Mounts {
        for _, precmd := range m.PremountCmds {
            if err := mountCmd(precmd); err != nil {
                return fmt.Errorf("error running premount command:%w", err)
            }
        }
        // Just before the loop we checked that if not empty, len(mountFds) == len(config.Mounts).
        // Therefore, we can access mountFds[i] without any concerns.
        if mountFds != nil && mountFds[i] != -1 {
            mountConfig.fd = &mountFds[i]
        } else {
            mountConfig.fd = nil
        }
        if err := mountToRootfs(m, mountConfig); err != nil {
            return fmt.Errorf("error mounting %q to rootfs at %q:%w", m.Source, m.Destination, err)
        }
    // 省略部分内容
    returnnil
}
prepareRootfs中调用了mountToRootfs:
func mountToRootfs(m *configs.Mount, c *mountConfig) error {
    rootfs := c.root
    // procfs and sysfs are special because we need to ensure they are actually
    // mounted on a specific path in a container without any funny business.
    switch m.Device {
    case"proc", "sysfs":
        // If the destination already exists and is not a directory, we bail
        // out. This is to avoid mounting through a symlink or similar -- which
        // has been a "fun" attack scenario in the past.
        // TODO: This won't be necessary once we switch to libpathrs and we can
        //       stop all of these symlink-exchange attacks.
        dest := filepath.Clean(m.Destination)
        if !strings.HasPrefix(dest, rootfs) {
            // Do not use securejoin as it resolves symlinks.
            dest = filepath.Join(rootfs, dest)
        }
        if fi, err := os.Lstat(dest); err != nil {
            if !os.IsNotExist(err) {
                return err
            }
        } elseif !fi.IsDir() {
            return fmt.Errorf("filesystem %q must be mounted on ordinary directory", m.Device)
        }
        if err := os.MkdirAll(dest, 0o755); err != nil {
            return err
        }
        // Selinux kernels do not support labeling of /proc or /sys.
        return mountPropagate(m, rootfs, "", nil)
    }
    // 省略部分内容
}
对于proc 和 sysfs 文件系统,最终会调用mountPropagate:
// Do the mount operation followed by additional mounts required to take care
// of propagation flags. This will always be scoped inside the container rootfs.
func mountPropagate(m *configs.Mount, rootfs string, mountLabel string, mountFd *int) error {
    var (
        data  = label.FormatMountLabel(m.Data, mountLabel)
        flags = m.Flags
    )
    // Delay mounting the filesystem read-only if we need to do further
    // operations on it. We need to set up files in "/dev", and other tmpfs
    // mounts may need to be chmod-ed after mounting. These mounts will be
    // remounted ro later in finalizeRootfs(), if necessary.
    if m.Device == "tmpfs" || utils.CleanPath(m.Destination) == "/dev" {
        flags &= ^unix.MS_RDONLY
    }
    // Because the destination is inside a container path which might be
    // mutating underneath us, we verify that we are actually going to mount
    // inside the container with WithProcfd() -- mounting through a procfd
    // mounts on the target.
    source := m.Source
    if mountFd != nil {
        source = "/proc/self/fd/" + strconv.Itoa(*mountFd)
    }
    if err := utils.WithProcfd(rootfs, m.Destination, func(procfd string) error {
        return mount(source, m.Destination, procfd, m.Device, uintptr(flags), data)
    }); err != nil {
        return err
    }
    // We have to apply mount propagation flags in a separate WithProcfd() call
    // because the previous call invalidates the passed procfd -- the mount
    // target needs to be re-opened.
    if err := utils.WithProcfd(rootfs, m.Destination, func(procfd string) error {
        for _, pflag := range m.PropagationFlags {
            if err := mount("", m.Destination, procfd, "", uintptr(pflag), ""); err != nil {
                return err
            }
        }
        returnnil
    }); err != nil {
        return fmt.Errorf("change mount propagation through procfd:%w", err)
    }
    returnnil
}
最终调用的标准库中的unix.Mount:
// mount is a simple unix.Mount wrapper. If procfd is not empty, it is used
// instead of target (and the target is only used to add context to an error).
func mount(source, target, procfd, fstype string, flags uintptr, data string) error {
    dst := target
    if procfd != "" {
        dst = procfd
    }
    if err := unix.Mount(source, dst, fstype, flags, data); err != nil {
        return &mountError{
            op:     "mount",
            source: source,
            target: target,
            procfd: procfd,
            flags:  flags,
            data:   data,
            err:    err,
        }
    }
    returnnil
}
从 runc 源码看,在挂载 proc 的时候,Mount 系统调用返回的错误信息。因此,挂载失败和 runc 调用 mount 时的现场有关系。
我们通过修改 runc 源码,打印 runc 挂载 proc 时相关的现场信息。
正常启动 case:
[31017-09+00 17:24:24] Destination: /proc,
uid: 0,
uid_map:          0       1000          1
         1     100000      65536
     65537     165536      65536
,
gid_map:          0       1000          1
         1     100000      65536
     65537     165536      65536
,
userns: user:[4026534201],
mntns: mnt:[4026534601],
cgroupns: cgroup:[4026531835],
mode: dr-xr-xr-x, owner: 65534,
fileGid: 65534
cap: "Name:\trunc:[2:INIT]\nUmask:\t0022\nState:\tR (running)\nTgid:\t749\nNgid:\t0\nPid:\t749\nPPid:\t739\nTracerPid:\t0\nUid:\t0\t0\t0\t0\nGid:\t0\t0\t0\t0\nFDSize:\t64\nGroups:\t0 \nNStgid:\t749\t1\nNSpid:\t749\t1\nNSpgid:\t749\t1\nNSsid:\t749\t1\nVmPeak:\t 1090280 kB\nVmSize:\t 1090280 kB\nVmLck:\t       0 kB\nVmPin:\t       0 kB\nVmHWM:\t    9664 kB\nVmRSS:\t    9664 kB\nRssAnon:\t    2540 kB\nRssFile:\t    7124 kB\nRssShmem:\t       0 kB\nVmData:\t   86400 kB\nVmStk:\t     132 kB\nVmExe:\t    5208 kB\nVmLib:\t     772 kB\nVmPTE:\t     148 kB\nVmSwap:\t       0 kB\nHugetlbPages:\t       0 kB\nCoreDumping:\t0\nTHP_enabled:\t1\nThreads:\t6\nSigQ:\t0/900794\nSigPnd:\t0000000000000000\nShdPnd:\t0000000000000000\nSigBlk:\t0000000000000000\nSigIgn:\t0000000000000000\nSigCgt:\tfffffffdffc1feff\nCapInh:\t0000000000000000\nCapPrm:\t0000003fffffffff\nCapEff:\t0000003fffffffff\nCapBnd:\t0000003fffffffff\nCapAmb:\t0000000000000000\nNoNewPrivs:\t0\nSeccomp:\t0\nSpeculation_Store_Bypass:\tthread vulnerable\nCpus_allowed:\t3ffffff,ffffffff\nCpus_allowed_list:\t0-57\nMems_allowed:\t00000000,00000001\nMems_allowed_list:\t0\nvoluntary_ctxt_switches:\t3\nnonvoluntary_ctxt_switches:\t0\n"
异常 case:
[31017-09+00 17:24:24] uid: 0,
uid_map:          0       1000          1
         1     100000      65536
     65537     165536      65536
,
gid_map:          0       1000          1
         1     100000      65536
     65537     165536      65536
,
userns: user:[4026534201],
mntns: mnt:[4026534601],
cgroupns: cgroup:[4026531835],
mode: dr-xr-xr-x, owner: 65534,
fileGid: 65534
cap: "Name:\trunc:[2:INIT]\nUmask:\t0022\nState:\tR (running)\nTgid:\t812\nNgid:\t0\nPid:\t812\nPPid:\t802\nTracerPid:\t0\nUid:\t0\t0\t0\t0\nGid:\t0\t0\t0\t0\nFDSize:\t64\nGroups:\t0 \nNStgid:\t812\t1\nNSpid:\t812\t1\nNSpgid:\t812\t1\nNSsid:\t812\t1\nVmPeak:\t 1090536 kB\nVmSize:\t 1090536 kB\nVmLck:\t       0 kB\nVmPin:\t       0 kB\nVmHWM:\t   10032 kB\nVmRSS:\t   10032 kB\nRssAnon:\t    2988 kB\nRssFile:\t    7044 kB\nRssShmem:\t       0 kB\nVmData:\t   86656 kB\nVmStk:\t     132 kB\nVmExe:\t    5208 kB\nVmLib:\t     772 kB\nVmPTE:\t     160 kB\nVmSwap:\t       0 kB\nHugetlbPages:\t       0 kB\nCoreDumping:\t0\nTHP_enabled:\t1\nThreads:\t6\nSigQ:\t0/900794\nSigPnd:\t0000000000000000\nShdPnd:\t0000000000000000\nSigBlk:\t0000000000000000\nSigIgn:\t0000000000000000\nSigCgt:\tfffffffdffc1feff\nCapInh:\t0000000000000000\nCapPrm:\t0000003fffffffff\nCapEff:\t0000003fffffffff\nCapBnd:\t0000003fffffffff\nCapAmb:\t0000000000000000\nNoNewPrivs:\t0\nSeccomp:\t0\nSpeculation_Store_Bypass:\tthread vulnerable\nCpus_allowed:\t3ffffff,ffffffff\nCpus_allowed_list:\t0-57\nMems_allowed:\t00000000,00000001\nMems_allowed_list:\t0\nvoluntary_ctxt_switches:\t3\nnonvoluntary_ctxt_switches:\t0\n"
正常拉起 docker 容器和拉起失败情况下,runc 的 userns 相同,target 的 owner 都是 65534(没有 map id)。此外,查看 runc 进程的 Capability,都包括 SYS_ADMIN:
# capsh --decode=0000003fffffffff
0x0000003fffffffff=cap_chown,cap_dac_override,cap_dac_read_search,cap_fowner,cap_fsetid,cap_kill,cap_setgid,cap_setuid,cap_setpcap,cap_linux_immutable,cap_net_bind_service,cap_net_broadcast,cap_net_admin,cap_net_raw,cap_ipc_lock,cap_ipc_owner,cap_sys_module,cap_sys_rawio,cap_sys_chroot,cap_sys_ptrace,cap_sys_pacct,cap_sys_admin,cap_sys_boot,cap_sys_nice,cap_sys_resource,cap_sys_time,cap_sys_tty_config,cap_mknod,cap_lease,cap_audit_write,cap_audit_control,cap_setfcap,cap_mac_override,cap_mac_admin,cap_syslog,cap_wake_alarm,cap_block_suspend,cap_audit_read
异常 case 和正常 case 的 runc 进程环境也没区别,为什么挂载 proc 会失败呢?
进一步修改 runc 源码,当 mount 失败之后,让进程不退出,保留现场,然后进入 runc 所在 user namespace 和 mount namespace,尝试 mount proc 文件系统,结果【失败】。
Bind mount 没问题,但是 mount proc 失败:
场景分析
通过分析,用户的使用场景大致如下:
docker in container architecture
- 用户挂载了节点上的一些目录到外部容器。 
- Docker 用的 runc 是社区提供的。 
核心问题:节点上 Containerd 版本的变化为什么会影响容器中的 Runc ?
对比容器中的挂载点信息,发现有些区别。
正常 case:
2409 2408 0:308 / /proc rw,nosuid,nodev,noexec,relatime master:461 - proc proc rw
2410 2409 0:235 /proc/cpuinfo /proc/cpuinfo ro,relatime master:462 - fuse.lxcfs lxcfs rw,user_id=0,group_id=0,allow_other,force_umount
2411 2409 0:235 /proc/meminfo /proc/meminfo ro,relatime master:463 - fuse.lxcfs lxcfs rw,user_id=0,group_id=0,allow_other,force_umount
2633 2632 0:308 / /run/containerd/io.containerd.runtime.v2.task/k8s.io/17c97f222c6a02eb46f170cdf5ed6621bee116836a57c6121de9f3818494182f/rootfs/proc rw,nosuid,nodev,noexec,relatime master:609 - proc proc rw
3010 3009 0:330 / /ebs/docker/165536.165536/fuse-overlayfs/70c276e0d397f4bebfea4239e967132baa2db0746181b0b7dc0ea7b24912f1e3/merged/proc rw,nosuid,nodev,noexec,relatime - proc proc rw
异常 case:
2779 2778 0:308 / /proc rw,nosuid,nodev,noexec,relatime master:461 - proc proc rw
2827 2779 0:235 /proc/meminfo /proc/meminfo ro,relatime master:462 - fuse.lxcfs lxcfs rw,user_id=0,group_id=0,allow_other,force_umount
2828 2779 0:235 /proc/cpuinfo /proc/cpuinfo ro,relatime master:463 - fuse.lxcfs lxcfs rw,user_id=0,group_id=0,allow_other,force_umount
3074 3073 0:308 / /run/containerd/io.containerd.runtime.v2.task/k8s.io/a33db564c39c8573ea92e3e5327be27ca205684cb965b8f03d9eaa0ae2b8aff7/rootfs/proc rw,nosuid,nodev,noexec,relatime master:608 - proc proc rw
3075 3074 0:235 /proc/meminfo /run/containerd/io.containerd.runtime.v2.task/k8s.io/a33db564c39c8573ea92e3e5327be27ca205684cb965b8f03d9eaa0ae2b8aff7/rootfs/proc/meminfo ro,relatime master:609 - fuse.lxcfs lxcfs rw,user_id=0,group_id=0,allow_other,force_umount
3076 3074 0:235 /proc/cpuinfo /run/containerd/io.containerd.runtime.v2.task/k8s.io/a33db564c39c8573ea92e3e5327be27ca205684cb965b8f03d9eaa0ae2b8aff7/rootfs/proc/cpuinfo ro,relatime master:610 - fuse.lxcfs lxcfs rw,user_id=0,group_id=0,allow_other,force_umount
注意,异常 case 中多出了两条挂载点。正常 case 只有:
- /run/containerd/xxx/rootfs/proc
异常 case 除了这条挂载点,还多出了:
- /run/containerd/xxx/rootfs/proc/meminfo
- /run/containerd/xxx/rootfs/proc/cpuinfo
Golang 版本(柳暗花明)
除了 Containerd 版本不一样,我们注意到,编译 Containerd 的环境也有一些区别,旧版本是用 Golang 1.18 编译,新版本使用了 Golang 1.20。基于这个思路,我们使用不同 Golang 版本编译了相同的 Containerd 源码。
Golang 1.18 编译的 containerd 没问题:
Golang 1.20 编译的 containerd 有问题:
从这个现象基本可以确定这个问题和 Golang 版本有关系。
通过编译 Golang 源码找到引入问题的 commit。golang 1.19 的第一个 commit:936c7fbc1c154964b6e3e8a7523bdf0c29b4e1b3[1]
通过二分查找的方法,找到引入问题的 commit:
Step 1:
4a4127bccc go1.19.1
43456202a1 go1.19
ad672e7ce1 go1.19rc2
bac4eb53d6 go1.19rc1
2cfbef4380 go1.19beta1 --> failed
d81dd12906 --> fail
797e8890 --> ok
2ea7d3461b go1.9.2 --> build error
7f40c1214d go1.9.1 --> build error
c8aec4095e go1.9 --> build error
048c9cfaac go1.9rc2 --> build error
65c6c88a94 go1.9rc1 --> build error
936c7fbc1c go1.19 start --> ok
Step 2:
1:* d81dd12906 --> fail
2:* 420a1fb223
88:* e0ae8540ab
735:* 0670afa1b3 --> fail
760:* 2c73f5f32f --> fail
772:* d2552037 --> fail
773:* 72e77a7f41 --> fail
774:* 9298f604f4 --> ok
775:* d65a41329e --> ok
778:* 517781b391 --> ok
785:* d85694ab4f --> ok
839:* 6f6942ef7a --> ok
992:* 1724077b78 --> ok
1136:* 79861be205
1269:* 797e889046 --> ok
最终,找到导致问题的 Commit:72e77a7f41bbf45d466119444307fd3ae996e257[2]
72e77a7f41:
上一个 commit 9298f604f4:
这个 commit 修改了sort.Sort()的实现,从稳定排序变成不稳定排序。
为了进一步验证这个改动对 containerd 的影响,修改 containerd 中所有sort.Sort()为sort.Stable(),即修改为稳定排序,问题修复。
Containerd 调用 sort.Sort() 排序 mounts:pkg/cri/opts/spec_linux.go#L120[3]
修改 containerd,输出稳定排序和非稳定排序场景下的 mounts 结果。
stable sort mounts:
unstable sort mounts:
为什么挂载顺序的区别会导致 mount proc 失败?
Mount 限制(根因)
runc 调用 mount 系统调用返回的错误信息,根因是mount_too_revealing 返回 1 导致挂载失败:
在 mount_too_revealing 函数中,如果是 proc 和 sys 会走到 mnt_already_visible 来检查权限。
“
参考 cve-2022-0492[4]
源码如下:
static bool mnt_already_visible(struct mnt_namespace *ns,
                                    const struct super_block *sb,
                                    int *new_mnt_flags)
    {
            int new_flags = *new_mnt_flags;
            struct mount *mnt;
            bool visible = false;
            down_read(&namespace_sem);
            lock_ns_list(ns);
            list_for_each_entry(mnt, &ns->list, mnt_list) {
                    struct mount *child;
                    int mnt_flags;
                    ...
                    list_for_each_entry(child, &mnt->mnt_mounts, mnt_child) {
                            struct inode *inode = child->mnt_mountpoint->d_inode;
/* Only worry about locked mounts */
                            if (!(child->mnt.mnt_flags & MNT_LOCKED))
                                    continue;
/* Is the directorypermanetly empty? */
                            if (!is_empty_dir_inode(inode))
                                    goto next;
                    }
/* Preserve the locked attributes */
                    *new_mnt_flags |= mnt_flags & (MNT_LOCK_READONLY | \
                                            MNT_LOCK_ATIME);
                    visible = true;
                    goto found;
            next:        ;
            }
    found:
            unlock_ns_list(ns);
            up_read(&namespace_sem);
            return visible;
    }
mnt_already_visible会遍历新的 mount namespace 并且检查是否有子挂载点,如果已经有子挂载点并且不是全部对当前 mount namespace 可见,则不能挂载 proc 和 sys。原因如下:procfs 和 sysfs 包括许多全局数据,不能直接挂载到容器中。
“
mnt_already_visiblewill iterate the new mount namespace and check whether it has child mountpoint. If it has child mountpoint, it is not fully visible to this mount namespace so the procfs will not be mounted.
“
This reason is as following. The procfs and sysfs contains some global data, so the container should not touch. So mouting procfs and sysfs in new user namespace should be restricted.
这是内核的限制:
回头看下我们拿到的容器中的挂载点信息。
正常 case:
2409 2408 0:308 / /proc rw,nosuid,nodev,noexec,relatime master:461 - proc proc rw
2410 2409 0:235 /proc/cpuinfo /proc/cpuinfo ro,relatime master:462 - fuse.lxcfs lxcfs rw,user_id=0,group_id=0,allow_other,force_umount
2411 2409 0:235 /proc/meminfo /proc/meminfo ro,relatime master:463 - fuse.lxcfs lxcfs rw,user_id=0,group_id=0,allow_other,force_umount
2633 2632 0:308 / /run/containerd/io.containerd.runtime.v2.task/k8s.io/17c97f222c6a02eb46f170cdf5ed6621bee116836a57c6121de9f3818494182f/rootfs/proc rw,nosuid,nodev,noexec,relatime master:609 - proc proc rw
3010 3009 0:330 / /ebs/docker/165536.165536/fuse-overlayfs/70c276e0d397f4bebfea4239e967132baa2db0746181b0b7dc0ea7b24912f1e3/merged/proc rw,nosuid,nodev,noexec,relatime - proc proc rw
异常 case:
2779 2778 0:308 / /proc rw,nosuid,nodev,noexec,relatime master:461 - proc proc rw
2827 2779 0:235 /proc/meminfo /proc/meminfo ro,relatime master:462 - fuse.lxcfs lxcfs rw,user_id=0,group_id=0,allow_other,force_umount
2828 2779 0:235 /proc/cpuinfo /proc/cpuinfo ro,relatime master:463 - fuse.lxcfs lxcfs rw,user_id=0,group_id=0,allow_other,force_umount
3074 3073 0:308 / /run/containerd/io.containerd.runtime.v2.task/k8s.io/a33db564c39c8573ea92e3e5327be27ca205684cb965b8f03d9eaa0ae2b8aff7/rootfs/proc rw,nosuid,nodev,noexec,relatime master:608 - proc proc rw
3075 3074 0:235 /proc/meminfo /run/containerd/io.containerd.runtime.v2.task/k8s.io/a33db564c39c8573ea92e3e5327be27ca205684cb965b8f03d9eaa0ae2b8aff7/rootfs/proc/meminfo ro,relatime master:609 - fuse.lxcfs lxcfs rw,user_id=0,group_id=0,allow_other,force_umount
3076 3074 0:235 /proc/cpuinfo /run/containerd/io.containerd.runtime.v2.task/k8s.io/a33db564c39c8573ea92e3e5327be27ca205684cb965b8f03d9eaa0ae2b8aff7/rootfs/proc/cpuinfo ro,relatime master:610 - fuse.lxcfs lxcfs rw,user_id=0,group_id=0,allow_other,force_umount
在异常 case 中,多出来的两个挂载点的父挂载点是 3074,也就是 proc 的挂载点。
至此,问题的根因已经确定。
runc 会先挂载 /proc/cpuinfo 和 /proc/meminfo,这两个目录实际对应节点上 /run/containerd 下的子目录,比较巧的是,用户刚好挂载了 /run/containerd 目录到容器。
因此,先挂载的 /proc/cpuinfo 和 /proc/meminfo 由于是/run/containerd/xxx/rootfs/proc 的子挂载点,在挂载 /run/containerd 目录的时候会被传播到容器内。而先挂载 /run/containerd 则不存在这个问题。
问题复现
- 在节点安装 lxcfs
$ apt-get update && apt-get install lxcfs -y
- 异常 case:创建 Pod,其中 /run/containerd晚于/proc/cpuinfo和/proc/meminfo挂载,docker 无法启动容器
apiVersion: v1
kind:Pod
metadata:
name:docker-mount-sort-stable-test-pod
namespace:default
spec:
hostNetwork:true
containers:
-image:ghcr.io/sctb512/docker-test:latest
    imagePullPolicy:Always
    name:docker
    securityContext:
      privileged:true
      runAsNonRoot:false
    terminationMessagePath:/dev/termination-log
    terminationMessagePolicy:File
    volumeMounts:
    -mountPath:/var/lib/containerd
      mountPropagation:HostToContainer
      name:containerd-image
    -mountPath:/proc/meminfo
      name:lxcfs-proc-meminfo
      readOnly:true
    -mountPath:/proc/cpuinfo
      name:lxcfs-proc-cpuinfo
      readOnly:true
    -mountPath:/run/containerd
      name:containerd-dir
volumes:
-hostPath:
      path:/var/lib/lxc/lxcfs/proc/meminfo
      type:File
    name:lxcfs-proc-meminfo
-hostPath:
      path:/var/lib/lxc/lxcfs/proc/cpuinfo
      type:File
    name:lxcfs-proc-cpuinfo
-hostPath:
      path:/run/containerd
      type:Directory
    name:containerd-dir
-hostPath:
      path:/var/lib/containerd
      type:Directory
    name:containerd-image
通过 docker 拉起容器:
$ docker run --rm -it nginx:latest
- 正常 case:创建 Pod /run/containerd早于/proc/cpuinfo和/proc/meminfo挂载,docker 可以启动容器
apiVersion: v1
kind:Pod
metadata:
name:docker-mount-sort-stable-test-pod
namespace:default
spec:
hostNetwork:true
containers:
-image:ghcr.io/sctb512/docker-test:latest
    imagePullPolicy:Always
    name:docker
    securityContext:
      privileged:true
      runAsNonRoot:false
    terminationMessagePath:/dev/termination-log
    terminationMessagePolicy:File
    volumeMounts:
    -mountPath:/var/lib/containerd
      mountPropagation:HostToContainer
      name:containerd-image
    -mountPath:/run/containerd
      name:containerd-dir
    -mountPath:/proc/meminfo
      name:lxcfs-proc-meminfo
      readOnly:true
    -mountPath:/proc/cpuinfo
      name:lxcfs-proc-cpuinfo
      readOnly:true
volumes:
-hostPath:
      path:/var/lib/lxc/lxcfs/proc/meminfo
      type:File
    name:lxcfs-proc-meminfo
-hostPath:
      path:/var/lib/lxc/lxcfs/proc/cpuinfo
      type:File
    name:lxcfs-proc-cpuinfo
-hostPath:
      path:/run/containerd
      type:Directory
    name:containerd-dir
-hostPath:
      path:/var/lib/containerd
      type:Directory
    name:containerd-image
总结
Containerd 老版本使用 Golang 1.18 编译,新版本使用 golang 1.20 编译,Golang 1.19 在 commit 72e77a7f41bbf45d466119444307fd3ae996e257[5] 将 sort.Sort 由稳定排序修改为不稳定排序。containerd 使用 sort.Sort 对挂载点进行排序,sort.Sort变为不稳定之后,containerd 传给 runc 的挂载点顺序发生了变化。
用户场景会将 /run/containerd 目录挂载到容器中,不同挂载顺序会导致 runc 挂载 procfs 时看到的子挂载点信息不同:
- /proc/meminfo和- /proc/cpuinfo先于- /run/containerd挂载,containerd 传的挂载参数是 rbind(类似于 bind,会递归 bind 当前挂载点上已有的子挂载点),meminfo 和 cpuinfo 作为- /run/containerd/xxx/proc的子挂载点在挂载- /run/containerd时会被挂载,也就是说,runc 挂载- procfs时,- /run/containerd/xxx/proc存在子挂载点,因此,不能挂载。
- /proc/meminfo和- /proc/cpuinfo晚于- /run/containerd挂载,runc 挂载- /run/containerd时 meminfo 和 cpuinfo 还没被挂载,因此,runc 挂载- /run/containerd时不存在子挂载点,可以挂载 procfs。
问题触发条件:
- 容器挂载了 - /proc/xxx目录 & 挂载了节点上的- /run/containerd目录
- sysfs 也有类似问题 
解决方案:
- Containerd 侧:修改sort.Sort为sort.Stable
pkg/cri/opts/spec_linux.go#L120[6]
有个有意思的点,containerd 社区给OrderedMounts 加了单元测试,用例中用的 sort.Stable,但代码逻辑中实际用的是 sort.Sort。可能默许了 sort.Sort 包含稳定排序的特征,只不过在 Golang 1.19 被打破了,才导致的问题。
pkg/cri/opts/spec_test.go#L44[7]
- 用户侧:确保 Pod yaml 中容器挂载点的顺序 /run/containerd在/proc/xxx挂载点之前。
向社区提交的 PR:
containerd/containerd/pull/10021[8]
回合到 1.6 分支:
containerd/containerd/pull/10045[9]
正式 release 版本:v1.6.32[10]
参考资料
[1] 936c7fbc1c154964b6e3e8a7523bdf0c29b4e1b3: https://github.com/golang/go/commit/936c7fbc1c154964b6e3e8a7523bdf0c29b4e1b3
[2] 72e77a7f41bbf45d466119444307fd3ae996e257: https://github.com/golang/go/commit/72e77a7f41bbf45d466119444307fd3ae996e257
[3] pkg/cri/opts/spec_linux.go#L120: https://github.com/containerd/containerd/blob/91a68edd775bba554a9eac7e04898b22069db5aa/pkg/cri/opts/spec_linux.go#L120
[4] cve-2022-0492: https://terenceli.github.io/技术/2022/03/06/cve-2022-0492
[5] 72e77a7f41bbf45d466119444307fd3ae996e257: https://github.com/golang/go/commit/72e77a7f41bbf45d466119444307fd3ae996e257
[6] pkg/cri/opts/spec_linux.go#L120: https://github.com/containerd/containerd/blob/v1.6.31/pkg/cri/opts/spec_linux.go#L120
[7] pkg/cri/opts/spec_test.go#L44: https://github.com/containerd/containerd/blob/v1.6.31/pkg/cri/opts/spec_test.go#L44
[8] containerd/containerd/pull/10021: https://github.com/containerd/containerd/pull/10021
[9] containerd/containerd/pull/10045: https://github.com/containerd/containerd/pull/10045
[10] v1.6.32: https://github.com/containerd/containerd/releases/tag/v1.6.32
Golang 版本导致的容器运行时问题的更多相关文章
- 使用kubeoperator安装的k8s 版本1.20.14 将节点上的容器运行时从 Docker Engine 改为 containerd
		官方文档:https://kubernetes.io/zh-cn/docs/tasks/administer-cluster/migrating-from-dockershim/change-runt ... 
- 浅析容器运行时奥秘——OCI标准
		背景 2013年Docker开源了容器镜像格式和运行时以后,为我们提供了一种更为轻量.灵活的"计算.网络.存储"资源虚拟化和管理的解决方案,在业界迅速火了起来. 2014年更是容器 ... 
- CRI 与 ShimV2:一种 Kubernetes 集成容器运行时的新思路
		摘要: 关于 Kubernetes 接口化设计.CRI.容器运行时.shimv2.RuntimeClass 等关键技术特性的设计与实现. Kubernetes 项目目前的重点发展方向,是为开发 ... 
- 第28 章 : 理解容器运行时接口 CRI
		理解容器运行时接口 CRI CRI 是 Kubernetes 体系中跟容器打交道的一个非常重要的部分.本文将主要分享以下三方面的内容: CRI 介绍 CRI 实现 相关工具 CRI 介绍 在 CRI ... 
- kubernetes/k8s CRI分析-容器运行时接口分析
		关联博客:kubernetes/k8s CSI分析-容器存储接口分析 概述 kubernetes的设计初衷是支持可插拔架构,从而利于扩展kubernetes的功能.在此架构思想下,kubernetes ... 
- Kubernetes容器运行时弃用Docker转型Containerd
		文章转载自:https://i4t.com/5435.html Kubernetes社区在2020年7月份发布的版本中已经开始了dockershim的移除计划,在1.20版本中将内置的dockersh ... 
- Android 6.0的运行时权限
		原文 http://droidyue.com/blog/2016/01/17/understanding-marshmallow-runtime-permission/ 主题 安卓开发 Andr ... 
- 聊一聊 Android 6.0 的运行时权限
		权限一刀切 棉花糖运行时权限 权限的分组 正常权限 正常权限列表 特殊权限危险权限 请求SYSTEM_ALERT_WINDOW 请求WRITE_SETTINGS 必须要支持运行时权限么 不支持运行时权 ... 
- 聊一聊Android 6.0的运行时权限
		Android 6.0,代号棉花糖,自发布伊始,其主要的特征运行时权限就很受关注.因为这一特征不仅改善了用户对于应用的使用体验,还使得应用开发者在实践开发中需要做出改变. 没有深入了解运行时权限的开发 ... 
- 程序员修神之路--打通Docker镜像发布容器运行流程
		菜菜哥,我看了一下docker相关的内容,但是还是有点迷糊 还有哪不明白呢? 如果我想用docker实现所谓的云原生,我的项目该怎么发布呢? 这还是要详细介绍一下docker了 Docker 是一个开 ... 
随机推荐
- Windows中GNURadio的安装
			对于一个常常使用Python的人来讲(此处指我),conda环境是必不可少的,(Anaconda或Miniconda). 在Windows中且已经安装过conda环境的情况下,安装GNURadio特别 ... 
- MySQL-8.0.20
			版本: 8.0.20 操作: Centos 7 Linux 未介绍针对数据库的详细操作,如有需求请前往 第一章 MySQL的介绍及安装 1.介绍 1.1 数据库管理系统(DBMS) RDBMS : O ... 
- git pull报错:Pulling without specifying how to reconcile divergent branches is discouraged.
			一.保存内容如下 二.翻译 三.设置为默认即可:git config pull.rebase false 
- telegraf、influxdb和grafana
			1 telegrafTelegraf 是一个开源的服务器代理,用于收集.处理和发送数据.它是 InfluxData 公司推出的 TICK 堆栈(Telegraf.InfluxDB.Chronograf ... 
- C++ 使用MIDI库演奏《晴天》
			那些在MIDI库里徘徊的十六分音符 终究没能拼成告白的主歌 我把周杰伦的<晴天>写成C++的类在每个midiEvent里埋藏故事的小黄花 调试器的断点比初恋更漫长而青春不过是一串未 ... 
- npm i 下载太慢
			1.在项目内部进入终端 2.输入:npm config set registry https://registry.npmmirror.com 修改npm下载地址为淘宝 3.然后再执行 npm i 进 ... 
- 4个Sprint目标的挑战以及解决的技巧
			1. Sprint 目标太大 有时,您的团队可能会尝试将过多的任务塞进冲刺中.抵制在冲刺中承担太多的诱惑,因为这会损害你的速度和持续交付的能力. 2. Sprint目标是模糊的 冲刺目标通常是不确定的 ... 
- 印度股票实时行情API数据源接口
			 StockTV API: 提供实时和历史行情数据,覆盖印度所有股票和指数,支持WebSocket和REST API接口.(推荐使用,对接简单,有技术支持) 新浪财经:提供股票市场数据,可以优先考虑 ... 
- 分享一个裁剪图片Chrome 扩展 —— Crop Image
			1. 前言 在日常工作和设计过程中,我们常常需要对图片进行裁剪,以适配不同的使用场景.无论是社交媒体头像.网站图片优化,还是艺术设计,精确的图片裁剪都是必不可少的.然而,许多在线工具使用复杂,或者功能 ... 
- MySQL查询建表规范
			因为之前一直再查找一些比较好的数据库规范,以方便在开发时连接 MySQL 进行查询/建表的时候,能根据规范来执行,达到提高 查询速度 / 执行 SQL 的性能 和提升 MySQL 的整体性能, 这里主 ... 
