cgroup cpu子系统

概述

cgroup 全名是 control groups,在 linux 上负责对进程的一系列资源进行管控。比如 CPU,Memory,Huge Pages 等。cgroup 下通过子系统(subsystem)来划分模块,每种资源都通过一个子系统来实现。

cgroup 通过文件系统的方式对外提供调用,并可以用层级的方式进行组合。这种层级通过文件系统目录的方式进行呈现。比如在 cgroup cpu 目录下创建子目录,就相当于在根 cpu cgroup 下创建了一个子 cgroup。并且子 cgroup 会继承父 cgroup 的限制。

cgroup 目前有两个版本:v1 和 v2,并且两个版本的设计差异较大。但是理念类似,因此即使版本不同,也可以一样来理解。下面会以 cgroup v1 cpu 子系统进行讲解。

cpu 子系统的使用

cgroup 描述起来一直是一个比较抽象的概念。下面用一个简单的例子来帮助认识 cgroup 是如何工作的。

首先在机器上启动一个 stress 进程,分配一个 cpu,然后查看该进程 cpu 占用情况:

$ stress -c 1

$ pidstat -p 480164 1
Linux 4.14.81.bm.26-amd64 (n251-254-159)    06/01/2021  _x86_64_    (8 CPU)
02:36:56 PM   UID       PID    %usr %system  %guest    %CPU   CPU  Command
02:36:57 PM  1001    480164  100.00    0.00    0.00  100.00     6  stress

可以看到,stress 进程已经占用了 1 cpu。现在我们创建一个名叫 stress 的 cgroup 来限制 cpu:

$ cd /sys/fs/cgroup/cpu

$ mkdir stress && cd stress

# 将 pid 写入到 cgroup.procs 中,就等同于将这个进程移到该 cgroup 中
$ echo 480164 > cgroup.procs

$ echo 100000 > cpu.cfs_period_us

$ echo 50000 > cpu.cfs_quota_us

# 再看看当前的 CPU 占用
$ pidstat -p 480164 1
Linux 4.14.81.bm.26-amd64 (n251-254-159)    06/04/2021  _x86_64_    (8 CPU)

05:17:49 AM   UID       PID    %usr %system  %guest    %CPU   CPU  Command
05:17:50 AM  1001   480164   50.00    0.00    0.00   50.00     6  stress

上述操作通过配置 cpu.cfs_period_uscpu.cfs_quota_us 参数达到了限制进程使用 CPU 的目的。

cgroup 还提供了一个 cpu.shares 参数,当 CPU 资源繁忙时,这个参数可以配置进程使用 CPU 的权重。下面我们在 cpu 为 1 的虚拟机演示。 在 cgroup 下创建两个子 cgroup 来展示这个参数的效果。

$ cd /sys/fs/cgroup/cpu,cpuacct
$ mkdir stress1 && cd stress1
$ stress -c 1
$ echo 3475127 > cgroup.procs
$ echo 1024 > cpu.shares

此时 PID 3475127 的 stress 进程 CPU 占用率接近 100%。在新的终端中执行以下命令:

$ mkdir stress2 && cd stress2
$ stress -c 1
$ echo 3479833 > cgroup.procs

此时两个 stress 进程的 CPU 占用大致相等,接近 50%。因为 stress2 cgroup 中没有设置 cpu.shares,所以取默认值为 1024。现在设置 stress2 cgroup 的 cpu.shares 参数:

$ echo 512 > cpu.shares

# 使用 top 查看
    PID USER      PR  NI    VIRT    RES    SHR S  %CPU  %MEM
  3475127 root      20   0    7948     96      0 R  65.1   0.0
  3479833 root      20   0    7948     92      0 R  32.2   0.0

stress1 中的进程 CPU 占用率大概是 stress2 中的两倍。这是因为 stress1 中 cpu.shares 的值是 stress2 中的两倍。当然上述情况必须在 CPU 资源不够时,cpu.shares 才会起作用。如果这是一个 2 cpu 的虚拟机,那么 stress1 和 stress2 都会占用 100%。

参数说明

上述出现了一些 cpu 的参数,这里统一解释一下:

  • cpu.cfs_period_us: 重新分配 CPU 资源的时间周期长度,单位是 us。cfs 是 linux 进程调度器的一种,全称为完全公平调度器。因此这个参数只针对使用 cfs 调度的进程。

  • cpu.cfs_quota_us: 进程在设置的时间周期长度内,可以使用的 CPU 时间上限。结合 cpu.cfs_period_us 就可以限制一个进程可以使用的总 CPU 时间了。计算方式为 (cpu.cfs_quota_us / cpu.cfs_period_us)*count(cpu)。这个参数只针对使用 cfs 调度的进程。

  • cpu.shares: 这个参数只有在 CPU 资源忙时才生效,它可以用来设置进程使用的 CPU 权重。上面的例子中,虚拟机只有 1 CPU,进程 1,2 都会占用一个 CPU,因此根据设置进程 1 的 cpu.shares 为 1024,进程 2 的 cpu.shares 为 512,就可以将 2/3 的 cpu 分配给进程 1,1/3 的 cpu 分配给进程 2 了。

除了上述例子中的几个参数,cgroup cpu 子系统还提供了以下的参数:

  • cpu.rt_period_us: 重新分配 CPU 资源的时间周期长度。 针对使用了实时调度器的进程
  • cpu.rt_runtime_us: 进程在设置的时间周期长度内,可以使用的 CPU 时间上限。这个和上面说的 cfs 的两个参数类似。
  • cpu.nr_periods: 这是一个统计参数。用来表示已经过去的 cpu 周期数(使用 cpu.cfs_period_us 来指定)
  • cpu.nr_throttled: cgroup 中进程被限制的次数(因为这些进程用完了分配的 cpu 时间)。
  • cpu.throttled_time: cgroup 中进程被限制的总时间(单位是 ns)。

参考

Linux进程调度:完全公平调度器CFS

redhat cfs cpu

containerd CRI 简要分析

概述

Containerd 在 release1.5 之后内置了 cri。通过暴露 CRIService 供 kubelet 调用。CRI 的封装并不复杂,都是利用了 containerd 本身的功能模块。通过 CRI 管理 pod 主要分为三个模块:

  • Sandbox 的管理:RunPodSandbox、StopPodSandbox、RemovePodSandbox、PodSandboxStatus、ListPodSandbox
  • Container 的管理:CreateContainer、StartContainer、StopContainer、RemoveContainer、ListContainers、UpdateContainerResources、ContainerStats、ListContainerStats、UpdateRuntimeConfig、Status
  • 容器其他方面的管理:ReopenContainerLog、ExecSync、Exec、Attach、PortForward

Sandbox 的管理

Sandbox 和 Container 在实现上类似,通过启动一个特殊的 pause 容器来创建一个沙箱环境。通常情况下,这个沙箱环境具有和主机隔离的 pid,uts,mount,network,ipc,user。

以 RunPodSandbox 为例,containerd 会执行以下操作:

  1. Containerd CRI 在 RunPodSandbox 时,会先保证 sandbox 的镜像是否存在。如果不存在则会进行 pull 操作。
  2. 创建 pod network namespace,并调用 CNI
  3. 使用 container task 来创建容器
  4. 启动 container task,也就是执行二进制文件 pause
  5. 更新 sandbox 的状态,包括 pid 置为 task pid,state 置为 ready 以及更新创建时间。
  6. 在新的 goroutine 中启动 sandbox exit monitoring。这样就可以在 sandbox 进程退出时,执行清理工作。然后更新 sandbox 的状态。

以 StopPodSandbox 为例,containerd 会执行以下操作:

  1. 使用 containerStore 来 list 出所有 container,并使用 container.SandboxID 过滤出属于当期 sandbox 的 container
  2. 依次停止 sandbox 下的 container
  3. 清理 sandbox 的文件,比如 unmount dev shm
  4. 如果 sandbox container(pause) 的状态是 Ready 或 Unknown,使用 SiGKILL 终止 sandbox container
  5. 调用 cni 清理 network namespace,然后移除。

Container 管理

在 sandbox 创建好之后,kubelet 就可以通过 CRI 来创建 container 了。CreateContainer 的逻辑如下:

  1. 获取 sandbox 的信息,后面创建的容器需要使用和 sandbox 同样的 runtime,namespace。
  2. 获取 container mounts,包括 /etc/hosts/etc/resolv.condev shm。然后设置
  3. 设置 container log path。
  4. 设置 container io,包括 stdin, stdout, stderr, terminal。
  5. 在 container store(metadata) 中创建容器记录。此时 container 处于 CREATED 状态。

创建好 container 后,调用 StartContainer 即可运行容器。步骤如下:

  1. 更新 container 状态为 Running,防止重复 start。
  2. 设置 container stdout,stderr 到 log 文件上。
  3. 启动 task 来运行 container 进程
  4. 更新 container status 的 Pid 和 StartedAt
  5. 在新的 goroutine 中启动 sandbox exit monitoring 来监控 container 的进程状态。

StopContainer 的步骤如下:

  1. 如果 container 设置了 stop timeout,使用 task.Kill 发送 SIGTERM 信号。然后等待 timeout 时间
  2. 使用 task.Kill 发送 SIGKILL 信号。

RemoveConrtainer 的步骤如下:

  1. 如果当前 container 处于 RUNNING 或者 UNKNOWN 状态,则使用 timeout 为 0 的 stopContainer 来强制停止容器。
  2. 设置 container 的状态为 removing。
  3. 从 store 中删除 container,checkpoint 以及一些缓存信息。

容器管理

Containerd CRI 除了实现了 sandbox 和 container 的生命周期管理,也提供了对容器其他方面的管理。比如:

  1. 在 kubelet 对 logfile rotate 之后,调用 ReopenContainerLog 来将 container log 输出到新的日志文件中
  2. 通过 GRPC 提供 GetAttach,返回 attach http endpoint 和 token,供 kubelet 通过 http stream 连接到 process 的 stdin,stdout 和 stderr 上。
  3. 通过 GRPC 提供 GetExec,返回 exec http endpoint 和 token,供 kubelet 通过 http stream 在容器命名空间内执行命令。
  4. 通过 GRPC 提供 GetPortforward,返回 portforward http endpoint 和 token。kubelet 通过 http stream 连接上后,使用 netns 在 sandbox network namespace 下 dial 容器内的端口,使用 io.Copy 转发输入输出流。