容器之 CGroup

2018-09-21 kernel docker

CGroup 是 Control Groups 的缩写,是 Linux 内核提供的一种可以限制进程组使用资源的机制,该项目最早是由 Google 的工程师 (主要时 Paul Menage 和 Rohit Seth) 在 2006 年发起,开始被称为进程容器 (Process Containers)。

在 2007 年,因为 Linux 内核中容器这一名词太过广泛,为避免混乱,重命名为 cgroup ,并且合并到 2.6.24 版本的内核中。

随着其功能逐渐完善,开始作为 LXC、容器等资源隔离机制的基础,这里详细介绍其使用机制。

简介

在系统设计时,经常会遇到类似的需求,就是希望能限制某个或者某些进程的分配资源,由此,逐渐有了容器的概念,在容器中,有分配好的特定比例的 CPU、IO、内存、网络等资源,这就是 controller group ,简称为 cgroup ,最初由 Google 工程师提出,后来被整合进 Linux 内核中。

cgroup 提供了一个虚拟文件系统,作为进行分组管理和各子系统设置的用户接口,使用时必须挂载 cgroup 文件系统,在挂载选项中指定使用哪个子系统。

基本概念

在实现时,cgroups 提供了一个通用的分组框架,不同资源的管理由不同 cgroup 子系统来实现,当需要多个限制策略时,例如同时限制 CPU 和内存,则只需要同时关联多个 cgroup 子系统即可。

为了方便管理,会以层级的方式进行管理,子节点会继承父节点的属性。

使用简介

从 CentOS 7 开始,已经通过 systemd 替换了之前的 cgroup-tools 工具,为了防止两者冲突,建议只使用 systemd ,只有对于一些不支持的,例如 net_prio ,再使用 cgroup-tools 工具。

如果需要使用之前的工具,可以通过 yum install libcgroup libcgroup-tools 安装。

在 CentOS 中,默认会将 cgroup 的根文件系统挂载到 /sys/fs/cgroup/ 目录下。

----- 查看系统已经存在cgroup子系统及其挂载点
# lssubsys -am
----- 或者通过mount查看cgroup类型的挂载点
# mount -t cgroup

----- 可以命令行挂载和卸载子系统,此时再次执行上述命令将看不到memory挂载点
# umount /sys/fs/cgroup/memory/
----- 挂载cgroup的memory子系统,其中最后的cgroup参数是在/proc/mounts中显示的名称
# mount -t cgroup -o memory cgroup /sys/fs/cgroup/memory/
# mount -t cgroup -o memory none /sys/fs/cgroup/memory/

另外,在 CentOS 中有 /etc/cgconfig.conf 配置文件,该文件中可用来配置开机自动启动时挂载的条目:

mount {
    net_prio = /sys/fs/cgroup/net_prio;
}

然后,通过 systemctl restart cgconfig.service 重启服务即可,然后可以通过如下方式使用。

使用步骤

简单介绍如何通过 libcgroup-tools 创建分组并设置资源配置参数。

1. 创建控制组群

可以通过如下方式创建以及删除群组,创建后会在 cpu 挂载目录下 /sys/fs/cgroup/cpu/ 目录下看到一个新的目录 test,这个就是新创建的 cpu 子控制组群。

# cgcreate -g cpu:/test
# cgdelete -g cpu:/test

2. 设置组群参数

cpu.shares 是控制 CPU 的一个属性,更多的属性可以到 /sys/fs/cgroup/cpu 目录下查看,默认值是 1024,值越大,能获得更多的 CPU 时间。

# cgset -r cpu.shares=512 test

3. 将进程添加到控制组群

可以直接将需要执行的命令添加到分组中。

----- 直接在cgroup中执行
# cgexec -g cpu:small some-program
----- 将现有的进程添加到cgroup中
# cgclassify -g subsystems:path_to_cgroups pidlist

例如,想把 sshd 添加到一个分组中,可以通过如下方式操作。

# cgclassify -g cpu:/test `pidof sshd`
# cat /sys/fs/cgroup/cpu/test/tasks

就会看到相应的进程在这个文件中。

CPU

在 CGroup 中,与 CPU 相关的子系统有 cpusets、cpuacct 和 cpu 。

  • cpuset 用于设置 CPU 的亲和性,可以限制该组中的进程只在(或不在)指定的 CPU 上运行,同时还能设置内存的亲和性,一般只会在一些高性能场景使用。
  • 另外两个,cpuaccct 和 cpu 保存在相同的目录下,其中前者用来统计当前组的 CPU 统计信息。

这里简单介绍 cpu 子系统,包括怎么限制 cgroup 的 CPU 使用上限及与其它 cgroup 的相对值。

cpu.cfs_period_us & cpu.cfs_quota_us

其中 cfs_period_us 用来配置时间周期长度;cfs_quota_us 用来配置当前 cgroup 在设置的周期长度内所能使用的 CPU 时间数,两个文件配合起来设置 CPU 的使用上限。

两个文件单位是微秒,cfs_period_us 的取值范围为 [1ms, 1s],默认 100ms ;cfs_quota_us 的取值大于 1ms 即可,如果 cfs_quota_us 的值为 -1(默认值),表示不受 cpu 时间的限制。

下面是几个例子:

----- 1.限制只能使用1个CPU,每100ms能使用100ms的CPU时间
# echo 100000 > cpu.cfs_quota_us
# echo 100000 > cpu.cfs_period_us

------ 2.限制使用2个CPU核,每100ms能使用200ms的CPU时间,即使用两个内核
# echo 200000 > cpu.cfs_quota_us
# echo 100000 > cpu.cfs_period_us

------ 3.限制使用1个CPU的50%,每100ms能使用50ms的CPU时间,即使用一个CPU核心的50%
# echo 50000 > cpu.cfs_quota_us
# echo 100000 > cpu.cfs_period_us

cpu.shares

用于设置相对值,这里针对的是所有 CPU (多核),默认是 1024,假如系统中有两个 A(1024) 和 B(512),那么 A 将获得 1024/(1204+512)=66.67% 的 CPU 资源,而 B 将获得 33% 的 CPU 资源。

对于 shares 有两个特点:

  • 如果A不忙,没有使用到66%的CPU时间,那么剩余的CPU时间将会被系统分配给B,即B的CPU使用率可以超过33%;
  • 添加了一个新的C,它的shares值是1024,那么A和C的限额变为1024/(1204+512+1024)=40%,B的资源变成了20%;

也就是说,在空闲时 shares 基本上不起作用,只有在 CPU 忙的时候起作用。但是这里设置的值是需要与其它系统进行比较,而非设置了一个绝对值。

示例

演示一下如何控制CPU的使用率。

----- 创建并查看当前的分组
# cgcreate -g cpu:/small
# ls /sys/fs/cgroup/cpu/small

----- 查看当前值,默认是1024
# cat /sys/fs/cgroup/cpu/small/cpu.shares
# cgset -r cpu.shares=512 small

----- 执行需要运行的程序,或者将正在运行程序添加到分组
# cgexec -g cpu:small ./foobar
# cgclassify -g cpu:small <PID>

----- 设置只能使用1个cpu的20%的时间
# echo 50000 > cpu.cfs_period_us
# echo 10000 > cpu.cfs_quota_us

----- 将当前bash加入到该cgroup
# echo $$
5456
# echo 5456 > cgroup.procs

----- 启动一个bash内的死循环,正常情况下应该使用100%的cpu,消耗一个核
# while :; do echo test > /dev/null; done

注意,如果是在启动进程之后添加的,实际上 CPU 资源限制的速度会比较慢,不是立即就会限制死的,而且不是严格准确。如果起了多个子进程,那么各个进程之间的资源是共享的。

其它

可以通过如下命令查看进程属于哪个 cgroup 。

# ps -O cgroup
# cat /proc/PID/cgroup

内存

相比来说,内存控制要简单的多,只需要注意物理内存和 SWAP 即可。

----- 创建并查看当前的分组
# cgcreate -g memory:/small
# ls /sys/fs/cgroup/memory/small

----- 查看当前值,默认是一个很大很大的值,设置为1M
# cat /sys/fs/cgroup/memory/small/memory.limit_in_bytes
# cgset -r memory.limit_in_bytes=10485760 small

----- 如果开启了swap之后,会发现实际上内存只限制了RSS,设置时需要确保没有进程在使用
# cgset -r memory.memsw.limit_in_bytes=104857600 small

----- 启动测试程序
# cgexec -g cpu:small -g memory:small ./foobar
# cgexec -g cpu,memory:small ./foobar

可以使用 x="a"; while [ true ];do x=$x$x; done; 命令进行测试。

内存回收

在通过 memory.usage_in_bytes 查询当前 cgroup 的内存资源使用情况时,如果对比当前组中进程的 RSS 资源时,可能会发现,前者要远大于后者。甚至于,当前 cgroup 中已经没有了进程,但是其内存使用量仍然很大。

构造的测试场景如下,其中 cgroup 仍然使用上述创建的组。

$ cat memory.usage_in_bytes
0
$ cgexec -g cpu:small -g memory:small dd if=/dev/sda1 of=/dev/null count=10M
10485760+0 records in
10485760+0 records out
5368709120 bytes (5.4 GB) copied, 33.8483 s, 159 MB/s
$ cat memory.usage_in_bytes
4930969600

实际这与内核处理系统内存时的机制一样,在内存足够的情况下,默认不会自动释放内存。所以,看到的内存使用情况与实际不符。

这样带来的问题时,如果开始设置的内存空间为 1G ,当前使用了 700M (实际 300M),当前如果想限制到 500M ,如果内存不能被回收那么可能会报错。

$ echo 524288000 > memory.limit_in_bytes

上述占用空间较大的是 buffer ,通过上述方式设置是可以被回收掉。

对于上述的场景,如果要回收所有的内存,有两种方式。

----- 释放的是系统的Buffer和Cache
# echo 3 > /proc/sys/vm/drop_caches
----- 需要保证该cgroup组下面没有进程,否者会失败
# echo 0 > memory.force_empty

OOM

当进程试图占用的内存超过了 cgroups 的限制时,会触发 out of memory 导致进程被强制 kill 掉。

----- 关闭默认的OOM
# echo 1 > memory.oom_control
# cgset -r memory.oom_control=1 small

注意,及时关闭了 OOM,对应的进程会处于 uninterruptible sleep 状态。

磁盘

可以通过 blkio 进行设置,不过只能针对设备限速,例如可以设置 /dev/sda 而无法设置具体的分区。

如下是一个示例。

----- 查看并选择其中的一个
# df -h
----- 直接读取某个磁盘分区
# dd if=/dev/sda1 of=/dev/null

可以通过 iotop 查看,因为是单纯的读取,其速度一般可以达到 100M/s 以上。

----- 新建一个cgroup的分组
# mkdir /sys/fs/cgroup/blkio/foobar
# cd /sys/fs/cgroup/blkio/foobar
----- 配置读取某个磁盘的最大速度,也就是1M
# echo '8:0 1048576' > blkio.throttle.read_bps_device

上述配置中的 8:0 为设备的主次设备号,可以通过 ls -l /dev/sda 查看。

在 blkio 中,除了设置,还可以监控 IO 的使用情况,所以大部分的文件都是只读的,可以配置的只有如下的几个。

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

其中 throttle 是用来配置流量限速的,而 weight 则是配置权重信息。blkio 子系统里还有很多统计项,用来监控当前 cgroup 的使用情况。

systemd

CentOS 7 中默认的资源隔离是通过 systemd 进行资源控制的,systemd 内部使用 cgroups 对其下的单元进行资源管理,包括 CPU、BlcokIO 以及 MEM,通过 cgroup 可以 。

systemd 的资源管理主要基于三个单元 service、scope 以及 slice:

  • service 通过 unit 配置文件定义,包括了一个或者多个进程,可以作为整体启停。
  • scope 任意进程可以通过 fork() 方式创建进程,常见的有 session、container 等。
  • slice 按照层级对 service、scope 组织的运行单元,不单独包含进程资源,进程包含在 service 和 scope 中。

常用的 slice 有 A) system.slice,系统服务进程可能是开机启动或者登陆后手动启动的服务,例如crond、mysqld、nginx等服务;B) user.slice 用户登陆后的管理,一般为 session;C) machine.slice 虚机或者容器的管理。

对于 cgroup 默认相关的参数会保存在 /sys/fs/cgroup/ 目录下,当前系统支持的 subsys 可以通过 cat /proc/cgroups 或者 lssubsys 查看。

常见命令

常用命令可以参考如下。

----- 查看slice、scope、service层级关系
# systemd-cgls

----- 当前资源使用情况
# systemd-cgtop

----- 启动一个服务
# systemd-run --unit=name --scope --slice=slice_name command
   unit   用于标示,如果不使用会自动生成一个,通过systemctl会输出;
   scope  默认使用service,该参数指定使用scope ;
   slice  将新启动的service或者scope添加到slice中,默认添加到system.slice,
          也可以添加到已有slice(systemctl -t slice)或者新建一个。
# systemd-run --unit=toptest --slice=test top -b
# systemctl stop toptest

----- 查看当前资源使用状态
$ systemctl show toptest

各服务配置保存在 /usr/lib/systemd/system/ 目录下,可以通过如下命令设置各个服务的参数。

----- 会自动保存到配置文件中做持久化
# systemctl set-property name parameter=value

----- 只临时修改不做持久化
# systemctl set-property --runtime name property=value

----- 设置CPU和内存使用率
# systemctl set-property httpd.service CPUShares=600 MemoryLimit=500M

另外,在 213 版本之后才开始支持 CPUQuota 参数,可直接修改 cpu.cfs_{quota,period}_us 参数,也就是在 /sys/fs/cgroup/cpu/ 目录下。

其它

一个自动挂载的脚本。

#!/bin/bash

CGROUP_PATH="/tmp/cgroup"
CGROUP_SUBSYS="cpu memory cpuacct cpuset freezer net_cls blkio devices"
MOUNTS_PATH="/proc/mounts"
FILESYSTEMS_PATH="/proc/filesystems"

CreateCGroup() {
        for sys in ${CGROUP_SUBSYS}; do
                if [ ! -d "${CGROUP_PATH}/${sys}" ]; then
                        mkdir -p "${CGROUP_PATH}/${sys}"
                        if [ $? -ne 0 ]; then
                                echo "Create directory '${CGROUP_PATH}/${sys}' failed." 2>&1
                                exit 1
                        fi
                        echo "Create directory '${CGROUP_PATH}/${sys}' done."
                fi
                mount -t cgroup -o "${sys},relatime" cgroup "${CGROUP_PATH}/${sys}"
                if [ $? -ne 0 ]; then
                        echo "Mount subsys ${sys} to '${CGROUP_PATH}/${sys}' failed."
                        exit 1
                fi
        done
}

if [ ! -f "${FILESYSTEMS_PATH}" ]; then
        echo "File '${FILESYSTEMS_PATH}' doesn't exist." 2>&1
        exit 1
fi

if [ `grep -c '\<cgroup$' ${FILESYSTEMS_PATH}` -eq '0' ]; then
        echo "Filesystem 'cgroup' doesn't support on current platform." 2>&1
        exit 1
fi

if [ ! -f "${MOUNTS_PATH}" ]; then
        echo "File '${MOUNTS_PATH}' doesn't exist." 2>&1
        exit 1
fi

if [ `grep -c '^cgroup\>' ${MOUNTS_PATH}` -eq '0' ]; then
        #echo "Filesystem 'cgroup' hasn't mounted."
        CreateCGroup
else
        #echo "Filesystem 'cgroup' has mounted."
        if [ `grep -cE '^cgroup\>.*(\<cpu\>|\<memory\>)' ${MOUNTS_PATH}` -ne '2' ]; then
                echo "Invalid cgroup subsys 'cpu' 'memory'." 2>&1
                exit 1
        fi
        info=`grep -E '^cgroup\>.*\<memory\>' /proc/mounts | awk '{ print $2 }'`
        CGROUP_PATH=`dirname ${info}`
fi
echo ${CGROUP_PATH}

参考

关于 systemd 的资源控制,详细可以通过 man 5 systemd.resource-control 命令查看帮助,或者查看 systemd.resource-control 中文手册;详细的内容可以参考 Resource Management Guide