写点什么

vivo AI 计算平台的 K8s 填坑指南

  • 2020 年 10 月 24 日
  • 本文字数:6011 字

    阅读完需:约 20 分钟

vivo AI 计算平台的K8s填坑指南

背景

在 2018 年底,vivo AI 研究院为了解决统一的高性能训练环境、大规模的分布式训练、计算资源的高效利用调度等痛点,着手建设 AI 计算平台。白驹过隙,将近两年时间过去了,平台的建设和落地取得了很大的进展,成为了 vivo AI 领域的核心基础平台。平台现在已经有超过 500 多个用户,来自人工智能、影像、互联网等多个部门。平台的容器集群有 1000 多台服务器,拥有 50000 多 CPU 核,1000 多张 GPU 卡,GPU 算力将近 100 PFLOPS。每天运行 1000 多个的算法训练任务,部署了 100 多个的模型推理服务和 AI 应用。这些训练任务和应用都是以容器的方式运行。平台从当初服务深度学习训练为主,到现在演进成包含 VTraining、VServing、VContainer 三大模块,对外提供模型训练、模型推理和容器化的能力。


计算平台的底座是 VContainer,是基于 Kubernetes 构建的容器平台,对上提供了容器运行、资源调度等能力。Kubernetes 是平台最基础最重要的组件,其稳定性对平台至关重要。本文是 vivo AI 计算平台技术演进系列文章之一,着重分享了平台在 Kubernetes 上遇到的疑难杂症和解决方法。


疑难杂症一:kmem accounting 问题

平台的 GPU 机器在运行算法训练的时候,经常会出现机器 Crash 重启或者卡死的现象。CPU 机器也会偶现此问题。通过排查,发现是臭名昭著的 kmem accounting 问题。这个问题在网上有很多资料,比如腾讯云的文章《Cgroup泄漏–潜藏在你的集群中》和 PingCap 的文章《诊断修复 TiDB Operator 在 K8s 测试中遇到的 Linux 内核问题》。这些资料提供了现象、根因的说明以及具体的修复方法,对我们修复问题提供很大的帮助,但现存的资料有以下问题:


  • 某些细节的信息有误。比如 PingCap 文章提到 docker 18.09.1 版本的 runc 已经将问题修复,但实际并没有。

  • 缺乏严谨的验证修复是否成功的方法。比如如何验证某个版本的 runc 修复了该问题。

  • 缺乏针对 GPU 机器的修复说明。

  • 该问题还会导致容器的内存指标虚高的问题。


本文针对上面的问题进行补充,希望给大家解决此问题带来帮助。


kubelet 的编译选项

有些资料提到 kubelet 版本是 v1.14 及以上的,可以用编译选项 BUILDTAGS=“nokmem"来关闭 kmem accounting 的特性。实际验证这个编译选项是无效的,正确的编译选项是 GOFLAGS=”-tags=nokmem"。完整的编译命令是在 k8s 项目的根路径下执行:


./build/run.sh make kubelet KUBE_BUILD_PLATFORMS=linux/amd64 BUILDTAGS="nokmem"
复制代码


验证修复的方法

首先,可以用命令 docker run -d --name test --kernel-memory 100M nginx:1.14.2 验证 runc 是否关闭了 kmem accounting。如果该命令执行成功,容器成功创建,说明 kmem accounting 特性还是开启的。如果命令失败,出现以下错误消息:


# docker run -d --name test --kernel-memory 100M nginx:1.14.2001f2ed0176941715d717b87a331fa05ab2bb2a9ab7107f3f66322ce9d6471a5docker: Error response from daemon: OCI runtime create failed: container_linux.go:345: starting container process caused "process_linux.go:430: container init caused \"process_linux.go:396: setting cgroup config for procHooks process caused \\\"kernel memory accounting disabled in this runc build\\\"\"": unknown.
复制代码


则说明 kmem accounting 特性已经被关闭了。


确认 runc 的 kmem accounting 关闭后,下一步是确认 kubelet 的 kmem accounting 是否关闭。重启机器后执行命令:


# find /sys/fs/cgroup/memory/kubepods/ -name memory.kmem.slabinfo | xargs cat > /tmp/mem.txt# cat /tmp/mem.txt
复制代码


如果 mem.txt 的内容是空,说明 kubelet 的 kmem accounting 也成功关闭。该机器的 kmem 问题已修复。


GPU 机器的特殊处理

我们将重新编译的 kubelet 和 runc 部署到 CPU 机器,通过上述验证方法,证明 kmem 问题已经修复了。但是同样部署到 GPU 机器后,却发现 kmem 问题还存在。经过排查后,我们找到了原因。GPU 机器上,docker 的 runtime 会设置为 nvidia-container-runtime:


# cat /etc/docker/daemon.json{    "runtimes": {        "nvidia": {            "path": "/usr/bin/nvidia-container-runtime",            "runtimeArgs": []        }    }}
复制代码


即 docker 会调用 nvidia-container-runtime,而不是 runc。我们使用了 nvidia-container-runtime v2.0.0.3,该版本是基于 runc 改动,添加了一个 pre-start hook,用于容器启动前在容器内挂载 GPU 驱动、nvidia 库和 GPU 设备,其原理如下图所示:



注意 nvidia-container-runtime 本身充当了 runc,机器上的 runc 二进制不会被调用。因此为了修复 GPU 机器上的 kmem 问题,我们需要关闭 nvidia-container-runtime 的 kmem accounting 特性。


接下来我们又遇到一个问题,在官方仓库中找不到 v2.0.0.3 的对应分支或者 tags。经过一番研究,我们发现 nvidia-container-runtime 对 runc 的改动是 PR Add patch updated for v1.0.0-rc8 的内容。我们自己从 runc 的 1.0.0-rc8 tag 拉出分支,做了一样的改动后,通过命令 make BUILDTAGS="seccomp nokmem"编译出关闭了 kmem 的 nvidia-container-runtime。将新的二进制部署到 GPU 机器,可以通过上述的验证。


如果大家使用的是 nvidia-container-runtime v3.0 以上版本,则不会遇到这个问题。因为 3.0 做了重构,nvidia-container-runtime 不再基于 runc,是一个独立的实现,会调用 runc。因此只需要 runc 关闭 kmem 即可。


容器内存指标虚高的问题

我们注意到平台的 calico 容器使用了很多的内存,下面所示的一个 calico-node 的 Pod 使用了 4.8G 内存。


# kubectl top pod -n kube-system |grep calico-node         calico-node-2cdkw                               921m         4841Mi
复制代码


一开始我们怀疑 calico 有内存泄露的问题,社区上也有相关的 issue: Large RAM consumption。但接着发现内存泄露问题在我们使用的 v3.6.5 版本已经修复,calico 相关的进程实际使用的内存也不多。进一步排查后发现是 kmem 导致了这个问题。


k8s 或者监控组件 cadvisor,会使用容器内/sys/fs/cgroup/memory/memory.usage_in_bytes 文件的值,作为容器的内存使用值。这个值是包含了/sys/fs/cgroup/memory/memory.kmem.usage_in_bytes 的值。


存在 kmem 问题的节点上,memory.kmem.usage_in_bytes 的值很大,从而导致 memory.usage_in_bytes 的值特别大:


# kubectl exec -it calico-node-zng2w -n kube-system sh/ # cat /sys/fs/cgroup/memory/memory.usage_in_bytes17915895808/ # cat  /sys/fs/cgroup/memory/memory.kmem.usage_in_bytes17763127296
复制代码


在正常的节点上,memory.kmem.usage_in_bytes 的值为 0,memory.usage_in_bytes 的值是正常的:


# kubectl exec -it  calico-node-q9vz4 -n kube-system sh/ #  cat /sys/fs/cgroup/memory/memory.usage_in_bytes151412736/ # cat  /sys/fs/cgroup/memory/memory.kmem.usage_in_bytes0
复制代码


由此可见,kmem 的问题除了会影响机器的稳定性,还会影响容器的内存指标。如果 Pod 配置了 limit 会导致 Pod 频繁被 OOM Kill。


疑难杂症二:CPU Manager 导致容器的 GPU 设备缺失

CPU Manager 是 k8s 的一个特性,可以在 kubelet 上开启。开启后满足需求的 Pod(QoS 为 Guaranteed, 且 cpu request 为正整数)会跑在固定的 CPU 核上并且独占 CPU 核,这能有效提升容器内应用的性能。这个特性在官方文章 Feature Highlight: CPU Manager 有详细说明。我们在 GPU 机器启动了该特性后,发现 GPU 容器无法访问 GPU 设备,执行 nvidia-smi 命令会有错误消息“Failed to initialize NVML: Unknown Error”。社区中有相关 issue:Updating cpu-manager-policy=static causes NVML unknown error。


根因分析

CPU Manager 特性是在 kubelet 的 CPU Manager 模块实现的。该模块通过 docker 的–cpuset-cpus 参数,可以限制容器使用指定的 CPU 核。它会维护一个数据结构,记录哪些 Pod 独占用了哪些 CPU 核。非独占核的 Pod 会共享没被独占的 CPU 核,如下图所示:



当新的 Pod 创建时,如果不满足独占核需求,cpuset-cpus 将设置为 Share-Pool 里的 CPU 核。如果满足独占核需求,则会从 Share-Pool 的 Assignable 里面分出 CPU 核给新的 Pod,Share-Pool 发生变化后也要更新非独占核 Pod 的 cpuset-cpus。CPU Manager 模块会定时刷新 Pod 的 cpuset-cpus 参数。


文章上一节提到,GPU 容器是通过 nvidia 设置 pre-start hook,在容器创建后启动前将 GPU 设备挂载到容器内,但这个设备信息没有同步给 docker。因此 CPU Manager 调用 docker API 更新容器 cpuset-cpus 参数时,GPU 设备的挂载会失效,导致了上述的问题。


修复方法

修复该问题需要修改 kubelet 的 CPU Manager 模块的代码。对于独占核的 Pod,不再定时去更新 Pod 的 cpuset-cpus 参数。这样规避了更新导致设备失效的问题。这个修复要求使用 GPU 的 Pod 必须独占核。具体的代码改动可以参见社区此说明。


疑难杂症三:容器内僵尸进程

按照容器的最佳实践,容器里应该只运行一个应用进程。有些业务场景由于特殊需求会在容器内启动多个子进程,比如一机多卡的算法训练。当子进程出现异常时,父进程没正确处理和回收,导致子进程变成僵尸进程。如下面的情况:


root     111457      1  0 Jul03 ?        00:06:41 containerd-shim -namespace moby -workdir /data/docker/containerd/daemon/io.containeroot     111476 111457  0 Jul03 ?        00:00:00  \_ [bash]root     114254 111476 99 Jul03 ?        213558-11:24:03  |   \_ python /data/glusterfs_workspaces/11121083/MAFNDeep/main.py root       1202 114254  0 Jul03 ?        00:00:00  |       \_ [python] <defunct>root       1204 114254  0 Jul03 ?        00:00:00  |       \_ [python] <defunct>root       1205 114254  0 Jul03 ?        00:00:00  |       \_ [python] <defunct>root       1206 114254  0 Jul03 ?        00:00:00  |       \_ [python] <defunct>
复制代码


这会导致删除 Pod 后,Pod 在 k8s 中一直处于 Terminating 状态,主机上也无法通过 docker 命令删除对应的容器。我们可以通过 kubectl delete pod {containerID} 命令将容器的 shim 进程强制杀掉。但是僵尸进程会一直存在于系统中,最后只有重启系统才能解决。在 Linux 中 1 号进程有处理和回收僵尸进程的职责。在容器内一般的应用进程作为 1 号进程并没有这个功能。为了解决这个问题,我们需要在容器内有一个正确发挥作用的 1 号进程。


社区的 tini 项目是专门为容器设计的 init 进程,可以通过将 tini 设置为 entrypoint 让它成为 1 号进程。这需要在镜像内安装 tini 和设置 entrypoint。平台上用户可以使用自己打包的镜像,这涉及太多镜像的改造。我们发现了一种改造成本更小的方案。k8s 启动的 Pod 在主机上除了业务容器还会有 pause 容器。pause 容器中的 pause 进程实际上也有回收僵尸进程的功能。因此通过给 Pod 设置 shareProcessNamespace: true,pause 容器和业务容器共享 PID 命名空间后,容器内的一号进程就是 pause 进程,这就解决了僵尸进程的问题。关于 pause 容器和进程的更多信息,可以参见 The Almighty Pause Container。


疑难杂症四:CIDRNotAvailable 问题

在 k8s 集群规模变大后,我们发现 Controller Manager 的日志有很多 CIDRNotAvailable 相关的错误,k8s 的 event 里也有很多相关事件:


# kubectl get eventLAST SEEN   TYPE      REASON             OBJECT                                MESSAGE4m21s       Normal    CIDRNotAvailable   node/10.193.131.130                   Node 10.193.131.130 status is now: CIDRNotAvailable4m34s       Normal    CIDRNotAvailable   node/10.193.131.135                   Node 10.193.131.135 status is now: CIDRNotAvailable 
复制代码


我们对此进行了排查和验证,最终确定该错误只会影响使用了 host-local 模式来分配容器 IP 的集群。平台的集群网络使用了 calico-ipam 模式,不受影响。


当容器 IP 分配使用 host local 模式,容器网络会划分成多个子网,每个节点分配一个子网,调度到节点上的容器的 IP 从这个子网分配。通过 Controller Manager 的参数 node-cidr-mask-size 可以指定子网的大小。Controller Manager 会根据这个参数分配子网给节点。node 的 spec.podCIDR 字段就是节点分配到的子网的信息:


# kubectl get node 10.196.2.1 -o yamlapiVersion: v1kind: Node  name: 10.196.2.1spec:  podCIDR: 10.227.228.0/24
复制代码


node-cidr-mask-size 参数默认值是 24, 即主机上的子网是 24 位,比如 10.42.0.0/24。整个容器网络是 16 位的子网,如 10.42.0.0/16,这个网络可以划分成 256 个 24 位的子网,集群中有 256 个节点可以分配到子网。超过了 256 个后,新的节点划分不到子网,就会出现这个错误。使用 host local 模式的集群,调度到新节点的容器因为分配不到 IP,状态会一直处于 ContainerCreating。平台使用 calico-ipam 模式,不依赖 podCIDR 字段,因此不受影响。


疑难杂症五:cadvisor CPU 利用率过高

平台通过使用 cadvisor 来采集容器指标。我们发现有时候某些节点上的容器指标缺失。经过排查,发现是节点的 cadvisor 卡住了,这时 cadvisor 的 CPU 利用率很高。社区有相关 issue:High CPU usage with low number of containers。Github 的文章Debugging network stalls on Kubernetes详细剖析了根因,此文问题排查的技巧和思路绝佳,强烈推荐大家阅读。Github 遇到服务出现偶现的网络延迟,和我们的现象不一样。但是导致问题的根因是一样的。


直接原因是 cadvisor 在读取/sys/fs/cgroup/memory/memory.stat 文件时,耗费了较多时间。这是因为当容器退出时,应用的内存已经释放了,但是内核用于缓存如 inode 等数据的内存还未释放。如果每次主动释放这些内存,会有较大的性能开销,因此内核采用了延迟释放的策略,只有当内存不够的时候,才释放这些内存,然后才会删除对应的 cgroup。这种策略导致系统中存在很多僵尸 cgroup,这些 cgroup 的进程已退出但是因为内核使用内存未释放,所以一直未被清理,还会包含在 memory.stat 中。当这些内存被回收和 cgroup 被清理时,读取 memory.stat 的耗时就会变长。这个问题在 4.19 以上的内核已经优化了。


相关文章:


《Kube-batch在vivo AI计算平台的应用》


《从VETH到Kubernetes网络通信剖析》


作者介绍:


吴梓洋,曾就职于 Oracle, Rancher 等公司,目前是 vivo AI 研究院计算平台组的资深工程师,也是 kube-batch, tf-operator 等项目的 contributor,关注 K8s、容器等云原生技术。


2020 年 10 月 24 日 08:003397

评论

发布
暂无评论
发现更多内容

极客时间架构师训练营 - week5 - 作业 2

jjn0703

极客大学架构师训练营

第5周作业

田振宇

1. 初识Jackson -- 世界上最好的JSON库

YourBatman

Jackson Fastjson JSON库

架构设计篇之面向对象设计

小诚信驿站

架构 架构师 架构分析 刘晓成 架构演进

一致性哈希算法实现及案例测试,java版

潜默闻雨

【架构师训练营 - 周总结 -5】

小动物

总结 极客大学架构师训练营 第五周

第5周 - 学习总结

大海

架构师训练营」第 4 周作业

edd

golang实现基于虚拟节点的一致性hash算法

朱月俊

分布式缓存总结

朱月俊

架构师训练营第五周作业 设计分布式缓存系统

Melo

极客大学架构师训练营

Week05 作业

极客大学架构师训练营

架构师训练营 - 学习笔记 - 第五周

心在飞

极客大学架构师训练营

实现一致性哈希算法

Aldaron

架构师训练营:第五周作业-一致性 hash实现

zcj

极客大学架构师训练营

Jira feat. Confluence助力敏捷项目管理

YY哥-杨勇

架构师训练营 第五周 学习总结

一雄

学习 极客大学架构师训练营 第五周

技术选型之缓存、队列、负载均衡

olderwei

极客大学架构师训练营

第 5 周 - 课后作业

大海

作业-05-java实现一致性hash算法

梦子说

极客大学架构师训练营

架构师训练营第五周总结

方堃

极客大学架构师训练营

架构师训练营 - 技术选型

Pontus

极客大学架构师训练营

架构师训练营作业 -- Week 5

吴炳华

极客大学架构师训练营

「架构师训练营」学习笔记:第 5 周 技术选型

Amy

总结 极客大学架构师训练营 消息队列 分布式缓存 第五周

领域模型为核心的架构设计 初篇

小隐乐乐

领域驱动设计 架构师

架构师训练营 Week 05 总结

Wancho

【第九课 + 第十课】技术选型:缓存架构 + 消息队列与异步架构

Aldaron

扯淡 Java 集合

CoderLi

Java 后端 hashmap 后台

Java实现一致性 Hash 算法实现(训练营第五课)

看山是山

极客大学架构师训练营 一致性hash

第05周 技术选型-01 学习总结

Jaye

第五周总结-缓存、消息中间件、负载均衡器、分布式数据库

吴建中

极客大学架构师训练营

vivo AI 计算平台的K8s填坑指南-InfoQ