1 运维侧的教训
运维侧最核心的目标就是保障 Kubernetes 集群的稳定性,在搭建 Kubernetes 集群的过程中,我们遇到了 2 个比较严重的问题,一个是容器产生僵尸进程,另一个是内核 Bug 引起的 Kubelet 负载飙升。
1.1 容器产生僵尸进程
Web 终端僵尸进程是困扰我们很久的问题,表现为当研发人员重启 Pod 时,发现集群中存在偶发的一些状态为 Not Ready 的节点,非常诡异,百思不得其解。后来发现原来是过多的 Bash 进程触发了 containerd-shim 的一个 Bug 所致。让我们一起来剖析问题的前因后果。
问题描述
在集群正常运行过程中,运维人员隔一段时间就会收到集群节点 Not Ready 的告警,当 Not Ready 状态持续一段时间后,Kubernetes 集群为了保持服务的高可用就会自动触发保护机制,进行整机迁移。
导致节点状态变为 Not Ready 的原因有很多,经排查发现,状态变为 Not Ready 的 Node 上存在一些处于 terminating 状态的 Pod,这些 Pod 的状态一直停留在 terminating,无法正常终止。同时,在 Node 上执行 docker ps 命令,发现命令无法正常输出,一直在等待 Docker 返回结果。由于 Kubelet 同样依赖 Docker 相关命令探测容器状态,就会导致 Kubelet 内部的健康检查超时,进而引发 Node 状态被标记为 Not Ready。为什么 docker ps 命令会卡死呢?经过进一步排查发现,所有出现问题的 Node 上均存在僵尸进程,并且这些僵尸进程均是由这些持续处于 terminating 状态的 Pod 所创建。
问题原因
为了便于开发人员调试和实时查看容器日志,发布平台提供了 Web 终端功能,让研发人员可以在浏览器中直接进入容器,并执行各种命令。
Web 终端通过 WebSocket 技术连接一个后端服务,该后端服务会调用 API Server 提供的 exec API(通过 client-go 实现),在容器中启动一个 Bash 进程,并将该 Bash 进程的标准输入、输出流与 WebSocket 连接到一起,最终实现了基于 Web 技术的终端。
问题就出现在这个 Bash 进程上,由于该进程并不是由容器父进程(Pid 0)派生的,而是由 containerd-shim 派生出来的,如图 17-1 所示,因此当容器被关闭时,Bash 进程无法收到容器父进程发送的退出信号,需要等待 Kubelet 通知 containerd-shim,由 containerd-shim 发送 killall 指令给容器内的所有进程,并等待这些进程退出。
图 17-1 Bash 僵尸进程原理图
containerd-shim 在实现这一步时,使用了一个长度为 32 的阻塞 Channel(Golang 的一种数据结构)来侦听子进程的退出事件,如果子进程较多,触发的退出事件会充满整个 Channel,进而触发 containerd-shim 出现死锁,无法正确回收子进程,从而导致产生了僵尸进程。而在 containerd-shim 发生死锁后,Kubelet 一旦运行 docker inspect 命令查看该容器状态,对应的线程便会挂起,最终导致 Node 进入 Not Ready 状态。
解决方案
定位到问题后,解决问题的核心思路是减少 containerd-shim 下派生的子进程 Bash 的数量上,我们通过 4 步来解决该问题。
优化 Web 终端代码,在用户关闭浏览器窗口后(WebSocket 连接断开),模拟发送 CTRL+C 和 exit 命令给 Bash 进程,触发 Bash 进程主动退出,如图 17-2 所示。
设置 Web 终端的超时时间,在 Web 终端空闲 10 分钟后(WebSocket 上没有数据传输),触发其主动退出。
如果用户使用了 Vim 等会抢占终端输入流的命令,便无法使用第 1 步的方法退出 Bash 进程,我们在每台 Node 上添加了定时任务,主动清理存活 30 分钟以上的 Bash 进程。
尽量避免使用 exec 类型的探针,而是使用 HTTP 探针替代,exec 探针同样是在 containerd-shim 下派生子进程,也容易造成子进程过多。
图17-2通过exit命令主动关闭终端进程
1.2 内核 Bug 引起的 Kubelet 负载飙升
问题描述
在测试阶段发现,当集群运行一段时间后,研发人员在发布新应用时 Pod 的创建非常缓慢,Web 终端连接超时,Prometheus 获取数据经常丢失,集群宿主机 CPU 负载飙升。
问题分析
从 Pod 创建的过程开始排查,我们使用 kubectl describe 命令查看 Pod 的创建过程,发现从 Pod 资源对象的创建到调度到具体 Node 的耗时很短,说明调度器没有问题。而在 Pod 调度完成后,Kubelet 拉取镜像和创建容器等动作耗时较长,我们怀疑是 Kubelet 的问题,经查看发现 Kubelet 使用的 CPU 时间片偶尔会达到 400%左右,系统调用占比较高,最高达到 40%,随后开始对 Kubelet 进行 CPU 性能分析。
GitHub 上有相同的问题, https://github.com/google/cadvisor/issues/1774
红帽官方也有此 Bug 的讨论地址为 https://bugzilla.redhat.com/show_bug.cgi?id=1795049
博文很长,总结要点如下。
在 Kubernetes 集群中,网络延迟会升高到 100ms。这是由于内核做了太多的工作(在 memcg_stat_show 中)导致网络依赖出现软中断。文章中的例子和我们遇到的场景类似,都
是因为 cAdvisor 从/sys/fs/cgroup/memory/memory.stat 中获取监控数据引发的。
解决方案
使用 shell 命令检查耗时,如果耗时大于 1s,甚至超过 10s,那么可以判定当前系统的内核存在上面描述的 Bug。
使用 shell 命令/proc/sys/vm/drop_caches>可以减缓网络延时,但治标不治本。
禁止 Kubelet 使用 cAdvisor 收集 Cgroup 信息,这样会失去部分监控数据。
升级内核版本。
其中方案 1、2、3 属于临时方案,推荐使用方案 4,升级内核版本,我们将内核版本升级到 4.19.113-300.el7.x86_64 后,就避免了这个问题。
2 开发侧的教训
除了运维侧踩了很多坑,开发侧同样也遇到了不少棘手的问题。
2.1 运营问题(使用方式和习惯的改变)
在平台推广初期,尽管新平台相较老平台在各方面都有了很大程度的提升,但平台的推广并没有收到令人满意的效果。这里主要存在开发习惯、迁移成本以及对于一个新产品的不信任等因素,因此,基础架构团队经过深入的用户调研和分析后,决心大力运营平台推广,主要从技术手段和人力手段两个维度并行展开。
在技术层面,针对老平台提供一键迁移代码仓库、一键托管配置文件等效率工具,帮助用户低成本地从老平台迁移到新平台,避免因为烦琐的操作而耗费用户的耐心。
在人力层面,为公司的每个业务技术团队分配专人进行推进,手把手协助业务团队做应用迁移。这一步看似低效,在实际执行中效果非常好。人与人、面对面的沟通,更容易建立业务线技术团队对新平台的信任,帮助他们初步迁移几个应用后,后续的迁移均是由各个业务线研发人员自主进行的,实际消耗时间成本并不高。同时,在手把手帮助业务线迁移的过程中,可以从用户视角观察产品交互效果,这个过程也帮助我们找到了很多平台存在的缺陷,大大促进了平台的进一步优化。
2.2 IP 白名单访问控制
在应用进行容器化部署的过程中,也暴露出了现有架构不合理的地方,比如在解决访问鉴权问题时,过度依赖 IP 白名单。IP 白名单是一种低成本且能初步满足需求的鉴权机制,但存在不灵活、不易扩展等问题,例如很容易出现上游应用部署实例进行变更或者扩容时,下游应用没有及时修改 IP 白名单,导致访问被拒绝,从而引发线上故障。同时 IP 白名单也不是一种足够安全的鉴权机制,尤其是 IP 可能会被回收并重新分配给其他应用使用,这时如果没有及时回收原有 IP 上的授权,很有可能引发权限泄漏。
在应用进行容器化改造后,由于我们直接使用的是原生的网络方案,在容器被重新调度后,容器的 IP 会发生改变,这样便无法沿用 IP 白名单鉴权机制。为此我们从访问方以及服务提供方两个方向入手,制定了 3 个阶段的改造方案。
第一阶段,添加代理层。我们在访问方与服务提供方二者之间,部署了一套 Nginx 代理服务器,将 Kubernetes Ingress 的 IP 作为服务提供方的上游客户端地址,配置进入 Nginx 相应域名的上游客户端。对访问方进行改造,将原始服务提供方的域名替换为代理服务器的域名。改造后,从服务提供方视角观察到的访问方 IP 变为代理服务器的 IP,这时将代理服务器的 IP 配置进服务提供方的 IP 白名单后,无论访问方的 IP 如何变化,都不会被服务提供方的 IP 白名单限制了。
第二阶段,提供服务方改造。在第一阶段实施完成后,虽然访问方实现了容器化改造,但在服务提供方留下了安全漏洞,只要获取新加入的代理层 IP,即可将自己伪装成刚刚完成容器化改造的应用,并以该应用的身份调用服务提供方。在第二阶段,我们要对服务提供方进行改造,让其支持 API Key、对称签名等与 IP 无关的访问控制机制,以抵御代理层引入的安全风险。
第三阶段,访问方改造。在服务提供方完成与 IP 无关的访问控制机制后,访问方应随之做改造,以支持新的访问控制方式。这时访问方就可以将服务提供方的地址从代理服务器切换回服务提供方的真实地址了。在经过一段时间的验证后,访问方即可将代理服务器的 IP 从 IP 白名单列表中移除,最终完成整个改造方案。
2.3 流量平滑迁移
在平台推广初期,除去试水的新应用,很大一批应用是从老发布平台迁移到容器化平台上的。在迁移过程中,我们必须帮助用户将老平台的流量平滑、逐步迁移到新平台上。
我们通过在原有 Nginx 代理中,添加 Kubernetes Ingress IP 的方式,进行一段时间的灰度发布,逐渐调高 Kubernetes 流量的权重,直至将 100%的流量迁移至 Kubernetes。在切换初期,我们并不会直接调整 DNS,而是将 Ingress IP 加入域名的上游客户端,例如以下 Nginx 配置代码片段。
其中 10.16.55.*段是应用的原始服务节点,而 10.216.15.96 与 10.216.15.196 是 Ingress 的 IP 地址,将 Ingress IP 加入域名的上游客户端中后,流量就可以根据我们配置的 weight 参数再均摊到多个节点中。通过逐步增大 Ingress 节点的 weight 值,将应用原节点的 weight 值降为 0,再持续运行一段时间后,即可将 DNS 直接配置解析到 Ingress 中,完成流量的最终切换。
在切换过程中,有以下需要注意的问题。
在迁移过程中,新需求上线时,业务线研发需要在新/老两个平台上同时上线,切记不能忘记更新原有平台的应用代码。
由于额外添加了一层代理,外部观察到的容器响应时间会比实际的高,这时需要从应用本身去观测性能指标,并与老平台的应用进行对比。
额外的代理层除了会对响应时间造成影响,还会额外记录一份响应日志,即访问会在原 Nginx 服务器上记录一次,同时在 Ingress 服务器上也记录一次。在迁移过程中,很难完全排查这部分统计误差。
2.4 上线后的分支要不要删除?
Aone Flow 分支模型虽然灵活,但会在代码仓库中创建大量短生命周期的分支,有两种分支需要进行定期清理。
第一种是发布分支。每次有特性分支从发布分支退出时,就会导致相应环境的发布分支被重新创建,如图 17-3 所示。
图17-3特性分支退出并重建发布分支
在特性分支(feature/001)退出后,我们会创建新的发布分支(release/daily/20211105),这时旧发布分支(release/daily/20211102)的生命周期便结束了,特性分支(feature/002)的后续提交均会合并到新的发布分支(release/daily/20211105)上,此时旧发布分支便可以被安全地删除。
注意,虽然删除发布分支一般不会导致代码丢失,但如果在该发布分支上解决过代码合并冲突,这部分解决合并冲突的工作需要在新的发布分支上重新进行一次。如果之前发生冲突时,是在特性分支上解决的,就无需重复进行冲突的解决了。这也是遇到分支冲突时,推荐在特性分支上解决冲突的原因。
第二种需要清理的分支是特性分支。在特性分支被发布到正式环境,并且合并到主干分支后,即可标记特性分支为可以被删除。与发布分支重建后,老发布分支可被立刻删除不同,我们对特性分支采取了延迟删除的策略,每周将历史上已经处于冻结状态的特性分支进行清理。
为什么不立刻删除已经上线的特性分支呢?这是因为虽然特性分支已经被合并到主干分支,但可能在上线后发现代码存在 Bug,需要进行及时修复,这时最高效的方式是在引入 Bug 的特性分支上进行代码修改,并立刻进行回归测试,然后重新上线修复。但特性分支在上线后,状态会被标记为已冻结,无法重新上线,这时发布平台可以从已冻结的特性分支中快速复制并得到一个新的分支,进行上线修复。如果我们在特性分支上线后,立刻删除该特性分支,就无法做到这一点了。
自如官方深度复盘云原生落地全过程,云原生和DevOps实践标准读物,沈剑、孙玄、乔新亮、史海峰等17位专家力荐.
会额外记录一份响应日志,即访问会在原Nginx服务器上记录一次,同时在Ingress服务器上也记录一次。在迁移过程中,很难完全排查这部分统计误差。
3.上线后的分支要不要删除?
Aone Flow分支模型虽然灵活,但会在代码仓库中创建大量短生命周期的分支,有两种分支需要进行定期清理。
第一种是发布分支。每次有特性分支从发布分支退出时,就会导致相应环境的发布分支被重新创建,如图17-3所示。
图17-3特性分支退出并重建发布分支
在特性分支(feature/001)退出后,我们会创建新的发布分支(release/daily/20211105),这时旧发布分支(release/daily/20211102)的生命周期便结束了,特性分支(feature/002)的后续提交均会合并到新的发布分支(release/daily/20211105)上,此时旧发布分支便可以被安全地删除。
注意,虽然删除发布分支一般不会导致代码丢失,但如果在该发布分支上解决过代码合并冲突,这部分解决合并冲突的工作需要在新的发布分支上重新进行一次。如果之前发生冲突时,是在特性分支上解决的,就无需重复进行冲突的解决了。这也是遇到分支冲突时,推荐在特性分支上解决冲突的原因。
第二种需要清理的分支是特性分支。在特性分支被发布到正式环境,并且合并到主干分支后,即可标记特性分支为可以被删除。与发布分支重建后,老发布分支可被立刻删除不同,我们对特性分支采取了延迟删除的策略,每周将历史上已经处于冻结状态的特性分支进行清理。
为什么不立刻删除已经上线的特性分支呢?这是因为虽然特性分支已经被合并到主干分支,但可能在上线后发现代码存在Bug,需要进行及时修复,这时最高效的方式是在引入Bug的特性分支上进行代码修改,并立刻进行回归测试,然后重新上线修复。但特性分支在上线后,状态会被标记为已冻结,无法重新上线,这时发布平台可以从已冻结的特性分支中快速复制并得到一个新的分支,进行上线修复。如果我们在特性分支上线后,立刻删除该特性分支,就无法做到这一点了。