容器技术回顾 - Kubernetes CPU request 和 limit 的作用与原理 一文中讲解了 CPU request 和 limit 的作用与原理,今天正好看到一篇文章,内容更加详细,故翻译过来,供大家学习!
随着 Kubernetes 和容器的兴起和流行,许多团队不仅直接开发和部署新应用到 Kubernetes,还需要将现有应用迁移到容器。这些遗留应用通常部署在裸机或虚拟机 (VM) 上。
容器强调“构建一次,到处运行”,允许开发和运维团队以更轻量级和系统化的方式管理应用程序。然而,我们经常观察到,将应用原样迁移到 Kubernetes 后,其性能可能达不到预期。
本文主要从CPU的角度探讨为什么将服务从VM迁移到Kubernetes(容器)后可能会遇到的问题以及这些问题如何导致性能下降。应用程序本身的性能瓶颈(例如网络 I/O 或磁盘 I/O)超出了本文的范围。
背景
当我们在使用虚拟机部署服务时,通常会请求固定数量的系统资源,例如具有 4 个 vCPU 和 16 GB RAM 等规格的虚拟机。然后,用户可以连接到虚拟机安装并运行所需的应用程序。
然而,在 Kubernetes 世界中,容器化应用的部署更加动态。为了避免影响其他容器的运行和耗尽节点资源,在部署Pod时进行适当的配置resource.request和resource.limit至关重要。
当应用的资源使用量达到配置值时,系统将采取相应的限制操作。对于 CPU 来说,这会导致 CPU 节流,而对于内存来说,则会触发 Out-Of-Memory Killer。
如果应用开发人员没有正确配置resource.limit和考虑程序的运行逻辑,很容易触发CPU节流。这通常表现为 p95 或 p99 等百分位数的意外性能问题,或者随着创建更多线程而性能下降的情况。
相比之下,对于虚拟机来说,不需要考虑此类资源配置问题,因此不会触发CPU限流等问题,也不会有性能下降。接下来,让我们深入研究一下什么是 CPU 限制以及为什么触发它会导致性能下降。
什么是CPU节流
读者可能遇到过有关 Kubernetes 通过 cgroup 管理容器运行时资源的讨论。要理解 CPU 节流,掌握 cgroup 和 Kubernetes 的resources.request/limit之间的实际关系至关重要。
CPU 请求(Request)
在 Kubernetes 中,CPU 请求有两个主要用途:
1. Kubernetes 会聚合 Pod 中所有容器的请求,使用该值来过滤没有足够资源进行调度的节点(从而得到可以调度的候选节点)。这个方面主要与调度有关。
2. Linux 内核 CFS(完全公平调度程序)将 CPU 时间分配给目标容器(CPU shares)。
让我们考虑部署一个具有三个容器的服务,每个容器的 CPU 请求分别为 250m、250m 和 300m。
apiVersion: apps/v1kind: Deploymentmetadata: name: www-deployment-resourcespec: replicas: 1 selector: matchLabels: app: www-resource template: metadata: labels: app: www-resource spec: containers: - name: www-server image: hwchiu/python-example resources: requests: cpu: "250m" - name: app image: hwchiu/netutils resources: requests: cpu: "250m" - name: app2 image: hwchiu/netutils resources: requests: cpu: "300m"
假设一致认为 1 个 vCPU 等于 1000ms,则使用情况可以如下可视化:
然而,CPU 操作并不是以 1 秒(1000ms)为单位发生的。CFS 运行期间,每次运行的时间由 cgroup 参数cfs_period_us决定,默认为 100ms。
因此,上述设置转化为实际运行时情况更像是:
但CPU资源本质上是有竞争的。我们如何确保这些应用在 CPU每个调度周期内至少有所申请的时间呢?
CFS通过CPU份额的设置来保证应用能够在每个周期内使用请求的时间。这可以看作是 CPU 运行时间的下限。剩下的时间就留给申请者去争夺。
在上例中,100ms间隔内每个应用的请求时间为:
25 毫秒、25 毫秒、30 毫秒。然后剩下的 20ms 由它们竞争获得。因此,在实践中,各种组合都是可能的,例如:
无论何种场景,都满足CPU Share要求,满足最低使用约束。
在使用 cgroup v2 的环境中,底层使用cpu.weight实现:
ubuntu@hwchiu:/sys/fs/cgroup/kubepods.slice/kubepods-burstable.slice/kubepods-burstable-podacdcc83d_4cca_4271_9145_7af6c44b1858.slice$ cat cri-containerd-*/cpu.weight121010ubuntu@hwchiu:/sys/fs/cgroup/kubepods.slice/kubepods-burstable.slice/kubepods-burstable-podacdcc83d_4cca_4271_9145_7af6c44b1858.slice$ cat cpu.weight32
在上面的示例中,CPU 权重按比例计算为 25:25:30,即 10:10:12。总权重为32。
CPU限制(Limit)
Kubernetes 中的 CPU 限制被定义为 CPU 使用量的上限。当CPU使用量达到这个水平时,就会触发CPU节流,作为CPU使用量的上限。
将上面的 YAML 修改为以下示例,每个应用程序都被赋予相应的限制:
apiVersion: apps/v1kind: Deploymentmetadata: name: cpu-limitspec: replicas: 1 selector: matchLabels: app: www-resource template: metadata: labels: app: www-resource spec: containers: - name: www-server image: hwchiu/python-example resources: requests: cpu: "250m" limits: cpu: "300m" - name: www-server2 image: hwchiu/netutils resources: requests: cpu: "250m" limits: cpu: "300m" - name: www-server3 image: hwchiu/netutils resources: requests: cpu: "300m" limits: cpu: "350m"
考虑 100 毫秒的周期,应用程序之间的 CPU 使用情况分布如下:
当应用程序超过其分配的时间时,它会进入节流状态,无法继续使用 CPU。在给出的示例中,假设三个应用程序想要竞争多余的 CPU,结果可能如下所示:
最终,CPU 将有 5 毫秒的空闲时间,因为所有三个应用程序都被限制了,没有多余的容量来继续运行。
在cgroup v1中,CPU配额(cfs_quota_us、cfs_period_us)用于指定允许的运行时间,而cgroup v2则使用cpu.max达到相同目的。
ubuntu@hwchiu:/sys/fs/cgroup/kubepods.slice/kubepods-burstable.slice/kubepods-burstable-podacdcc83d_4cca_4271_9145_7af6c44b1858.slice$ cat cri-containerd-*/cpu.max35000 10000030000 10000030000 100000ubuntu@hwchiu:/sys/fs/cgroup/kubepods.slice/kubepods-burstable.slice/kubepods-burstable-podacdcc83d_4cca_4271_9145_7af6c44b1858.slice$ cat cpu.max95000 100000
在上面的示例中,三个容器是同一 K8s Pod 的一部分,每个容器的 CPU.MAX 分别设置为 35000(ns)、30000(ns) 和 30000(ns)。Pod 的 cpu.max 计算为所有容器值的总和,结果为 95000(ns)。
基于这种理解,当应用遇到 CPU 节流时,操作系统会限制应用在每个 CPU 周期内使用 CPU 时间,从而导致即使此时有空闲的 CPU,应用也会受到限制(无法调度到CPU上执行)。
线程
CPU节流看似是合理且正常的行为,但是什么时候会导致应用出现性能问题呢?包括前面提到的p95、p99性能不佳呢?
CPU 份额的计算是基于容器进程的,因此如果进程本身创建了多个线程,则CPU份额的计算会涉及到所有线程的完整总和。
考虑部署一个限制为 100 毫秒的应用程序,但其 CPU 工作负载只需要 50 毫秒即可完成:
单线程使用没有问题。如果使用两个线程运行:
由于每个线程只需要50ms,所以总计仍然是100ms,没有超过限制(quota)。因此,使用上不存在任何问题。
但是,当我们使用3个线程时会发生什么?(假设系统至少有3个可用vCPU)
由于总量只有100ms,三个线程竞争,每个线程平均只能使用33ms。这就会触发节流机制,每个线程在剩余空闲时间(67ms)内无法做任何事情。
因此,所有线程必须等到第二个 CPU 周期才能继续执行所需的任务:
在第二个周期中,每个线程大约需要 17 毫秒,尽管所有三个线程最终都成功完成,但每个线程从开始到完成花费了 117 毫秒(50 毫秒的工作时间和 67 毫秒的空闲时间)。
如果将示例改为8个线程,情况会变得更加严重:
在这种情况下,每个线程每个CPU周期只能使用12.5ms,至少需要5个周期才能完成所有工作。因此,最初只需要 50 毫秒,现在需要 412.5 毫秒,从而导致客户端请求超时或延迟显着增加。
线程数量和 CPU 限制不匹配可能会导致应用经常触发 CPU 限制。虽然 CPU 最终完成了任务,但每个作业所花费的时间却延长了,导致 P95 和 P99 性能指标中反映的系统繁忙度增加。
如何避免
至少有两种方法可以避免 CPU 限制:
1. 增加 CPU 限制。
2. 检查应用的线程配置是否正确。
增加 CPU Limit 是最直接的方法,但确定一个合适的 CPU Limit 值并不简单。一般来说,建议使用监控系统来观察数据并评估合适的值。如果我们关心Pod QoS,则限制设置必须与请求一致。
关于线程的数量,取决于应用开发人员的关注程度。线程数通常通过两种方式设置:
1. 手动设置所需数量。
2. 由编程语言或框架自动检测。
对于(1),开发者需要同步Kubernetes资源设置。例如,根据需求打开更多线程可能需要调整 limit.CPU 和 request.CPU 设置以避免更频繁的 CPU 节流。
对于(2),自动检测依赖于应用程序开发人员对所使用的编程语言和框架的熟悉程度。例如,在具有 128 个 vCPU 的服务器上运行容器(请求:2vCPU,限制:4vCPU)可能会引发有关这些语言或框架如何根据什么标准自动调整数量的问题。
以Java为例,从8u131版本开始,它具备了容器环境下系统资源检测的能力。如果没有此功能,较旧的 Java 应用程序可能会根据物理机(128 个 vCPU)设置线程数量,从而容易触发限制。
监控指标
如果环境中安装了Prometheus,可以通过三个指标来观察应用是否遇到CPU节流:
1. container_cpu_cfs_throttled_seconds_total
2. container_cpu_cfs_periods_total
3. container_cpu_cfs_throttled_periods_total
container_cpu_cfs_throttled_seconds_total指标记录目标容器被节流的时间(以秒为单位)。它是计数器类型,因此需要使用rate等类似的函数来观察变化。请小心,因为该指标返回总时间。如果应用使用较多的线程数,则报告的秒数可能会非常高,需要仔细解释。
使用后两个指标更为实用。container_cpu_cfs_periods_total是一个计数器类型的指标,表示容器到目前为止经历的累积 CPU 周期(所有 CFS 周期,通常为 100 毫秒)。另一个container_cpu_cfs_throttled_periods_total遵循相同的计算周期,但专门对 CPU 节流的周期进行计数。
相对于前者,计算节流百分比可以为container_cpu_cfs_throttled_periods_total/ container_cpu_cfs_periods_total。在下面的示例中,应用程序总共需要五个周期,其中四个周期发生 CPU 限制。因此,计算出的百分比为 4/5 = 80%。
值得注意的是,这些指标从一开始就积累了所有数据。重要的不是一开始的数据,而是当前的运行状况。因此,类似increase的表达式用于计算差异,然后将其转换为百分比。例如:
sum(increase(container_cpu_cfs_throttled_periods_total{container!=""}[5m])) by (container, pod, namespace)/sum(increase(container_cpu_cfs_periods_total[5m])) by (container, pod, namespace)
专属CPU(CPU 绑定)
背景
影响容器和虚拟机之间CPU性能的另一个因素是CPU利用效率。考虑以下场景:有一台16个vCPU的服务器,要部署一个需要使用4个vCPU、4个线程的服务。在容器和虚拟机中运行此服务时可能会出现哪些差异呢?
下图表示一台具有 16 个 vCPU 的服务器,每个圆圈代表一个 vCPU。
对于 VM,一个常见的示例是分配专用于 VM 的固定 4 个 vCPU。然后,VM 内核将在这 4 个 vCPU 上调度该服务。
但对于容器来说,资源是共享的,CPU(请求)的概念是满足每个周期4个vCPU的使用。实际上,无法保证所使用的具体是哪些 CPU。因此,对于具有 4 个线程的应用程序,执行期间的 CPU 分配可能会表现出随时间变化的各种模式。
为什么CPU分配会影响性能?为了理解这一点,让我们回到CPU的基本概念。为什么当我们在云环境中讨论 CPU时,通常使用术语 vCPU(虚拟 CPU)呢?
随着技术的进步和超线程的发展,每个物理CPU可以执行多个线程(也称为虚拟CPU或线程)。传统上,物理CPU被称为核心,一个socket上有多个核心。
我们可以使用该lscpu命令查看 CPU 相关的一些详细信息。
ubuntu@blog-test:~$ lscpuArchitecture: x86_64CPU op-mode(s): 32-bit, 64-bitByte Order: Little EndianAddress sizes: 46 bits physical, 57 bits virtualCPU(s): 32On-line CPU(s) list: 0-31Thread(s) per core: 2Core(s) per socket: 16Socket(s): 1NUMA node(s): 1
在上面的例子中,你可以看到:
1. 有1个插座。
2. 每个插槽有 16 个核心。
3. 每个核心有 2 个线程。
因此,计算出的 vCPU 总数为 1 * 16 * 2 = 32。
以下结果来自另一台机器:
ubuntu@hwchiu:~$ lscpuVendor ID: GenuineIntel Model name: Intel(R) Xeon(R) Gold 6230R CPU @ 2.10GHz Thread(s) per core: 2 Core(s) per socket: 8 Socket(s): 2NUMA: NUMA node(s): 2 NUMA node0 CPU(s): 0-15 NUMA node1 CPU(s): 16-31
虽然总数也是2 * 8 * 2 = 32,但底层架构明显不同。
基于 CPU Socket和 Core的架构,这两种场景都可以代表具有 32 个 vCPU 的服务器。
除了上面提到的概念之外,现在还有一种架构称为 NUMA(非统一内存访问)。NUMA将整个服务器划分为多个NUMA节点,每个节点都有多个核心(物理CPU)。节点共享相同的内存控制器。所以,如果核心之间存在内存相关的访问需求,那么在同一个NUMA节点内速度会是最快的,而跨NUMA节点时可能会有一些性能损失。
上面的第一个示例代表单个 NUMA 节点,而第二个示例有两个 NUMA 节点。CPU 编号 0–15 属于第一个 NUMA 节点,16–31 属于第二个 NUMA 节点。我们可以使用numactl以下方法来检查:
ubuntu@hwchiu:~$ numactl -Havailable: 2 nodes (0-1)node 0 cpus: 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15node 0 size: 16058 MBnode 0 free: 14530 MBnode 1 cpus: 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31node 1 size: 16075 MBnode 1 free: 14284 MB
结合cat /proc/cpuinfo查看各个CPU编号与核心的关系,大致可以画出下图:
除了前面提到的基本概念外,CPU架构在不同block之间还有不同级别的Cache。将这些缓存添加到架构图中:
有了这些基本概念,我们再回到最初的问题:为什么CPU的分布会影响性能?原因就是Cache + Context Switch(跨不同CPU调度)。
当同时运行的vCPU需要互相访问数据时,这些数据存储在哪里?它们之间有缓存吗?如果这些正在运行的 vCPU 跨越不同的套接字甚至不同的 NUMA 节点,访问此信息可能会导致 Cache Miss。此外,等待下一个 CPU 周期可能会导致被调度到不同的核心上,从而导致上下文切换,从而降低性能。
对于大多数应用程序来说,这可能并不明显,但对于高性能要求的应用程序来说,避免上述问题可以稍微提高系统性能。
绑核方法
Linux 一直有一个名为taskset的工具。手册描述如下:
taskset 命令用于设置或检索给定 pid 的正在运行的进程的 CPU 亲和力,或启动具有给定 CPU 亲和力的新命令。CPU 关联性是一种调度程序属性,它将进程“绑定”到系统上给定的一组 CPU。Linux 调度程序将遵循给定的 CPU 关联性,并且该进程不会在任何其他 CPU 上运行。
因此,对于裸机服务器上的应用程序,系统管理员可以采用taskset绑定CPU来提高性能。然而,对于容器或 Kubernetes 来说,我们如何操作呢?
在Docker中,官方文档解释说可以使用“cpuset-cpus”来指定要使用的CPU编号。
— cpuset-cpus=0–2 # 限制容器可以使用的特定 CPU 或内核。
Kubernetes 很早就注意到了这个问题,并从 Kubernetes v1.8 开始引入了“CPU Manager”功能。该功能在 v1.10 中成为 Beta,并在 v1.26 中正式成为 GA。
CPU Manager 策略本身支持两种配置,在 Kubelet 的启动参数上配置:
● None
● Stack
由于是基于Kubelet配置的,如果需要开启该功能,我们需要修改Kubelet的启动参数。另外,请注意不同 Kubernetes 版本之间的差异;请参阅官方文档来确认哪些功能需要打开特定的功能门。
“None”设置意味着不采取任何操作,CPU Manager 不会执行任何特殊操作。这是 Kubernetes 中的默认值。
“Static”设置允许某些容器在专用 vCPU 上运行。这本质上是通过 cpuset cgroup 控制器完成的。
容器要考虑的条件:
1. QoS 为 Guaranteed(也就是 request 和 limit 都设置了,且两者相等)
2. CPU 数量是一个整数。
由于该设置需要独占 vCPU 分配,因此需要整数单位。否则,在独占 vCPU 上保留 0.5 个 vCPU 显然是对系统资源的浪费。此外,虽然独占系统资源使用可以提高容器性能,但如果服务本身没有充分利用 vCPU 资源,则可能会浪费它们。因此,它并不适用于所有 Pod,而是专门适用于 QoS 设置为guarantee 的 Pod。
设置为静态时,可以启用其他功能:
1. full-pcpus-only
2. distribute-cpus-across-numa
3. align-by-socket
(1)的目的是让容器不只使用vCPU,而是使用整个Core,即物理CPU(pCPU)。目标是减少邻居的噪音问题,避免其他 vCPU(线程)造成的性能损失。
例如,在没有full-pcpus-only的场景中,虽然vCPU是独占保留的,但每个Core上可能运行不同的容器。由于业务不同,L1/L2 Cache可能会出现Cache Miss。
启用full-pcpus-only后,您可以独占保留物理CPU(pCPU)。这使您可以拥有整个缓存,从而提高性能。然而,如果应用程序本身只需要一个vCPU,则仍然可以分配完整的Core。在这种情况下,独占两个vCPU但只使用一个可能会导致资源浪费。因此,仔细关注底层架构和精确配置是必要的。
总结
在将应用程序从虚拟机迁移到Kubernetes时,经常会出现性能达不到预期的问题。在不了解细节的情况下,最简单的做法就是增加各种资源或者部署更多副本。虽然这些方法可能会缓解症状,但如果不解决根本问题,它们的改善效果有限。了解容器和虚拟机内部的每个设置和细节可以从不同的角度来解决未来的性能瓶颈。
此外,CPU 节流并不是绝对(正确的)的解决方案。可能内核中存在bug,使得 CPU 节流不精确,从而导致应用被不必要的限制。当问题出现时,如果发现数据对不上,我们应当调查一下当前的Kernel是否有相关的bug。
参考
0.原文:https://hwchiu.medium.com/why-does-my-2vcpu-application-run-faster-in-a-vm-than-in-a-container-6438ffaba245
https://github.com/kubernetes/enhancements/blob/master/keps/sig-node/3570-cpumanager/README.md
https://github.com/kubernetes/enhancements/tree/master/keps/sig-node/2625-cpumanager-policies-thread-placement
https://github.com/kubernetes/enhancements/tree/master/keps/sig-node/2902-cpumanager-distribute-cpus-policy-option
https://github.com/kubernetes-monitoring/kubernetes-mixin/issues/108
https://www.datadoghq.com/blog/kubernetes-cpu-requests-limits/
https://www.cnblogs.com/charlieroro/p/17074808.html