前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
圈层
工具
发布
首页
学习
活动
专区
圈层
工具
社区首页 >专栏 >【Pod Terminating原因追踪系列】之 containerd 中被漏掉的 runc 错误信息

【Pod Terminating原因追踪系列】之 containerd 中被漏掉的 runc 错误信息

作者头像
腾讯云原生
修改于 2020-09-02 06:46:22
修改于 2020-09-02 06:46:22
4.9K0
举报

李志宇,腾讯云后台开发工程师,日常负责集群节点和运行时相关的工作,以及 containerd、docker、runc 等运行时组件的定制开发和问题排查。

前一段时间发现有一些containerd集群出现了Pod卡在Terminating的问题,经过一系列的排查发现是containerd对底层异常处理的问题。最后虽然通过一个短小的PR修复了这个bug,但是找到bug的过程和对问题的反思还是值得和大家分享的。

本文中会借由排查bug的过程来分析kubelet删除Pod的调用链,这样不仅仅可以了解containerd的bug,还可以借此了解更多Pod删除不掉的原因。在文章的最后会对问题进行反思,来探讨OCI出现的问题。

一个删除不掉的Pod

可能大家都会遇到这种问题,就是集群中有那么几个Pod无论如何也删除不掉,看起来和下图一样。当然可有很多可能导致Pod卡在Terminating的原因,比如mount目录被占用、dockerd卡死了或镜像中有“i”属性的文件。因为节点上复杂的组件(docker、containerd、cri、runc)和过长的调用链,导致很难瞬间定位出现问题的位置。所以一般遇到此类问题都会通过日志、Pod的信息和容器的状态来逐步缩小排查范围。

当然首先看下集群的信息,发现没有使用docker而直接用的cri和containerd。直接使用containerd照比使用docker会有更短的调用链和更强的鲁棒性,照比使用docker应该更稳定才对(比如经常出现的docker和containerd数据不一致的问题在这里就不会出现)。接下来当然是查看kubelet日志,如下(只保留了核心部分),从这条日志中可以发现貌似是kubelet调用cri接口,最终调用runc去删除容器时报错导致删除失败。

代码语言:txt
AI代码解释
复制
$ journalctl -u kubeletFeb 01 11:37:27 VM_74_45_centos kubelet[687]: E0201 11:37:27.241794     687 pod_workers.go:190] Error syncing pod 18c3d965-38cc-11ea-9c1d-6e3e7be2a462 ("advertise-api-bql7q_prod(18c3d965-38cc-11ea-9c1d-6e3e7be2a462)"), skipping: error killing pod: [failed to "KillContainer" for "memcache" with KillContainerError: "rpc error: code = Unknown desc = failed to kill container \"55d04f7c957e81fcf487b0dd71a4e50fe138165303cf6e96053751fd6770172c\": unknown error after kill: runc did not terminate sucessfully: container \"55d04f7c957e81fcf487b0dd71a4e50fe138165303cf6e96053751fd6770172c\" does not exist\n: unknown"

接下来我们打算分析下容器当前的状态,简单介绍下,containerd中用container来表示容器、用task来表示容器的运行状态,创建一个容器相当于创建container,而把容器运行起来相当于创建一个task并把task状态置为Running。当然停掉容器就相当于把task的状态设置为Stopped。通过ctr命令看下containerd中container和task的状态,容器55d04f对应的container和task都还在、task状态是STOPPED。接下来查看containerd日志,我们节选了一小部分,发现了如下现象,第一条日志是stop容器55d04f时做umount失败,接下来都是kill容器55d04f时发现container不存在。

代码语言:txt
AI代码解释
复制
error="failed to handle container TaskExit event: failed to stop container: failed rootfs umount: failed to unmount target /run/containerd/io.containerd.runtime.v1.linux/k8s.io/55d04f.../rootfs: device or resource busy: unknown"error="failed to handle container TaskExit event: failed to stop container: unknown error after kill: runc did not terminate sucessfully: container "55d04f..." does not exist"error="failed to handle container TaskExit event: failed to stop container: unknown error after kill: runc did not terminate sucessfully: container "55d04f..." does not exist"error="failed to handle container TaskExit event: failed to stop container: unknown error after kill: runc did not terminate sucessfully: container "55d04f..." does not exist"

当然得到这些信息直觉会认为排查方向是:

  • 为何rootfs会被占用,只要找出来是谁在占用rootfs就可以解决问题了
  • 既然umount报错,我们是否可以使用lazy umount
  • 反正之后containerd还会重试,再后来的重试中是否可以正确删除容器

第一个选项直接被排除了,看起来占用rootfs的进程并不是长期存在,等发现问题登录到节点上排查时进程已经不在了。如果不是常驻进程问题就变得麻烦了,可能是某个周期执行的监控组件,也可能是用户的某个日志收集容器某次收集时间较长在rootfs上多停留了一会。

处于懒惰的本能,我们先尝试下第二个方案。刚刚我们说过容器在containerd中被定义为containertask,查看容器信息时发现task并没有被删掉,于是我们直接在containerd的代码中找到了umount容器rootfs的代码,如下(为了阅读体验,已经简化):

代码语言:txt
AI代码解释
复制
func (p *Init) delete(ctx context.Context) error {    err := p.runtime.Delete(ctx, p.id, nil)  // ...    if err2 := mount.UnmountAll(p.Rootfs, 0); err2 != nil {        log.G(ctx).WithError(err2).Warn("failed to cleanup rootfs mount")        if err == nil {            err = errors.Wrap(err2, "failed rootfs umount")        }    }    return err}func unmount(target string, flags int) error {    for i := 0; i < 50; i++ {        if err := unix.Unmount(target, flags); err != nil {            switch err {            case unix.EBUSY:                time.Sleep(50 * time.Millisecond)                continue            default:                return err            }        }        return nil    }    return errors.Wrapf(unix.EBUSY, "failed to unmount target %s", target)}

containerd创建容器时会创建一个containerd-shim进程来管理创建出来的容器,原本containerd对容器进程的操作就转化成了containerd对shim的RPC调用;而调用runc来操作容器的工作自然就会交给shim来做,这样最大的好处就是可以方便的实现live-restore能力,也就是即使containerd重启也不会影响到容器进程。

上面代码中的 delete函数就是由containerd-shim调用的,函数中主要工作有两个:调用runc delete删掉容器、调用umount卸载掉容器的rootfs。containerd日志中第一次device busy导致的umount失败就是在这里产生的。当然在umount函数中还是有个短暂的重试的,看来社区还是考虑到了偶尔可能会出现rootfs被占用的情况(怀疑是容器进程还没来的急被回收,但在某些场景下,可能这个重试的时间还有点短)。

这里要注意unmount的flags是0,查看docker代码,发现docker在umount时加了MNT_DETACH。在简单地修改了shim的代码后,在节点上测试,果然添加了MNT_DETACH以后就不会出现device busy了。于是自信的向社区提了PR,结果得到的回复却是:

What typically happens in cases like this is you there is a mount marked as private that gets copied into a new mount namespace. A new mount namespace is created for every container, for systemd services that have MountPropagation or PrivateTmp defined, and these types of things. When those namespaces are created they get a copy of the root namespace, anything that has a private mount cannot be unmounted until all the namespaces are shut down. Mounts get marked private depending on the propagation defined on their root mount or if explicitly set.... so for example if you have /var/foo mounted and /var is mounted with mount private propagation, /var/foo will inherit the private propagation. In this case MNT_DETACH only detaches the mount and hides very real problems. Even if you remove the mountpoint the data will not be freed until (possibly?) a reboot or all other namespaces with copies of that mount in them are shut down.

大概意思就是如果你用了MNT_DETACH,会有一些真正的问题被藏起来。(这里有待测试,我觉得社区里这个人回复的思路有问题)。

看起来我们只能排查下为什么重试时还会失败了,节点上执行删除Pod的流程还是比较长的,很难简单通过几个举例直接说明问题,所以接下来分析下kubelet从cri到OCI删除容器的流程。

kubelet如何删除Pod中的容器

对于kubelet的分析就要从大名鼎鼎的SyncPod开始分析了,在SyncPod开始时会计算podContainerChanges,接下来整个流程都是根据podContainerChanges的情况来执行对容器的操作。我们假设change就是KillPod,而kubelet执行KillPod会先通过创建多个goroutine并发执行StopContainers,等到所有Containers都删除成功后再删除Pod的Sandbox。具体调用流程如下:

图中用红色标记的StopContainer其实就是最终调用了cri接口(container runtime interface),比如以下是两个和删除容器相关的两个cri接口,Kubernetes要求每种容器运行时都要实现cri接口。docker通过docker-shim实现了cri接口;而container通过cri插件实现了cri接口,两者并没区别。比如运行时是containerd时,对cri的调用就会通过containerd-shim最终在容器上产生影响。

代码语言:txt
AI代码解释
复制
// StopContainer stops a running container with a grace period (i.e., timeout).// This call is idempotent, and must not return an error if the container has// already been stopped.// TODO: what must the runtime do after the grace period is reached?StopContainer(context.Context, *StopContainerRequest) (*StopContainerResponse, error)// RemoveContainer removes the container. If the container is running, the// container must be forcibly removed.// This call is idempotent, and must not return an error if the container has// already been removed.RemoveContainer(context.Context, *RemoveContainerRequest) (*RemoveContainerResponse, error)

当请求到了cri后,剩下的任务就都交给了containerd和containerd-cri。cri以插件的方式运行在containerd中,本质和containerd是同一个进程,因此可以通过containerd提供的client直接通过函数调用containerd提供的service。正常情况下整个调用链如下图所示。

另外,cri插件中存在一个eventloop专门处理从containerd中获取的event。比如当容器删除后,会收到TaskExit事件,这时cri会做清理工作;比如当容器oom时,会收到OOMKill事件,cri除了清理还会更新Reason。接下来我们了解下整个删除流程:

  1. 当kubelet调用cri的StopContainer接口后,cri会调用containerd的task.Kill接口(这里的task就是containerd中用来表示容器运行状态的模块),containerd收到请求后会调用containerd-shim的kill接口,而containerd-shim会通过命令行工具runc来kill掉容器进程。runc虽然不是守护进程,但是也有部分数据会被持久化到文件系统中,执行runc kill后,不只会给容器进程发送信号,同时还会修改runc的持久化数据。另外,当容器进程被干掉后,会被父进程shim回收掉。
  2. shim成功干掉容器后,会给cri发送TaskExit的事件。当cri收到事件后会调用containerd的task.Delete接口,这个接口会先通过shim清理runc保留的容器持久化数据和容器运行时所用的rootfs。当两者都被清理后,shim留着也没用了,这时干脆直接发信号kill掉shim,并清理掉containerd保存的task信息。这时containerd中和容器状态相关的信息就都消失了,当然containerd中的container还完好无损。
  3. 哪怕代码中不存在bug,这么长的调用链也可能会遇到系统问题。eventLoop调用task.Delete如果返回错误会把当前的event放到一个backoff队列,等过一段时间拿出来重试。这样就保证哪怕当前对一个容器的操作失败了,过段时间还可以重试。

回到之前的问题上,可能有些聪明的同学通过上面的流程图和分析之前的日志就可以猜到答案了。没猜到也没关系,现在和大家一起分析下。还记的当时containerd的日志分成两部分么,首先是执行umount报错device busy,之后反复出现unknown error after kill: runc did not terminate sucessfully: container "55d04f..." does not exist"。这两部分和我们上面说的“delete task时清理rootfs,如果失败了会隔段时间进行重试”这个表述很接近。我们再把调用的流程图画的更细点,这下应该就可以在图中找到答案了。

当容器被kill掉之后还一切正常,cri收到了容器退出的信号,调用containerd的task.Delete()时,可以注意到,这里多了个withKill选项(上面的流程中其实也有,只不过被省略掉了)。添加这个选项会在调用shim的Delete接口之前再次调用Kill,这样可以防止Delete了正在运行的容器,算是“悲观”的决定。

第一次task Delete的流程中,一切运行的都很顺畅,runc kill掉一个已经挂掉的容器也没什么问题。直到umount容器的rootfs,发现rootfs被占用了,而且在umount的50次重试中占用rootfs的进程并没有退出,shim只好通过containerd向cri返回一个错误。cri会在之后的一段时间里重新尝试处理刚刚的这个event。

在接下来重试 task Delete中,会和第一次执行一样,都会在delete之前执行kill。但由于第一次runc delete成功的删除了runc所持久化的容器信息,重试时执行runc kill会报错container does not exist。不巧的是shim和containerd并没有特别处理这个错误信息,而是直接返回给了cri。这就导致了cri删除容器会失败,并且再也无法umount容器的rootfs了。cri中的容器无法被删掉,自然发起删除流程的syncPod也会出现问题,这样最终就导致了Pod卡在了Terminating。

最终修复与反思

当然这里的修复也很简单,只需要在调用runc kill后添加特殊判断就可以了,具体修复的pr见https://github.com/containerd/containerd/pull/4214,目前已经合并到主干,并且回溯到1.2的版本中了。很多时候发现问题远比修复问题要复杂的多,虽然最终修复bug的代码很简单,但是整个为了发现bug,我们用了好几天时间来分析梳理整个流程。简单看下错误处理的代码,这里的error就是调用runc出现错误的返回结果。

代码语言:txt
AI代码解释
复制
if strings.Contains(err.Error(), "os: process already finished") ||        strings.Contains(err.Error(), "container not running") ||                strings.Contains(strings.ToLower(err.Error()), "no such process") ||        err == unix.ESRCH {        return errors.Wrapf(errdefs.ErrNotFound, "process already finished")    } else if strings.Contains(err.Error(), "does not exist") {    // we add code here !        return errors.Wrapf(errdefs.ErrNotFound, "no such container")    }    return errors.Wrapf(err, "unknown error after kill")}

显而易见这坨代码存在问题:

containerd-shim原本目的就是支持各种OCI工具,但是却把runc的错误处理信息写死在调用OCI的路径上,这样最终可能导致shim只能为runc服务,而不好适配其他的OCI。比如完善containerd测试时就会发现这坨代码对crun并不work(crun是用纯c语言实现的OCI工具)。不可能在containerd中适配每一种OCI工具,所以问题还是出现在制定OCI规范时没考虑到错误处理的情况,同样我们也和OCI社区提了issue。

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2020-08-10,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 腾讯云原生 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
暂无评论
推荐阅读
编辑精选文章
换一批
【Pod Terminating原因追踪系列之三】让docker事件处理罢工的cancel状态码
岳家瑞,腾讯云后台开发工程师,日常负责K8s生态和运行时相关工作,包括K8s插件开发和运行时问题排查。
腾讯云原生
2020/08/31
2.1K0
CRI shim:kubelet怎么与runtime交互(一)
实现了 CRI 接口的容器运行时通常称为 CRI shim, 这是一个 gRPC Server,监听在本地的 unix socket 上;而 kubelet 作为 gRPC 的客户端来调用 CRI 接口,来进行 Pod 和容器、镜像的生命周期管理。另外,容器运行时需要自己负责管理容器的网络,推荐使用 CNI。
没有故事的陈师傅
2023/05/01
6280
CRI shim:kubelet怎么与runtime交互(一)
一文搞定 Containerd 的使用
在学习 Containerd 之前我们有必要对 Docker 的发展历史做一个简单的回顾,因为这里面牵涉到的组件实战是有点多,有很多我们会经常听到,但是不清楚这些组件到底是干什么用的,比如 libcontainer、runc、containerd、CRI、OCI 等等。
CNCF
2021/08/26
11K0
一文搞定 Containerd 的使用
Containerd深度剖析-CRI篇
目前我司现网的K8s集群的运行时已经完成从docker到Containerd的切换,有小伙伴对K8s与Containerd调用链涉及的组件不了解,其中Containerd和RunC是什么关系,docker和containerd又有什么区别,以及K8s调用Containerd创建容器又是怎样的流程,最终RunC又是如何创建容器的,诸如此类的疑问。本文就针对K8s使用Containerd作为运行时的整个调用链进行介绍和源码级别的分析。
zouyee
2023/01/31
1.5K0
Containerd深度剖析-CRI篇
Docker EOF问题排查
某天接到客户反馈,pod的事件中出现大量的 warning event: Readiness probe failed: OCI runtime exec failed: exec failed: EOF: unknown。但不影响客户访问该服务。
没有故事的陈师傅
2021/06/24
5.1K0
Docker EOF问题排查
1.Containerd容器运行时初识与尝试
本章主要讲解,目前K8S使用率最多的容器运行时讲解, 由于k8s在2020年宣布1.20版本之后将弃用dockershim(其中也有kubernetes与Docker爱恨情仇)时才把containerd拉回大众的视野之中,本章主要讲解containerd基础入门。
全栈工程师修炼指南
2021/07/25
2.8K0
1.Containerd容器运行时初识与尝试
Pod 一直停留在 Terminating 状态,我等得花儿都谢了~
近期,弹性云线上集群发生了几起特殊的容器漂移失败事件,其特殊之处在于容器处于 Pod Terminating 状态,而宿主则处于 Ready 状态。
米开朗基杨
2020/11/30
1.4K0
Pod 一直停留在 Terminating 状态,我等得花儿都谢了~
Kubernetes决定弃用Docker,到底会影响到谁?
近几年,Kubernetes 已经成为自有机房、云上广泛使用的容器编排方案,最广泛的使用方式是 Kubernetes+Docker。从 DevOps 人员的角度,一面用 kubctl 命令、k8s API 来操作集群,一面在单机用 Docker 命令来管理镜像、运行镜像。
xcbeyond
2020/12/14
6180
Kubernetes决定弃用Docker,到底会影响到谁?
容器能不能将 volume 挂载直接挂到根目录?—— 浅析 kubelet 到 runc 的调用过程
这件事起源于有小伙伴在某群里问,在 K8s 中,能不能把 volume 挂载直接挂到根目录?我的第一反应是不能。容器会使用 union filesystem 将容器的内容挂到根目录下,这点在正常情况下是无法更改的。但是就止于此吗?发现给不出合理解释的时候,突然感觉自己对于容器的认知只停留在了很表面的阶段。
腾讯云 CODING
2023/03/31
1.3K0
容器能不能将 volume 挂载直接挂到根目录?—— 浅析 kubelet 到 runc 的调用过程
漏洞分析|RunC TOCTOU逃逸CVE-2021-30465分析
背景 国外安全研究员champtar[1]在日常使用中发现Kubernetes tmpfs挂载存在逃逸现象,研究后发现runC存在条件竞争漏洞,可以导致挂载逃逸[2]。 关于条件竞争TOCTOU和一些linux文件基础知识可见这篇文章《初探文件路径条件竞争 - TOCTOU&CVE-2019-18276》[3]。 CVE-2021-30465在Redteam的研究者视角中比较鸡肋,因为需要K8S批量创建POD的权限。但在产品安全的视角恰恰相反,针对Caas(Container as a service)类
云鼎实验室
2021/08/19
1.1K0
扩展 Kubernetes 之 CRI
使用 cri-containerd 的调用流程更为简洁, 省去了上面的调用流程的 1,2 两步
王磊-字节跳动
2020/02/03
4.6K0
Containerd深入浅出-安全容器篇
Containerd 是一个高度模块化的高级运行时,所有模块均可插拔,模块均以 RPC service 形式注册并调用(gRPC 或者 TTRPC)。不同插件通过声明互相依赖,由 Containerd 核心实现统一加载,使用方可以自行实现插件以实现定制化的功能。当然这种设计虽然使得 Containerd 拥有强大的跨平台、可插拔的能力,同时也带来一些缺点,模块之间功能互调必须通过 RPC 调用。
zouyee
2022/11/07
1.1K0
Containerd深入浅出-安全容器篇
一文带你了解Docker与Containerd的区别
容器运行时(Container Runtime)是一种负责在操作系统层面创建和管理容器的软件工具或组件。它是容器化技术的核心组件之一,用于在容器内部运行应用程序,并提供隔离、资源管理和安全等功能。 在Kubernetes中,容器运行时是负责管理和运行容器的组件。在过去,Docker是最常用的容器运行时,但随着时间的推移,containerd成为Kubernetes的另一个受欢迎的容器运行时选择。
用户1107783
2023/09/11
11K0
一文带你了解Docker与Containerd的区别
Kubernetes 1.20版本开始不推荐使用Docker,你知道吗?
CRI:容器运行时接口 container runtime interface,CRI 中定义了容器和镜像两个接口,实现了这两个接口目前主流的是:CRI-O、Containerd。(目前 PCI 产品使用的即为 Containerd)。
YP小站
2020/12/16
3.1K0
Kubernetes 1.20版本开始不推荐使用Docker,你知道吗?
Docker、Containerd、RunC分别是什么
上一篇文章《真正运行容器的工具:深入了解 runc 和 OCI 规范》已经讲清楚了Runc与OCI。这里再讲解一下概念。
没有故事的陈师傅
2021/11/02
4.3K0
Docker、Containerd、RunC分别是什么
Containerd 介绍与使用
在学习 Containerd 之前我们有必要对 Docker 的发展历史做一个简单的回顾,因为这里面牵涉到的组件实战是有点多,有很多我们会经常听到,但是不清楚这些组件到底是干什么用的,比如 libcontainer、runc、containerd、CRI、OCI 等等。
王先森sec
2023/10/17
1.6K0
Containerd 介绍与使用
大话 Kubernetes Runtime
回想最开始接触 k8s 的时候, 经常搞不懂 CRI 和 OCI 的联系和区别, 也不知道为啥要垫那么多的 “shim”(尤其是 containerd-shim 和 dockershim 这两个完全没啥关联的东西还恰好都叫 shim). 所以嘛, 这篇就写一写 k8s 的 runtime 部分, 争取一篇文章把下面这张 Landscape 里的核心项目给白话明白:
iMike
2019/06/04
1.3K0
大话 Kubernetes Runtime
容器中的 Shim 到底是个什么鬼?
Kubernetes 1.20 版开始废除了对 dockershim 的支持,改用 Containerd[1] 作为默认的容器运行时。本文将介绍 Containerd 中的 "shim" 接口。
米开朗基杨
2022/02/28
7.7K0
Containerd容器镜像管理
containerd支持oci标准的镜像,所以可以直接使用docker官方或dockerfile构建的镜像
鱼找水需要时间
2023/08/06
5.9K0
Containerd容器镜像管理
WebAssembly 在云原生中的实践指南
WebAssembly(Wasm)是一种通用字节码技术,它可以将其他编程语言(如 Go、Rust、C/C++ 等)的程序代码编译为可在浏览器环境直接执行的字节码程序。
Se7en258
2023/09/05
1.6K0
WebAssembly 在云原生中的实践指南
相关推荐
【Pod Terminating原因追踪系列之三】让docker事件处理罢工的cancel状态码
更多 >
领券
社区富文本编辑器全新改版!诚邀体验~
全新交互,全新视觉,新增快捷键、悬浮工具栏、高亮块等功能并同时优化现有功能,全面提升创作效率和体验
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档
查看详情【社区公告】 技术创作特训营有奖征文