Yandex 内核调参分享

标题:Семинар: Настройка параметров ядра

日期:2024/06/22

作者:Александр Костриков

链接:https://www.youtube.com/watch?v=I_mVU41I-YE

注意:此为 AI 翻译生成 的中文转录稿,详细说明请参阅仓库中的 README 文件。


谢谢。大家好!今天由Yandex基础设施团队为大家带来分享,我和Anton Suvorov将为大家讲解如何让你们的项目像我们的一样酷炫且高可用。我叫Aleksandr Kostrikov,在网络基础设施部门工作。我们部门负责将流量高性能地传输到Yandex的所有服务。我们拥有许多数据中心,海量的流量和网络设备。同时,我们也处理大量关于负载均衡和防火墙处理的请求。今天我将讲述如何基于Linux配置所有这些系统,因为Linux是一个已被证明高可用、可靠并允许我们按自己所需配置系统的平台。我直接进入我们正在使用的产品。这是一个高性能流量处理器。它已经在NextHop和其他会议上介绍过。这是一个基于Linux运行的系统。Linux允许运行高性能应用程序来处理大流量。它使用了所有可能的优化技术。包括DPDK(数据平面开发工具包)、NUMA节点、使用大页(huge page)以及为流量单独处理而进行的核心隔离。

那么,为什么我们选择Linux?首先,Linux允许我们根据需要配置任何东西。我们可以重新编译内核,此外,我们还可以通过内核参数选择我们需要的功能。例如,可以查看Linux中可用的命令行参数。今天我将展示我们在工作中是如何使用它们的。我们主要使用那些能实现高性能的功能。即大页和CPU隔离。

大页是一种允许以大块分配内存的系统,当访问这块内存时,不需要按某个自定义地址逐个访问小块,而是可以一次性获取大量数据。通过这种方式,实现了内存访问的高性能。结果就是我们在内存访问上获得了百分之几十的提升。在我们需要近乎实时处理数据包的情况下,这非常重要。今天我将展示如何在你们的应用程序上实现这一点。这不仅适用于流量处理,也适用于任何使用内存的其他应用程序。并且你想进行优化,获得百分之几十的提升,但同时又不想自己去优化算法本身。可能你已经优化到极限了。而大页正好可以帮助你解决这个问题。

还有一个参数是核心隔离。例如,我们可以分配一些核心,并将它们从Linux默认调度器中拿走。这样,当我们的应用程序在这些核心上运行时,就不会收到任何额外的应用程序。这可以消除我们应用程序的中断和一些不必要的上下文切换。这对我们特别重要,因为我们有很多缓存数据在CPU本身。每个闯入我们CPU的应用程序都会干扰非常快速的流式流量处理。

让我来展示一下我们这是如何工作的。例如,可以通过/proc/cmdline查看我们内核启动时使用了哪些参数。这里可以看到我们有image(内核镜像),是VM Linux,initrd – 这是引导加载程序镜像。后面是关于加载哪个rootfs、文件系统类型的参数。但这些都是常规参数,比如控制台。我们感兴趣的是其他的。这就是大页和大页尺寸,它们的数量。我们使用的是1GB的大页。这是为高性能应用程序分配核心最方便的模式。原因在于,使用1GB的大页,我们获得了最小的地址转换时间。如果我们使用更小的页尺寸,那么仍然会有一些额外的虚拟内存地址访问开销。这台机器使用了40个大页,并且我们在第3、5、7、8个核心上添加了处理器隔离。同时,我们在这些核心上禁用了某些加速功能,并通过IRQ Affinity(中断亲和性)将所有中断绑定到第1和第2个核心。这样做是为了确保没有人使用我们计划用于我们应用程序的核心。

正如我所说,我们使用这些大页进行流量处理,实际上你可以将其用于任何应用程序。对于流量处理这是必需的,因为需要访问位于内存某处的数据包,更新其某些参数并将其发送出去。但我们也可以简单地使用某个大量访问内存的脚本,通过为其分配大页就能使其速度提升百分之几十。可以看看如何设置大页,之后可以执行update-grub,重启,然后我们就能获得我们页表的一些状态。要查看当前使用了多少页,可以使用命令hugeadm,也可以通过/proc/meminfo查看。

可以看到,我们总共有20个大页,其中10页是空闲的。为了测试大页在普通应用程序上的效果,我使用了一个简单的矩阵乘法实用程序。它只需要编译,然后可以看看如何在单个核心上隔离我们的应用程序。我释放了第八个CPU,并在其上运行矩阵乘法。可以看到这大约花了1.2秒(译注:原文“1,2 степени на секунду”可能指1.2秒或1.2e-1秒,结合上下文更可能是1.2秒)。现在让我们试试大页。可以看到我们执行这个命令的速度显著加快了。这一切的发生是因为我导出了环境变量,这些变量允许将存储在内存中的数据放入大页表。这个命令是hugeadm morecore。它允许将我们应用程序的页面加载到内存中。LD_PRELOAD是一个特殊的指令,它指示我们需要预加载某个库。在我们的例子中,就是libhugetlbfs.so。让我们看看是否使用了我们的大页。现在我们有10页空闲。运行脚本。可以看到我们仍然有10页空闲。也就是说,我们确实使用了某个大页页面并运行了我们的脚本。这次运行稍微快了一点。这是因为我们毕竟是在虚拟机上运行。内存转换并不总是按预期工作。但在物理硬件上,对于内存访问非常密集的应用程序,你会稳定地看到10-20%的性能提升。

因此,如果你有一个已经达到极限的数值计算程序(молотилка чисел),你可以利用大页表并获得10%的提升。这非常方便,实际上不需要任何特别的复杂操作。你可以在任何现代内核上做到这一点。

我展示了我们可以通过grub修改内核参数中指定的某些值。也就是说,可以看到我们设置了某些RQ affinity等等。但有时我们可能希望在不重启内核的情况下更新传递给/boot/的参数。我们可以使用kexec来实现这一点。这个功能允许将我们的内核替换为新版本,而无需重启带来的任何问题。如果我们只是想测试,我们运行kexec并获得我们需要的东西。例如,我想在命令行中添加一定数量的大页表。为此,我拿另一台虚拟机。

可以看到这里没有大页。 这里在描述的内核参数中也没有大页。在这种情况下,我将命令行复制到一个变量中,添加我需要传递到新内核版本的参数。然后使用kexec命令,指定要使用的Linux内核(内核镜像vmlinuz和initrd)。通过--append选项,我可以设置我的命令行。我设置了内核参数。现在,要执行它,我必须加上-e(执行)选项。这里有个细节,可以在不中断连接的情况下进行内核重启(译注:指kexec的“热”切换)。但让我们做一次完整的内核更换。不尝试额外卸载任何参数。直接重启我们的机器。

因为我们进行了完整的内核版本替换,没有修改grub,也没有任何其他附加条件,所以在一段时间内连接丢失了,直到所有网络设备重新初始化完成。然后我们将获得我们的新内核版本。需要稍等片刻,直到它再次可以通过网络访问。

好了,它可以访问了。让我们检查一下我们有多少个大页。 可以看到我们有了大页。让我们看看/proc/cmdline。我们有了负责大页的参数。而且这是在没有任何重启的情况下完成的。这样,如果你不想重启系统,就可以测试内核参数在你的系统上如何工作。如果这是某个需要长时间初始化的硬件,比如我们的负载均衡器和防火墙,那么为了测试,这样做会更方便。

至此,我们了解了内核参数是如何工作的。我们从大页的角度检查了它们的效果。看到了如何几乎不做任何事情就能将你的应用程序加速10-15%。唯一的条件是它需要处理内存。让我们继续。

我们的内核也可以通过sysctl工作。并非所有参数都暴露为内核启动参数(kernel parameters)。有些东西我们可以在运行时无需kexec就能编辑。这通过sysfssysctl完成。sysctl是一个在运行时处理内核参数的实用程序。它通过procfs工作。许多东西可以直接在/proc/下看到。你可能在查看应用程序的文件描述符或其他信息时已经遇到过这个。此外,/proc/cmdline就是一个例子,说明如何通过/proc/读取关于我们内核的数据。可以通过/etc/sysctl.conf加载所有文件。可以选择单个文件。可以通过sysctl重启内核(译注:此处指应用配置,通常sysctl -p加载配置,重启内核需要其他命令)。总之,sysctl提供了很多操作可能性。可以看看sysctl -a(或/proc/sys/目录)。这里列出了几乎所有可以在内核中看到的参数。通常,这些是与网络、与共享内存(shm),或者与Linux中某些通用系统相关的东西。从顶部可以看到我们有fs参数、kernel(通用参数)、net(网络)、一些legacy(遗留)、sunrpc(用户空间和虚拟内存相关)等。

现在让我们看一个网络相关的sysctl配置,它负责我们如何使用ping和ICMP数据包来查看远程主机。

我们可以调整使用哪些本地端口来访问远程系统。但现在,我们可能要看的是ping_group_range是如何工作的。

那么,我们可以看到这台机器上IPv6地址的yaru.resolvice(译注:可能指某个解析结果或主机名)。我们可以尝试ping这个地址。我禁用了能力(capability)。现在我可以恢复它们。

如所见,地址可以被ping通。CAP_NET_RAW能力是生成不仅仅是IPv6、IPv4、TCP、UDP数据包,还包括ICMP数据包所必需的。CAP_NET_RAW之所以需要,是因为它允许生成更低层的网络包,这在安全性上可能是个问题。有人可以利用ping生成一些自定义数据包并将其发送到网络中。我们当然不希望这样,所以以前在大多数情况下我们会禁用它。或者只允许root用户这样做,但借助内核中的能力系统,我们现在可以授予某个程序使用内核网络功能的能力。

现在,我设置了ping_group_range从1到最大值。我去掉了能力。看看我是否能ping通那个地址。确实,我可以ping通它。这是因为内核中还有一个特殊的“旋钮”(控制点),它允许我们ping和发送ICMP数据包,即使我们没有CAP_NET_RAW能力。

可以看到,我们没有任何能力。让我们把值改回默认。现在我又不能ping远程地址了。我现在为ping恢复能力。

让我尝试通过mtr来ping。如所见,mtr可以工作。mtr有能力吗?如所见,mtr没有任何能力。然而它仍然可以ping通YARU。看起来这怎么可能呢?但这里有个小秘密。mtr内部调用了一个小的辅助程序mtr-packet。如所见,mtr-packet拥有允许它操作网络的能力。也就是说,我们并不总是能运行某个应用程序就明白它如何与网络交互。有时它有一些辅助命令来与网络交互,以便为主应用程序提供功能。

那么,让我们看看如何用tcpdump来查看我们的数据包是否被发送。

我使用tcpdump应用程序。我在主机上的eth0接口监听,想看到所有发往YARU地址的数据包。我添加了&(后台运行符号),以便能在我的Shell中运行ping命令,同时通过tcpdump查看数据包。

我们只需要一次ping迭代就能明白发生了什么。如所见,在我的Shell中显示了经过我们设备的包。可以增加一点可见性。让我用fg(前台)命令把tcpdump调回前台。当我将其调回前台后,我可以按Ctrl-C,应用程序就会被中断。可以看到tcpdump运行在和我的应用程序相同的Shell中。我可以看到我发送的数据包,并且通过过滤器,我可以理解哪些数据包确实是从我的应用程序发送到外部世界的。

至此,我们看到了如何通过内核参数调整系统性能。我们也看到了如何在运行时通过sysctl调整某些参数。但我们并不总是有能力改变我们想要的东西,因为Linux内核开发者并不总是满足我们的愿望。有时我们不得不自己弄清楚为什么某些东西不工作,或者如何在不重新编译内核、不为Linux内核编写补丁的情况下在内核中添加某些功能。

以前这非常困难。必须编写内核模块。必须联系内核开发者让他们接受补丁,或者维护一个不断与内核版本产生冲突的补丁,并投入精力去完善它。幸运的是,现在可以用不同的方式来做,而BPF在这方面提供了很大帮助。BPF是内核内部的一种虚拟机,就像HTML中的JavaScript一样,它允许添加交互性。借助BPF,我们可以调节网络功能,可以在内核内部处理安全问题,也可以在内核的任何地方添加可观测性(observability)。这得益于现在几乎整个内核都通过函数、助手(helpers)和BPF连接起来,而BPF本身提供了一种语言,可以用它编写这些小脚本并加载到内核中。

这些脚本本身相当简单。现在越来越多的功能被添加到这些脚本中,所以几年后,可能会出现非常复杂的应用程序。但目前,通常是一些加载到网络设备或内核本身的小脚本,它们允许收集数据,并通过BPF程序调节数据包的发送和接收。

让我们来看一个程序,它能帮助我们理解数据包在哪里丢失。让我们看看这个脚本。它其实并不特别复杂。可以看到它将通过BPF的trace功能执行。这里关于我们如何处理地址有一个小技巧。我们将ping本地主机(127.0.0.1),为了在内核内部使用这些地址,我们需要进行一些转换。如果你已经知道的话,虚拟机内部存在大端序和小端序的差异。通常,许多系统使用小端序编码。我们可以说字节是反着写的(译注:指低位字节在低地址)。这样机器计算更方便,它在内部就是这样表示的。但我们看到的数据包,原则上看起来相当标准。它们的字节顺序没有错乱。为了从一种字节序转换到另一种,我们需要进行一些转换。Python 3可以在这方面帮助我们。可以在其中导入socket模块,并使用htonl()命令(Host TO Network Long)来理解我们的数据应该是什么样。H代表主机(Host),to是介词,表示我们要转换到某物,n代表网络(network),l代表我们要转换的长整数(long)。这样我们就可以看到,我们获取来自主机的数据并将其转换为网络字节序。在某些系统上,这个命令可能几乎不做任何事情,但在我们的系统上会是一个不同的数字,我们仍然需要执行这个命令,以便我们能够按这些IP地址进行过滤。让我们开始做这件事。我们可以看看我们得到了什么数字。让我们运行我们的应用程序。记得我们想ping 127.0.0.1。

让我们发送一个数据包。我们能看见什么?在脚本运行期间,我们收到了一些信息,显示我们从第一个IP地址向第三个IP地址(译注:指127.0.0.1的组成部分)发送了数据包。这就是我们的htonl()。这个东西叫做堆栈跟踪(stack trace),我们就这么称呼它。可以看到我们发生了某个系统调用(syscall),在套接字(socket)上接收数据,我们收到了原始数据包(raw packet),然后通过一系列函数我们到达了__sys_recvmsg。有一个有趣的函数叫consume_skb,如果回到我们的脚本,可以看到我们正好在consume_skbkfree_skb上挂载了探针(probe)。我是从哪里找到这些函数的?这些函数是众所周知的,几乎所有内核中的数据包处理工作都以它们结束。有一个很好的网站elixir.bootlin.com,可以在上面找到这个函数。可以看到它的原型,可以把它看作一个函数。在里面是kfree_skb,可以跳转到kfree_skb。什么是kfree_skb?在现代内核中,这是清理存放我们数据包的缓冲区,原因未指定(not specified)。可以看到我们可能有的几个原因。其中一个原因是skb consumed,即数据包被消耗并传递给某个用户空间应用程序。还有原因是我们找不到套接字,数据包太小,校验和(checksum)不匹配等等。原因有很多,这些辅助函数kfree_skbconsume_skb让我们无需这些额外的东西就能获取数据包命运的信息。我们可以获取更定制的消息,但这需要进一步研究。现在我们考虑我们脚本的最简单版本。

假设我们想检查IP版本是第四版(IPv4),并且目标地址(destination address)是那个htonl()转换后的地址,源地址(source address)也是htonl()转换后的地址。如果数据包符合这个描述,那么我们就写入被调用的函数堆栈,然后我们写入协议版本、地址,以及saddr(源地址)和daddr(目标地址)作为我们经过内核内部转换后的整数。对kfree_skb也做同样的事情,这样我们就可以看到数据包是如何在成功消耗或失败的情况下被接收的。

如所见,这里我们的自定义skb被消耗了,ping成功完成。让我们尝试丢弃ICMP数据包。ICMP。启动丢弃(drop)。

如所见,发送ICMP数据包失败了。我们在这里能看到什么?我们做了系统调用,在发送消息的过程中我们经过了一些函数,比如ip_local_out等等,然后看到我们发生了kfree_skb,也就是说,很可能在发送时我们的数据包被丢弃了。让我们恢复数据包,让它稳定发送。

好了,数据包通过了。让我们检查一下,当禁止在input(输入)路径上接收数据包时,发送到input会发生什么。这里已经可以看到我们在发送消息(sendmsg)方面没有问题,但在另一个地方出现了接收数据包的问题。嗯,原则上,数据包不会到达。让我们按Ctrl-C。可以看到这里我们有发送挂钩(hook)的记录,我们经过了ip_finish_output。但在ip_local_deliver中我们遇到了某个问题,然后转到了kfree_skb。原则上,如果我们愿意,我们可以进一步细化。甚至有一个项目允许这样做。

这个包(项目)叫pwru。可以理解为“Packet Where Are You”。它也使用了所有eBPV选项。可以与Kubernetes一起工作。这里有一个过滤器的例子,可以在内核内部处理我们的“Packet Where Are You”。和我们的项目里用到的几乎一样。可以看到发生了什么。这是一个更高级的工具。但内部使用的原理和我们项目中几乎相同。只是添加了一些封装。

因此,借助eBPF,我们可以理解数据包在哪里出问题,调整其行为。并且在必要时,通过XDP(eXpress Data Path)添加数据包处理功能。这是BPF更高级的版本。我们还可以收集关于这些丢包的指标,并添加到像Prometheus这样的系统中。这使得可以通过eBPF对我们的内核进行可观测性操作。

因此,eBPF允许在内核参数缺失的地方添加功能,既能加速数据包处理,也能发现在系统运行过程中可能出现的问题。

今天我们讨论了有哪些内核参数,如何通过sysfs在运行时编辑内核参数。以及当内核启动参数(kernel parameters)和sysfs中都没有我们需要的参数时该怎么办——我们可以使用eBPF。

谢谢。谢谢大家。