用 cgruops 管理进程内存占用

cgroups 中有个 memory 子系统,用于限制和报告进程的内存使用情况。

其中,很明显有两组对应的文件,一组带 memsw ,另一组不带

memory.failcnt
memory.limit_in_bytes
memory.max_usage_in_bytes
memory.usage_in_bytes

memory.memsw.failcnt
memory.memsw.limit_in_bytes
memory.memsw.max_usage_in_bytes
memory.memsw.usage_in_bytes

带 memsw 的表示虚拟内存,即物理内存加交换区。不带 memsw 的那组仅包括物理内存。其中,limit_in_bytes 是用来限制内存使用的,其他的则是统计报告。

# echo 10485760 >/sys/fs/cgroup/memory/foo/memory.limit_in_bytes

即可限制该组中的进程使用的物理内存总量不超过 10MB。对 memory.memsw.limit_in_bytes 来说,则是限制虚拟内存使用。memory.memsw.limit_in_bytes 必须大于或等于 memory.limit_in_byte。这些值还可以用更方便的 100M,20G 这样的形式来设置。要解除限制,就把这个值设为 -1 即可。

这种方式限制进程内存占用会有个风险。当进程试图占用的内存超过限制,访问内存时发生缺页,又没有足够的非活动内存页可以换出时会触发 oom ,导致进程直接被杀,从而造成可用性问题。即使关闭控制组的 oom killer,进程在内存不足的时候,虽然不会被杀,但是会长时间进入 D (等待系统调用的不可中断休眠)状态,无法继续执行,导致仍然无法服务。因此,我认为,用 memory.limit_in_bytes 或 memory.memsw.limit_in_bytes 限制进程内存占用仅应当作为一个保险,避免在进程异常时耗尽系统资源。如,预期一组进程最多只会消耗 1G 内存,那么可以设置为 1.4G 。这样在发生内存泄露等异常情况时,可以避免造成更严重问题。

在 memory 子系统中,还有一个 memory.soft_limit_in_bytes 。和 memory.limit_in_bytes 的差异是,这个限制并不会阻止进程使用超过限额的内存,只是在系统内存不足时,会优先回收超过限额的进程占用的内存,使之向限定值靠拢。

前面说控制组的 oom killer 是可以关闭的,就是通过 memory.oom_control 来实现的。cat memory.oom_control 可以看到当前设置以及目前是否触发了 oom 。echo 1 >memory.oom_control 就可以禁用 oom killer。

usage_in_bytes、max_usage_in_bytes、failcnt 则分别对应 当前使用量,最高使用量和发生的缺页次数。

memory 子系统中还有一个很重要的设置是 memory.use_hierarchy 这是个布尔开关,默认为 0。此时不同层次间的资源限制和使用值都是独立的。当设为 1 时,子控制组进程的内存占用也会计入父控制组,并上溯到所有 memory.use_hierarchy = 1 的祖先控制组。这样一来,所有子孙控制组的进程的资源占用都无法超过父控制组设置的资源限制。同时,在整个树中的进程的内存占用达到这个限制时,内存回收也会影响到所有子孙控制组的进程。这个值只有在还没有子控制组时才能设置。之后在其中新建的子控制组默认的 memory.use_hierarchy 也会继承父控制组的设置。

memory.swappiness 则是控制内核使用交换区的倾向的。值的范围是 0 – 100。值越小,越倾向使用物理内存。设为 0 时,只有在物理内存不足时才会使用交换区。默认值是系统全局设置: /proc/sys/vm/swappiness 。

memory.stat 就是内存使用情况报告了。包括当前资源总量、使用量、换页次数、活动页数量等等。

用 cgroups 管理进程磁盘 io

linux 的 cgroups 还可以限制和监控进程的磁盘 io。这个功能通过 blkio 子系统实现。

blkio 子系统里东西很多。不过大部分都是只读的状态报告,可写的参数就只有下面这几个:

blkio.throttle.read_bps_device
blkio.throttle.read_iops_device
blkio.throttle.write_bps_device
blkio.throttle.write_iops_device
blkio.weight
blkio.weight_device

这些都是用来控制进程的磁盘 io 的。很明显地分成两类,其中带“throttle”的,顾名思义就是节流阀,将流量限制在某个值下。而“weight”就是分配 io 的权重。

“throttle”的那四个参数看名字就知道是做什么用的。拿 blkio.throttle.read_bps_device 来限制每秒能读取的字节数。先跑点 io 出来

dd if=/dev/sda of=/dev/null &
[1] 2750

用 iotop 看看目前的 io

  TID  PRIO  USER     DISK READ  DISK WRITE  SWAPIN     IO>    COMMAND
 2750 be/4 root       66.76 M/s    0.00 B/s  0.00 % 68.53 % dd if=/dev/sda of=/dev/null
...

然后修改一下资源限制,把进程加入控制组

echo '8:0   1048576' >/sys/fs/cgroup/blkio/foo/blkio.throttle.read_bps_device
echo 2750 >/sys/fs/cgroup/blkio/foo/tasks

这里的 8:0 就是对应块设备的主设备号和副设备号。可以通过 ls -l 设备文件名查看。如

# ls -l /dev/sda
brw-rw----. 1 root disk 8, 0 Oct 24 11:27 /dev/sda

这里的 8, 0 就是对应的设备号。所以,cgroups 可以对不同的设备做不同的限制。然后来看看效果

  TID  PRIO  USER     DISK READ  DISK WRITE  SWAPIN     IO>    COMMAND
 2750 be/4 root      989.17 K/s    0.00 B/s  0.00 % 96.22 % dd if=/dev/sda of=/dev/null
...

可见,进程的每秒读取立马就降到了 1MB 左右。要解除限制,写入如 “8:0 0” 到文件中即可

对写入的限制也类似。只是测试的时候要注意,dd 需要加上 oflag=sync,避免文件系统缓存影响。如

dd if=/dev/zero of=/data/test.dat oflag=sync

还有别犯傻把 of 输出直接写设备文件 🙂

不过需要注意的是,这种方式对小于采样间隔里产生的大量 io 是没用的。比如,就算在 1s 内产生一个每秒写入 100M 的峰值,也不会因此被限制掉。

再看看 blkio.weight 。blkio 的 throttle 和 weight 方式和 cpu 子系统的 quota 和 shares 有点像,都是一种是绝对限制,另一种是相对限制,并且在不繁忙的时候可以充分利用资源,权重值的范围在 10 – 1000 之间。

测试权重方式要麻烦一点。因为不是绝对限制,所以会受到文件系统缓存的影响。如在虚拟机中测试,要关闭虚机如我用的 VirtualBox 在宿主机上的缓存。如要测试读 io 的效果,先生成两个几个 G 的大文件 /tmp/file_1,/tmp/file_2 ,可以用 dd 搞。然后设置两个权重

# echo 500 >/sys/fs/cgroup/blkio/foo/blkio.weight
# echo 100 >/sys/fs/cgroup/blkio/bar/blkio.weight

测试前清空文件系统缓存,以免干扰测试结果

sync
echo 3 >/proc/sys/vm/drop_caches

在这两个控制组中用 dd 产生 io 测试效果。

# cgexec -g "blkio:foo" dd if=/tmp/file_1 of=/dev/null &
[1] 1838
# cgexec -g "blkio:bar" dd if=/tmp/file_2 of=/dev/null &
[2] 1839

还是用 iotop 看看效果

  TID  PRIO  USER     DISK READ  DISK WRITE  SWAPIN     IO>    COMMAND
 1839 be/4 root       48.14 M/s    0.00 B/s  0.00 % 99.21 % dd if=/tmp/file_2 of=/dev/null
 1838 be/4 root      223.59 M/s    0.00 B/s  0.00 % 16.44 % dd if=/tmp/file_1 of=/dev/null

两个进程每秒读的字节数虽然会不断变动,但是大致趋势还是维持在 1:5 左右,和设定的 weight 比例一致。blkio.weight_device 是分设备的。写入时,前面再加上设备号即可。

blkio 子系统里还有很多统计项

blkio.time
​​​各​​​设​​​备​​​的​​​ io 访​​​问​​​时​​​间,单位毫秒
blkio.sectors
换入​​​者​​​或​​​出​​​各​​​设​​​备​​​的​​​扇​​​区​​​数
blkio.io_serviced
各设​​​备​​​中​​​执​​​行​​​的各类型​​​ io 操​​​作​​​数,分read、​​​write、​​​sync、async 和 total​​​
blkio.io_service_bytes
各类型​​​ io ​​​换入​​​者​​​或​​​出​​​各​​​设​​​备​​​​​​的​​​字​​​节​​​数​​​
blkio.io_service_time
各设​​​备​​​中​​​执​​​行​​​的各类型​​​ io 时间,单位微秒​​​
blkio.io_wait_time
各设​​​备​​​中各类型​​​ io 在队列中的 等待时间​​​
blkio.io_merged
各设​​​备​​​中各类型​​​ io 请求合并的次数​​​
blkio.io_queued
各设​​​备​​​中各类型​​​ io 请求当前在队列中的数量​​​

通过这些统计项更好地统计、监控进程的 io 情况

echo 1 >blkio.reset_stats

可以将所有统计项清零。

用 cgroups 管理 cpu 资源

这回说说怎样通过 cgroups 来管理 cpu 资源。先说控制进程的 cpu 使用。在一个机器上运行多个可能消耗大量资源的程序时,我们不希望出现某个程序占据了所有的资源,导致其他程序无法正常运行,或者造成系统假死无法维护。这时候用 cgroups 就可以很好地控制进程的资源占用。这里单说 cpu 资源。

cgroups 里,可以用 cpu.cfs_period_us 和 cpu.cfs_quota_us 来限制该组中的所有进程在单位时间里可以使用的 cpu 时间。这里的 cfs 是完全公平调度器的缩写。cpu.cfs_period_us 就是时间周期,默认为 100000,即百毫秒。cpu.cfs_quota_us 就是在这期间内可使用的 cpu 时间,默认 -1,即无限制。

跑一个耗 cpu 的程序

# echo 'while True: pass'|python &
[1] 1532

top 一下可以看到,这进程占了 100% 的 cpu

  PID USER      PR  NI  VIRT  RES  SHR S %CPU %MEM    TIME+  COMMAND
 1532 root      20   0  112m 3684 1708 R 99.6  0.7   0:30.42 python
...

然后就来对这个进程做一下限制。先把 /foo 这个控制组的限制修改一下,然后把进程加入进去。

echo 50000 >/sys/fs/cgroup/cpu/foo/cpu.cfs_quota_us
echo 1532 >/sys/fs/group/cpu/foo/tasks

可见,修改设置只需要写入相应文件,将进程加入 cgroup 也只需将 pid 写入到其中的 tasks 文件即可。这里将 cpu.cfs_quota_us 设为 50000,相对于 cpu.cfs_period_us 的 100000 即 50%。再 top 一下看看效果。

  PID USER      PR  NI  VIRT  RES  SHR S %CPU %MEM    TIME+  COMMAND
 1532 root      20   0  112m 3684 1708 R 50.2  0.7   5:00.31 python
...

可以看到,进程的 cpu 占用已经被成功地限制到了 50% 。这里,测试的虚拟机只有一个核心。在多核情况下,看到的值会不一样。另外,cfs_quota_us 也是可以大于 cfs_period_us 的,这主要是对于多核情况。有 n 个核时,一个控制组中的进程自然最多就能用到 n 倍的 cpu 时间。

这两个值在 cgroups 层次中是有限制的,下层的资源不能超过上层。具体的说,就是下层的 cpu.cfs_period_us 值不能小于上层的值,cpu.cfs_quota_us 值不能大于上层的值。

另外的一组 cpu.rt_period_us、cpu.rt_runtime_us 对应的是实时进程的限制,平时可能不会有机会用到。

在 cpu 子系统中,cpu.stat 就是用前面那种方法做的资源限制的统计了。nr_periods、nr_throttled 就是总共经过的周期,和其中受限制的周期。throttled_time 就是总共被控制组掐掉的 cpu 使用时间。

还有个 cpu.shares, 它也是用来限制 cpu 使用的。但是与 cpu.cfs_quota_us、cpu.cfs_period_us 有挺大区别。cpu.shares 不是限制进程能使用的绝对的 cpu 时间,而是控制各个组之间的配额。比如

/cpu/cpu.shares : 1024
/cpu/foo/cpu.shares : 2048

那么当两个组中的进程都满负荷运行时,/foo 中的进程所能占用的 cpu 就是 / 中的进程的两倍。如果再建一个 /foo/bar 的 cpu.shares 也是 1024,且也有满负荷运行的进程,那 /、/foo、/foo/bar 的 cpu 占用比就是 1:2:1 。前面说的是各自都跑满的情况。如果其他控制组中的进程闲着,那某一个组的进程完全可以用满全部 cpu。可见通常情况下,这种方式在保证公平的情况下能更充分利用资源。

此外,还可以限定进程可以使用哪些 cpu 核心。cpuset 子系统就是处理进程可以使用的 cpu 核心和内存节点,以及其他一些相关配置。这部分的很多配置都和 NUMA 有关。其中 cpuset.cpus、cpuset.mems 就是用来限制进程可以使用的 cpu 核心和内存节点的。这两个参数中 cpu 核心、内存节点都用 id 表示,之间用 “,” 分隔。比如 0,1,2 。也可以用 “-” 表示范围,如 0-3 。两者可以结合起来用。如“0-2,6,7”。在添加进程前,cpuset.cpus、cpuset.mems 必须同时设置,而且必须是兼容的,否则会出错。例如

# echo 0 >/sys/fs/cgroup/cpuset/foo/cpuset.cpus
# echo 0 >/sys/fs/cgroup/cpuset/foo/cpuset.mems

这样, /foo 中的进程只能使用 cpu0 和内存节点0。用

# cat /proc/<pid>/status|grep '_allowed_list'

可以验证效果。

cgroups 除了用来限制资源使用外,还有资源统计的功能。做云计算的计费就可以用到它。有一个 cpuacct 子系统专门用来做 cpu 资源统计。cpuacct.stat 统计了该控制组中进程用户态和内核态的 cpu 使用量,单位是 USER_HZ,也就是 jiffies、cpu 滴答数。每秒的滴答数可以用 getconf CLK_TCK 来获取,通常是 100。将看到的值除以这个值就可以换算成秒。

cpuacct.usage 和 cpuacct.usage_percpu 是该控制组中进程消耗的 cpu 时间,单位是纳秒。后者是分 cpu 统计的。

P.S. 2014-4-22

发现在 SLES 11 sp2、sp3 ,对应内核版本 3.0.13、 3.0.76 中,对 cpu 子系统,将 pid 写入 cgroup.procs 不会实际生效,要写入 tasks 才行。在其他环境中,更高版本或更低版本内核上均未发现。

linux cgroups 概述

从 2.6.24 版本开始,linux 内核提供了一个叫做 cgroups(控制组)的特性。cgroups 就是 control groups 的缩写,用来对一组进程所占用的资源做限制、统计、隔离。也是目前轻量级虚拟化技术 lxc (linux container)的基础之一。每一组进程就是一个控制组,也就是一个 cgroup。cgroups 分为几个子系统,每个子系统代表一种设施或者说是资源控制器,用来调度某一类资源的使用,如 cpu 时钟、内存、块设备 等。在实现上,cgroups 并没有增加新的系统调用,而是表现为一个 cgroup 文件系统,可以把一个或多个子系统挂载到某个目录。如

mount -t cgroup -o cpu cpu /sys/fs/cgroup/cpu

就将 cpu 子系统挂载在了 /sys/fs/cgroup/cpu 。也可以在一个目录上挂载多个子系统,甚至全部挂载到一个目录也是可以的,不过我觉得,把每个子系统都挂载在不同目录会有更好的灵活性。用 mount|awk '$5=="cgroup" {print $0}' 可以看到当前挂载的控制组。用 cat /proc/cgroups 可以看到当前所有控制组的状态。下面这个脚本,可以把全部子系统各种挂载到各自的目录上去。

#!/bin/bash

cgroot="${1:-/sys/fs/cgroup}"
subsys="${2:-blkio cpu cpuacct cpuset devices freezer memory net_cls net_prio ns perf_event}"

mount -t tmpfs cgroup_root "${cgroot}"
for ss in $subsys; do
  mkdir -p "$cgroot/$ss"
  mount -t cgroup -o "$ss" "$ss" "$cgroot/$ss"
done

看看那些目录里都有些啥,比如 ls 一下 /sys/fs/cgroup/cpu。

cgroup.event_control  cpu.cfs_period_us  cpu.rt_period_us   cpu.shares  notify_on_release  tasks
cgroup.procs          cpu.cfs_quota_us   cpu.rt_runtime_us  cpu.stat    release_agent

其中 “cpu.” 开头的就是这个子系统里特有的东西。其他的那些是每个子系统所对应目录里都有的。这些文件就是用来读取资源使用信息和进行资源限制的。要创建一个控制组,就在需要的子系统里创建一个目录即可。如 mkdir /sys/fs/cgroup/cpu/foo 就创建了一个 /foo 的控制组。在新建的目录里就会出现同样一套文件。在这个目录里,也一样可以继续通过创建目录来创建 cgroup。也就是说,cgroup 是可以和目录结构一样有层次的。对与每个子系统挂载点点目录,就相当于根目录。每一条不同的路径就代表了一个不同的 cgroup。在不同的子系统里,路径相同就代表了同一个控制组。如,在 cpu、memory 中都有 foo/bar 目录,就可以用 那 /foo/bar 来操作 cpu、memory 两个子系统。对于同一个子系统,每个进程都属于且只属于一个 cgroup,默认是在根 cgroup。层次结构方便了控制组的组织和管理,对于某些配置项来说,层次结构还和资源分配有关。另外,也可以修改某个目录的 owner ,让非 root 用户也能操作某些特定的安全组。

cgroups 的设置和信息读取是通过对那些文件的读写来进行的。例如

# echo 2048 >/sys/fs/cgroup/cpu/foo/cpu.shares

就把 /foo 这个控制组的 cpu.shares 参数设为了 2048。

前面说,有些文件是每个目录里共有的。那些就是通用的设置。其中,tasks 和 cgroups.procs 是用来管理控制组中的进程的。要把一个进程加入到某个控制组,把 pid 写入到相应目录的 tasks 文件即可。如

# echo 5678 >/sys/fs/cgroup/cpu/foo/tasks

就把 5678 进程加入到了 /foo 控制组。那么 tasks 和 cgroups.procs 有什么区别呢?前面说的对“进程”的管理限制其实不够准确。系统对任务调度的单位是线程。在这里,tasks 中看到的就是线程 id。而 cgroups.procs 中是线程组 id,也就是一般所说的进程 id 。将一个一般的 pid 写入到 tasks 中,只有这个 pid 对应的线程,以及由它产生的其他进程、线程会属于这个控制组,原有的其他线程则不会。而写入 cgroups.procs 会把当前所有的线程都加入进去。如果写入 cgroups.procs 的不是一个线程组 id,而是一个一般的线程 id,那会自动找到所对应的线程组 id 加入进去。进程在加入一个控制组后,控制组所对应的限制会即时生效。想知道一个进程属于哪些控制组,可以通过 cat /proc/<pid>/cgroup 查看。

要把进程移出控制组,把 pid 写入到根 cgroup 的 tasks 文件即可。因为每个进程都属于且只属于一个 cgroup,加入到新的 cgroup 后,原有关系也就解除了。要删除一个 cgroup,可以用 rmdir 删除相应目录。不过在删除前,必须先让其中的进程全部退出,对应子系统的资源都已经释放,否则是无法删除的。

前面都是通过文件系统访问方式来操作 cgroups 的。实际上,也有一组命令行工具。

lssubsys -am 可以查看各子系统的挂载点,还有一组“cg”开头的命令可以用来管理。其中 cgexec 可以用来直接在某些子系统中的指定控制组运行一个程序。如 cgexec -g "cpu,blkio:/foo" bash 。其他的命令和具体的参数可以通过 man 来查看。

下面是个 bash 版的 cgexec,演示了 cgroups 的用法,也可以在不确定是否安装命令行工具的情况下使用。

#!/bin/bash

# usage:
# ./cgexec.sh cpu:g1,memory:g2/g21 sleep 100

blkio_dir="/sys/fs/cgroup/blkio"
memory_dir="/sys/fs/cgroup/memory"
cpuset_dir="/sys/fs/cgroup/cpuset"
perf_event_dir="/sys/fs/cgroup/perf_event"
freezer_dir="/sys/fs/cgroup/freezer"
net_cls_dir="/sys/fs/cgroup/net_cls"
cpuacct_dir="/sys/fs/cgroup/cpuacct"
cpu_dir="/sys/fs/cgroup/cpu"
hugetlb_dir="/sys/fs/cgroup/hugetlb"
devices_dir="/sys/fs/cgroup/devices"

groups="$1"
shift

IFS=',' g_arr=($groups)
for g in ${g_arr[@]}; do
  IFS=':' g_info=($g)
  if [ ${#g_info[@]} -ne 2 ]; then
    echo "bad arg $g" >&2
    continue
  fi
  g_name=${g_info[0]}
  g_path=${g_info[1]}
  if [ "$g_path" == "${g_path#/}" ]; then
    g_path="/$g_path"
  fi
  echo $g_name $g_path
  var="${g_name}_dir"
  d=${!var}
  if [ -z "$d" ]; then
    echo "bad cg name $g_name" >&2
    continue
  fi
  path="${d}${g_path}"
  if [ ! -d "$path" ]; then
    echo "cg not exists" >&2
    continue
  fi
  echo "$$" >"${path}/tasks"
done

exec $*

cgroups 中的东西很多,本来打算只写一篇的,后来觉着还是分成几篇说得更明白些。之后还会写一些具体使用的东西。

参考资料:
cgroups docs – kernel.org
Resource Management Guide – redhat.com
How I Used CGroups to Manage System Resources – oracle.com