关于腾讯云:内存回收导致关键业务抖动案例分析论云原生OS内存QoS保障

39次阅读

共计 10297 个字符,预计需要花费 26 分钟才能阅读完成。

蒋彪,腾讯云高级工程师,10+ 年专一于操作系统相干技术,Linux 内核资深发烧友。目前负责腾讯云原生 OS 的研发,以及 OS/ 虚拟化的性能优化工作。

导语

云原生场景,相比于传统的 IDC 场景,业务更加简单多样,而原生 Linux kernel 在面对云原生的各种简单场景时,时常显得有些力不从心。本文基于一个腾讯云原生场景中的一个理论案例,展示针对相似问题的一些排查思路,并心愿借此透视 Linux kernel 的相干底层逻辑以及可能的优化方向。

背景

腾讯云客户某要害业务容器所在节点,偶发 CPU sys(内核态 CPU 占用)冲高的问题,导致业务抖动,复现无规律。节点应用内核为 upstream 3.x 版本。

景象

在业务负载失常的状况下,监控可见显著的 CPU 占用率毛刺,最高可达 100%,同时节点 load 飙升,此时业务会随之呈现抖动。

捕捉数据

思路

故障景象为 CPU sys 冲高,即 CPU 在内核态继续运行导致,剖析思路很简略,须要确认 sys 冲高时,具体的执行上下文信息,能够是堆栈,也能够是热点。

难点:
因为故障呈现随机,持续时间比拟短 (秒级),而且因为是内核态 CPU 冲高,当故障复现时,惯例排查工具无奈失去调度运行,登录终端也会 hung 住(因为无奈失常调度),所以惯例监控(通常粒度为分钟级) 和排查工具均无奈及时抓到现场数据。

具体操作

秒级监控

通过部署秒级监控(基于 atop),在故障复现时能抓到故障产生时的零碎级别的上下文信息,示例如下:

从图中咱们能够看到如下景象:

  1. sys 很高,usr 比拟低
  2. 触发了页面回收(PAG 行),且十分频繁
  3. 比方 ps 之类的过程广泛内核态 CPU 使用率较高,而用户态 CPU 使用率较低,且处于退出状态

至此,抓到了零碎级别的上下文信息,能够看到故障过后,零碎中正在运行的、CPU 占用较高的过程和状态,也有一些零碎级别的统计信息,但仍无从通晓故障过后,sys 具体耗费在了什么中央,须要通过其余办法 / 工具持续抓现场。

故障现场

如后面所说,这里说的 现场 ,能够是故障过后的刹时堆栈信息,也能够是热点信息。
对于堆栈的采集,间接能想到的简略形式:

  1. pstack
  2. cat /proc/<pid>/stack

当然这两种形式都依赖:

  1. 故障过后 CPU 占用高的过程的 pid
  2. 故障时采集过程能及时执行,并失去及时调度、解决

显然这些对于以后的问题来说,都是难以操作的。

对于热点的采集,最间接的形式就是 perf 工具,简略、间接、易用。但也存在问题:

  1. 开销较大,难以常态化部署;如果常态化部署,采集数据量微小,解析艰难
  2. 故障时不能保障能及时触发执行

perf 实质上是通过 pmu 硬件进行周期性采样,实现时采纳 NMI(x86)进行采样,所以,一旦触发采集,就不会受到调度、中断、软中断等因素的烦扰。但因为执行 perf 命令的动作自身必须是在过程上下文中触发(通过命令行、程序等),所以在故障产生时,因为内核态 CPU 使用率较高,并不能保障 perf 命令执行的过程能失去失常调度,从而及时采样。

因而针对此问题的热点采集,必须提前部署 (常态化部署)。通过两种形式可解决(缓解) 后面提到的开销大和数据解析艰难的问题:

  1. 升高 perf 采样频率,通常升高到 99 次 /s,实测对实在业务影响可控
  2. Perf 数据切片。通过对 perf 采集的数据按时间段进行切片,联合云监控中的故障工夫点(段),能够精确定位到相应的数据片,而后做针对性的统计分析。

具体方法:
采集:

`.``/perf` `record -F99 -g -a`

剖析:

# 查看 header 外面的 captured on 工夫,应该示意完结工夫,time of last sample 最初采集工夫戳,单位是秒,可往前追溯现场工夫
./perf report --header-only
#依据工夫戳索引
./perf report --time start_tsc,end_tsc

按此思路,通过提前部署 perf 工具采集到了一个 现场,热点剖析如下:

能够看到,次要的热点在于 shrink_dentry_list 中的一把 spinlock。

剖析

现场剖析

依据 perf 的后果,咱们找到内核中的热点函数 dentry_lru_del,简略看下代码:

// dentry_lru_del()函数:static void dentry_lru_del(struct dentry *dentry) {if (!list_empty(&dentry->d_lru)) {spin_lock(&dcache_lru_lock);
        __dentry_lru_del(dentry);
        spin_unlock(&dcache_lru_lock);
    }
}

函数中应用到的 spinlock 为 dentry_lru_lock,在 3.x 内核代码中,这是一把超大锁 (全局锁)。单个文件系统的所有的 dentry 都放入同一个 lru 链表(位于 superblock) 中,对该链表的简直所有操作 (dentry_lru_(add|del|prune|move_tail)) 都须要拿这把锁,而且所有的文件系统共用了同一把全局锁(3.x 内核代码),参考 add 流程:

static void dentry_lru_add(struct dentry *dentry) {if (list_empty(&dentry->d_lru)) {
        // 拿全局锁
        spin_lock(&dcache_lru_lock);
       // 把 dentry 放入 sb 的 lru 链表中
       list_add(&dentry->d_lru, &dentry->d_sb->s_dentry_lru);
       dentry->d_sb->s_nr_dentry_unused++;
       dentry_stat.nr_unused++;
       spin_unlock(&dcache_lru_lock);
    }
}

因为 dentry_lru_lock 是全局大锁,能够想到的一些典型场景中都会持这把锁:

  1. 文件系统 umount 流程
  2. rmdir 流程
  3. 内存回收 shrink_slab 流程
  4. 过程退出清理 /proc 目录流程(proc_flush_task)- 后面抓到的现场

其中,文件系统 umount 时,会清理掉对应 superblock 中的所有 dentry,则会遍历整个 dentry 的 lru 链表,如果 dentry 数量过多,将间接导致 sys 冲高,而且其余依赖于 dentry_lru_lock 的流程也会产生重大的锁竞争,因为是 spinlock,也会导致其余上下文 sys 冲高。
接下来,再回过头看之前的秒级监控日志,就会发现故障是零碎的 slab 占用近 60G,十分大:

而 dentry cache(位于 slab 中)很可能是罪魁祸首,确认 slab 中的对象的具体散布的最简便的办法:Slabtop,在雷同业务集群其余节点找到相似环境,可见的确 dentry 占用率绝大部分:

咱们接下来能够应用 crash 工具在线解析对应文件系统的 superblock 的 dentry lru 链表,可见 unused entry 数量高达 2 亿 +

另一方面,依据业务的上下文日志,能够确认其中一类故障时,业务有删除 pod 的操作,而删除 pod 过程中,会 umount overlayfs,而后会触发文件系统 umount 操作,而后就呈现这样的景象,场景齐全吻合!
进一步,在有 2 亿 +dentry 环境中,手工 drop slab 并通过 time 计时,靠近 40s,阻塞工夫也能吻合。

`time` `echo` `2 > ``/proc/sys/vm/drop_caches`

至此,根本能解释:sys 冲高的间接起因为 dentry 数量太多。

亿级 Dentry 从何而来

接下来的疑难:为何会有这么多 dentry?
间接的解答办法,找到这些 dentry 的绝对路径,而后依据门路反推业务即可。那么 2 亿 +dentry 如何解析?

两种方法:

办法 1:在线解析

通过 crash 工具在线解析 (手工操练),
基本思路:

  1. 找到 sb 中的 dentry lru list 地位
  2. List 所有的 node 地址,后果存档
  3. 因为 entry 数量过多,能够进行切片,分批保留至独自文档,后续能够批量解析。
  4. Vim 列编辑存档文件,批量插入命令(file),保留为批量执行命令的文件
  5. crash - i 批量执行命令文件,后果存档
  6. 对批量执行后果进行文本处理,统计文件门路和数量

后果示例:

其中:

  1. db 为前面提及的相似 xxxxx_dOeSnotExist_.db 文件,占大部分。
  2. session 为 systemd 为每个 session 创立的临时文件

db 文件剖析如下:

文件名称有几个显著特色:

  1. 有对立的计数,可能是某一个容器产生
  2. 名称中蕴含字符串“dOeSnotExist“
  3. 都领有.db 的后缀

对应的绝对路径示例如下(用于确认所在容器)

如此能够通过持续通过 overlayfs id 持续查找对应的容器(docker inspect),确认业务。

办法 2:动静跟踪

通过编写 systemtap 脚本,追踪 dentry 调配申请,可抓到对应过程(在可复现的前提下),脚本示例如下:

probe kernel.function("d_alloc") {printf("[%d] %s(pid:%d ppid:%d) %s %s\n", gettimeofday_ms(), execname(), pid(), ppid(), ppfunc(), kernel_string_n($name->name, $name->len));
}

按过程维度统计:

Xxx_dOeSnotExist_.db 文件剖析

通过后面抓取到的门路能够判断该文件与 nss 库(证书 / 密钥相干)相干,https 服务时,须要应用到底层 nss 明码库,拜访 web 服务的工具如 curl 都应用到了这个库,而 nss 库存在 bug:
https://bugzilla.mozilla.org/…
https://bugzilla.redhat.com/s…

大量拜访不存在的门路这个行为,是为了检测是否在网络文件系统上拜访 nss db, 如果拜访长期目录比拜访数据库目录快很多,会开启 cache。这个探测过程会尝试 33ms 内循环 stat 不存在的文件 (最大 1 万次), 这个行为导致了大量的 negative dentry。
应用 curl 工具可模仿这个 bug,在测试机中执行如下命令:

`strace` `-f -e trace=access curl ``'https://baidu.com'`

躲避办法:设置环境变量 NSS_SDB_USE_CACHE=yes
解决办法:降级 pod 内的 nss 服务
至此,问题剖析近乎实现。看起来就是一个由平平无奇的用户态组件的 bug 引发的血案,分析方法和伎俩也平平无奇,但前面的剖析才是咱们关注的重点。

另一种景象

回忆后面讲到的 dentry_lru_lock 大锁竞争的场景,仔细分析其余几例呈现 sys 冲高的秒级监控现场,发现这种场景中并无删除 pod 动作(也就是没有 umount 动作),也就意味着没有遍历 dentry lru 的动作,按理不应该有重复持有 dentry_lru_lock 的状况,而且同时会呈现 sys 冲高的景象。

能够看到,故障前后的 cache 回收了 2G+,但理论的 free 内存并没有减少,反而缩小了,阐明此时,业务应该正在大量调配新内存,导致内存不足,从而导致内存始终处于回收状态(scan 数量减少很多)。

而在内存缓和进入间接回收后时,会(可能)shrink_slab,以至于须要持 dentry_lru_lock,这里的具体逻辑和算法不剖析了:)。当回收内存压力继续时,可能会重复 / 并发进入间接回收流程,导致 dentry_lru_lock 锁竞争,同时,在呈现问题的业务场景中,单 pod 过程领有 2400+ 线程,批量退出时调用 proc_flush_task 开释 /proc 目录下的过程目录项,从而也会批量 / 并发获取 dcache_lru_lock 锁,加剧锁竞争,从而导致 sys 冲高。

两种景象都能根本解释了。其中,第二种景象相比于第一种,更简单,起因在于其中波及到了内存缓和时的并发解决逻辑。

解决 & 思考

间接解决 / 躲避

基于后面的剖析,能够看出,最间接的解决形式为:
降级 pod nss 服务,或者设置设置环境变量躲避
但如果再思考下:如果 nss 没有 bug,但其余组件也做了相似可能产生大量 dentry 的动作,比方执行相似这样的脚本:

#!/bin/bash
i=0
while ((i < 1000000)) ; do
  if test -e ./$i; then
    echo $i > ./$i
  fi
  ((i++))
done

实质上也会不停的产生 dentry(slab),面对这种场景该怎么办?可能的简便的解决 / 躲避办法是:周期性 drop cache/slab,尽管可能引发偶然的性能小稳定,但根本能解决问题。

锁优化

后面剖析指出,导致 sys 冲高的间接起因是 dcache_lru_lock 锁的竞争,那这把锁是否有优化空间呢?
答案是:有
看看 3.x 内核代码中的锁应用:

static void dentry_lru_add(struct dentry *dentry) {if (list_empty(&dentry->d_lru)) {
        // 全局锁
        spin_lock(&dcache_lru_lock);
        list_add(&dentry->d_lru, &dentry->d_sb->s_dentry_lru);
        dentry->d_sb->s_nr_dentry_unused++;
        dentry_stat.nr_unused++;
        spin_unlock(&dcache_lru_lock);
    }
}

能够显著看出这是个全局变量,即所有文件系统专用的全局锁。而理论的 dentry_lru 是放在 superblock 中的,显然这把锁的范畴跟 lru 是不统一的。
于是,新内核版本中,果然把这把锁放入了 superblock 中:

static void d_lru_del(struct dentry *dentry) {D_FLAG_VERIFY(dentry, DCACHE_LRU_LIST);
    dentry->d_flags &= ~DCACHE_LRU_LIST;
    this_cpu_dec(nr_dentry_unused);
    if (d_is_negative(dentry)) this_cpu_dec(nr_dentry_negative);
    // 不再加独自的锁,应用 list_lru_del 原语中自带的 per list 的 lock
    WARN_ON_ONCE(!list_lru_del(&dentry->d_sb->s_dentry_lru, &dentry->d_lru));
 }
bool list_lru_add(struct list_lru *lru, struct list_head *item) {int nid = page_to_nid(virt_to_page(item));
    struct list_lru_node *nlru = &lru->node[nid];
    struct mem_cgroup *memcg;
    struct list_lru_one *l;
    // 应用 per lru list 的 lock
    spin_lock(&nlru->lock);
    if (list_empty(item)) {// …}
    spin_unlock(&nlru->lock);
    return false;
}
`

新内核中,弃用了全局锁,而改用了 list_lru 原语中自带的 lock,而因为 list_lru 本身位于 superblock 中,所以,锁变成了 per list(superblock)的锁,尽管还是有点大,但相比之前减小了许多。

所以,新内核中,对锁做了优化,但未必能齐全解决问题。

持续思考 1

为什么拜访不存在的文件 / 目录 (nss cache 和上述脚本) 也会产生 dentry cache 呢?一个不存在的文件 / 目录的 dentry cache 有何用处呢?为何须要保留?外表看,看似没有必要为一个不存在的文件 / 目录保留 dentry cache。其实,这样的 dentry cache(后文简称 dcache)在内核中有规范的定义:Negative dentry

`A special form of dcache entry gets created ``if` `a process attempts to access a non-existent ``file``. Such an entry is known as a negative dentry.`

Negative dentry 具体有何用途?因为 dcache 的次要作用是:用于放慢文件系统中的文件查找速度,构想如下场景:如果一个利用总是从一些事后配置好的门路列表中去查找指定文件(相似于 PATH 环境变量),而且该文件仅存在与这些门路中的一个,这种状况下,如果存在 negative dcache,则能减速失败门路的查找,整体晋升文件查找的性能。

持续思考 2

是否能独自限度 negative dcache 的数量呢?
答案是:能够。

Rhel7.8 版本内核中(3.10.0-1127.el7),合入了一个 feature:negative-dentry-limit,专门用来限度 negative dcache 的数量,对于这个 feature 的阐明请参考:
https://access.redhat.com/sol…

对于 feature 的具体实现,请参考:
https://lwn.net/Articles/813353/
具体原理就不解释了:)

残暴的事实是:rhel8 和 upstream kernel 都没有合入这个 feature,为啥呢?

请参考:
Redhat 的官网解释(其实并没有解释分明)
https://access.redhat.com/sol…

再看看社区的强烈探讨:
https://lore.kernel.org/patch…

Linus 也亲自站进去拥护。整体基调是:现有的 cache reclaim 机制曾经够用(够简单了), 再联合 memcg 的 low 水线等保护措施(cgroup v2 才有哦),能解决好 cache reclaim 的活,如果限度的话,可能会波及到同步回收等,引入新阻塞、问题和不必要的简单,negative dache 相比于一般的 pagecache 没有特别之处,不应该被区别对待(被虐待),而且 negative dcache 自身回收很快,balabala。

后果是,还是不能进社区,只管这个性能看起来是如此“实用”。

持续思考 3

还有其余形式能限度 dcache 吗?
答案是:还有
文件系统层,提供了 unused_dentry_hard_limit 参数,能够管制 dcache 的整体数量,整体管制逻辑相似。具体代码原理也不赘述了,欢送大家查阅代码。
遗憾的是,该参数依赖于各文件系统本身实现,3.x 内核中只看到 overlayfs 有实现,其余文件系统没有。所以,通用性有所限度,具体成果未知 (未理论验证)。
至此,看似真的曾经剖析分明了?

Think More

是否再思考一下:为什么 dentry 数量这么多,而没有被及时回收呢?
以后案例外表上看似一个有利用(nss)bug 引发的内核抖动问题,但如果认真思考,你会发现这其实还是内核本身面对相似场景的能力有余,其本质问题还在于:

  1. 回收不及时
  2. cache 无限度

回收不及时

因为内核中会将拜访过的所有文件 (目录) 对应的 dentry 都缓存起来存于 slab 中 (除非有个性标记),用于下次访问时提醒效率,能够看到出问题的环境中,slab 占用都高达 60G,其中绝大部分都是 dentry 占用。
而内核中,仅 (绝大部分场景) 当内存缓和时 (达到内存水线) 才会触发被动回收 cache(次要包含 slab 和 pagecache),而问题环境中,内存通常很短缺,理论应用较少,绝大部分为缓存 (slab 和 pagecache)。
当零碎 free 内存低于 low 水线时,触发异步回收 (kswapd);当 free 内存低于 min 水线是触发同步回收。也就是说仅当 free 内存低到肯定水平(水线) 时能力开始回收 dentry,而因为水线通常较低,导致回收机会较晚,而当业务有突发内存申请时,可能导致短期内处于内存重复回收状态。
注:水线 (全局) 由内核默认依据内存大小计算的,upstream 内核中默认的水线比拟低。在局部容器场景的确不太正当,新版本内核中有局部优化(能够设置 min 和 low 之间的间隔),但也不完满。

Memcg async reclaim
在云原生 (容器) 场景中,针对 cache 的无效、及时回收,内核提供了规范异步回收形式:达到 low 水线后的 kswapd 回收,但 kswapd 是 per-node 粒度(全局),即便在调大 min 和 low 水线之间的 distance 之后(高版本内核反对),仍存在如下有余:

  1. distance 参数难以通用,难以管制
  2. 全局扫描开销较大,比拟轻便
  3. 单线程 (per-node) 回收,仍可能较慢,不及时

在理论利用中,也常见因为内存回收不及时导致水线被击穿,从而呈现业务抖动的问题。针对相似场景的问题,社区在多年前有人提交了 memcg async relaim 的想法和补丁 (绝对原始),基本原理为:为每个 pod (memcg) 创立一个相似 kswapd 这样的内存异步回收线程,当 pod 级别的 async low 水线达到后,触发 per-cgroup 根本的异步内存回收。实践上也能比拟好的解决 / 优化相似场景的问题。但最终通过长时间探讨后,社区最终没有承受,次要起因还是出于容器资源开销和 Isolation 的思考:

  1. 如果为每个 cgroup 创立一个内核线程,当容器数量较多时,内存线程数量增多,开销难以管制。
  2. 后续优化版本去除了 per-cgroup 的内核回收线程,而借用于内核自带的 workqueue 来做,因为 workqueue 的池化能力,能够合并申请,缩小线程线程创立数量,管制开销。但随之而来的是隔离性 (Isolation) 的问题,问题在于新提交的 workqueue 申请无奈 account 到具体的 pod(cgroup),毁坏了容器的隔离性。

从 Maintainer 的角度看,回绝的理由很充沛。但从 (云原生) 用户的角度看,只能是再次的失落,毕竟理论的问题并未失去真正充沛解决。
尽管 memcg async reclaim 性能最终未能被社区承受,但仍有多数厂商保持在本人的版本分支中合入了相应性能,其中的典型代表如 Google,另外还包含咱们的 TencentOS Server (原 TLinux),咱们不仅合入 / 加强了原有的 memcg async reclaim 性能,还将其整体融入了咱们的云原生资源 QoS 框架,整体为保障业务的内存服务质量提供底层撑持。

cache 无限度

Linux 偏向于尽可能将闲暇内存利用起来,用做 cache(次要是 page cache 和 slab),用于晋升性能 (次要是文件拜访)。意味着零碎中 cache 能够简直不限度(只有有 free 内存) 的增长。在事实场景中带来不少的问题,本案例中的问题就是其中一种典型。如果有 cache limit 能力,实践上能很大水平解决相似问题。

Cache limit
而对于 page cache limit 话题,多年前曾在 Kernel upstream 社区中继续争执了很长一段时间,但最终还是未能进入 upstream,次要起因还在于违反了尽量利用内存的初衷。只管在一些场景中的确存在一些问题,社区仍倡议通过其余形式解决 (业务或者其余内核伎俩)。
尽管社区未承受,但少部分厂商还是保持在本人的版本分支中合入了 page cache limit 性能,其中典型代表如 SUSE,另外还包含咱们的 TencentOS Server(原 TLinux),咱们不仅合入 / 加强了 page cache limit 性能,反对同步 / 异步回收,同时还加强了 slab limit 的限度,能够同时限度 page cache 和 slab 的用量。该性能在很多场景中起到了关键作用。

论断

  1. 在如下多个条件同时产生时,可能呈现 dentry list 相干的锁竞争,导致 sys 高:

    • 零碎中存在大量 dentry 缓存(容器拜访过的大量文件 / 目录,不停累积)
    • 业务突发内存申请,导致 free 内存冲破水线,触发内存回收(重复)
    • 业务过程退出,退出时须要清理 /proc 文件,期间依赖于 dentry list 的大锁,呈现 spinlock race。
  2. 用户态利用 nss bug 导致 dcache 过多,是事变的间接起因。
  3. 深层次思考,能够发现,upstream kernel 为思考通用性、架构优雅等因素,放弃了很多实用功能和设计,在云原生场景中,难以满足极致需要,要成为云原生 OS 的外围底座,还须要深度 hack。
  4. TencentOS Server 为云原生海量场景做了大量深度定制和优化,能自若应答简单、极其云原生业务带来各种挑战(包含本案例中波及的问题)。此外,TencentOS Server 还设计实现了云原生资源 QoS 保障个性(RUE),为不同优先级的容器提供了各种要害资源的 QoS 保障能力。敬请期待相干分享。

结语

在云原生场景中,upstream kerne l 难以满足极其场景的极致需要,要成为云原生 OS 的底座,还须要深度 hack。而 TencentOS Server 正为之不懈努力!

【注:案例素材取自腾讯云虚拟化团队和云技术经营团队】

容器服务(Tencent Kubernetes Engine,TKE)是腾讯云提供的基于 Kubernetes,一站式云原生 PaaS 服务平台。为用户提供集成了容器集群调度、Helm 利用编排、Docker 镜像治理、Istio 服务治理、自动化 DevOps 以及全套监控运维体系的企业级服务。

【腾讯云原生】云说新品、云研新术、云游新活、云赏资讯,扫码关注同名公众号,及时获取更多干货!!

正文完
 0