vlambda博客
学习文章列表

深入了解 Linux 容器调度

微服务从传统虚拟机迁移到在 Kubernetes 上运行的 Docker 容器是大势所趋。Docker 容器是完整的可交付软件包和依赖项,通常可以被认为是轻量级虚拟机。虽然这可能是一个非常方便的简化,但了解容器是如何使用 Linux 控制组 (cgroup) 和命名空间实现的很重要。了解这些特性和限制有助于我们提高服务的性能,尤其是在性能压力较大的情况下。

docker 容器实现原理

在主机上运行的所有容器最终共享相同的内核和资源。事实上,Docker 容器在 Linux 中并不是一个首创的概念,而只是一组进程,属于 Linux 命名空间和控制组(cgroups)组成。cgroup 主要提供资源隔离机制,例如 CPU、内存、磁盘和网络带宽可以被这些 cgroup 限制。命名空间用于限制进程对系统其余部分的可见性。通过使用 ipc、mnt、net、pid、user、cgroups 和 uts 命名空间子系统,cgroups 命名空间实际上是用来限制 cgroups 的视图的,cgroups 本身并不是命名空间。

任何未明确分配给 cgroup 的进程都会自动包含在根 cgroup 中。在 CentOS Linux 发行版上,根 cgroup 和任何子 cgroup 作为可变文件系统挂载在/sys/fs/cgroup. (如果您在不同的 Linux 发行版上,可以使用 mount 检查。)具有足够权限的用户可以使用基本的 shell 命令或 libcgroup-tools 提供的高级实用程序轻松创建 cgroup、修改它们或将任务移动到它们的安装包。特别有意思的是安装在子系统/sys/fs/cgroup/cpu, cpuacct的 cpu 和 cpuacctcgroup. cpuacct 子系统很简单——它只收集 CPU 运行时信息。但是,cpu 子系统使用完全公平调度器 (CFS)(Linux 和 Docker 上的默认设置)或实时调度器 (RT) 来调度对每个 cgroup 的 CPU 访问。

当我们运行 Docker 容器镜像quay.io/klynch/java-simple-http-server时,Docker 守护进程会创建一个容器并在其中生成一个 Java 进程。容器31dbff344530a41c11875346e3a2eb3c1630466173cf9f1e8bca498182c0c267 由 Docker 守护程序分配一个唯一标识符,稍后将用于标记和识别构成容器的各种组件。然而,这个标识符对内核没有实际意义。

默认情况下,Docker 会为这个容器创建一个 pid 命名空间,将进程与其他命名空间隔离开来;Java 进程在执行之前附加到这个新的 pid 命名空间,并由 Linux 内核分配 PID 1。然而,这个进程并不完全与系统上的其他进程隔离开来。因为 PID 命名空间是嵌套的,所以除了初始根命名空间之外的每个命名空间都有一个父命名空间。在命名空间中运行的进程可以看到子 pid 命名空间的所有进程。这意味着在根命名空间中运行的进程,例如我们的 shell,可以看到系统上运行的所有进程。在我们的示例中,我们可以看到 java 进程具有 PID 30968。我们还可以看到我们的进程被分配到的 cgroup 和命名空间.

# cat /proc/30968/cgroup
11:cpuacct,cpu:/docker/31dbff344530a41c11875346e3a2eb3c1630466173cf9f1e8bca498182c0c267
10:net_prio,net_cls:/docker/31dbff344530a41c11875346e3a2eb3c1630466173cf9f1e8bca498182c0c267
9:freezer:/docker/31dbff344530a41c11875346e3a2eb3c1630466173cf9f1e8bca498182c0c267
8:memory:/docker/31dbff344530a41c11875346e3a2eb3c1630466173cf9f1e8bca498182c0c267
7:pids:/docker/31dbff344530a41c11875346e3a2eb3c1630466173cf9f1e8bca498182c0c267
6:perf_event:/docker/31dbff344530a41c11875346e3a2eb3c1630466173cf9f1e8bca498182c0c267
5:devices:/docker/31dbff344530a41c11875346e3a2eb3c1630466173cf9f1e8bca498182c0c267
4:blkio:/docker/31dbff344530a41c11875346e3a2eb3c1630466173cf9f1e8bca498182c0c267
3:cpuset:/docker/31dbff344530a41c11875346e3a2eb3c1630466173cf9f1e8bca498182c0c267
2:hugetlb:/docker/31dbff344530a41c11875346e3a2eb3c1630466173cf9f1e8bca498182c0c267
1:name=systemd:/docker/31dbff344530a41c11875346e3a2eb3c1630466173cf9f1e8bca498182c0c267
# ls -l /proc/30968/ns/*
lrwxrwxrwx 1 root root 0 Jun  7 14:16 ipc -> ipc:[4026532461]
lrwxrwxrwx 1 root root 0 Jun  7 14:16 mnt -> mnt:[4026532459]
lrwxrwxrwx 1 root root 0 Jun  7 15:41 net -> net:[4026531956]
lrwxrwxrwx 1 root root 0 Jun  7 14:16 pid -> pid:[4026532462]
lrwxrwxrwx 1 root root 0 Jun  7 15:41 user -> user:[4026531837]
lrwxrwxrwx 1 root root 0 Jun  7 14:16 uts -> uts:[4026532460]

我们还可以通过在文件中查找我们的 pid/sys/fs/cgroup/cpu,cpuacct/docker/31dbff344530a41c11875346e3a2eb3c16304661 73cf9f1e8bca498182c0c267/tasks或运行命令 systemd-cgls 并搜索有问题的进程来验证进程的容器。但是,这并不能告诉我们我们的进程在容器内部映射到什么!您可以通过查看进程 status 文件来访问它。不幸的是,这是在尚未向后移植到 CentOS 7.3 内核的内核补丁中引入的。但是,在实践中,识别容器内的适当进程应该很简单。以下命令向我们展示了我们的进程映射到其命名空间内的 PID 1。

# grep NSpid /proc/30968/status
NSpid:  30968    1

我们可以验证我们的进程名称空间内部的视图是否有点不同。我们可以使用该 docker exec 命令运行交互式 shell,前提是我们的容器有一个用于 shell 的二进制文件。在大多数情况下,此命令是比 nsenter 程序更简单的解决方案。运行 exec 后,您将看到一个 shell 提示,它与我们的 java 进程共享相同的命名空间,包括 pid 命名空间。

# docker exec -it java-http bash

# ps aux
USER       PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
root         1  5.8 18.6 4680796 724080 ?      Ssl  05:10  41:51 java SimpleHTTPServer

同样,cgroup 命名空间仅限于容器的 cgroup,进一步将我们的进程与系统上运行的其他进程隔离开来。这使我们可以直接访问放置在容器上的任何潜在约束。例如,我们可以直接从容器中访问 CPU 调度和使用情况。

# ls -l /sys/fs/cgroup/cpuacct,cpu
-rw-r--r-- 1 root root 0 Jun  7 05:10 cgroup.clone_children
--w--w--w- 1 root root 0 Jun  7 05:10 cgroup.event_control
-rw-r--r-- 1 root root 0 Jun  7 17:04 cgroup.procs
-rw-r--r-- 1 root root 0 Jun  7 05:10 cpu.cfs_period_us
-rw-r--r-- 1 root root 0 Jun  7 05:10 cpu.cfs_quota_us
-rw-r--r-- 1 root root 0 Jun  7 05:10 cpu.rt_period_us
-rw-r--r-- 1 root root 0 Jun  7 05:10 cpu.rt_runtime_us
-rw-r--r-- 1 root root 0 Jun  7 05:10 cpu.shares
-r--r--r-- 1 root root 0 Jun  7 05:10 cpu.stat
-r--r--r-- 1 root root 0 Jun  7 05:10 cpuacct.stat
-rw-r--r-- 1 root root 0 Jun  7 05:10 cpuacct.usage
-r--r--r-- 1 root root 0 Jun  7 05:10 cpuacct.usage_percpu
-rw-r--r-- 1 root root 0 Jun  7 05:10 notify_on_release
-rw-r--r-- 1 root root 0 Jun  7 05:10 tasks

调度

当我们将容器视为轻量级虚拟机时,很自然地会根据处理器数量等离散资源来考虑资源。然而,Linux 内核动态调度进程,就像管理程序将请求调度到离散硬件上一样。Linux 和 Docker 中使用的默认调度程序是 CFS,即完全公平调度程序。在 CFS 中调度 cgroup 需要我们考虑时间片而不是处理器数量。cgroup 子系统负责调度,cpu 可以调整以支持相对最少的资源以及用于限制进程使用比预置资源更多的资源上添加强制措施。这种调节 CPU 资源占用的方式看起来让人很困惑。

CPU 份额- share

CPU 份额为 cgroup 中的任务提供了相对数量的 CPU 时间,从而为任务提供了运行机会。该文件 cpu.shares 定义分配给 cgroup 的共享数量。分配给给定 cgroup 的时间量是份额数除以可用份额总数。此比例分配是针对 cgroup 层次结构中的每个级别计算的。在 CentOS 中,这从具有 1024 个份额和 100% 的 CPU 资源的 root / cgroup 开始。根 cgroup 通常仅限于少数关键用户态内核进程和初始 SystemD 进程。然后,其余资源在组/system.slice(系统服务)、/user.slice(用户进程)和/docker(Docker 容器)之间平均提供,每个组的默认权重为 1024。

在最小的 CentOS 安装中,我们通常可以忽略系统服务和用户进程的影响。这将允许调度程序向 /docker 组提供与每个容器的份额成比例的几乎所有 CPU 时间。如果在四核系统上存在三个权重分别为 2048、1024 和 1024 的容器,则第一个 cgroup 将被分配相当于两个核心,其余两个 cgroup 将分别被分配一个核心。如果一个 cgroup 中的所有任务都处于空闲状态并且没有等待运行,那么任何未使用的份额都会被放置在一个全局池中以供其他任务使用。因此,如果第一个 cgroup 中有单个任务,则未使用的份额将被放回全局池中。

CPU 配额- quota

虽然 CPU 份额无法在不完全控制系统的情况下保证最少的 CPU 时间,但对分配给进程的 CPU 时间实施硬限制。

引入是为了防止任务超过给定 cgroup 的总分配 CPU 时间。默认情况下,对 cpu.cfs_quota_us 设置为 –1 的 cgroup 禁用配额。如果启用,CFS 配额 cpu.cfs_quota_us 将允许组在微秒内最多执行 cpu.cfs_period_us 微秒(默认为 100 毫秒)。如果一个组的任务不受限制,他们将被允许使用主机上尽可能多的未使用资源。通过调整 cgroup 相对于时间段的配额,我们可以有效地将整个核心分配给一个组!100 毫秒的配额。将允许该组中的任务运行总共 100 毫秒。在整个 100 毫秒窗口内,我们可以很容易地计算出我们可用的核心数量,方法是 cpu.cfs_quota_us 除以 cpu.cfs_period_us,使我们能够适当地扩展我们的进程数量!

如果两个任务在不同核上的同一个 cgroup 中执行,每个任务都会贡献配额。如果在仍有任务等待执行的情况下消除了整个配额,则即使主机具有未使用的 CPU 资源,同样也会受到限制。一个 cgroup 被限制的周期数和累计时间量(以纳秒为单位)在 cpu.stat 文件中报告为 nr_throttled 和 throttled_time 统计信息。同样,如果有足够多的任务处于等待状态足够长的时间,我们可能会看到平均负载增加。Netflix Vector 等性能工具可以帮助轻松识别受限制的容器。systemd-cgtop 实用程序还可用于显示每个 cgroup 正在消耗多少资源。

使用配额调度容器时,为进程提供适当的执行时间窗口非常重要。如果一个 cgroup 一直受到限制,它可能没有被分配足够的资源。在运行像 JVM 这样的复杂系统时尤其如此,这些系统对运行它的系统做出了许多假设。因为 JVM 仍然能够看到正在运行的系统上的核心数量,所以它会将垃圾收集器线程的数量调整为主机上的物理核心数量,而不管其配额限制如何。当在 64 核机器上运行 JVM 但将其限制为相当于 2 核时,如果低版本 Java 虚拟机无法识别机器核时,这可能会导致灾难性后果,因为垃圾收集器可能导致应用程序暂停时间超过预期。

我们通过将 JVM 线程的数量限制为最多可用的内核数量来防止我们的容器过早地被限制,并允许我们的应用程序线程有更多的机会执行。我们的基础 Docker 镜像会自动检测容器可用的资源,并在启动时相应地调整 JVM。设置标志-XX:ParallelGCThreads、-XX:ConcGCThreads和-Djava.util.concurrent.ForkJoinPool.common.parallelism防止许多不必要的停顿。但是许多 JVM 组件仍然依赖于Runtime.getRuntime.availableProcessors()返回可用物理内核的数量。为了克服这个问题,我们编译并加载了一个覆盖 JVM 原生函数 JVM_ActiveProcessorCount 并返回我们计算值的 C 库。这使我们能够完全控制限制 JVM 的所有动态扩展问题,而不会造成性能损失。

总结

在这篇文章中,我们研究了 Linux cgroups 如何为 Docker 容器分配和调度资源。因为资源需求是高度可变的,所以通常不可能对资源进行可预测的分区。但是,cgroups 允许我们合理地划分资源并使用完全公平调度器轻松调度基于容器的进程。虽然这篇文章主要关注 cgroups v1 的使用,但重要的是要知道这将在未来发生变化。在 4.5 内核中引入了 cgroups v2 以简化第一个版本的复杂性。此版本删除了层次结构,引入了一种更易于实现和理解的新模型。但是,调度功能仍在制定中,很可能在很长一段时间内都不会引入到 RHEL 内核中。目前,我们必须依靠第一个版本来限制和调度我们的容器。通过了解 cgroup 的运行方式,我们能够适当地调整 JVM 并在一台机器上调度许多 Java 微服务实例,而不会造成任何性能损失。这将使我们能够继续快速地将我们的微服务转换为容器,并大大简化部署过程。

推荐




原创不易,随手关注或者”在看“,诚挚感谢!