nr_dying_descendants 引发的在线业务性能抖动

最近发现一则比较奇怪的业务性能抖动问题

简单来说,就是离线作业频繁的触发oom,导致在线业务指标陡增。理论上来说,离线作业触发自身硬限,不应该导致其他容器出问题才是

先来说一下我们的 memory cgroup 层级结构,可以简单理解为如下:

/cgroups/v2/kubepods/

/cgroups/v2/kubepods/online/a -> a为在线服务

/cgroups/v2/kubepods/offline/job1 -> job1 为离线服务

通常来说,为了解决混部的内存争抢问题,我们会对整个 offline 大框设置一个内存上限,防止离线作业使用内存太多,导致在线服务出现内存压力。这看起来似乎没什么问题。

不过最近一个问题,发现离线 job1 频繁 oom,引发了在线的性能抖动

最后定位的结论如下:

  1. /cgroups/v2/kubepods/offline/cgroup.stat 下看到有大量的 nr_dying_descendants
  2. offline 大框频繁出发 oom
  3. oom 的回收过程有个效率不太好的地方,导致 oom 回收效率底下 -> 导致内核各种锁争抢严重 -> 进程sys高 -> cpu排队严重 -> 在线业务性能抖动

根因分析:nr_dying_descendants 的原因是由于容器被销毁时,容器内还有未完全回收干净的 page cache,所以内核不会立即释放这个 cgroup(用户态操作系统从 /cgroups 文件系统下已经看不到了,但是内核态的数据结构还在)

cat /cgroups/v2/kubepods/online/cgroup.stat

nr_descendants 10

nr_dying_descendants 4172

这就有一个问题,如果有大量的dying cgroups,内核在oom处理过程中:

  1. 如果是cgroup oom,会优先尝试从 dying cgroups 回收内存,但是最多只回收5个 -> 这个地方有效率问题
  2. 如果是整机回收,不处理dying cgroups内存

 


cgroup 内存管理之 mlock 内存

我们在使用容器的过程中,可能会遇到一个问题,容器利用率很低,但是经常发生 oom,这是什么情况?

很可能是业务使用了一些不可回收的 page cache,其中最主要的应该就是 mlock

1. mlock 的背景

mlock 的作用就是防止页面被换出到 swap 分区,或者 page cache 被内核回收

由于线上服务器,基本默认都会关闭 swap 分区,所以这种情况暂不讨论,防止 page cache 被内核回首是主要作用

什么样的 page 需要 mlock?

1)通过 mmap 方式映射到内存中的 page cache,比如一些关键的索引数据,需要经常访问,那就得 mlock 住,否则 pgmajfault 带来的性能抖动是很大的

2)程序自身依赖的一些 so 动态链接库,由于内核的 page cache 回收算法,并不感知 page 具体是普通的文件 cache,还是 so 动态链接库,所以当容器内存不足时,内核通过一些粗略的回收算法回收 page cache,一旦把 so 的缓存页回收掉了,程序在调用相关函数时,会出现严重的性能抖动

因此,通过 mlock,显式的把一些关键的不希望被回收的 page cache 锁定起来,达到保证业务性能的目的

2. mlock 的内核实现

mlock 相关的系统调用可以参考:https://linux.die.net/man/2/mlock

2.1. unevictable 内存

cgroup 的 memory.stat 接口,包含一个容器内存使用的详细信息

cat /cgroups/v2/matrix/memory.stat 
anon 200723943424
file 158665015296
kernel_stack 0
slab 0
sock 229249024
shmem 4258582528
file_mapped 48234749952
file_dirty 8605696
file_writeback 0
inactive_anon 4258234368
active_anon 200570675200
inactive_file 73935654912
active_file 40680521728
unevictable 39942680576

其中 unevictable 表示不剔除的 page cache,mlock 属于 unevictable 的一部分,除此之外,unevictable 还有一些其他内存


cgroup 内存管理之 tmpfs

本文内核代码版本:https://elixir.bootlin.com/linux/v4.14.281/source/mm/shmem.c

1. tmpfs 内存简介

tmpfs 文件系统是 pod 中常见的一种“存储”介质,也叫 ram disk,都是一个东西

tmpfs 的特殊的地方在于:

  1. 首先它是个文件系统
  2. 但是它的文件数据是完全存放在内存里面的,不在磁盘上

所以要讲 tmpfs 的话,就得把这两部分都讲清楚,一个是文件系统的实现,一个是底层“持久化”层内存的管理

通常应用程序之间会通过 tmpfs 文件系统来实现高效的数据共享

/dev/shm 就是一个最典型的 tmpfs 文件系统,是操作系统为了解决大多数程序数据共享而默认挂在的一个 tmpfs

2. tmpfs 文件系统的实现

我们知道 file 是linux内核最重要的设计,一切皆文件

除了普通的文件,平时我们接触到的,unix管道、socket、proc、cgroup 等等,都是基于文件的实现

为了实现灵活可扩展的文件系统架构,Linux设计了 virtual file system 抽象层,简称 vfs,对用户台程序屏蔽了所有具体的底层文件系统的实现细节,提供统一的文件系统接口

2.1. virtual file system 接口定义

https://www.kernel.org/doc/html/latest/filesystems/vfs.html

vfs 属于一个专题,我们这里不讲那么复杂,有时间可以专门展开讲

vfs 定义了文件系统实现最关键的2个接口:

  1. 一个是 struct file_operations:文件读写的接口
  2. 一个是 struct inode_operations:inode操作接口

inode_opertions 定义如下:

struct inode_operations {
    int (*create)(struct user_namespace*, struct inode*, struct dentry*, umode_t, bool);
    int (*symlink)(struct user_namespace*, struct inode*, struct dentry*, const char*);
    int (*mkdir)(struct user_namespace*, struct inode*, struct dentry*, umode_t);
    int (*rmdir)(struct inode*, struct dentry*); /* 省略一万字 */
};

是不是很熟悉?

file_operations 的定义如下:

struct file_operations {
    int (*open)(struct inode*, struct file*);
    loff_t (*llseek)(struct file*, loff_t, int);
    ssize_t (*read)(struct file*, char __user*, size_t, loff_t*);
    ssize_t (*write)(struct file*, const char __user*, size_t, loff_t*); /* 省略一万字 */
};

是不是也很熟悉?

内核 tmpfs 文件系统的源码:mm/shmem.c

tmpfs 其实并没有实现 vfs 中的所有接口,主要原因是因为由于绝大部分的 file system 底层实现其实都是一样的,没区别,所以内核为了简化文件系统的开发,把这些通用的实现都抽象出来,变成 generic_file_read/write 之类的通用实现

static const struct file_operations shmem_file_operations = { /* 普通文件的读、写、seek、fsync */ .mmap = shmem_mmap, .get_unmapped_area = shmem_get_unmapped_area, #ifdef CONFIG_TMPFS .llseek = shmem_file_llseek, .read_iter = shmem_file_read_iter, .write_iter = generic_file_write_iter, .fsync = noop_fsync, .splice_read = generic_file_splice_read, .splice_write = iter_file_splice_write, .fallocate = shmem_fallocate, #endif };
static const struct inode_operations shmem_inode_operations = { /* 这个是针对普通文件的 inode 操作 */ .getattr = shmem_getattr, .setattr = shmem_setattr, };
static const struct inode_operations shmem_dir_inode_operations = { /* 这个是针对目录的 inode 操作 */ #ifdef CONFIG_TMPFS .create = shmem_create, .lookup = simple_lookup, .link = shmem_link, .unlink = shmem_unlink, .symlink = shmem_symlink, .mkdir = shmem_mkdir, .rmdir = shmem_rmdir, .mknod = shmem_mknod, .rename = shmem_rename2, .tmpfile = shmem_tmpfile, #endif };


记一则 cfs 硬限引发的 cgroup_mutex 死锁

cgroup_mutex 是内核实现 cgroup 子系统而重度依赖的一把全局锁,这把全局锁在很多场景下会带来非常多的性能问题。具体这里不展开讲了,google 一下 cgroup_mutex deadlock,可以看到一堆 bugs report

最近线上恰好遇到一次机器死机的场景,后来分析发现和 cgroup_mutex 死锁有关(严格来说不叫死锁,叫夯死更合适一些),以此记录分析过程

1. 现象

机器存在大量的 D 进程,监控发现系统负载缓慢持续增高,应该是由于D进程持续不断地堆积,导致负载越来越高

所有与 cgroup_mutex 打交道的地方,都会卡死,比如简单来说 cat /proc/self/cgroup 会立即进入夯死状态,进程无法 kill -9 杀掉

我们通过一些艰难的内核栈跟踪,终于捕捉到了内核临死前的锁持有的状态(此处省略掉1万字跟踪过程)

2. 内核栈分析

读过内核源码或者了解内核原理的应该知道,vfs_write 是内核文件系统的抽象层。vfs_write 接着调用 cgroup_file_write,这个说明当前进程正在写 cgroup 文件系统,然后在写过程中,陷入 synchronize_sched,进程被换出,然后应该是一直卡在这里了。

但是真正导致系统死机的,并不是 synchronize_sched 这个地方,二是一个很复杂的链,触发场景:

    1. 进程持有 cgroup_mutex
  1. 进程尝试获取其他锁,或者进入睡眠态
  2. 进程 cfs 时间片被 throt,导致进程无法重新获得 cpu 的控制权,cgroup_mutex 无法释放

cgroup_mutex 是一把极大的锁,几乎任何 cgroup 操作都会涉及到这把锁的操作。在我们这个场景里,进程持有 cgroup_mutex 之后陷入 __wait_rcu,其他进程在尝试持有 cgroup_mutex 的时候几乎全部都夯住了


CTS: An operating system CPU scheduler to mitigate tail latency for latency-sensitive multi-threaded applications

论文原址:https://www.cs.jhu.edu/~erfan/files/cts.pdf

cts 提出了一种优化长尾延迟的方法,通过改进 linux 内核默认的 cfs 调度器,引入线程调度机制

论文的一些要点:

  • It has been proven that FCFS scheduling of threads leads to lower tail latency
  • Experiments show that CFS policies lead to LCFS scheduling, aggravating tail latency
  • CTS policies ensure FCFS thread scheduling, which yields lower tail latency.
  • CTS enforces our policies while maintaining the key features of the Linux scheduler
  • Experimental results show that CTS significantly outperforms the Linux scheduler

延迟敏感型的程序,通常有两种进程模型,一种是 thread-driven,另一种是 event-driven。前者很容易理解,起1个线程来服务一个请求,模型很简单。后者相对复杂一些,比如 libev,libevent 都是用来实现 event-driven 架构的基础库,通过 io 多路复用技术,将 io 线程和 worker 线程分离,io 线程专注数据转发,worker 线程专注业务逻辑处理

但是这个 io 线程很关键,如果数据 ready,但是得不到处理,会导致严重的长尾延迟。io 密集型的程序瓶颈不在 cpu,而是 io,CPU 上的时间片消耗是非常小的,远小于1ms,因而这种进程理论上应该优先获得响应


Dynamic writeback throttling

https://lwn.net/Articles/405076/
Dynamic writeback throttling最主要的核心思想就是IO带宽估算。
the bandwidth estimation allows the kernel to scale dirty limits and I/O sizes to make the best use of all of the devices in the system, regardless of any specific device’s performance characteristics.
传统writeback机制的做法是,当进程脏页超过一定比例时,调用balance_dirty_pages()函数进入同步写dirty pages过程,直到dirty pages的比例下降到一定比例,之后才允许该进程返回。
该机制存在三个问题:
  1. 进程脏页比率多少才合适?
  2. 内存压力太大时,多个后台进程同时writeback,会产生大量的随机IO,设备吞吐量下降
  3. 如何更准确的估算设备的真实带宽?
Dynamic writeback throttling的基本做法是:启发式的计算设备的真实带宽,所有进程不再同步writeback改由sleep一段时间,等待后台线程统一writeback。


深入 prjquota,源码剖析

ext4 prjquota 实现原理,参考了 xfs prjquota,并且复用了linux 内核的磁盘配额管理机制的大部分实现,所以源码上分析起来还是非常简单的

linux内核本身就已经支持user、group级别的磁盘配额管理,用法可以参考:https://access.redhat.com/documentation/en-us/red_hat_enterprise_linux/7/html/storage_administration_guide/ch-disk-quotas

从文件系统实现层面来看,文件系统本身并不了解什么是uid,gid,因此disk quota的实现一定是在raw file system 之上的。正因为是如此,所以 prjquota 得以复用原有 disk quota 的大量实现,之需要在原有基础之上,扩展一个新的 quota 类型而已

具体内核提交的 patch:https://lore.kernel.org/patchwork/patch/541891/

4.14 内核时,已经进入主干,因此可以参考:https://lxr.missinglinkelectronics.com/linux+v4.14/fs/ext4/

简述一下其基本设计:

  1. 在 super block 中,有一块专门用来存储 project id 用量的元数据区
  2. 每个文件,属于哪个 project id,是记录在文件的 xattr 属性里面的(正是因为 ext4 文件系统支持 xattr 扩展,所以才很方便的移植这个特性)
  3. 文件写入的时候,先查找这个文件的 project id,然后判断当前 project 的 usage + 文件的增量的大小,是否超过 project 的 hardlimit,如果超过,返回 EDOUT,文件写入失败

CFS 调度算法的基本原理

调度单元有三种状态:

  1. 睡眠:不在 CPU 运行队列里
  2. PENDING:调度单元被唤醒,比如网络数据包到达,IO ready等,进程被唤醒,进入运行队列,但是还没得到 CPU 时间片
  3. 运行:调度单元得到 CPU 控制权,开始运行

调度延时其实就是指进程被唤醒,进入运行队列到得到 CPU 时间片之间的等待时间,也就是处于 PENDING 状态的时间

Linux 通过一个红黑树来维护所有进程的状态,每个 CPU 都会有一个运行队列,管理所有进程和进程组【注意,进程组也是内核的一个基本的调度单元】

每个调度单元都会有一个 vruntime 的属性,用来记录当前调度单元以运行的虚拟时间


cpu iowait 到底算 busy 还是 idle?

我们在追查线上问题的时候,经常会碰到 cpu iowait 很高的 case,并且通常这种机器伴随着难登录、操作卡顿等现象,一看 cpu idle 还非常低,以为机器 cpu 被打爆了

其实不然,真正的罪魁祸首是 io,而不是 cpu。你看到的 cpu idle 很低,其实是因为部分 idle 时间被算到了 iowait 里面,导致看起来 cpu idle 很低而已。而纯粹的 iowait 很高,并不会引起系统卡顿,如果你发现系统卡顿,大概率是因为 io 异常的磁盘正好是根分区而已,不过今天我们不讨论 io,只讨论 cpu

那么问题来了,cpu iowait 到底是算 busy 还是 idle?

  1. 如果 cpu iowait 不算 busy,那衡量一个 cpu 的繁忙程度的指标到底是什么?
  2. 如果 cpu iowait 算 busy,这和直觉又不符合啊?既然是 io wait,cpu 都被切换出去执行其他进程了,又何来 busy 一说
  3. 初次之外,内核是如何统计 cpu iowait 的?

初识 prjquota,原理、实践

prjquota 的前身其实是 subtreequota,最早由 openvz 提出,2010年之后好像就没有消息了,没有进入内核主干,有点遗憾。后来我们移植过一次,但是由于设计过于复杂,功能不稳定,并且缺少社区的技术支持,最终选择了放弃。

prjquota 是 xfs 文件系统的一个原生特性,其设计简单,功能健壮。并且有人尝试把他移植到了 ext4 文件系统上。4系内核已经进入主干

prjquota 功能和 subtreequota 一样,能够限制一组具有相同 prjid 属性的文件的总大小。这些具有相同属性的 prjid,可能散落在不同的目录下,但属于同一个项目的文件拥有一个想同的project id标示,正如同一个用户的文件,或者同一个用户组的文件有相同的UserID,或者GroupID

具体实现,可以参考内核 patch:https://lwn.net/Articles/671627/

1)使能 prjquota 特性

磁盘project quota初始化,如下任意一种方法都可以:

  • 重新格式化一个磁盘来支持project quota: /root/ext4/e2fsprogs/misc/mke2fs /dev/hdb -O quota,project
  • 或者在已有的磁盘上使能project quota:/root/ext4/e2fsprogs/misc/tune2fs /dev/hdb  -O quota,project

mount设备支持project quota:

  • mount -t ext4 -o prjquota /dev/hdb xxxx/   或者:
  • mount -t ext4 /dev/hdb xxxx/; /root/ext4/quota-tools/quotaon  xxxx/,但是这个方法,需要在磁盘上没有任何文件被打开的时候才能执行

创建 project id和quota限制管理

  1. 设置一个目录属于一个project id:/root//ext4/e2fsprogs/misc/chattr -p 1001 xxxx/test1
  2. 使得这个目录下的文件默认继承这个project id:/root//ext4/e2fsprogs/misc/chattr  +P xxxx/test1
  3. 设置project的配额:/root/ext4/quota-tools/setquota -P 1001 100 100 400 500 xxxx,可以重复设置,例如更新quota,立即生效