使用单台高配服务器(部署你的业务)

本文是Use One Big Server的翻译,前两天在HackerNews上看到这篇文章,虽然是2022年的(文章刚出时,也引起了不少的讨论,不知道怎么现在又被挖了出来),但是也引发了很多热议。 行业内一直有不少关于“单体与微服务”架构的争论,但这场争论背后的真正问题是分布式系统架构是否值得花费开发者时间和成本开销。通过对系统的实际运行情况进行分析,可以深入了解大多数情况下我们是否真的需要分布式系统。 我们已经对虚拟化以及软件和运行软件的服务器之间的抽象变得如此熟悉。如今,“Serverless”计算风靡一时,甚至“裸金属”也成为了一种虚拟机。然而,每一段软件都运行在一台服务器上。由于我们现在生活在一个虚拟化的世界里,大多数这些服务器比我们实际认为的要大得多、便宜得多。 认识你的服务器 这是一张微软 Azure 使用 AMD CPU 的服务器图片。从左边开始,左侧的大金属固定装置(带有铜管)是散热器,铜管连接的金属盒是每个 CPU 上的热交换器。这些 CPU 是 AMD 的第三代服务器 CPU,每个 CPU 都拥有: 64 核 128 线程 约 2-2.5 GHz 主频 核心每时钟周期可处理 4-6 条指令 256 MB L3 缓存 这台服务器总共有 128 个核心和 256 个并发线程。所有核心协同工作时,这台服务器能够达到 4 TFLOPs 的峰值双精度计算性能。这台服务器如果放在 2000 年初将位居 top500 超级计算机榜首。直到 2007 年,这台服务器才跌出 top500 榜单。每个 CPU 核心都比 10 年前的单个核心强大得多,并拥有更宽的计算流水线。 CPU 的上方和下方是内存:每个CPU有 16 个 DDR4-3200 RAM 插槽。目前“性价比”最好的内存是单根 64 GB。考虑到性价比,这台服务器可以容纳 1 TB 内存。如果配置专用的高容量 DIMM(通常比小容量 DIMM 慢),这台服务器最大可支持高达 8 TB 内存。在 DDR4-3200 的速度下,总共有 16 个内存通道,这台服务器的所有核心可能会看到约 200 GBps 的内存吞吐量。 在 I/O 方面,每个 CPU 提供 64 个 PCIe gen 4 通道。总共有 128 个 PCIe 通道,这台服务器能够支持 30 个 NVMe SSD 和一张网卡。一般典型的服务器将提供大约 16 个 SSD 或磁盘的插槽。我想在这张图中指出的最后一点是右上角的网卡。这台服务器可能配备了 50-100 Gbps 的网络连接。 一台服务器的能力 对于今天的一台服务器,可以做到: 以 400 Gbps(现在是 800 Gbps)的速度提供视频文件 在 NoSQL 数据库上实现 100 万 IOPS 在 PostgreSQL 中实现 7 万 IOPS 每秒处理 50 万个 Nginx 请求 在 20 秒内编译 Linux 内核 以 75 FPS 的速度使用 x264 渲染 4K 视频 除此以外,现在还有很多其他公开的基准测试,如果你了解自己的业务情况,也可能会找到类似场景的基准测试。 一台服务器的成本 在大型托管服务提供商 OVHCloud 中,可以租用一台 HGR-HCI-6 服务器,其规格与上述类似,拥有 128 个物理核心(256 个线程)、512 GB 内存和 50 Gbps 带宽,每月费用为 1,318 美元。 换到 Hetzner,你可以租用一台较小的服务器,拥有 32 个物理核心和 128 GB RAM,每月约 140.00 欧元。这比 OVHCloud 的服务器小(四分之一),但它可以让你对托管服务提供商之间的价格差异有所了解。 在 AWS 中,你可以租用的最大服务器之一是 m6a.metal 服务器。它提供 50 Gbps 网络带宽、192 个虚拟 CPU(96 个物理核心)和 768 GB 内存,在美国东部地区每小时成本为 8.2944 美元。每月合计 6,055 美元。看来云服务的溢价是真实存在的! 一台类似的服务器,拥有 128 个物理核心和 512 GB 内存(以及相应的网卡、SSD 和支持合同),可以从戴尔网站上以大约 40,000 美元的价格购买。但是,如果你打算花这么多钱买一台服务器的话,那就应该单独与销售人员沟通,从而获得更好的折扣。除了买硬件的钱之外,你还需要支付服务器托管费和网络带宽的费用。 相比之下,与使用云服务器相比,购买服务器大约需要 8 个月才能实现收支平衡,而与租服务器相比需要 30 个月才能实现收支平衡。当然,无论是直接买服务器或者租服务器,相比使用云来说,还是有很多缺点得,所以接下来,我们将思考一下“云服务溢价”以及是否应该支付它(剧透警告:答案是“可以支付,但不必支付云公司希望的那么多”)。 对于云服务的思考 “云时代”在 2010 年左右正式开始。当时最先进的 CPU 是 8 核的 Intel Nehalem CPU。超线程刚刚开始,所以那颗 8 核 CPU 提供了高达 16 个线程。AES 加密的硬件加速即将到来,向量宽度为 128 位。最强的 CPU 有 24 MB 缓存,服务器可以安装高达 256 GB 的 DDR3-1066 内存。如果想存储数据,希捷刚刚开始提供 3 TB 容量的硬盘。每个CPU核心每周期可以计算 4 FLOPs,这意味着 8 核服务器以 2.5 GHz 运行可提供惊人的 80 GFLOPs算力。 分布式计算的繁荣正是乘着这股浪潮:如果想做任何涉及数据检索的事情,就需要大量的磁盘才能获得所需的存储吞吐量。如果想进行大型计算,那通常需要大量的 CPU。这意味着需要协调大量的 CPU 才能完成大多数事情。 而从那时起,服务器的能力已经大大提升了,SSD 的 IOPS 增加了至少 100 倍,但主流虚拟机和容器的尺寸并没有增加多少,我们仍然使用性能更像HDD而不是 SSD 的虚拟化磁盘(尽管这个差距正在缩小)。 一台服务器(加上备份)通常就足够了 如果你正在做除了视频流之外的任何事情,并且 QPS 小于 1 万,那么对于大多数 Web 服务来说,一台服务器通常就够了。对于一些非常简单的服务,一台服务器甚至可以达到大约 100 万 QPS。很少有 Web 服务能获得如此大的流量。即使你正在提供视频服务,针对控制平面只运行一台服务器也是非常合理的。基准测试,或者使用类似应用程序的常见基准测试,可以帮助你确定需要的机器配置。 高比多好 当需要一个计算机集群时,如果一台服务器不够用,使用少量高配置服务器通常会比使用大量低配置机器更好。协调集群中的机器会产生一些开销开销,并且这些开销通常在每台服务器上是 O(n)。为了减少这种开销,通常应该选择使用几台高配服务器,而不是许多低配置服务器。在使用Serverless计算等情况下,分配微小的短生命周期容器,这种开销占使用成本的很大一部分。对于另外一个极端,只有一台机器的集群,那协调的开销是微不足道的。 高配服务器和可用性 使用一台高配服务器的主要缺点是可用性。服务器需要停机维护,并且也会出现故障。通常情况下,在不同的数据中心运行一台主服务器和一台备份服务器就足够了。即使是偏执狂,2x2 的配置也应该让人满意了:主数据中心(或云提供商)中的两台服务器和备份数据中心中的两台服务器可以提供极大的冗余。如果还想要第三个备份部署,通常也可以使其比主部署和次要部署小。 无论如何,批次性的硬件故障还是需要关心一下的。硬盘(现在是 SSD)偶尔会出现批次性故障:如果一个磁盘出现了故障,并且磁盘都来自同一批次,那么在恢复过程中,是很有可能遇到第二次故障的。像 Backblaze 这样的服务通过使用来自多个制造商的许多不同型号的磁盘来克服这个问题。Hacker news 最近也遇到主服务器和备份服务器同时宕机的问题,因此也吸取了类似的教训。 如果你使用的是托管服务提供商出租的设备,那么在主数据中心和备份数据中心租用两种不同类型的服务器是明智之举。这应该可以避免现代系统中几乎所有的故障模式。 使用云,但不要过于“云化” 可用性和易用性的结合是我(和大多数其他工程师)喜欢云计算的主要原因之一。是的,你需要支付高额溢价来租用云上的机器,但云提供商在构建服务器方面经验丰富,你甚至感受不到大多数故障,对于其他故障,你也可以通过从他们几乎无限的计算资源池中租用新机器来快速恢复服务。确保机器不间断运行是他们的工作,虽然他们不总是做得完美,但他们做得相当好。 一些服务器托管服务提供商是云的便宜替代,但这些提供商可能不了解网络配置和相关硬件故障等问题。此外,从当前租用的一台服务器迁移到另一台高配服务器比直接调整云虚拟机的规格要麻烦得多。云服务器溢价是有道理的。 当你和云打交道时,云的销售人员通常会推动你采用“云原生”架构。包括自动伸缩虚拟机中的微服务、组件间的负载均衡,还有一些增加云绑定的产品,如Serverless计算服务和RDS。这些销售推动“云架构”是有充分理由的,毕竟这对他们更有利! 一般来说,使用云架构是好的,毕竟云可以毫不费力地扩展。但需要明白一点,需要为很多人提供服务,并不是必须使用云原生架构的理由:毕竟大多数服务可以用一台服务器同时为数百万人提供服务,并且永远不会出现突然的的五位数账单。 为什么应该为峰值负载付费? 对于“使用一台高配服务器”的一个常见批评是,这样意味着会一直为峰值负载付费,而不是按需付费。因为Serverless计算或微服务+虚拟机更能使云的消费与利润保持一致。 不幸的是,由于最终所有服务都会运行在服务器上(不管你喜不喜欢),整个链条中的某人一定会根据他们的峰值负载向你收费。负载均衡、Serverless计算服务和低配虚拟机的“云溢价”的一部分就是基于云需要构建多少额外容量才能处理他们的峰值负载。无论如何,你都在为某个人的峰值负载买单! 如果你的工作负载是特别突发的——比如一个需要运行一次然后永远关闭的模拟器——那就应该优先选择“云”解决方案,但如果你的工作负载不是那么突发,那选择少数几台高配服务器通常会更便宜(也更容易维护)。如果你的云提供商的使用情况比你的更突发,你将白白支付这笔溢价。 这种溢价同样也适用于虚拟机,而不仅仅是某些云服务。比如,如果你一直需要一台虚拟机,就可以使用包年包月的计价模式,或者你的需求足够的大,一般也可以与销售人员协商来避免支付“峰值负载溢价”。 一般来说,流量突发突发越高,架构就应该越“云化”。 “云化”需要花多少钱? “云化”是昂贵的。一般来说,我预计从云公司购买的服务会有 5-30 倍的价格溢价,具体取决于所购买的产品和基线。不是 5-30%,而是 5 到 30 倍的系数。 这是 AWS Lambda 的定价:每 100 万个请求 0.20 美元 + 每 GB/秒 RAM 0.0000166667 美元。我这里使用的是 x86 CPU 的定价,以与我们上面看到的 m6a.metal 实例保持一致。对于ARM架构的服务器和Serverless计算服务会更便宜点。 假设你的服务器每小时成本为 8.2944 美元,并且能够以 768 GB RAM 实现 1k QPS的性能: 每秒 1000 个请求,相当于每分钟6W个请求,每小时360W个请求 平摊一下,每个请求使用0.768 GB*秒的RAM 同样的流量如果使用Serverless服务的话,每小时将花费约 46 美元 Serverless服务相对于裸金属的溢价是 5.5 倍。如果你能将裸金属服务器的利用率保持在 20% 以上,那么就会比使用Serverless服务更便宜。这还不包括你还可以针对裸金属服务做一些成本优化——如果你可以从市场租用服务器,或者通过包年包月获取1年的折扣价,如果这么计算,那么云的溢价率甚至更高。 如果你将 AWS Lambda 的价格与 OVHCloud 租用服务器的价格进行比较,那溢价率将会是 25 倍。 如果你正在考虑从低成本托管服务提供商租用服务器或使用 AWS Lambda,假如你能让服务器利用率超过 5% 的话,那就应该优先选择托管服务提供商! 另外也需要注意,实际的 QPS 数字无关紧要:如果每小时 8.2944 美元的服务器可以支撑 100k QPS,单次请求所消耗的内存时间可能只有100分之1或者更低,这意味着你将获得相同的 5.5 倍(或 25 倍)溢价。无论如何,你都应该根据你的应用程序调整服务器的配置。 对于使用单台高配服务器的反对意见 如果你建议使用一台高配服务器部署业务,那通常会遇到来自更习惯云、更喜欢时尚或有其他合理担忧的人的反对意见。因此在评估时需要仔细判断,大多数人其实都大大低估了“云架构”与底层计算相比的实际成本。以下是一些常见的反对意见: 如果我使用云架构,我就不需要专门的系统管理员了 不,你仍然需要。一方面他们现在被称为“云运维”了,归属于不同的管理部门。另一方面,能够解读云公司深奥的文档并跟上相应的大量更新和弃用趋势的能力的人,很可能会比系统管理员贵 5 倍。 如果我使用云架构,我就不必进行安全更新了 不,你仍然需要。可能需要做的事情变少了,但那些省掉的更新是很容易自动化的。那些省不掉的,比如审计所使用的库以及确保所有配置都安全的工作,依然会让人痛苦。 如果我使用云架构,我就不必担心它会宕机了 使用云和微服务架构带来的“高可用性”,仅仅是弥补了架构的复杂而导致的脆弱性。一般来说,如果你使用两个不同的云区域或两个云提供商,就可以认为这足以避免服务宕机。况且,云提供商过去经常发生全球性中断,没有理由认为云数据中心的宕机频率会低于你的一台服务器。 请记住,我们在努力避免发生批次性故障。但实际上云的数据中心中有很多部件可能发生批次性的故障。托管服务提供商的这些部件要少得多。同样,复杂的云服务,如RDS,比一些简单的服务(虚拟机)有更多的故障场景。 但是如果我使用云架构,我可以开发得更快 那就去用云,只是需要多关注账单,并考虑什么时间点值得切换。这个理由可能是支持使用云架构的最有力论据。然而,如果你在成长过程中不更多地思考,你很可能会在需要切换到更“无趣”的方案之后,在云架构上浪费大量金钱。 我的工作负载真的很突发 那就拥抱云吧。这是使用Serverless服务等工具的绝佳理由。云架构的一大优点是它能很好地向下缩减。如果你的工作负载长时间处于空闲状态,然后突然出现大量不可预测的活动,那么云架构应该会非常适合你。 CDN 呢? 通过一台高配服务器,不可能获得 CDN 的优势,包括延迟改善和带宽节省。对于其他需要分布式部署的系统,如备份,情况也是如此。幸运的是,CDN 和备份是竞争激烈的市场,相对便宜。这些都是值得购买而不是自行构建的东西。 关于微服务架构和单体架构的一些说明 说起“使用一台高配服务器”这个话题,自然地就会与单体架构联系起来。然而,你不必使用单体架构才能使用一台服务器。你可以在一台高配服务器上运行许多容器,每个容器一个微服务。但是,当你在一台高配服务器上运行微服务架构时,通常会给整个系统带来大量开销,而获得的收益却值得怀疑。 结论 当你遇到增长瓶颈,接近当前服务器的极限时,如今的传统观念是采用分片和横向扩展,或者使用能“免费”提供横向扩展能力的云架构。然而,通常垂直扩展会更容易、更高效。使用一台高配服务器相对便宜,能将开销降到最低,而且如果你注意预防一些批次性的硬件故障,它实际上拥有相当好的可用性保障。它不那么光鲜亮丽,也无助于你的简历,但一台高配服务器将很好地为你服务。

2025/9/8
articleCard.readMore

一个Solidigm P41 Plus的冷数据问题

现在用的笔记本电脑是公司去年发的HP EliteBook 640 G10,机器自带了一块Solidigm的SSD,从型号上应该就是P41 Plus的OEM版本,最近使用中发现机器越来越慢了,一开始以为是内存用的多,直到有一次重启发现了一些异常,这机器重启竟然需要20分钟的时间!重启进入桌面之后,系统也非常卡顿,完全没办法使用,好不容易打开任务管理器,发现磁盘一直100%占用,响应时间已经飙上了天。很显然,磁盘遇到问题了。 问题分析 由于和磁盘相关,赶紧用CrystalDiskInfo看看是不是出现0E错误了,之前三星还有其他品牌的SSD,出现0E问题的案例不少,要是有问题,我就赶紧备份数据准备报修换盘了。 但是看了SMART信息,发现并没有什么异常,所有的数值都在正常范围内,连警告都没有,写入量也很低,不到30T: 从数据上看一切正常,但实际表现呢?肯定还是有问题的,这时候开始怀疑是有些冷数据的问题,毕竟这块盘一开始使用的时候是没有问题的,现在使用了将近1年时间,刚好又是QLC颗粒,如果颗粒漏电导致需要ECC纠错的情况还是有可能的。 拷贝测试 尝试找了一个大文件,进行拷贝测试,看看实际的读写速度如何。 看起来确实有点问题,一方面在拷贝过程中,速度会降到十几M/s,另外一方面,从任务管理器看磁盘的性能数据,延迟已经接近1s了,这个性能数据对于一块PCIe 4.0的NVMe SSD来说,实在是太差了。现在的性能基本就是一个大号U盘的水平了。 底层读取测试 感觉已经找到了问题,但还是有点不放心,比较拷贝文件这个动作,和文件系统,还有cache以及磁盘写入性能等等都相关,有没有什么工具可以绕过文件系统,直接读原始磁盘,并且不写入磁盘呢?这样可以更好的反映SSD本身的性能了,Linux下有个非常好用的工具fio,专门用于做类似的事情,不过使用起来有点麻烦,于是又想到另外一个工具:dd。相比fio来说,dd本身也不是一个磁盘测试工具,只能进行简单的读写操作,功能上也差距很大,但架不住简单啊,针对当前的需求,也够用了。 于是就找到了一个DD for Windows,使用管理员权限的Powershell执行一下: PS C:\WINDOWS\system32> dd.exe if=\\.\PhysicalDrive0 of=/dev/null bs=1M --progressrawwrite dd for windows version 0.6beta3.Written by John Newbigin <jn@it.swin.edu.au>This program is covered by terms of the GPL Version 2.202,227M 执行过程中再看看任务管理器的性能数据: 可以看到在纯读的场景,性能依然是非常不稳定的,一方面读取速度和过山车一样,忽高忽低,另外一方面,延迟也是接近100ms,虽然相比拷贝文件时的延迟好一点,但依然不是一块NVMe的正常性能。 网络案例和报修 又尝试搜索了一下网络上的案例,也许是这块盘用的人确实不多的原因,几乎没找到有人明确反馈类似的问题,最后找到一个帖子:NVMe P41PL NVMe SOLIDIGM 512GB也有“SSD冷数据门”问题。这个帖子里说的是DELL的机器,也是P41 Plus这块盘,只不过是DELL的OEM版本。不过问题确实也是类似的,开机慢,读取慢。概率也不是很高,大概也就5%的样子。看来不是我一个人的问题啊,既然这样,还是尝试找下机器的售后吧,希望能给我换一块盘才是最好的。 可惜经过了一番和HP售后的强力对线,HP的售后依然没办法以性能相关的问题给我换盘,只能当磁盘出现0E或者其他告警时,才能走换盘流程😂。 DiskFresh 既然售后不给换盘,我也没什么更好的办法了,只能给HP打差评了。 好在上面的帖子里说了一个工具DiskFresh,可以用来复写一下磁盘。理论上如果因为NAND漏电,冷数据会出现读取问题,但是对于新写入的数据,这个问题不应该存在,DiskFresh就是这样一个工具,它可以读取所有扇区的数据,并且写回源扇区,将“老”数据变成“新”数据,从而解决类似冷数据的问题。对于SSD来说,即使写入时扇区号是一样的,但是由于磨损均衡等等算法,新写的数据极大概率已经不在原始的NAND块上了。应该可以解决遇到的问题。 虽然这个工具是2013年的,距离现在已经十几年了,不过值得一试!使用也非常简单,直接选择全盘Refresh就行了: 经过漫长的时间(真的很慢😫),终于完成重写入: 再尝试dd一下,发现读取速度立马接近1G/s了。延迟也降到了900us,性能恢复了。只是不知道这个性能能够坚持多久,毕竟本质上还是由于NAND体质问题影响了数据,重新写入的数据很快又会经历漏电变冷的过程,只能到时再继续刷新了。

2025/8/25
articleCard.readMore

测试ARP/ND双发效果的小工具

在上一篇Blog里说了一下关于ARP/ND双发的实现,但是还遗留了一个小问题,就是如何测试最终的效果,毕竟正常情况下,ARP还有ND相关的报文,都是由内核协议栈根据需要发出的,不太稳定,总不能一直抓包等着内核发包吧?所以还是需要借助一些工具来实现。 ARP双发检测 ARP双发的测试还是比较简单的,毕竟大家都知道arping这个工具,可以用来发送ARP请求并接收ARP应答。使用起来也是非常顺畅的: # arping 192.68.100.1 -c 2ARPING 192.68.100.1 from 192.68.100.21 eth0Unicast reply from 192.68.100.1 [00:11:22:33:44:01] 1.315msUnicast reply from 192.68.100.1 [00:11:22:33:44:01] 1.355msUnicast reply from 192.68.100.1 [00:11:22:33:44:01] 1.112msUnicast reply from 192.68.100.1 [00:11:22:33:44:01] 1.233msSent 2 probes (1 broadcast(s))Received 4 response(s) 可以发现,arping工具发送了2个ARP请求,成功地收到了4次ARP应答,这表明ARP双发功能正常。两次请求中,第一次是广播请求,可以模拟第一次学习MAC地址时的场景,第二次是单播请求,可以模拟已有MAC后的确认场景,尝试抓包,也是可以看到结果是符合预期的: # sudo tcpdump -i eth0 arp -nnndropped privs to tcpdumptcpdump: verbose output suppressed, use -v[v]... for full protocol decodelistening on eth0, link-type EN10MB (Ethernet), snapshot length 262144 bytes20:16:03.982709 ARP, Request who-has 192.68.100.1 (ff:ff:ff:ff:ff:ff) tell 192.68.100.21, length 2820:16:03.983231 ARP, Reply 192.68.100.1 is-at 00:11:22:33:44:01, length 4620:16:03.983298 ARP, Reply 192.68.100.1 is-at 00:11:22:33:44:01, length 4620:16:04.982738 ARP, Request who-has 192.68.100.1 (00:11:22:33:44:01) tell 192.68.100.21, length 2820:16:04.983343 ARP, Reply 192.68.100.1 is-at 00:11:22:33:44:01, length 4620:16:04.983412 ARP, Reply 192.68.100.1 is-at 00:11:22:33:44:01, length 466 packets captured6 packets received by filter0 packets dropped by kernel ND双发检测 接下来轮到IPv6的ND报文了,这里介绍一个工具包NDisc6,可以部分替代arping的功能,为什么是部分替代呢?因为和arping不同,ndisc6不支持发送单播NS报文,只支持发送组播报文,这样就只能模拟第一次学习的情况,没办法模拟后续了,我们先用这个工具模拟一下组播的场景: # sudo ndisc6 2408:fffe::1 eth0 -mSoliciting 2408:fffe::1 (2408:fffe::1) on eth0...Target link-layer address: 00:10:00:54:00:24 from 2408:fffe::1Target link-layer address: 00:10:00:54:00:24 from 2408:fffe::1 可以看到发送一个NS,收到两个NA,说明ND双发功能也是正常的。尝试抓包,也是可以看到结果是符合预期的: # sudo tcpdump -i eth0 icmp6 -nnndropped privs to tcpdumptcpdump: verbose output suppressed, use -v[v]... for full protocol decodelistening on eth0, link-type EN10MB (Ethernet), snapshot length 262144 bytes20:18:18.959590 IP6 fe80::f816:3eff:fefa:556f > ff02::1:ff00:1: ICMP6, neighbor solicitation, who has 2408:fffe::1, length 3220:18:18.961594 IP6 2408:fffe::1 > fe80::f816:3eff:fefa:556f: ICMP6, neighbor advertisement, tgt is 2408:fffe::1, length 3220:18:18.962687 IP6 2408:fffe::1 > fe80::f816:3eff:fefa:556f: ICMP6, neighbor advertisement, tgt is 2408:fffe::1, length 323 packets captured3 packets received by filter0 packets dropped by kernel 那单播NS报文怎么办呢?因为没找到合适的工具,因此还是借助AI自己实现了一个脚本来完成这个任务: #!/usr/bin/env python3"""IPv6邻居发现工具 - 类似arping的NS/NA实现使用Scapy发送和接收ICMPv6邻居发现报文"""import argparseimport timefrom scapy.all import Ether, IPv6, ICMPv6ND_NS, ICMPv6ND_NA, srp, get_if_listdef send_ns(target_ip, iface=None, timeout=1, retry=1, verbose=False, dst_mac=None): """ 发送一个NS报文并等待所有NA响应 :param target_ip: 目标IPv6地址 :param iface: 网络接口名称 :param timeout: 超时时间(秒) :param retry: 重试次数(仅在没有收到任何响应时重试) :param verbose: 详细输出模式 :param dst_mac: 目标MAC地址(单播模式) :return: 响应列表 [(src_ip, src_mac), ...] 或 [] """ if iface and iface not in get_if_list(): print(f"警告: 接口 {iface} 不存在") return [] dst_mac = dst_mac if dst_mac else "33:33:ff:00:00:00" ns_pkt = Ether(dst=dst_mac) / \ IPv6(dst=target_ip) / \ ICMPv6ND_NS(tgt=target_ip) if verbose: print("发送NS报文:") ns_pkt.show() all_responses = [] for attempt in range(retry): if verbose: print(f"尝试 {attempt+1}/{retry}...") # 发送一个NS报文并收集所有响应,在超时时间内持续监听 answered, unanswered = srp(ns_pkt, iface=iface, timeout=timeout, verbose=0, multi=True) # 处理所有收到的响应 for sent, received in answered: if received.haslayer(ICMPv6ND_NA): src_ip = received[IPv6].src src_mac = received[Ether].src response_info = (src_ip, src_mac) all_responses.append(response_info) if verbose: print(f"收到NA响应 #{len(all_responses)}:") received.show() # 如果收到了响应,就不再重试 if all_responses: if verbose: print(f"收到 {len(all_responses)} 个NA响应,停止重试") break elif verbose: print("本轮未收到响应") return all_responsesdef main(): parser = argparse.ArgumentParser(description="IPv6邻居发现工具") parser.add_argument("target", help="目标IPv6地址") parser.add_argument("-i", "--iface", help="网络接口名称") parser.add_argument("-t", "--timeout", type=int, default=1, help="超时时间(秒)") parser.add_argument("-c", "--count", type=int, default=1, help="重试次数(默认1次,仅在无响应时重试)") parser.add_argument("-v", "--verbose", action="store_true", help="详细输出模式") parser.add_argument("-m", "--mac", help="指定目标MAC地址(单播模式)") args = parser.parse_args() if args.verbose: print("可用网络接口:", ", ".join(get_if_list())) results = send_ns(args.target, args.iface, args.timeout, args.count, args.verbose, args.mac) if results: print(f"\n收到 {len(results)} 个NA响应:") for i, (ip, mac) in enumerate(results, 1): print(f" {i}. {args.target} 的MAC地址是 {mac} (来自 {ip})") else: print(f"\n无法获取 {args.target} 的MAC地址")if __name__ == "__main__": main() 借助这个脚本,就可以实现发送单播NS,并收集所有的ND返回: # sudo python3 ip6ndisc.py 2408:fffe::1 -m 00:10:00:54:00:24收到 2 个NA响应: 1. 2408:fffe::1 的MAC地址是 00:10:00:54:00:24 (来自 2408:fffe::1) 2. 2408:fffe::1 的MAC地址是 00:10:00:54:00:24 (来自 2408:fffe::1) 抓包也符合预期: # sudo tcpdump -i eth0 icmp6 -nnndropped privs to tcpdumptcpdump: verbose output suppressed, use -v[v]... for full protocol decodelistening on eth0, link-type EN10MB (Ethernet), snapshot length 262144 bytes14:27:25.782766 IP6 2408:fffe::21:1 > 2408:fffe::1: ICMP6, neighbor solicitation, who has 2408:fffe::1, length 2414:27:25.784537 IP6 2408:fffe::1 > 2408:fffe::21:1: ICMP6, neighbor advertisement, tgt is 2408:fffe::1, length 3214:27:25.784739 IP6 2408:fffe::1 > 2408:fffe::21:1: ICMP6, neighbor advertisement, tgt is 2408:fffe::1, length 323 packets captured3 packets received by filter0 packets dropped by kernel

2025/8/14
articleCard.readMore

硬件Bonding卸载场景下的ARP/ND双发

大约在2019年的时候,公司的服务器接入网络架构开始向双上联去堆叠方向迁移,相比于之前老的接入网络而言,新的网络架构在各方面的提升都非常明显,尤其是在带宽利用率和冗余性方面,关于网络架构的部分,这里暂时就不多做介绍了,具体的可以参考京东以及H3C的相关分享和文档:异构去堆叠 | 一种完美提升网络高可用SLA的方案,H3C数据中心交换机S-MLAG最佳实践。 在新的网络架构中,我们的方案是通过ARP转主机路由方式来实现网络层面的负载均衡和高可用的,这个方案有个依赖,需要主机实现ARP/ND相关协议包的双发。 Bonding的双发问题 为什么会有这个双发的需求呢?因为在我们选择的去堆叠方案中,主机的网卡是通过Bonding的方式来实现双上联的,要想实现对负载均衡和高可用的同时支持,Bonding的流量分载只能是基于hash的模式,这会带来一个问题:对于Host侧的ARP请求/响应报文,只会发送给某一台交换机,假设Host连接了LeafA和leafB两台交换机,当LeafA交换机的ARP表项即将过期,需要发送ARP请求探测Host状态时,Host回复的响应报文可能因为hash原因回复给LeafB了,这样一来,LeafA就无法及时获取到Host的最新状态信息,从而影响到整个网络的稳定性和可靠性。 因为上面的这个原因,Host侧内核的Bonding模块必须作出相应的调整,将所有ARP的请求和响应报文,都进行复制,并在所有的子接口上进行发送。当然实现的方法并不麻烦,具体的可以参考龙蜥社区的实现anolis: bond: broadcast ARP or ND messages to all slaves。 至此就基本解决了物理服务器层面对新接入网络架构融合的问题。 虚拟化的网卡Bonding 作为第一个接入新网络架构的虚拟化平台,只解决物理服务器层面的问题还不够,因为虚拟化的核心产品:VM的网络也需要解决。对于虚拟化业务的网卡,选择的都是Mellanox(现NVIDIA)的产品,不得不说,Mellanox的网卡对于虚拟化的场景来说,还是非常友好的,相比于普通的网卡而言,支持很多虚拟化相关的Offload特性,完全可以称得上是SmartNIC。 对于Mellanox的网卡,有个比较重要的特性是OVS Offload Using ASAP² Direct,基于这个特性,可以将虚拟化网络常见的封装等等都卸载到硬件,极大的提升网络的性能,这个特性里还有一个功能SR-IOV VF LAG,可以将Bonding的功能卸载到硬件,这两个特性,就是虚拟化平台最需要的能力。 对于大部分公有云的场景,存在VPC的概念,不同用户的VM之间有租户的隔离,VM与VM之间的通信,会通过overlay网络通信,针对这种场景,ASAP²+SR-IOV VF LAG的功能可以很好的满足需求。VM的流量通过硬件卸载,Bonding通过硬件卸载,VM感知不到Bonding的存在,但是却可以享受到Bonding带来的高可用和负载均衡。与此同时,因为VM所有的流量都会额外封装一层隧道,所以对于交换机来说,只能看到宿主机的隧道端点IP地址,因此,对于类似的场景来说,只需要针对宿主机的IP进行ARP/ND双发即可。而这个需求,通过上面的patch就已经可以实现了。 Underlay网络的ARP双发 我们的场景和公有云有些不同,因为我们是一个私有云,并没有VPC的概念,所有的VM也是直接通过underlay网络通信的,这个网络模型,其实有点像早期公有云的经典网络。相比overlay网络而言,underlay网络的最大好处就是简单,所有VM的流量,只需要额外打上一个VLAN TAG即可,整个网络是扁平的,可以规避很多overlay的问题。并且作为一个私有云,所有的用户就是我们自己,也没有像公有云那样的租户隔离需求,因此使用这个模型是很自然的。 但是这也带来了一个原本不存在的问题:因为VM的流量是直接通过underlay网络通信的,所以VM的ARP/ND请求也需要“双发”了。一个可行的办法是把网络的结构暴露给VM,在VM里也打上bonding,然后就可以复用上面的patch,和物理服务器一样实现ARP/ND双发。很显然这不是想要的,既然提供了VM服务,就应该给用户更好的体验,尽可能隔离掉底层的这些细节。 这时候SR-IOV VF LAG的功能就派上用场了,SR-IOV VF LAG的功能可以将Bonding的功能卸载到硬件,这样一来,VM就感知不到Bonding的存在了。但是用上VF LAG之后,VM的流量都会被卸载到硬件里,就无法直接在网络层面实现ARP/ND双发了,因为硬件卸载的Bonding并不支持ARP/ND双发。当然修改网卡的固件也许能够解决这个问题,但定制固件的方案在时间和成本上都不太可行。 我们先看看在VF LAG的场景下,网卡硬件是如何转发流量的。在ASAP²的场景下,网卡工作在一个叫做“switchdev”的模式下。在这个模式下,网卡化身为一个交换机,这个交换机,可以根据“流表”来转发流量。这个流表是由OVS来管理的,当网卡中一条流表规则也没有的时候,网卡默认会把网络包原封不动的转发给OVS,靠OVS实现流表的学习和转发,一旦OVS学习到流表之后,再通过Linux的TC flower或者DPDK的rte_flow下发给网卡,当有后续的流量可以匹配到对应的流表规则,网卡会直接根据流表规则转发流量,不再把流量转发给OVS。 那流表长啥样?下面是一个线上实际的OVS流表例子和一些解释: # 00:11:22:33:44:01是交换机(网关)的MAC地址,port(1)是交换机侧在OVS里的代表口# 52:11:22:33:44:55是VM的MAC地址,port(2)是VM侧在OVS里的代表口# 交换机发送的ARP请求,将VLAN TAG去掉,转发给VMrecirc_id(0),in_port(1),eth(src=00:11:22:33:44:01,dst=52:11:22:33:44:55),eth_type(0x8100),vlan(vid=1000,pcp=0),encap(eth_type(0x0806)), packets:7, bytes:448, used:0.067s, actions:pop_vlan,2# VM发送的ARP请求,打上VLAN TAG,转发给交换机recirc_id(0),in_port(2),eth(src=52:11:22:33:44:55,dst=00:11:22:33:44:01),eth_type(0x0806), packets:2, bytes:104, used:0.081s, actions:push_vlan(vid=1000,pcp=0),1# 交换机发送的IPv4数据包(eth_type 0x0800是IPv4),将VLAN TAG去掉,转发给VMrecirc_id(0),in_port(1),eth(src=00:11:22:33:44:01,dst=52:11:22:33:44:55),eth_type(0x8100),vlan(vid=1000,pcp=0),encap(eth_type(0x0800)), packets:22772, bytes:4038655, used:2.350s, actions:pop_vlan,2# VM发送的IPv4数据包,打上VLAN TAG,转发给交换机recirc_id(0),in_port(2),eth(src=52:11:22:33:44:55,dst=00:11:22:33:44:01),eth_type(0x0800), packets:26714, bytes:3151911, used:0.300s, actions:push_vlan(vid=1000,pcp=0),1# 交换机发送的IPv6数据包(eth_type 0x86dd是IPv6),将VLAN TAG去掉,转发给VMrecirc_id(0),in_port(1),eth(src=00:11:22:33:44:01,dst=52:11:22:33:44:55),eth_type(0x8100),vlan(vid=1000,pcp=0),encap(eth_type(0x86dd)), packets:111019, bytes:140906750, used:1.490s, actions:pop_vlan,2# VM发送的IPv6数据包,打上VLAN TAG,转发给交换机recirc_id(0),in_port(2),eth(src=52:11:22:33:44:55,dst=00:11:22:33:44:01),eth_type(0x86dd), packets:96546, bytes:8451184, used:1.490s, actions:push_vlan(vid=1000,pcp=0),1 可以看到,因为这几条流表的存在,交换机和VM之间的流量几乎都可以被匹配上,直接在硬件中进行转发。 由于ARP是作为独立的一个EtherType 0x0806进行传输的,仔细观察流表就不难发现ARP的流量有两条单独的流表规则,默认情况下,ARP的流表也会被写入到硬件中,从而实现硬件的offload加速,而我们需要进行ARP的双发,那第一步肯定是不希望ARP的流表被写入到硬件中,这样一来,ARP的流量会被硬件上送到OVS,由OVS来处理,刚好一个关键点是在流表的卸载方式选择上,我们使用了OVS-Kernel的方式,OVS处理的流量最终会通过内核进行转发,这样一来,ARP相关的报文自然而然的进入到了内核Bonding模块处理,而双发的逻辑也就起了作用。 那理论上分析可行,剩下的就是需要修改一下OVS的代码了,实现起来倒是也不困难,OVS针对硬件流表的下发,都放在lib/netdev-offload-tc.c这个文件中。所有的流表下发逻辑在netdev_tc_flow_put函数中,因此只需要在这个函数中添加一个判断,如果是ARP的流表,直接返回EOPNOTSUPP即可。 Underlay网络的ND双发 相比IPv4的ARP来说,IPv6的ND双发就有些麻烦了,一方面,用于IPv6邻居发现的Neighbor Discovery(ND)协议是通过ICMPv6来实现的,另一方面,ICMPv6的报文并没有单独的EtherType,而是和IPv6数据包一起共用了0x86DD这个EtherType,从上面的流表也可以看出来,对于IPv6的流量,默认情况下只会有一组流表规则,根本区分不出业务的TCP/UDP流量和ICMPv6的数据报文,更区分不出ICMPv6中更细的ND相关的报文。 但是理论上,只要类似ARP那样,让硬件匹配不到,或者让硬件匹配到,然后上送给OVS处理,就可以类似ARP那样实现ND的双发了。 通过NVIDIA提供的文档Classification Fields (Matches),可以看到网卡是支持匹配IPv4/IPv6的TCP/UDP/ICMP/ICMPv6这几个Protocol的,既然可以匹配ICMPv6,那就尝试手动添加一条流表,看看是否可以匹配上。 # 添加一条ICMPv6的流表规则,匹配ICMPv6# ovs-ofctl add-flow ovs-sriov "table=0,priority=200,icmp6 actions=normal"# 再尝试dump当前学习到的流表# ovs-appctl dpctl/dump-flows|grep 0x86ddrecirc_id(0),in_port(2),eth(src=52:11:22:33:44:55,dst=00:11:22:33:44:01),eth_type(0x86dd),ipv6(proto=58), packets:1, bytes:1, used:0s, actions:push_vlan(vid=1000,pcp=0),1recirc_id(0),in_port(1),eth(src=00:11:22:33:44:01,dst=52:11:22:33:44:55),eth_type(0x8100),vlan(vid=1000,pcp=0),encap(eth_type(0x86dd),ipv6(proto=58)), packets:1, bytes:1, used:0s, actions:pop_vlan,2recirc_id(0),in_port(2),eth(src=52:11:22:33:44:55,dst=00:11:22:33:44:01),eth_type(0x86dd),ipv6(proto=17), packets:3, bytes:210, used:1.560s, actions:push_vlan(vid=1000,pcp=0),1recirc_id(0),in_port(1),eth(src=00:11:22:33:44:01,dst=52:11:22:33:44:55),eth_type(0x8100),vlan(vid=1000,pcp=0),encap(eth_type(0x86dd),ipv6(proto=17)), packets:0, bytes:0, used:1.560s, actions:pop_vlan,2recirc_id(0),in_port(2),eth(src=52:11:22:33:44:55,dst=00:11:22:33:44:01),eth_type(0x86dd),ipv6(proto=6), packets:49, bytes:3923, used:2.850s, actions:push_vlan(vid=1000,pcp=0),1recirc_id(0),in_port(1),eth(src=00:11:22:33:44:01,dst=52:11:22:33:44:55),eth_type(0x8100),vlan(vid=1000,pcp=0),encap(eth_type(0x86dd),ipv6(proto=6)), packets:411, bytes:605503, used:2.850s, actions:pop_vlan,2 可以看到,IPv6的TCP(proto=6)、UDP(proto=17)和ICMPv6(proto=58)都被单独匹配出来了,现在的流表规则,已经类似ARP的流表规则了,那接下来理论上参考ARP的修改,继续让ICMPv6的流表不卸载到硬件,就可以实现双发了。 但相比于ARP来说,ICMPv6本身包含的功能还是比较多的,这样修改的话,除了ND相关的报文,IPv6 Ping相关的报文也会被上送OVS处理了,总体来说还是会影响一些效率,是否可以继续拆分呢?比如只把ND相关的报文拆成单独流表不下发,剩下的Ping报文依然走硬件转发。OVS是支持这样的配置的,可以尝试一下,稍微修改一下下发的流表: # 添加一条ICMPv6的流表规则,匹配ICMPv6的ND报文,icmp_type=135是ND协议中的NS包,也是我们需要双发的包# ovs-ofctl add-flow ovs-sriov "table=0,priority=200,icmp6,icmp_type=135 actions=normal"# ovs-appctl dpctl/dump-flows|grep 0x86ddrecirc_id(0),in_port(2),eth(src=52:11:22:33:44:55,dst=00:11:22:33:44:01),eth_type(0x86dd),ipv6(proto=58),icmpv6(type=135), packets:0, bytes:0, used:0s, actions:push_vlan(vid=1000,pcp=0),1recirc_id(0),in_port(1),eth(src=00:11:22:33:44:01,dst=52:11:22:33:44:55),eth_type(0x8100),vlan(vid=1000,pcp=6),encap(eth_type(0x86dd),ipv6(proto=58),icmpv6(type=136/0xfc)), packets:0, bytes:0, used:0s, actions:pop_vlan,2recirc_id(0),in_port(2),eth(src=52:11:22:33:44:55,dst=00:11:22:33:44:01),eth_type(0x86dd),ipv6(proto=58),icmpv6(type=type=128/0xfc), packets:0, bytes:0, used:0s, actions:push_vlan(vid=1000,pcp=0),1recirc_id(0),in_port(1),eth(src=00:11:22:33:44:01,dst=52:11:22:33:44:55),eth_type(0x8100),vlan(vid=1000,pcp=6),encap(eth_type(0x86dd),ipv6(proto=58),icmpv6(type=type=129/0xfc)), packets:0, bytes:0, used:0s, actions:pop_vlan,2 可以看到,NS(type=135)和NA(type=136)的流表规则已经被拆分出来了,同样的Ping的Echo Request(type=128)和Echo Reply(type=129)的流表规则也被拆分出来了,理论上也可以如法炮制,只让NS报文相关的流表不下发到硬件,这样一来,ICMPv6的双发就可以实现了。但这里有一个问题,就是网卡其实不支持匹配更细致的报文了,直接看一下下发到硬件中的流表和对应的状态: # 由于OVS-Kernel使用TC下发流表规则到网卡驱动,所以可以通过tc命令查询流表和对应的状态# tc filter show dev vf0 ingressfilter protocol ipv6 pref 3 flower chain 0filter protocol ipv6 pref 3 flower chain 0 handle 0x1 dst_mac 00:11:22:33:44:01 src_mac 52:11:22:33:44:55 eth_type ipv6 ip_proto icmpv6 icmp_type 135 not_in_hw action order 1: vlan push id 1000 protocol 802.1Q priority 0 pipe index 8 ref 1 bind 1 no_percpu action order 2: mirred (Egress Redirect to device bond0) stolen index 11 ref 1 bind 1 cookie b332255e9d440e02192dbb9b04d20332 no_percpu 可以发现,虽然OVS生成了NS报文的流表,并且也成功的转成TC规则并下发,但dump出来的规则里not_in_hw表示这条规则并没有下发到硬件中,网卡在这种场景下不支持匹配ICMPv6的type字段,所以即使像之前一样修改OVS不下发该流表,最终网卡的行为都是一致的。 虽然没办法精确的将NS报文从硬件中拾取出来,但将ICMPv6报文全部上送软件处理,也算是可以接受的方案了,即使有些不完美,考虑到大部分业务的流量还是走的TCP/UDP,对业务的影响应该微乎其微。 总结 虽然最终没有达到最完美的状态,但最终还是实现了在硬件Bonding卸载的场景下,ARP和ND的双发。经过这几年的实践,这个网络方案在我们的私有云上运行的非常稳定,一方面,因为场景和公有云的差异,选择了一个比较简单的Underlay+OVS-Kernel卸载的方案,而不是基于DPDK的方案,另一方面,正是基于这样的选择,带来了额外的VM双发需求,只能说环环相扣,处处充满妥协了😀。 那么未来呢,未来的虚拟化网络是属于DPU(或者说IPU)的,借助DPU的帮助,终于可以在固件层面做一些想做的事了,类似的问题也就不是问题了,未来值得期待。

2025/7/18
articleCard.readMore

“无限”套娃,在WSL的Docker中使用YOLOv11做目标检测

前几天偶然发现,Windows 11的WSL2可以通过WSLg来无缝使用GUI应用。类似推理/训练等任务,都不在话下,这瞬间勾起了我的好奇心,决定试试微软提供的这个神奇功能。其实微软在2021年就发布了WSLg,现在已经是2025年了,刚刚开始折腾也算是后知后觉了。 WSL支持GUI应用只能算是WSLg的一个最简单的功能了,当涉及模型训练或者GPU加速时,GPU驱动还有CUDA等等相关的配置就会变得复杂起来。我并不希望因为“折腾”这么一下,就把我的WSL环境搞得一团糟,这时候,Docker的价值就体现出来了。利用Docker,可以在不修改现有WSL环境的情况下,快速搭建一个隔离的环境来做些简单的测试。我决定使用YOLO作为测试对象,看看在WSL+WSLg+Docker的场景下,YOLO还能不能很好的工作,正确使用我的GPU进行加速。 确认WSLg的正确安装 理论上,WSLg是WSL默认启用的功能,当你安装完成WSL2,并且安装了Ubuntu等Linux发行版后,就可以直接使用GUI应用了。具体的安装方法,这里就不多说了,毕竟微软的文档在适用于 Linux 的 Windows 子系统上运行 Linux GUI 应用写的非常的详细: 安装完成后,如果WSLg被正确启用的话,应该就可以看到: # mount|grep wslgnone on /mnt/wslg type tmpfs (rw,relatime)/dev/sdc on /mnt/wslg/distro type ext4 (ro,relatime,discard,errors=remount-ro,data=ordered)none on /mnt/wslg/versions.txt type overlay (rw,relatime,lowerdir=/systemvhd,upperdir=/system/rw/upper,workdir=/system/rw/work)none on /mnt/wslg/doc type overlay (rw,relatime,lowerdir=/systemvhd,upperdir=/system/rw/upper,workdir=/system/rw/work)tmpfs on /mnt/wslg/run/user/1000 type tmpfs (rw,nosuid,nodev,relatime,size=809692k,nr_inodes=202423,mode=700,uid=1000,gid=1000) 与此同时,在不用安装任何Linux版本GPU驱动的情况下,应该也可以正确识别到GPU设备: # which nvidia-smi/usr/lib/wsl/lib/nvidia-smi# nvidia-smiSun Jun 8 19:59:36 2025+-----------------------------------------------------------------------------------------+| NVIDIA-SMI 560.35.02 Driver Version: 560.94 CUDA Version: 12.6 ||-----------------------------------------+------------------------+----------------------+| GPU Name Persistence-M | Bus-Id Disp.A | Volatile Uncorr. ECC || Fan Temp Perf Pwr:Usage/Cap | Memory-Usage | GPU-Util Compute M. || | | MIG M. ||=========================================+========================+======================|| 0 NVIDIA GeForce RTX 4070 ... On | 00000000:2B:00.0 On | N/A || 0% 45C P8 7W / 285W | 895MiB / 16376MiB | 4% Default || | | N/A |+-----------------------------------------+------------------------+----------------------++-----------------------------------------------------------------------------------------+| Processes: || GPU GI CI PID Type Process name GPU Memory || ID ID Usage ||=========================================================================================|| 0 N/A N/A 25 G /Xwayland N/A |+-----------------------------------------------------------------------------------------+ 以我的这台主机为例,可以看到nvidia-smi可以正常识别到GPU(甚至nvidia-smi也不是我安装的,微软直接帮我挂载了) 再试试运行一个GUI应用: # sudo apt install -y x11-apps# /usr/bin/xcalc 安装NVIDIA的容器运行环境 由于要使用Docker并且还需要在Docker里使用GPU,Docker的安装比较简单,在Ubuntu里,直接sudo apt install -y docker.io即可,而对于我的NVIDIA显卡来说,还需要安装NVIDIA的容器运行环境,这里可以参考NVIDIA的文档Installing the NVIDIA Container Toolkit进行安装,安装完成之后,可以跑一个简单的测试,确认一下安装是否成功,以及容器里是否可以正确识别并调用到GPU: # sudo docker run --gpus all --runtime=nvidia nvcr.io/nvidia/k8s/cuda-sample:nbody nbody -gpu -benchmarkRun "nbody -benchmark [-numbodies=<numBodies>]" to measure performance. -fullscreen (run n-body simulation in fullscreen mode) -fp64 (use double precision floating point values for simulation) -hostmem (stores simulation data in host memory) -benchmark (run benchmark to measure performance) -numbodies=<N> (number of bodies (>= 1) to run in simulation) -device=<d> (where d=0,1,2.... for the CUDA device to use) -numdevices=<i> (where i=(number of CUDA devices > 0) to use for simulation) -compare (compares simulation results running once on the default GPU and once on the CPU) -cpu (run n-body simulation on the CPU) -tipsy=<file.bin> (load a tipsy model file for simulation)NOTE: The CUDA Samples are not meant for performance measurements. Results may vary when GPU Boost is enabled.> Windowed mode> Simulation data stored in video memory> Single precision floating point simulation> 1 Devices used for simulationMapSMtoCores for SM 8.9 is undefined. Default to use 128 Cores/SMMapSMtoArchName for SM 8.9 is undefined. Default to use AmpereGPU Device 0: "Ampere" with compute capability 8.9> Compute 8.9 CUDA device: [NVIDIA GeForce RTX 4070 Ti SUPER]67584 bodies, total time for 10 iterations: 41.515 ms= 1100.228 billion interactions per second= 22004.559 single-precision GFLOP/s at 20 flops per interaction 可以看到在容器里看到并且使用了我的GPU。 在Docker里使用YOLOv11 接下来就可以在Docker里使用YOLOv11了。由于官方已经给我们打好了Docker镜像,所以只需要拉取镜像并运行即可,因为我还想能将推理结果实时的显示出来,所以还需要处理一些显示相关的环境变量,需要注意的是,虽然YOLO的文档里也有在Docker中使用GUI的说明,但这里需要参考WSLg的文档Containerizing GUI applications with WSLg来启动容器: # sudo docker run -it -v /tmp/.X11-unix:/tmp/.X11-unix -v /mnt/wslg:/mnt/wslg -e DISPLAY=$DISPLAY -e WAYLAND_DISPLAY=$WAYLAND_DISPLAY -e XDG_RUNTIME_DIR=$XDG_RUNTIME_DIR -e PULSE_SERVER=$PULSE_SERVER --net=host --ipc=host --gpus all ultralytics/ultralytics:latestroot@DESKTOP:/ultralytics# yolo predict model=yolo11n.pt show=TrueWARNING ⚠️ 'source' argument is missing. Using default 'source=/ultralytics/ultralytics/assets'.WARNING ⚠️ Environment does not support cv2.imshow() or PIL Image.show()OpenCV(4.11.0) /io/opencv/modules/highgui/src/window.cpp:1301: error: (-2:Unspecified error) The function is not implemented. Rebuild the library with Windows, GTK+ 2.x or Cocoa support. If you are on Ubuntu or Debian, install libgtk2.0-dev and pkg-config, then re-run cmake or configure script in function 'cvShowImage'Ultralytics 8.3.151 🚀 Python-3.11.12 torch-2.7.0+cu126 CUDA:0 (NVIDIA GeForce RTX 4070 Ti SUPER, 16376MiB)YOLO11n summary (fused): 100 layers, 2,616,248 parameters, 0 gradients, 6.5 GFLOPsimage 1/2 /ultralytics/ultralytics/assets/bus.jpg: 640x480 4 persons, 1 bus, 47.2msimage 2/2 /ultralytics/ultralytics/assets/zidane.jpg: 384x640 2 persons, 1 tie, 41.1msSpeed: 5.1ms preprocess, 44.2ms inference, 93.1ms postprocess per image at shape (1, 3, 384, 640)Results saved to /ultralytics/runs/detect/predict💡 Learn more at https://docs.ultralytics.com/modes/predict 发现出了点小问题,虽然可以正确调用到GPU,但是没办法实时显示推理结果,从日志上看,有一条报错: OpenCV(4.11.0) /io/opencv/modules/highgui/src/window.cpp:1301: error: (-2:Unspecified error) The function is not implemented. Rebuild the library with Windows, GTK+ 2.x or Cocoa support. If you are on Ubuntu or Debian, install libgtk2.0-dev and pkg-config, then re-run cmake or configure script in function 'cvShowImage' 看起来是opencv出现了一些兼容性的问题,经过一番搜索,发现了一个issue,也是同样的问题,因为默认ultralytics/ultralytics:latest镜像里安装了opencv-python-headless,并没有对GUI的支持,因此需要重新安装一下opencv-python: root@DESKTOP:/ultralytics# pip uninstall opencv-python-headless opencv-python -yFound existing installation: opencv-python-headless 4.11.0.86Uninstalling opencv-python-headless-4.11.0.86: Successfully uninstalled opencv-python-headless-4.11.0.86Found existing installation: opencv-python 4.11.0.86Uninstalling opencv-python-4.11.0.86: Successfully uninstalled opencv-python-4.11.0.86root@DESKTOP:/ultralytics# pip install opencv-python==4.11.0.86Collecting opencv-python==4.11.0.86 Downloading opencv_python-4.11.0.86-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.metadata (20 kB)Requirement already satisfied: numpy>=1.21.2 in /opt/conda/lib/python3.11/site-packages (from opencv-python==4.11.0.86) (2.1.3)Downloading opencv_python-4.11.0.86-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (63.0 MB) ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 63.0/63.0 MB 753.5 kB/s eta 0:00:00Installing collected packages: opencv-pythonSuccessfully installed opencv-python-4.11.0.86 重新安装完成后,再次运行YOLO的推理命令: root@DESKTOP:/ultralytics# yolo predict model=yolo11n.pt show=TrueWARNING ⚠️ 'source' argument is missing. Using default 'source=/ultralytics/ultralytics/assets'.Ultralytics 8.3.151 🚀 Python-3.11.12 torch-2.7.0+cu126 CUDA:0 (NVIDIA GeForce RTX 4070 Ti SUPER, 16376MiB)YOLO11n summary (fused): 100 layers, 2,616,248 parameters, 0 gradients, 6.5 GFLOPsimage 1/2 /ultralytics/ultralytics/assets/bus.jpg: 640x480 4 persons, 1 bus, 51.1msimage 2/2 /ultralytics/ultralytics/assets/zidane.jpg: 384x640 2 persons, 1 tie, 41.6msSpeed: 4.1ms preprocess, 46.3ms inference, 72.4ms postprocess per image at shape (1, 3, 384, 640)Results saved to /ultralytics/runs/detect/predict2💡 Learn more at https://docs.ultralytics.com/modes/predict 这次没有报错了,并且可以看到一闪而过的推理结果窗口: 嘿嘿,效果不错。 实时视频检测 从网上找一段视频,再看看实时推理的效果: root@DESKTOP:/ultralytics# yolo predict model=yolo11n.pt show=True source=car.mp4Ultralytics 8.3.151 🚀 Python-3.11.12 torch-2.7.0+cu126 CUDA:0 (NVIDIA GeForce RTX 4070 Ti SUPER, 16376MiB)YOLO11n summary (fused): 100 layers, 2,616,248 parameters, 0 gradients, 6.5 GFLOPsvideo 1/1 (frame 1/617) /ultralytics/car.mp4: 384x640 2 persons, 14 cars, 2 buss, 1 truck, 44.8msvideo 1/1 (frame 2/617) /ultralytics/car.mp4: 384x640 2 persons, 14 cars, 2 buss, 1 truck, 14.5msvideo 1/1 (frame 3/617) /ultralytics/car.mp4: 384x640 2 persons, 14 cars, 2 buss, 1 truck, 32.5msvideo 1/1 (frame 4/617) /ultralytics/car.mp4: 384x640 2 persons, 14 cars, 2 buss, 1 truck, 19.3msvideo 1/1 (frame 5/617) /ultralytics/car.mp4: 384x640 2 persons, 14 cars, 2 buss, 1 truck, 14.5msvideo 1/1 (frame 6/617) /ultralytics/car.mp4: 384x640 2 persons, 14 cars, 2 buss, 1 truck, 13.2msvideo 1/1 (frame 7/617) /ultralytics/car.mp4: 384x640 3 persons, 13 cars, 2 buss, 2 trucks, 12.0msvideo 1/1 (frame 8/617) /ultralytics/car.mp4: 384x640 3 persons, 13 cars, 2 buss, 2 trucks, 12.3msvideo 1/1 (frame 9/617) /ultralytics/car.mp4: 384x640 3 persons, 14 cars, 1 bus, 2 trucks, 14.2ms...video 1/1 (frame 613/617) /ultralytics/car.mp4: 384x640 12 cars, 2 buss, 4 trucks, 24.0msvideo 1/1 (frame 614/617) /ultralytics/car.mp4: 384x640 14 cars, 2 buss, 3 trucks, 24.2msvideo 1/1 (frame 615/617) /ultralytics/car.mp4: 384x640 14 cars, 2 buss, 4 trucks, 28.3msvideo 1/1 (frame 616/617) /ultralytics/car.mp4: 384x640 13 cars, 2 buss, 3 trucks, 25.6msvideo 1/1 (frame 617/617) /ultralytics/car.mp4: 384x640 12 cars, 2 buss, 3 trucks, 1 traffic light, 22.4msSpeed: 2.7ms preprocess, 16.9ms inference, 2.7ms postprocess per image at shape (1, 3, 384, 640)Results saved to /ultralytics/runs/detect/predict4💡 Learn more at https://docs.ultralytics.com/modes/predict 性能还不错,毕竟也算是比较强力的显卡了,与此同时,还可以可以看到在任务管理器里的GPU使用率: 总结 确实有够折腾的,Windows + WSL + WSLg + Docker + YOLOv11的”无限”套娃组合,无痛的跑起来了YOLO的推理,理论上来说,包括当前LLM等等各种AI应用,都可以在这套体系下很好的工作,Windows不愧是“最好的”Linux发型版。微软还是牛逼啊。

2025/6/8
articleCard.readMore

在三条指令内实现闰年判断

本文是A leap year check in three instructions的翻译,前两天在HackerNews上看到这篇文章,闰年的判断方法,从一开始学编程就是一个经典的练习题,但是深挖下去,作者只用了三条指令就能实现闰年的判断,感觉还挺有意思的,所以就翻译分享一下。 省流不看版(基于DeepSeek总结): 文章介绍了一种使用大约3个CPU指令实现快速闰年检查的方法。这种方法不同于标准算法(涉及模运算和分支),而是利用位操作和魔法数字,将闰年规则(能被4整除,但不能被100整除,除非能被400整除)巧妙地映射到对年份乘以一个常数后的结果进行位范围检查。这种位操作方法对于随机年份输入表现出显著的速度提升,并且针对特定范围已证明是最优解。这项优化技术涉及复杂的位运算细节。 以下是原文的翻译: 通过以下代码,我们可以在大约 3 个 CPU 指令内检查 0 ≤ y ≤ 102499 的年份是否是闰年: bool is_leap_year_fast(uint32_t y) { return ((y * 1073750999) & 3221352463) <= 126976;} 这是如何工作的呢?答案出奇地复杂。本文解释了其中的原理,主要是为了享受位操作的乐趣;最后,我将简要讨论其实际用途。 我们一般使用的基础版闰年检查代码是这样的: bool is_leap_year(uint32_t y) { if ((y % 4) != 0) return false; if ((y % 100) != 0) return true; if ((y % 400) == 0) return true; return false;} 我们使用的是前推格里高利历,它将格里高利历从1582年引入的时间向前延伸,并包含0年。因此,我们无需对1582年之前的年份做特殊处理。为简化起见,我们忽略负数年份,使用无符号年份。 标准方法的优化 我们先做一些简单的优化,以便获得一个良好的基准线。我不确定应该把功劳归给谁,这些技巧可能已经被独立实现很多次了。 我们可以将(y % 100) != 0替换为(y % 25) != 0:我们已经知道y是2²的倍数,所以如果它也是5²的倍数,它就是2² * 5² = 100的倍数。类似地,我们可以将(y % 400) == 0替换为(y % 16) == 0:我们已经知道y是5²的倍数,所以如果它也是2⁴的倍数,它就是5² * 2⁴ = 400的倍数: bool is_leap_year1(uint32_t y) { if ((y % 4) != 0) return false; if ((y % 25) != 0) return true; if ((y % 16) == 0) return true; return false;} 这很有用,因为我们现在可以用位掩码替换对 4 和 16 的取余运算。还有一个编译器实现者熟知的技巧,可以将对 25 的取余运算降低成本。用 gcc 编译(x % 25) != 0并翻译回 C,我们得到x * 3264175145 > 171798691。乘法在典型的延迟约为 3 个周期,而取余运算至少需要 20 个周期,这是一个巨大的改进。我只会简要说明其工作原理的直觉;更多细节可以在以下资源中找到: Faster remainder by direct computation: Applications to compilers and software libraries by Daniel Lemire, Owen Kaser, and Nathan Kurz for lowering modulo by a constant in general; Euclidean affine functions and their application to calendar algorithms by Cassio Neri and Lorenz Schneider more specifically for calendar calculations; and Identifying leap years by David Turner for the leap year check (including formal proofs!). 关于3264175145和171798691这两个”magic numbers”是从哪里来的呢?我们有 232 * 19/25 = 3264175144.96 (这个是精确值) 因此,通过乘以3264175145,我们可以近似得到乘以 (19/25) 的小数部分,如果是乘以3264175144.96这个精确值的话,对于25的倍数,我们将得到一个整数。但实际乘的数是比精确值大了0.04,因此会有最大0.04 * (2³² - 1) = 171798691.8的误差,这也是171798691的来源。 这个技巧对于x % 100效果不太好,需要多一条修正指令,所以从y % 100减少到y % 25还是有必要的。 到这里,我们的代码变成了这样: bool is_leap_year2(uint32_t y) { if ((y & 3) != 0) return false; if (y * 3264175145u > 171798691u) return true; if ((y & 15) == 0) return true; return false;} 需要注意的是,像 gcc 或 clang 这样的现代编译器会从 is_leap_year1 生成类似 is_leap_year2 的代码,因此在 C 源代码中这样做意义不大,但在其他编程语言中可能还是有用的。 这段代码通常会被编译成带分支跳转的汇编代码。然而实际上这个函数的输入通常是可预测的,所以这不一定是坏事。如果我们想在牺牲最好场景下的性能来避免大部分场景下分支预测失败的开销的话,我们可以稍微调整一下顺序,得到无分支跳转的代码: bool is_leap_year3(uint32_t y) { return !(y & ((y % 25) ? 3 : 15));} 当然如果您想了解更多日历计算相关的加速方法,可以查阅 Jacob Pratt 的Optimizing with Novel Calendrical Algorithms。 寻找基于位操作的方法 我们能否通过放弃对所有输入的正确性来改进闰年计算?毕竟,我们通常不关心年份3584536493是否是闰年;实际上,Python、C# 和 Go 只支持 0 年(或 1 年)到 9999 年(此时相对于季节的漂移已经超过 4 天)。我的想法是,如果存在更短的形式,它基本上会像使用魔术常数进行某种奇怪的哈希操作,所以我尝试了一些方法,并通过暴力搜索猜测常数。(y * f) <= t的形式似乎有用,但不够强大。我的一个备选方案是添加一个掩码:((y * f) & m) <= t。现在我们需要猜测 96个bit位,这无法单独通过暴力搜索完成。让我们使用z3,一个支持位向量约束的求解器,它非常适合这项工作。 import z3BITS = 32f, m, t, y = z3.BitVecs('f m t y', BITS)def target(y): return z3.And((y & 3) == 0, z3.Or(z3.URem(y, 25) != 0, (y & 15) == 0))def candidate(x): return z3.ULE((x * f) & m, t)solver = z3.Solver()solver.add(z3.ForAll(y, z3.Implies(z3.ULE(y, 400), candidate(y) == target(y))))if solver.check() == z3.sat: print(f'found solution: {solver.model()}')else: print('no solution found') 在几秒钟内,这找到了一些常数,它们在一定年份范围内给出了正确的结果。扩展范围后,大约花费了半小时的计算时间,我最终找到了在0年到102499年范围内给出正确结果的常数,并证明了这是32位的最优解: bool is_leap_year_fast(uint32_t y) { const uint32_t f = 1073750999u; const uint32_t m = 3221352463u; const uint32_t t = 126976u; return ((y * f) & m) <= t;} 解释 它是如何工作的呢?我们能将所有这些计算压缩到三条指令中,这似乎令人惊讶,感觉就像是魔法一样,不过,上面的内容已经给我们足够多的工具来理解它了。 下面是这三个常量的二进制表示,并且用ABCD标识出来了相关的bit范围: 让我们首先考虑乘积 p := y * f,与 m 进行位与操作后再和t进行比较的作用。在区块 A 中,t 的位为 0,因此只要 p 中 A 中的任何位被设置,结果就为 false。否则,区块 B 就变得相关。在这里,t 中的所有位都为 1,所以只要 p 中 B 中的任何位未设置,结果就为 true。否则,对于区块 C,我们要求 p 中所有位都未设置。通过这种方式,多个位范围的比较都被统一到一个单一的 <= 操作中。 因此,我们可以将 is_leap_year_fast 重写如下: bool is_leap_year_fast2(uint32_t y) { uint32_t p = y * 1073750999u; const uint32_t A = 0b11000000000000000000000000000000; const uint32_t B = 0b00000000000000011111000000000000; const uint32_t C = 0b00000000000000000000000000001111; if ((p & A) != 0) return false; if ((p & B) != B) return true; if ((p & C) == 0) return true; return false;} 这看起来非常像is_leap_year2!实际上,这三个条件的目的是完全相同的。我们可以证明: 当(p & A) != 0时,(y % 4) != 0 也成立; 当(p & B) != B时,(y % 100) != 0也成立; 当(p & C) == 0时,(y % 16) == 0(而且 (y % 400) == 0,因为我们已经知道 y 是25的倍数)。 针对(1)和(3)这两种简单场景: (1):f 中 A 的1位将 y 的低两位重现在 p 的 A 位置。这不会被与 D 中的位相乘的结果所破坏:我们能得到的最大值是 102499 * (f & D) = 940428325,它只有30位。因此,检查 p 中 A 是否为零等同于检查 y 是否模4为0。 (3):检查 p 的最低4位是否都未设置,就是检查 p 是否模16为0。然而,我们实际想检查的是 y。这不是问题:只需查看 f 的最低4位即可,而 f 在那里是11112 = 7。乘以7不会引入额外的因数2,因此依然是可以被16整除的。 针对(2)这种有趣的场景: 接下来,让我们尝试找出哪些数满足 p & B ≠ B。为此,f & A 中的1位不起作用,所以考虑 f & D 中的位。它们是 100011110101112 = 9175。让我们看看哪些数通过了测试: >>> B = 0b00000000000000011111000000000000>>> s = [y for y in range(5000) if ((y * 9175) & B) == B]>>> for i in range(0, len(s), 16): print(*(f'{n:4d}' for n in s[i:i+16])) 14 57 71 100 114 157 171 200 214 257 271 300 314 357 371 400 414 457 471 500 514 557 571 600 614 657 671 700 714 757 771 800 814 857 871 900 914 957 971 1000 1014 1057 1071 1100 1114 1157 1171 12001214 1257 1271 1300 1314 1357 1371 1400 1414 1457 1471 1500 1514 1557 1571 16001614 1657 1671 1700 1714 1757 1771 1800 1814 1857 1871 1900 1914 1957 1971 20002014 2057 2071 2100 2114 2157 2171 2200 2214 2257 2271 2300 2314 2357 2371 24002414 2457 2471 2500 2514 2557 2571 2600 2614 2657 2671 2700 2714 2757 2771 28002814 2857 2871 2900 2914 2957 2971 3000 3014 3057 3071 3100 3114 3157 3171 32003214 3257 3271 3300 3314 3357 3371 3400 3414 3457 3471 3500 3514 3557 3571 36003614 3657 3671 3700 3714 3757 3771 3800 3814 3857 3871 3900 3914 3957 3971 40004014 4057 4071 4100 4114 4157 4200 4214 4257 4300 4314 4357 4400 4414 4457 45004514 4557 4600 4614 4657 4700 4714 4757 4800 4814 4857 4900 4914 4957 正如所愿,100的倍数在这里出现了,但也出现了一堆其他的数字。但只要它们都不是4的倍数都没关系,因为这些数字会在前一步里先被被过滤掉。另外,0不见了,但这也不是问题,因为0也是400的倍数。 让我们试着理解这个规律。乍一看,它看起来非常简单:我们有 *14, *57, *71 和 *00。然而,从4171开始,*71 就消失了(你注意到了吗?)。后面也有新的规律出现。让我们再借助Python来分析一下: def test(y): B = 126976 return ((y * 9175) & B) == Bactive = set()for y in range(120000): r = y % 100 if test(y): if r not in active: print(f'{y:6}: started *{r:02}') active.add(r) else: if r in active: print(f'{y:6}: stopped *{r:02}') active.remove(r) 可以得到: 14: started *14 57: started *57 71: started *71 100: started *00 4171: stopped *71 32843: started *43 36914: stopped *14 65586: started *86 69657: stopped *57 98329: started *29102500: stopped *00 所以,从102500开始,我们不再捕获100的倍数,这解释了为什么102499是is_leap_year_fast能获得正确结果的最后一个数字。我们还看到,在此之下,除了100的倍数外,没有其他数字是4的倍数(方便的是,我们只需知道最后两位十进制数字就可以检查这一点)。如果信任这种暴力枚举的结果,这就完成了条件(2)的证明;但我们继续更深入地理解为什么我们恰好得到了这些数字。 让我们深入研究一下为什么我们首先得到了100的倍数。因子9175在17位定点表示中接近于1/100的倍数: 217 * 7/100 = 9175.04 (这个是精确值)。 将100的倍数乘以9175.04,会得到一个整数(7的倍数),位于第17位及以上,以及低于第17位的17个零位,例如: 9175.04 * 500 = 100011000000000000000002, 其中1000112 = 35 = 5 * 7。 将100的倍数乘以9175会得到略小的结果: 9175 * 500 = 100011000000000000000002 − 500 * 0.04 = 100010111111111111011002 一般来说,从一个以很多零结尾的数字中减去一点点,除了末尾的0之外,会得到一个以很多一结尾的数字。在这里,我们检查 B 中的5位。对于 y 是100的倍数,这些位保证都是1,随着误差慢慢累计累积达到 B 的低位,这只有在 y = 217 / 0.04 = 102400之后才会发生,所以这是符合预期的。 那么像14、57、71这样的其他数字是从哪里来的呢?让我们换个角度来看: 我们有 9175 = 217 * 0.06999969482421875 (精确值),而 B = 217 * 0.96875,所以: p & B = B ⇔ {y * 0.06999969482421875} ≥ 0.96875 其中 {x} 是 x 的小数部分 ⇔ 6.999969482421875y mod 100 ≥ 96.875 这也同样解释了为什么100的倍数是可以的:对于100的倍数,7y mod 100 是 0,所以 6.999969482421875y mod 100 会稍微小于 100,并且只有在 y = (100 − 96.875) / (7 − 6.999969482421875) = 102400 之后才会降到 96.875 以下。 为了理解在我们序列中出现的其他数字,让我们首先考虑如果我们在不等式中是整数 7,解会是什么: 7y mod 100 ≥ 96.875 ⇔ 7y mod 100 ∈ {97, 98, 99}。 为了找到这个解,我们首先需要 7 mod 100 的模逆元,也就是说,一个数字 x 使得 7x mod 100 = 1。我们可以使用扩展欧几里德算法计算它,或者直接使用在线计算器,它会告诉我们结果是 43。那么解就是 43 * 97 (mod 100),43 * 98 (mod 100),以及 43 * 99 (mod 100),结果分别为 71、14 和 57 (mod 100)。这解释了为什么我们最初会看到 *14、*57 和 *71 形式的数字。这也解释了为什么我们在 4071 之后不再看到 *71 等数字:虽然 7 * 4171 = 29197,但我们有 6.999969482421875 * 4171 = 29196.872711181640625,它(模 100)小于 96.875。类似地,32843 出现是因为累积误差 (7 − 6.999969482421875) * 32843 = 1.002288818359375 超过了1。再花一些精力,我们就可以手动重现上面的 Python 程序的输出,并检查这些数字中没有任何一个是4的倍数。 扩展到其他比特位 现在我们理解了这个技巧的工作原理,我们可以尝试为其他比特位寻找参数,可变部分是区块B,以及 f & D 中100的小数部分: uint64_t score(uint64_t f, uint64_t m, uint64_t t) { for (uint64_t y = 0; ; y++) if ((((y * f) & m) <= t) != is_leap_year(y)) return y; } int main() { uint64_t best_score = 0; for (int k = 0; k < BITS; k++) { for (int k2 = 0; k2 < k; k2++) { uint64_t t = (1ULL << k) - (1ULL << k2); uint64_t m = (0b11ULL << (BITS - 2)) | t | 0b1111; for (int n = 0; n < 100; n++) { uint64_t f = (0b01ULL << (BITS - 2)) | (((1ULL << k) * n) / 100); uint64_t new_score = score(f, m, t); if (new_score > best_score) { printf("%llu %llu %llu: %llu (%d %d %d)\n", f, m, t, new_score, k, k - k2, n); best_score = new_score; } } } } return 0; } 对于BITS = 64的情况,花了大约7分钟,我们找到了 f = 4611686019114582671,m = 13835058121854156815,t = 66571993088,这个组合对于 y = 5965232499 及以下的年份是都正确的。这很棒,因为 5965232499 > 217,所以任何32位整数年份都可以基于这个组合来计算。 对于64位来说,这是我们能达到的最好结果吗?也许还有其他常数效果更好?我无法立即找到证明方法,所以我采用了久经考验的方法——让别人为我做这件事,将其发布到Code Golf StackExchange上。果然,仅1小时后,用户ovs就发布了一个非常好的结果,两天后用户Exalted Toast发布了证明,表明5965232499确实是64位的最佳可能范围,同样的,他也使用了z3来进行求解。 性能测试 想要做一个有意义的基准测试不太容易,因为函数执行时间都非常短,而且对于普通带分支跳转的版本,执行时间和输入强相关。我们尝试了两个极端情况:只输入2025年,以及使用完全随机的年份。以下是在 i7-8700K (Coffee Lake, 4.7 GHz) 上使用g++ -O3 -fno-tree-vectorize编译的基准测试结果: 函数名称2025 (ns)random (ns) is_leap_year0.652.61 is_leap_year20.652.75 is_leap_year30.670.88 is_leap_year_fast0.690.69 这个结果还是有些奇怪的地方的: is_leap_year2在随机年份情况下比is_leap_year还要稍微慢一点。这有点奇怪,因为y % 100比is_leap_year2中的实现还要多一条指令。(根据Cassio Neri的评论,一个可能的解释是分支预测错误的概率差距导致,is_leap_year平均每100次预测错误一次,而is_leap_year2平均每25次预测就会错误一次。) is_leap_year3在随机数据上比固定值慢一点。这也很奇怪,因为它没有任何分支跳转,理论上时间应该固定的。 除了“基准测试很难”之外,我无法解释这一点。 从结果看,对于随机数据,新函数is_leap_year_fast比标准实现快 3.8 倍,对于完美可预测的输入,大约慢 6%。总的来说,这看起来相当不错。 总结 总结一下,这么做一个小优化是否真的值得?我们是否应该用这个新的函数替换现有的实现,比如将CPython datetime替换掉?嗯,这还是得根据实际情况决定。实践中查询的大多数年份会是当前年份,或者至少是相当可预知的年份,在这种情况下,我们并没有很大的优势。为了充分证明改变是合理的,理想情况下我们需要一个使用闰年检查作为子程序的实际数据基准测试,而不仅仅是微基准测试。我很乐意听到任何此类结果!

2025/5/29
articleCard.readMore

一个中断关闭时间太长导致的网络延迟问题

近期线上出现了一个问题,现象是有一台机器,网络出现了不定时的延迟: # ping -i 0.05 192.168.0.5#...64 bytes from 192.168.0.5: icmp_seq=673 ttl=63 time=0.203 ms64 bytes from 192.168.0.5: icmp_seq=674 ttl=63 time=0.210 ms64 bytes from 192.168.0.5: icmp_seq=675 ttl=63 time=0.218 ms64 bytes from 192.168.0.5: icmp_seq=676 ttl=63 time=0.233 ms64 bytes from 192.168.0.5: icmp_seq=677 ttl=63 time=406 ms64 bytes from 192.168.0.5: icmp_seq=678 ttl=63 time=354 ms64 bytes from 192.168.0.5: icmp_seq=679 ttl=63 time=302 ms64 bytes from 192.168.0.5: icmp_seq=680 ttl=63 time=251 ms64 bytes from 192.168.0.5: icmp_seq=681 ttl=63 time=199 ms64 bytes from 192.168.0.5: icmp_seq=682 ttl=63 time=147 ms64 bytes from 192.168.0.5: icmp_seq=683 ttl=63 time=94.8 ms64 bytes from 192.168.0.5: icmp_seq=684 ttl=63 time=43.0 ms64 bytes from 192.168.0.5: icmp_seq=685 ttl=63 time=0.216 ms64 bytes from 192.168.0.5: icmp_seq=686 ttl=63 time=0.248 ms#... 以50ms为间隔ping,发现概率性的会出现超过400ms的延迟,但是并没有丢包的现象发生。 首先想到了使用nettrace工具来分析一下这个问题,nettrace是一个腾讯开源的基于bpftrace的网络流量追踪工具,可以追踪到内核网络栈的每一个函数调用。期望能通过这个工具来看一下这个延迟是不是消耗在了内核的网络协议栈上。根据ping结果中响应时间最长的seq=677的包,过滤出来从协议栈接收包到发送回复包的所有函数调用: # nettrace -p icmp#...***************** d8061b00 ***************[24583464.629102] [napi_gro_receive_entry] ICMP: 192.168.0.4 -> 192.168.0.5 ping request, seq: 677, id: 392[24583464.629114] [dev_gro_receive ] ICMP: 192.168.0.4 -> 192.168.0.5 ping request, seq: 677, id: 392[24583464.629248] [__netif_receive_skb_core] ICMP: 192.168.0.4 -> 192.168.0.5 ping request, seq: 677, id: 392[24583464.629253] [packet_rcv ] ICMP: 192.168.0.4 -> 192.168.0.5 ping request, seq: 677, id: 392[24583464.629256] [tcf_classify ] ICMP: 192.168.0.4 -> 192.168.0.5 ping request, seq: 677, id: 392[24583464.629262] [__netif_receive_skb_core] ICMP: 192.168.0.4 -> 192.168.0.5 ping request, seq: 677, id: 392[24583464.629268] [ip_rcv ] ICMP: 192.168.0.4 -> 192.168.0.5 ping request, seq: 677, id: 392[24583464.629271] [ip_rcv_core ] ICMP: 192.168.0.4 -> 192.168.0.5 ping request, seq: 677, id: 392[24583464.629276] [nf_hook_slow ] ICMP: 192.168.0.4 -> 192.168.0.5 ping request, seq: 677, id: 392 *ipv4 in chain: PRE_ROUTING*[24583464.629284] [ip_route_input_slow ] ICMP: 192.168.0.4 -> 192.168.0.5 ping request, seq: 677, id: 392[24583464.629293] [fib_validate_source ] ICMP: 192.168.0.4 -> 192.168.0.5 ping request, seq: 677, id: 392[24583464.629299] [ip_local_deliver ] ICMP: 192.168.0.4 -> 192.168.0.5 ping request, seq: 677, id: 392[24583464.629301] [nf_hook_slow ] ICMP: 192.168.0.4 -> 192.168.0.5 ping request, seq: 677, id: 392 *ipv4 in chain: INPUT*[24583464.629304] [nft_do_chain ] ICMP: 192.168.0.4 -> 192.168.0.5 ping request, seq: 677, id: 392 *iptables table:filter, chain:INPUT*[24583464.629311] [ip_local_deliver_finish] ICMP: 192.168.0.4 -> 192.168.0.5 ping request, seq: 677, id: 392[24583464.629316] [icmp_rcv ] ICMP: 192.168.0.4 -> 192.168.0.5 ping request, seq: 677, id: 392[24583464.629321] [icmp_echo ] ICMP: 192.168.0.4 -> 192.168.0.5 ping request, seq: 677, id: 392[24583464.629325] [icmp_reply ] ICMP: 192.168.0.4 -> 192.168.0.5 ping request, seq: 677, id: 392[24583464.629413] [consume_skb ] ICMP: 192.168.0.4 -> 192.168.0.5 ping request, seq: 677, id: 392 *packet is freed (normally)*#...***************** a3faf500,a3faec00,a3faf800 ***************[24583464.629343] [__ip_local_out ] ICMP: 192.168.0.5 -> 192.168.0.4 ping reply, seq: 677, id: 392[24583464.629347] [nf_hook_slow ] ICMP: 192.168.0.5 -> 192.168.0.4 ping reply, seq: 677, id: 392 *ipv4 in chain: OUTPUT*[24583464.629350] [nft_do_chain ] ICMP: 192.168.0.5 -> 192.168.0.4 ping reply, seq: 677, id: 392 *iptables table:filter, chain:OUTPUT*[24583464.629354] [ip_output ] ICMP: 192.168.0.5 -> 192.168.0.4 ping reply, seq: 677, id: 392[24583464.629358] [nf_hook_slow ] ICMP: 192.168.0.5 -> 192.168.0.4 ping reply, seq: 677, id: 392 *ipv4 in chain: POST_ROUTING*[24583464.629361] [ip_finish_output ] ICMP: 192.168.0.5 -> 192.168.0.4 ping reply, seq: 677, id: 392[24583464.629365] [ip_finish_output2 ] ICMP: 192.168.0.5 -> 192.168.0.4 ping reply, seq: 677, id: 392[24583464.629368] [__dev_queue_xmit ] ICMP: 192.168.0.5 -> 192.168.0.4 ping reply, seq: 677, id: 392[24583464.629372] [tcf_classify ] ICMP: 192.168.0.5 -> 192.168.0.4 ping reply, seq: 677, id: 392[24583464.629376] [skb_clone ] ICMP: 192.168.0.5 -> 192.168.0.4 ping reply, seq: 677, id: 392[24583464.629381] [__dev_queue_xmit ] ICMP: 192.168.0.5 -> 192.168.0.4 ping reply, seq: 677, id: 392[24583464.629385] [dev_hard_start_xmit ] ICMP: 192.168.0.5 -> 192.168.0.4 ping reply, seq: 677, id: 392 *skb is successfully sent to the NIC driver*[24583464.629389] [bond_dev_queue_xmit ] ICMP: 192.168.0.5 -> 192.168.0.4 ping reply, seq: 677, id: 392[24583464.629393] [__dev_queue_xmit ] ICMP: 192.168.0.5 -> 192.168.0.4 ping reply, seq: 677, id: 392[24583464.629396] [dev_hard_start_xmit ] ICMP: 192.168.0.5 -> 192.168.0.4 ping reply, seq: 677, id: 392 *skb is successfully sent to the NIC driver*[24583464.629398] [skb_clone ] ICMP: 192.168.0.5 -> 192.168.0.4 ping reply, seq: 677, id: 392[24583464.629401] [packet_rcv ] ICMP: 192.168.0.5 -> 192.168.0.4 ping reply, seq: 677, id: 392[24583464.629404] [consume_skb ] ICMP: 192.168.0.5 -> 192.168.0.4 ping reply, seq: 677, id: 392 *packet is freed (normally)*[24583464.629409] [consume_skb ] ICMP: 192.168.0.5 -> 192.168.0.4 ping reply, seq: 677, id: 392 *packet is freed (normally)*[24583464.630375] [consume_skb ] ICMP: 192.168.0.5 -> 192.168.0.4 ping reply, seq: 677, id: 392 *packet is freed (normally)*#... 从过滤出来的结果看,最左侧的时间一栏,发现从接受icmp请求到将回复请求扔给网卡,整个协议栈只消耗了不到1ms的时间,几乎可以排除是内核网络协议栈的问题。那是怎么回事呢? 尝试继续分析一下nettrace工具的输出: # grep -E 'napi_gro_receive_entry|__ip_local_out' trace.log#...[24583464.120360] [napi_gro_receive_entry] ICMP: 192.168.0.4 -> 192.168.0.5 ping request, seq: 675, id: 392[24583464.120449] [__ip_local_out ] ICMP: 192.168.0.5 -> 192.168.0.4 ping reply, seq: 675, id: 392[24583464.172360] [napi_gro_receive_entry] ICMP: 192.168.0.4 -> 192.168.0.5 ping request, seq: 676, id: 392[24583464.172456] [__ip_local_out ] ICMP: 192.168.0.5 -> 192.168.0.4 ping reply, seq: 676, id: 392# 上次回复之后,大约等待了450ms,才继续收到下一个ping包,并且在同1ms内连续收到了8个包[24583464.629102] [napi_gro_receive_entry] ICMP: 192.168.0.4 -> 192.168.0.5 ping request, seq: 677, id: 392[24583464.629118] [napi_gro_receive_entry] ICMP: 192.168.0.4 -> 192.168.0.5 ping request, seq: 678, id: 392[24583464.629503] [napi_gro_receive_entry] ICMP: 192.168.0.4 -> 192.168.0.5 ping request, seq: 679, id: 392[24583464.629515] [napi_gro_receive_entry] ICMP: 192.168.0.4 -> 192.168.0.5 ping request, seq: 680, id: 392[24583464.629521] [napi_gro_receive_entry] ICMP: 192.168.0.4 -> 192.168.0.5 ping request, seq: 681, id: 392[24583464.629526] [napi_gro_receive_entry] ICMP: 192.168.0.4 -> 192.168.0.5 ping request, seq: 682, id: 392[24583464.629531] [napi_gro_receive_entry] ICMP: 192.168.0.4 -> 192.168.0.5 ping request, seq: 683, id: 392[24583464.630165] [napi_gro_receive_entry] ICMP: 192.168.0.4 -> 192.168.0.5 ping request, seq: 684, id: 392[24583464.629343] [__ip_local_out ] ICMP: 192.168.0.5 -> 192.168.0.4 ping reply, seq: 677, id: 392[24583464.629452] [__ip_local_out ] ICMP: 192.168.0.5 -> 192.168.0.4 ping reply, seq: 678, id: 392[24583464.629578] [__ip_local_out ] ICMP: 192.168.0.5 -> 192.168.0.4 ping reply, seq: 679, id: 392[24583464.629781] [__ip_local_out ] ICMP: 192.168.0.5 -> 192.168.0.4 ping reply, seq: 680, id: 392[24583464.629886] [__ip_local_out ] ICMP: 192.168.0.5 -> 192.168.0.4 ping reply, seq: 681, id: 392[24583464.629989] [__ip_local_out ] ICMP: 192.168.0.5 -> 192.168.0.4 ping reply, seq: 682, id: 392[24583464.630081] [__ip_local_out ] ICMP: 192.168.0.5 -> 192.168.0.4 ping reply, seq: 683, id: 392[24583464.630282] [__ip_local_out ] ICMP: 192.168.0.5 -> 192.168.0.4 ping reply, seq: 684, id: 392[24583464.637354] [napi_gro_receive_entry] ICMP: 192.168.0.4 -> 192.168.0.5 ping request, seq: 685, id: 392[24583464.637439] [__ip_local_out ] ICMP: 192.168.0.5 -> 192.168.0.4 ping reply, seq: 685, id: 392[24583464.689368] [napi_gro_receive_entry] ICMP: 192.168.0.4 -> 192.168.0.5 ping request, seq: 686, id: 392[24583464.689469] [__ip_local_out ] ICMP: 192.168.0.5 -> 192.168.0.4 ping reply, seq: 686, id: 392[24583464.741353] [napi_gro_receive_entry] ICMP: 192.168.0.4 -> 192.168.0.5 ping request, seq: 687, id: 392[24583464.741462] [__ip_local_out ] ICMP: 192.168.0.5 -> 192.168.0.4 ping reply, seq: 687, id: 392[24583464.793361] [napi_gro_receive_entry] ICMP: 192.168.0.4 -> 192.168.0.5 ping request, seq: 688, id: 392[24583464.793477] [__ip_local_out ] ICMP: 192.168.0.5 -> 192.168.0.4 ping reply, seq: 688, id: 392#... 从日志可以看到,在回复完seq=676的包后,经过了450ms才继续接收到了seq=678的包,并且在同1ms内连续收到了8个包,之后的处理也非常快,几乎没有延迟。这个日志也可以和ping的结果对应上,从ping的源端来看,由于ping的间隔是50ms,seq=676的包发送出去,等待50ms发送seq=677的包,而这个包被延迟了400ms被目的端收到并返回,依次类推,在这400ms期间,共有8个被发出,在处理seq=677的包同时,其他包也被收到并处理,所以从源端看,678-684的包相应时间的以大约50ms的间隔缩短。 感觉像是中断处理慢了,毕竟也没有发生丢包的情况,只是延迟了。借助bcc-tools里的hardirqs和softirqs工具,来看看中断的处理情况: # /usr/share/bcc/tools/hardirqs 1 100Tracing hard irq event time... Hit Ctrl-C to end.HARDIRQ TOTAL_usecsnvme0q52 5nvme0q49 31nvme0q73 44mlx5_async17@pci:0000:1a:00.0 68acpi 73mlx5_async17@pci:0000:1a:00.1 73nvme0q51 90nvme0q76 120nvme0q75 135ens33f0-0 782ens33f1-0 913HARDIRQ TOTAL_usecsmlx5_async17@pci:0000:1a:00.0 4mlx5_async17@pci:0000:1a:00.1 7megasas0-msix51 48megasas0-msix75 58nvme0q52 60nvme0q76 61acpi 70nvme0q75 83nvme0q49 104nvme0q51 123nvme0q73 133ens33f0-0 340ens33f1-0 598HARDIRQ TOTAL_usecsacpi 37mlx5_async17@pci:0000:1a:00.1 38mlx5_async17@pci:0000:1a:00.0 72nvme0q73 113nvme0q75 144nvme0q51 148nvme0q52 196ens33f0-0 374ens33f1-0 581#... # /usr/share/bcc/tools/softirqs 1 100Tracing soft irq event time... Hit Ctrl-C to end.SOFTIRQ TOTAL_usecsnet_tx 6tasklet 483sched 3923rcu 6545net_rx 9657timer 24720SOFTIRQ TOTAL_usecstasklet 221sched 3256net_rx 4408rcu 5641timer 25195SOFTIRQ TOTAL_usecstasklet 287sched 4734net_rx 6355rcu 8406timer 25333#... 并没有发现中断处理慢了的情况,那有没有可能是中断被屏蔽了呢?如果中断被屏蔽了400ms,在这400ms期间,收到的包都会存放在网卡的缓冲区里,但是网卡没办法通过中断的方式通知内核来处理这些包,等到中断被打开后,内核才会收到中断,并开始处理网卡缓冲区里的数据,这样就会出现上面提到的现象。这个假设似乎说得通,但是怎么验证呢? 想起字节跳动几年前开源了一个叫做trace-irqoff的工具,可以用来追踪和定位中断或者软中断关闭的时间。 尝试使用一下这个工具: # cat /proc/trace_irqoff/distributehardirq-off: msecs : count distribution 10 -> 19 : 1 |********** | 20 -> 39 : 0 | | 40 -> 79 : 0 | | 80 -> 159 : 1 |********** | 160 -> 319 : 2 |******************** | 320 -> 639 : 4 |****************************************|# cat /proc/trace_irqoff/trace_latencytrace_irqoff_latency: 50ms hardirq: cpu: 0 COMMAND: kubelet PID: 1162942 LATENCY: 425ms save_trace.isra.0+0x18c/0x1b0 [trace_irqoff] trace_irqoff_record+0xa0/0xd0 [trace_irqoff] trace_irqoff_hrtimer_handler+0x47/0xd6 [trace_irqoff] __hrtimer_run_queues+0x12a/0x2c0 hrtimer_interrupt+0xfc/0x210 __sysvec_apic_timer_interrupt+0x5f/0x110 sysvec_apic_timer_interrupt+0x6d/0x90 asm_sysvec_apic_timer_interrupt+0x16/0x20 _raw_spin_unlock_irq+0x14/0x25 blkcg_print_blkgs+0x72/0xe0 tg_print_rwstat_recursive+0x40/0x50 seq_read_iter+0x124/0x4b0 new_sync_read+0x118/0x1a0 vfs_read+0xf5/0x190 ksys_read+0x5f/0xe0 do_syscall_64+0x5c/0x90 entry_SYSCALL_64_after_hwframe+0x63/0xcd COMMAND: kubelet PID: 1162942 LATENCY: 462ms save_trace.isra.0+0x18c/0x1b0 [trace_irqoff] trace_irqoff_record+0xa0/0xd0 [trace_irqoff] trace_irqoff_hrtimer_handler+0x47/0xd6 [trace_irqoff] __hrtimer_run_queues+0x12a/0x2c0 hrtimer_interrupt+0xfc/0x210 __sysvec_apic_timer_interrupt+0x5f/0x110 sysvec_apic_timer_interrupt+0x6d/0x90 asm_sysvec_apic_timer_interrupt+0x16/0x20 _raw_spin_unlock_irq+0x14/0x25 blkcg_print_blkgs+0x72/0xe0 tg_print_rwstat_recursive+0x40/0x50 seq_read_iter+0x124/0x4b0 new_sync_read+0x118/0x1a0 vfs_read+0xf5/0x190 ksys_read+0x5f/0xe0 do_syscall_64+0x5c/0x90 entry_SYSCALL_64_after_hwframe+0x63/0xcd#... 还真是发现了中断被长时间屏蔽的情况,从这个调用栈看,kubelet进程会读取cgroups中的内容,在这个过程中,内核在处理blkcg_print_blkgs时,屏蔽了中断425ms和462ms。这个时间和ping延迟的400ms十分接近了,那几乎就是因为这个原因导致的问题了。 具体到为什么会出现这么长的中断屏蔽时间,这里就不继续把分析的详细过程贴出来了,最终是找到了cgroups一个内存泄漏的bug,导致blkcg_print_blkgs时需要遍历超多的items,导致了中断屏蔽时间过长。 确实需要感谢一下腾讯和字节跳动开源的这两个工具,极大的加速了问题的定位😁。说起来阿里开源的运维工具SysAK里也有一个irqoff工具,是基于eBPF实现的,相比字节的可能还要灵活一些,不过就得等下次再试用了😊。

2025/5/10
articleCard.readMore

一个Linux新老内核对于硬件中断的统计差异

最近接到一个客户反馈,说他们的机器,遇到了top命令中,hardirq的值特别高的问题。 top - 15:27:37 up 43 days, 3:42, 1 user, load average: 44.75, 53.47, 51.66Tasks: 244 total, 1 running, 243 sleeping, 0 stopped, 0 zombie%Cpu0 : 10.3 us, 19.8 sy, 0.0 ni, 39.7 id, 0.4 wa, 28.2 hi, 1.6 si, 0.0 st%Cpu1 : 10.7 us, 21.3 sy, 0.0 ni, 40.7 id, 0.4 wa, 26.1 hi, 0.8 si, 0.0 st%Cpu2 : 10.0 us, 19.1 sy, 0.0 ni, 41.4 id, 0.4 wa, 27.9 hi, 1.2 si, 0.0 st%Cpu3 : 10.4 us, 20.7 sy, 0.0 ni, 40.2 id, 0.4 wa, 26.7 hi, 1.6 si, 0.0 st%Cpu4 : 10.4 us, 15.5 sy, 0.0 ni, 45.4 id, 0.4 wa, 28.3 hi, 0.0 si, 0.0 st%Cpu5 : 10.8 us, 21.9 sy, 0.0 ni, 39.4 id, 0.4 wa, 27.1 hi, 0.4 si, 0.0 st%Cpu6 : 10.1 us, 18.6 sy, 0.0 ni, 41.7 id, 0.4 wa, 28.7 hi, 0.4 si, 0.0 st%Cpu7 : 10.6 us, 25.2 sy, 0.0 ni, 36.6 id, 0.0 wa, 26.4 hi, 1.2 si, 0.0 st 从top命令输出可以看到,hardirq的值特别高,超过了25%。这导致了idle的下降,触发了监控的频繁报警。 而同样业务以及相似的业务量情况下,另外一台机器表现就正常许多: top - 15:31:19 up 118 days, 20:15, 1 user, load average: 93.25, 74.45, 63.84Tasks: 209 total, 1 running, 208 sleeping, 0 stopped, 0 zombie%Cpu0 : 5.5 us, 18.2 sy, 0.0 ni, 76.4 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st%Cpu1 : 2.8 us, 6.0 sy, 0.0 ni, 90.8 id, 0.0 wa, 0.0 hi, 0.4 si, 0.0 st%Cpu2 : 6.6 us, 19.8 sy, 0.0 ni, 72.7 id, 0.4 wa, 0.0 hi, 0.4 si, 0.0 st%Cpu3 : 2.4 us, 6.3 sy, 0.0 ni, 91.4 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st%Cpu4 : 5.9 us, 17.4 sy, 0.0 ni, 76.7 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st%Cpu5 : 2.4 us, 7.1 sy, 0.0 ni, 90.6 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st%Cpu6 : 5.0 us, 18.7 sy, 0.0 ni, 76.3 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st%Cpu7 : 2.4 us, 6.7 sy, 0.0 ni, 90.6 id, 0.4 wa, 0.0 hi, 0.0 si, 0.0 st%Cpu8 : 5.1 us, 16.8 sy, 0.0 ni, 77.6 id, 0.0 wa, 0.0 hi, 0.5 si, 0.0 st 这俩机器在业务性能表现上是类似的,最大的区别就是hardirq高的机器使用了更新的RockyLinux 9操作系统,内核版本会新一点。而另一台机器还在使用老的CentOS 7操作系统,内核版本会老一点。 初步调查 除了Rocky 9机器hardirq高之外,两台机器的其他各方面指标都非常的相似,比较明显的,两台机器的中断数量以及context switch数量都比较高: ~]$ dstat -y---system-- int csw 364k 1247k 358k 1242k 384k 1271k 547k 1480k 562k 1505k 上下文切换超过了100万次/s。两者的火焰图也十分相似,不过从火焰图上观察,进程会大量调用nanosleep这个系统调用,调用比例和hardirq的比例是类似的: 如果是大量nanosleep调用的话,那应该很容易用stress-ng工具复现了。事实证明,使用stress-ng工具可以获得和生产环境非常相似的现象,尝试了Rocky 9.5、CentOS 7.9,物理机、虚拟机的各种组合,结果发现确实和操作系统的内核版本关系很大。 问题分析 在后续分析中,找到了这样一篇资料Is Your Linux Version Hiding Interrupt CPU Usage From You?。 在这篇文章中,作者发现Ubuntu 20.10及其默认Linux内核5.8.0版本的系统中,即使使用fio压测实现了11M IOPS的性能时,dstat和其他监控工具报告的hardirq时间依然为零,而在相同的机器上启动了使用Oracle Enterprise Linux 8.3及5.4.17版本内核时,发现hardirq时间会占用差不多27%的CPU。 这和我们遇到的问题非常相似,他给出了一个关键性的内核配置项CONFIG_IRQ_TIME_ACCOUNTING。 在作者的Ubuntu系统中,CONFIG_IRQ_TIME_ACCOUNTING配置没有被启用,同样的,这个配置在我们的CentOS 7环境里也没有启用(甚至并没有这个配置项): $ uname -r3.10.0-1160.el7.x86_64$ grep CONFIG_IRQ_TIME_ACCOUNTING /boot/config-`uname -r`$ awk '/^cpu /{ print "HW interrupt svc time " $7 * 10 " ms" }' /proc/statHW interrupt svc time 0 ms 而在更新的Rocky 9中,该配置被启用了: $ uname -r5.14.0-503.23.1.el9_5.x86_64$ grep CONFIG_IRQ_TIME_ACCOUNTING /boot/config-`uname -r`CONFIG_IRQ_TIME_ACCOUNTING=y$ awk '/^cpu /{ print "HW interrupt svc time " $7 * 10 " ms" }' /proc/statHW interrupt svc time 432097660 ms 可以看到,启用了这个配置后,硬件中断处理程序中花费的时间才会被统计,这也是为什么线上用户会报告硬件中断处理程序中花费的时间不匹配的原因。 一些解释 关于中断时间的统计,文章作者也给了一些解释,这里也翻译一下,供大家参考。 中断处理会影响任何线程的CPU时间,因为中断不关心CPU上正在运行的是什么,它们只是突然接管。这正是它们被称为中断的原因! 更长的解释如下: 首先假设 IRQ 时间统计被禁用: 中断可以“随机”地发生在任何时间,这取决于硬件设备何时向 CPU 发出中断信号。 当 CPU 恰好在运行某个运行在用户模式的应用程序线程时收到中断信号,中断处理的 CPU 周期将被计入该应用程序进程(正如您在 top 中看到的那样),并显示为 %usr 类型的利用率。 当 CPU 恰好仍在运行一个由于发出系统调用而处于内核模式的应用程序线程时收到中断信号,那么中断 CPU 时间将被添加到该应用程序线程,但会显示为 %sys 类型。 当 CPU 正在执行内核线程时(并且内核代码路径没有为某些关键部分临时禁用中断),收到中断信号,中断处理的 CPU 时间将被添加到该内核线程的 %sys 模式中。这感觉很直观,因为人们通常认为硬件设备、驱动程序和中断都是内核工作的一部分。 这引发了一个有趣的问题:当 CPU 在收到中断信号时恰好处于空闲状态时会发生什么? 像往常一样,这取决于硬件功能,例如电源感知中断路由是否可用,但完全有可能空闲的 CPU 被唤醒并必须使用 CPU 来处理 IRQ。由于 CPU 在空闲时没有运行任何用户/内核线程(没有可用的 task_struct 线程上下文结构),CPU 时间被计入 Linux 内核伪任务 0,该任务在诸如 perf 之类的工具中显示为 swapper。因此,像 top 这样的工具不会在进程列表中显示任何来自“空闲” CPU 的高 IRQ CPU 使用率的进程,但基于 /proc/stat 的系统级工具(如 dstat、mpstat 甚至 top 标头中的系统级摘要)会显示某些东西正在使用 %sys 模式的 CPU。 现在,启用 IRQ 时间统计后: 启用 IRQ 时间统计后,硬件中断处理程序将使用 CPU 内置的时间戳计数器读取指令 (rdtsc) 在中断处理操作的入口和出口获取高精度“时间戳”。 这些时间增量归因于 %irq 指标,而不是 %usr 或 %sys 线程 CPU 使用率(/proc/stat 每 CPU 都有一行包含 CPU 使用率指标)。 像 mpstat 甚至 top 系统摘要部分这样的系统级工具现在将正确地细分 %irq% CPU 使用率。 然而,进程/线程级别的指标(如 top 进程列表部分)仍然会将任何 IRQ 时间与 %usr 和 %sys 指标混在一起,因为 Linux 不跟踪进程/线程级别的“意外”IRQ 服务时间。 这只是关于裸机 Linux 服务器上硬件中断处理的简短总结,我不会在此深入探讨。此外,为了保持本文简洁,我特意没有涵盖软件中断或上半部/下半部中断处理延迟架构。 请注意,我运行的是一个合成基准测试,它在用户空间线程中并没有做太多的工作,大部分时间都花在了系统调用和内核块层上。因此,当我们的进程已经处于内核模式时,更可能发生 CPU 处理的“突然”硬件中断。对于一个中断密集型应用程序(例如网络!),它的大部分时间都花在用户空间中,我们会看到更多的 %usr CPU 时间切换到 %hiq。

2025/4/23
articleCard.readMore

IPIP隧道导致的DPDK收包RSS队列不均匀问题

最近线上出现一些VM网卡收包队列不均匀的问题,即使是将网卡队列中断均匀的绑定到各个CPU上,依然会出现某个核特别高的情况: %Cpu0 : 0.0 us, 0.0 sy, 0.0 ni, 98.3 id, 0.0 wa, 0.9 hi, 0.9 si, 0.0 st%Cpu1 : 0.0 us, 0.0 sy, 0.0 ni, 97.5 id, 0.0 wa, 0.8 hi, 1.7 si, 0.0 st%Cpu2 : 0.0 us, 0.0 sy, 0.0 ni, 99.1 id, 0.0 wa, 0.0 hi, 0.9 si, 0.0 st%Cpu3 : 0.9 us, 0.0 sy, 0.0 ni, 98.3 id, 0.0 wa, 0.0 hi, 0.9 si, 0.0 st%Cpu4 : 0.0 us, 0.0 sy, 0.0 ni, 98.3 id, 0.0 wa, 0.0 hi, 1.7 si, 0.0 st%Cpu5 : 0.0 us, 0.0 sy, 0.0 ni, 97.4 id, 0.0 wa, 0.9 hi, 1.7 si, 0.0 st%Cpu6 : 0.0 us, 0.0 sy, 0.0 ni, 97.4 id, 0.0 wa, 0.9 hi, 1.7 si, 0.0 st%Cpu7 : 0.0 us, 0.0 sy, 0.0 ni, 98.3 id, 0.0 wa, 0.0 hi, 1.7 si, 0.0 st%Cpu8 : 0.0 us, 0.0 sy, 0.0 ni, 46.3 id, 0.0 wa, 3.4 hi, 50.3 si, 0.0 st%Cpu9 : 0.0 us, 0.0 sy, 0.0 ni, 97.4 id, 0.0 wa, 0.9 hi, 1.7 si, 0.0 st%Cpu10 : 0.0 us, 0.0 sy, 0.0 ni, 98.3 id, 0.0 wa, 0.0 hi, 1.7 si, 0.0 st%Cpu11 : 0.0 us, 0.0 sy, 0.0 ni, 99.1 id, 0.0 wa, 0.0 hi, 0.9 si, 0.0 st%Cpu12 : 0.0 us, 0.0 sy, 0.0 ni, 98.3 id, 0.0 wa, 0.0 hi, 1.7 si, 0.0 st%Cpu13 : 0.0 us, 0.0 sy, 0.0 ni, 99.1 id, 0.0 wa, 0.0 hi, 0.9 si, 0.0 st%Cpu14 : 0.0 us, 0.0 sy, 0.0 ni, 98.3 id, 0.0 wa, 0.0 hi, 1.7 si, 0.0 st%Cpu15 : 0.0 us, 0.0 sy, 0.0 ni, 98.3 id, 0.0 wa, 0.0 hi, 1.7 si, 0.0 st 能看到其他核大部分还是比较均匀的,就是cpu8确实比其他核高很多。经过了一些排查,发现和VM使用了IPIP Tunnel有关。 用户的场景是,在网络入口处有一台机器充当负载均衡的角色,然后这台负载均衡再通过IPIP Tunnel将用户的请求转发到这台VM,由于IPIP的原理是在原有的IP包基础上再“套”一层IP包头,导致RSS计算的Hash的之后只能看到外层的IP,所以即使内层IP包的五元组分布非常均匀,也会出现所以隧道的流量都跑到一个核上的情况。 为了网络的灵活性,VM的网络流量,是经过了一个DPDK程序进行转发的,这个DPDK程序逻辑非常简单,就是从网卡对应的rx队列N接收数据包,然后发送到VM对应的rx队列N,因此如果VM的接收队列不平衡,也就意味着从网卡收包的时候就是不均匀的。 那怎么解决这个问题呢,很显然的一个思路是,当DPDK收包之后,重新计算一下数据包的Hash,在计算过程中,如果发现数据包是一个IPIP数据包,就按内层IP头去计算Hash,然后再根据这个Hash计算VM的接受队列,并把包转发到对应队列。在这种情况下,不管从网卡收包是否是均衡的,到VM的流量基本就会是均衡的。这样确实会非常灵活(这也是当时多加一层DPDK而不是直接网卡直通的原因),但很显然DPDK程序的计算量增加了,对性能会有不小的影响。 那网卡能不能支持针对IPIP数据包提供个“更高级的”RSS算法呢?毕竟现在的网卡功能特性都比较多,功能也比较强大,很有可能可以直接在网卡层面直接实现支持基于隧道内层IP头进行RSS的能力,如果能通过网卡层面实现,那是最优解了。 跟网卡厂商交流之后,确认了网卡是支持这个特性的,而且,在使用Linux驱动收包的情况下,默认就是开启的,也就是说,如果使用的是网卡直通的模式,那直接就不会遇到这个问题。但我们使用了DPDK进行中转,默认是没有这个行为的,如果要开启基于IPIP Tunnel内层IP头进行RSS,需要给网卡下发这样一条流表:flow create 0 group 0 ingress pattern eth / ipv4 proto is 4 / ipv4 / tcp / end actions rss queues 0 1 2 3 4 end level 2 / end。简单翻译一下,就是通过流表去匹配ipv4.proto == 4(也就是IPIP Tunnel协议)的数据包,并让网卡以内层IP进行rss,并分配到0 1 2 3 4这几个队列中。 知道了这个规则,我们就可以用testpmd测试下了: testpmd> set fwd rxonlySet rxonly packet forwarding modetestpmd>testpmd> startrxonly packet forwarding - ports=1 - cores=1 - streams=16 - NUMA support enabled, MP allocation mode: nativeLogical Core 1 (socket 0) forwards packets on 16 streams: RX P=0/Q=0 (socket 0) -> TX P=0/Q=0 (socket 0) peer=02:00:00:00:00:00 RX P=0/Q=1 (socket 0) -> TX P=0/Q=1 (socket 0) peer=02:00:00:00:00:00 RX P=0/Q=2 (socket 0) -> TX P=0/Q=2 (socket 0) peer=02:00:00:00:00:00 RX P=0/Q=3 (socket 0) -> TX P=0/Q=3 (socket 0) peer=02:00:00:00:00:00 RX P=0/Q=4 (socket 0) -> TX P=0/Q=4 (socket 0) peer=02:00:00:00:00:00 RX P=0/Q=5 (socket 0) -> TX P=0/Q=5 (socket 0) peer=02:00:00:00:00:00 RX P=0/Q=6 (socket 0) -> TX P=0/Q=6 (socket 0) peer=02:00:00:00:00:00 RX P=0/Q=7 (socket 0) -> TX P=0/Q=7 (socket 0) peer=02:00:00:00:00:00 RX P=0/Q=8 (socket 0) -> TX P=0/Q=8 (socket 0) peer=02:00:00:00:00:00 RX P=0/Q=9 (socket 0) -> TX P=0/Q=9 (socket 0) peer=02:00:00:00:00:00 RX P=0/Q=10 (socket 0) -> TX P=0/Q=10 (socket 0) peer=02:00:00:00:00:00 RX P=0/Q=11 (socket 0) -> TX P=0/Q=11 (socket 0) peer=02:00:00:00:00:00 RX P=0/Q=12 (socket 0) -> TX P=0/Q=12 (socket 0) peer=02:00:00:00:00:00 RX P=0/Q=13 (socket 0) -> TX P=0/Q=13 (socket 0) peer=02:00:00:00:00:00 RX P=0/Q=14 (socket 0) -> TX P=0/Q=14 (socket 0) peer=02:00:00:00:00:00 RX P=0/Q=15 (socket 0) -> TX P=0/Q=15 (socket 0) peer=02:00:00:00:00:00 rxonly packet forwarding packets/burst=32 nb forwarding cores=1 - nb forwarding ports=1 port 0: RX queue number: 16 Tx queue number: 16 Rx offloads=0x0 Tx offloads=0x10000 RX queue: 0 RX desc=4096 - RX free threshold=64 RX threshold registers: pthresh=0 hthresh=0 wthresh=0 RX Offloads=0x0 TX queue: 0 TX desc=4096 - TX free threshold=0 TX threshold registers: pthresh=0 hthresh=0 wthresh=0 TX offloads=0x10000 - TX RS bit threshold=0testpmd> stopTelling cores to stop...Waiting for lcores to finish... ------- Forward Stats for RX Port= 0/Queue=12 -> TX Port= 0/Queue=12 ------- RX-packets: 139389 TX-packets: 0 TX-dropped: 0 ---------------------- Forward statistics for port 0 ---------------------- RX-packets: 139389 RX-dropped: 0 RX-total: 139389 TX-packets: 0 TX-dropped: 0 TX-total: 0 ---------------------------------------------------------------------------- +++++++++++++++ Accumulated forward statistics for all ports+++++++++++++++ RX-packets: 139389 RX-dropped: 0 RX-total: 139389 TX-packets: 0 TX-dropped: 0 TX-total: 0 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++Done. 可以看到默认情况下,所有的包全跑到Queue=12这个队列了。很显然默认情况下RSS是有些问题的,那就继续试试下发流表之后的情况: testpmd> flow create 0 group 0 ingress pattern eth / ipv4 proto is 4 / ipv4 / tcp / end actions rss queues 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 end level 2 / endFlow rule #0 createdtestpmd> startrxonly packet forwarding - ports=1 - cores=1 - streams=16 - NUMA support enabled, MP allocation mode: nativeLogical Core 1 (socket 0) forwards packets on 16 streams: RX P=0/Q=0 (socket 0) -> TX P=0/Q=0 (socket 0) peer=02:00:00:00:00:00 RX P=0/Q=1 (socket 0) -> TX P=0/Q=1 (socket 0) peer=02:00:00:00:00:00 RX P=0/Q=2 (socket 0) -> TX P=0/Q=2 (socket 0) peer=02:00:00:00:00:00 RX P=0/Q=3 (socket 0) -> TX P=0/Q=3 (socket 0) peer=02:00:00:00:00:00 RX P=0/Q=4 (socket 0) -> TX P=0/Q=4 (socket 0) peer=02:00:00:00:00:00 RX P=0/Q=5 (socket 0) -> TX P=0/Q=5 (socket 0) peer=02:00:00:00:00:00 RX P=0/Q=6 (socket 0) -> TX P=0/Q=6 (socket 0) peer=02:00:00:00:00:00 RX P=0/Q=7 (socket 0) -> TX P=0/Q=7 (socket 0) peer=02:00:00:00:00:00 RX P=0/Q=8 (socket 0) -> TX P=0/Q=8 (socket 0) peer=02:00:00:00:00:00 RX P=0/Q=9 (socket 0) -> TX P=0/Q=9 (socket 0) peer=02:00:00:00:00:00 RX P=0/Q=10 (socket 0) -> TX P=0/Q=10 (socket 0) peer=02:00:00:00:00:00 RX P=0/Q=11 (socket 0) -> TX P=0/Q=11 (socket 0) peer=02:00:00:00:00:00 RX P=0/Q=12 (socket 0) -> TX P=0/Q=12 (socket 0) peer=02:00:00:00:00:00 RX P=0/Q=13 (socket 0) -> TX P=0/Q=13 (socket 0) peer=02:00:00:00:00:00 RX P=0/Q=14 (socket 0) -> TX P=0/Q=14 (socket 0) peer=02:00:00:00:00:00 RX P=0/Q=15 (socket 0) -> TX P=0/Q=15 (socket 0) peer=02:00:00:00:00:00 rxonly packet forwarding packets/burst=32 nb forwarding cores=1 - nb forwarding ports=1 port 0: RX queue number: 16 Tx queue number: 16 Rx offloads=0x0 Tx offloads=0x10000 RX queue: 0 RX desc=4096 - RX free threshold=64 RX threshold registers: pthresh=0 hthresh=0 wthresh=0 RX Offloads=0x0 TX queue: 0 TX desc=4096 - TX free threshold=0 TX threshold registers: pthresh=0 hthresh=0 wthresh=0 TX offloads=0x10000 - TX RS bit threshold=0testpmd> stopTelling cores to stop...Waiting for lcores to finish... ------- Forward Stats for RX Port= 0/Queue= 0 -> TX Port= 0/Queue= 0 ------- RX-packets: 6001 TX-packets: 0 TX-dropped: 0 ------- Forward Stats for RX Port= 0/Queue= 1 -> TX Port= 0/Queue= 1 ------- RX-packets: 5894 TX-packets: 0 TX-dropped: 0 ------- Forward Stats for RX Port= 0/Queue= 2 -> TX Port= 0/Queue= 2 ------- RX-packets: 5931 TX-packets: 0 TX-dropped: 0 ------- Forward Stats for RX Port= 0/Queue= 3 -> TX Port= 0/Queue= 3 ------- RX-packets: 5759 TX-packets: 0 TX-dropped: 0 ------- Forward Stats for RX Port= 0/Queue= 4 -> TX Port= 0/Queue= 4 ------- RX-packets: 5821 TX-packets: 0 TX-dropped: 0 ------- Forward Stats for RX Port= 0/Queue= 5 -> TX Port= 0/Queue= 5 ------- RX-packets: 5787 TX-packets: 0 TX-dropped: 0 ------- Forward Stats for RX Port= 0/Queue= 6 -> TX Port= 0/Queue= 6 ------- RX-packets: 5893 TX-packets: 0 TX-dropped: 0 ------- Forward Stats for RX Port= 0/Queue= 7 -> TX Port= 0/Queue= 7 ------- RX-packets: 5909 TX-packets: 0 TX-dropped: 0 ------- Forward Stats for RX Port= 0/Queue= 8 -> TX Port= 0/Queue= 8 ------- RX-packets: 6013 TX-packets: 0 TX-dropped: 0 ------- Forward Stats for RX Port= 0/Queue= 9 -> TX Port= 0/Queue= 9 ------- RX-packets: 5956 TX-packets: 0 TX-dropped: 0 ------- Forward Stats for RX Port= 0/Queue=10 -> TX Port= 0/Queue=10 ------- RX-packets: 5735 TX-packets: 0 TX-dropped: 0 ------- Forward Stats for RX Port= 0/Queue=11 -> TX Port= 0/Queue=11 ------- RX-packets: 5885 TX-packets: 0 TX-dropped: 0 ------- Forward Stats for RX Port= 0/Queue=12 -> TX Port= 0/Queue=12 ------- RX-packets: 5771 TX-packets: 0 TX-dropped: 0 ------- Forward Stats for RX Port= 0/Queue=13 -> TX Port= 0/Queue=13 ------- RX-packets: 5878 TX-packets: 0 TX-dropped: 0 ------- Forward Stats for RX Port= 0/Queue=14 -> TX Port= 0/Queue=14 ------- RX-packets: 5844 TX-packets: 0 TX-dropped: 0 ------- Forward Stats for RX Port= 0/Queue=15 -> TX Port= 0/Queue=15 ------- RX-packets: 5930 TX-packets: 0 TX-dropped: 0 ---------------------- Forward statistics for port 0 ---------------------- RX-packets: 94007 RX-dropped: 0 RX-total: 94007 TX-packets: 0 TX-dropped: 0 TX-total: 0 ---------------------------------------------------------------------------- +++++++++++++++ Accumulated forward statistics for all ports+++++++++++++++ RX-packets: 94007 RX-dropped: 0 RX-total: 94007 TX-packets: 0 TX-dropped: 0 TX-total: 0 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++Done. 可以看到下发流表后,流量相对均匀的分布到了0-15这16个队列上。说明网卡的功能没有问题。 剩下来就是如何将这个规则下发以代码的形式集成到转发逻辑中了。 static int create_ipip_rss_flow(dpdk_port_t port_id) {// flow create 0 group 0 ingress pattern eth / ipv4 proto is 4 / ipv4 / end actions rss queues 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 end level 2 / end struct rte_flow_error error; struct rte_flow *flow;struct rte_flow_attr flow_attr = {.ingress = 1,};uint16_t queue_list[16];for (int i = 0; i < 16; i++) { queue_list[i] = i; } struct rte_flow_item patterns[] = {{.type = RTE_FLOW_ITEM_TYPE_ETH,},{.type = RTE_FLOW_ITEM_TYPE_IPV4,.spec = &(struct rte_flow_item_ipv4){ .hdr.next_proto_id = IPPROTO_IPIP, },.mask = &(struct rte_flow_item_ipv4){ .hdr.next_proto_id = 0xFF, },},{.type = RTE_FLOW_ITEM_TYPE_IPV4,},{.type = RTE_FLOW_ITEM_TYPE_END,},}; struct rte_flow_action actions[] = {{.type = RTE_FLOW_ACTION_TYPE_RSS,.conf = &(struct rte_flow_action_rss){.queue_num = 16,.queue = queue_list,.level = 2,},},{.type = RTE_FLOW_ACTION_TYPE_END,},}; flow = rte_flow_create(port_id, &flow_attr, patterns, actions, &error); if (!flow) { log_error("Failed to create ipip_rss_flow: %s", error.message); return -1; } return 0;} 实际开发过程中,稍微调整了一下流表的规则,不再匹配tcp/udp协议,而是只匹配到ipv4层,这样就可以同时支持TCP/UDP了。 最后感谢一下Github Copilot和Cursor在代码研发中提供的巨大帮助!

2024/12/25
articleCard.readMore

在KVM虚拟机中开启TSC作为时钟源

上一篇x86平台的TSC(TIME-STAMP COUNTER)中大概分析了一下TSC的一些相关的特性,以及TSC作为系统时钟源的一些基础条件。那么,在虚拟化的场景下,如何让Guest也用上TSC呢?这篇文章就来讨论一下TSC在KVM虚拟化中的使用。 基础分析 默认情况下,KVM虚拟机首选的时钟源是kvm-clock,即使将VM的CPU Model设置为host-passthrough,也不会使用TSC作为时钟源。 # lscpu|grep FlagsFlags: fpu vme de pse tsc msr pae mce cx8 apic sep mtrr pge mca cmov pat pse36 clflush dts mmx fxsr sse sse2 ss ht syscall nx pdpe1gb rdtscp lm constant_tsc arch_perfmon pebs bts rep_good nopl xtopology cpuid tsc_known_freq pni pclmulqdq dtes64 vmx ssse3 fma cx16 pdcm pcid sse4_1 sse4_2 x2apic movbe popcnt tsc_deadline_timer aes xsave avx f16c rdrand hypervisor lahf_lm abm 3dnowprefetch cpuid_fault ssbd ibrs ibpb stibp ibrs_enhanced tpr_shadow flexpriority ept vpid ept_ad fsgsbase tsc_adjust bmi1 avx2 smep bmi2 erms invpcid avx512f avx512dq rdseed adx smap avx512ifma clflushopt clwb avx512cd sha_ni avx512bw avx512vl xsaveopt xsavec xgetbv1 xsaves wbnoinvd arat vnmi avx512vbmi umip pku ospke avx512_vbmi2 gfni vaes vpclmulqdq avx512_vnni avx512_bitalg avx512_vpopcntdq la57 rdpid fsrm md_clear flush_l1d arch_capabilities# cat /sys/devices/system/clocksource/clocksource0/available_clocksourcekvm-clock acpi_pm# cat /sys/devices/system/clocksource/clocksource0/current_clocksourcekvm-clock 可以看到,即使CPU有大部分TSC相关的Flags,但是available_clocksource里并没有TSC,current_clocksource也是kvm-clock,原因可以从dmesg里看到: # dmesg |grep -i tsc[ 0.000000] tsc: Detected 2199.998 MHz processor[ 0.001000] clocksource: tsc-early: mask: 0xffffffffffffffff max_cycles: 0x1fb63109b96, max_idle_ns: 440795265316 ns[ 0.001000] TSC deadline timer enabled[ 0.577230] clocksource: tsc: mask: 0xffffffffffffffff max_cycles: 0x1fb63109b96, max_idle_ns: 440795265316 ns[ 0.692265] tsc: Marking TSC unstable due to TSC halts in idle states deeper than C2 可以看到,在启动的时候,但是由于TSC在C2状态下会停止,所以被标记为不稳定。 当然,还有另外一种情况: # cat /sys/devices/system/clocksource/clocksource0/available_clocksourcekvm-clock tsc acpi_pm# cat /sys/devices/system/clocksource/clocksource0/current_clocksourcekvm-clock 这种情况下,虽然TSC是可用的,但是还是没有被优先使用。虽然有两种可能性,但其实根因都是一个,那就是在Guest里,CPU缺少一个关键特性,那就是上篇文章提到的Invariant TSC。 # cpuid -1 -l 0x80000007CPU: RAS Capability (0x80000007/ebx): MCA overflow recovery support = false SUCCOR support = false HWA: hardware assert support = false scalable MCA support = false Advanced Power Management Features (0x80000007/ecx): CmpUnitPwrSampleTimeRatio = 0x0 (0) Advanced Power Management Features (0x80000007/edx): TS: temperature sensing diode = false FID: frequency ID control = false VID: voltage ID control = false TTP: thermal trip = false TM: thermal monitor = false STC: software thermal control = false 100 MHz multiplier control = false hardware P-State control = false TscInvariant = false CPB: core performance boost = false read-only effective frequency interface = false processor feedback interface = false APM power reporting = false connected standby = false RAPL: running average power limit = false 可以看到,关键的TscInvariant是false,在第一种情况下,intel_idle驱动正常加载,在驱动代码中: static bool __init intel_idle_verify_cstate(unsigned int mwait_hint){unsigned int mwait_cstate = (MWAIT_HINT2CSTATE(mwait_hint) + 1) &MWAIT_CSTATE_MASK;unsigned int num_substates = (mwait_substates >> mwait_cstate * 4) &MWAIT_SUBSTATE_MASK;/* Ignore the C-state if there are NO sub-states in CPUID for it. */if (num_substates == 0)return false;if (mwait_cstate > 2 && !boot_cpu_has(X86_FEATURE_NONSTOP_TSC))mark_tsc_unstable("TSC halts in idle states deeper than C2");return true;} 会检测CPU是否有X86_FEATURE_NONSTOP_TSC也就是TscInvariant,如果没有,就会标记TSC为不稳定。那么在这种情况下,因为TSC被标记为不稳定了,所以tsc是不会出现在available_clocksource中的。 那第二种情况呢,TSC没有被标记为不稳定,也出现在了available_clocksource中,但是为什么还是没有被优先使用呢?这是因为默认情况下,kvm-clock的优先级比TSC要高,可以看到在内核中的代码: static struct clocksource kvm_clock = {.name= "kvm-clock",.read= kvm_clock_get_cycles, // 默认情况下,kvm-clock的rating是400,这比TSC的rating 300要高,所以当两者同时存在时,系统会优先使用kvm-clock作为时钟源.rating= 400,.mask= CLOCKSOURCE_MASK(64),.flags= CLOCK_SOURCE_IS_CONTINUOUS,.id = CSID_X86_KVM_CLK,.enable= kvm_cs_enable,}; 但是在kvm-clock初始化的过程中,如果发现TSC满足条件的话,会主动降低自己的rating: void __init kvmclock_init(void){ // .../* * X86_FEATURE_NONSTOP_TSC is TSC runs at constant rate * with P/T states and does not stop in deep C-states. * * Invariant TSC exposed by host means kvmclock is not necessary: * can use TSC as clocksource. * */if (boot_cpu_has(X86_FEATURE_CONSTANT_TSC) && boot_cpu_has(X86_FEATURE_NONSTOP_TSC) && !check_tsc_unstable())kvm_clock.rating = 299;clocksource_register_hz(&kvm_clock, NSEC_PER_SEC);pv_info.name = "KVM";} 可以看到,只要CPU支持TscInvariant,那么kvm-clock的rating会主动降低自己的rating到299,那么在这种情况下,TSC将会成为rating更高的时钟源,从而被优先使用。但是由于Guest里CPU不支持TscInvariant,所以TSC并没有被优先使用。 到这里可以看出,要想让Guest支持并默认使用TSC作为时钟源,TscInvariant这个特性是十分关键的。 开启TscInvariant特性 Qemu最早在2.1版本中已经支持了TscInvariant,可以看到在这个版本的Changelog中: New “invtsc” (Invariant TSC) CPU feature. When enabled, this will block migration and savevm, so it is not enabled by default on any CPU model. To enable invtsc, the migratable=no flag (supported only by -cpu host, by now) is required. So, invtsc is available only if using: -cpu host,migratable=no,+invtsc. 开启方法很简单,只需要在启动时加上参数-cpu host,migratable=no,+invtsc即可,或者等价的,在Libvirt的XML中: <cpu mode='host-passthrough' migratable='off'> <feature policy='require' name='invtsc'/></cpu> 按文档启动一个虚拟机,然后查看对应的效果: # lscpu |grep FlaFlags: fpu vme de pse tsc msr pae mce cx8 apic sep mtrr pge mca cmov pat pse36 clflush mmx fxsr sse sse2 ss ht syscall nx pdpe1gb rdtscp lm constant_tsc arch_perfmon rep_good nopl xtopology nonstop_tsc cpuid tsc_known_freq pni pclmulqdq vmx ssse3 fma cx16 pdcm pcid sse4_1 sse4_2 x2apic movbe popcnt tsc_deadline_timer aes xsave avx f16c rdrand hypervisor lahf_lm abm 3dnowprefetch cpuid_fault ssbd ibrs ibpb stibp ibrs_enhanced tpr_shadow flexpriority ept vpid ept_ad fsgsbase tsc_adjust bmi1 avx2 smep bmi2 erms invpcid avx512f avx512dq rdseed adx smap avx512ifma clflushopt clwb avx512cd sha_ni avx512bw avx512vl xsaveopt xsavec xgetbv1 xsaves wbnoinvd arat vnmi avx512vbmi umip pku ospke avx512_vbmi2 gfni vaes vpclmulqdq avx512_vnni avx512_bitalg avx512_vpopcntdq la57 rdpid fsrm md_clear arch_capabilities# dmesg |grep tsc[ 0.000005] tsc: Detected 2199.998 MHz processor[ 0.112544] clocksource: tsc-early: mask: 0xffffffffffffffff max_cycles: 0x1fb63109b96, max_idle_ns: 440795265316 ns[ 0.310799] clocksource: tsc: mask: 0xffffffffffffffff max_cycles: 0x1fb63109b96, max_idle_ns: 440795265316 ns[ 0.310905] clocksource: Switched to clocksource tsc# cpuid -1 -l 0x80000007|grep TscInvariant TscInvariant = true# cat /sys/devices/system/clocksource/clocksource0/available_clocksourcetsc kvm-clock acpi_pm# cat /sys/devices/system/clocksource/clocksource0/current_clocksourcetsc 可以看到,TSC已经成为可用并且是默认的时钟源了。 VM热迁移 现在我们已经实现了Guest默认使用TSC作为时钟源,但是还有一个问题,从上面的changelog里其实也能看出来,那就是现在的配置,VM是没有迁移能力的,当前的配置下,如果尝试迁移VM,会出现如下错误: Requested operation is not valid: cannot migrate domain: State blocked by non-migratable CPU device (invtsc flag) 为什么有了TscInvariant之后就无法迁移了呢?我们可以简单想象一下,一开始VM运行在Host1上,并且使用了TSC作为时钟源,那么VM里TSC的频率和Host1是一致的,这时如果VM被迁移到Host2上,并且Host2的TSC频率和Host1不一致的话,那此时VM读取到的TSC频率就会发生变化,这很显然并不是我们想要的结果。 但是,KVM是支持用户自定义VM的TSC频率的,如果我们手动设置一个TSC频率,让迁移前后,Guest看到的TSC频率保持一致,自然也就不会导致问题了,因此在Qemu 2.9版本中,也是支持了这种情况,当用户指定了TSC的频率,即使在有invtsc的情况下,依然可以支持热迁移,具体的修改可以参考这个commit。而我们需要做的,就是在启动参数里加上-cpu host,migratable=on,+invtsc,tsc-freq=XXX,或者等价的,使用libvirt xml: <cpu mode='host-passthrough' migratable='on'> <feature policy='require' name='invtsc'/></cpu><clock offset='utc'> <timer name='tsc' frequency='2200000000'/></clock> TSC虚拟化的硬件加速 还剩下最后的一个问题,KVM是如何高效的实现固定Guest TSC频率的?当Guest TSC频率和Host TSC频率不一致时,这中间又是如何转换的?以及如何在迁移过程中确保TSC不会发生跳变? 在这种场景下,CPU支持的TSC scaling以及TSC offseting这两个特性就十分重要了,怎么理解呢,如果启用了TSC offseting,那么Guest在读取TSC的时候,硬件会在原始TSC值的基础上,加上一个设置的offset,这样在迁移过程中,源和目的宿主机的TSC base值不一样的情况下,只需要改一下这个offset值就好了,由于这个offset也只会在Guest读取时加上,因此也不会影响宿主机使用TSC。 TSC scaling也是类似的机制,通过设置一个频率倍率,让Guest读取TSC时将CPU当前的TSC值乘以这个倍率之后返回给Geust,从而解决用户设置的TSC频率和CPU本身TSC频率不一致的问题。 具体的信息,可以参考一下Intel的开发手册: 26.6.5 Time-Stamp Counter Offset and Multiplier The VM-execution control fields include a 64-bit TSC-offset field. If the “RDTSC exiting” control is 0 and the “use TSC offsetting” control is 1, this field controls executions of the RDTSC and RDTSCP instructions. It also controls executions of the RDMSR instruction that read from the IA32_TIME_STAMP_COUNTER MSR. For all of these, the value of the TSC offset is added to the value of the time-stamp counter, and the sum is returned to guest software in EDX:EAX. Processors that support the 1-setting of the “use TSC scaling” control also support a 64-bit TSC-multiplier field. If this control is 1 (and the “RDTSC exiting” control is 0 and the “use TSC offsetting” control is 1), this field also affects the executions of the RDTSC, RDTSCP, and RDMSR instructions identified above. Specifically, the contents of the time-stamp counter is first multiplied by the TSC multiplier before adding the TSC offset. See Chapter 26 for a detailed treatment of the behavior of RDTSC, RDTSCP, and RDMSR in VMX non-root operation. 27.3 CHANGES TO INSTRUCTION BEHAVIOR IN VMX NON-ROOT OPERATION RDTSC. Behavior of the RDTSC instruction is determined by the settings of the “RDTSC exiting” and “use TSC offsetting” VM-execution controls: - If both controls are 0, RDTSC operates normally. - If the “RDTSC exiting” VM-execution control is 0 and the “use TSC offsetting” VM-execution control is 1, the value returned is determined by the setting of the “use TSC scaling” VM-execution control: If the control is 0, RDTSC loads EAX:EDX with the sum of the value of the IA32_TIME_STAMP_COUNTER MSR and the value of the TSC offset. If the control is 1, RDTSC first computes the product of the value of the IA32_TIME_STAMP_COUNTER MSR and the value of the TSC multiplier. It then shifts the value of the product right 48 bits and loads EAX:EDX with the sum of that shifted value and the value of the TSC offset. - If the “RDTSC exiting” VM-execution control is 1, RDTSC causes a VM exit. 可以看到在Intel平台,TSC-offset以及TSC multiplier是VMCS中的两个字段,通过修改这两个以及RDTSC exiting字段,可以很好的控制Guest中TSC的行为。 当然,AMD的实现和Intel还有一些区别,具体的也可以参考AMD的文档 15.30.5 TSC Ratio MSR (C000_0104h) Writing to the TSC Ratio MSR allows the hypervisor to control the guest’s view of the Time Stamp Counter. The contents of TSC Ratio MSR sets the value of the TSCRatio. This constant scales the timestamp value returned when the TSC is read by a guest via the RDTSC or RDTSCP instructions or when the TSC, MPERF, or MPerfReadOnly MSRs are read via the RDMSR instruction by a guest running under virtualization. This facility allows the hypervisor to provide a consistent TSC, MPERF, and MPerfReadOnly rate for a guest process when moving that process between cores that have a differing P0 rate. The TSCRatio does not affect the value read from the TSC, MPERF, and MPerfReadOnly MSRs when in host mode or when virtualization is disabled. System Management Mode (SMM) code sees unscaled TSC, MPERF and MPerfReadOnly values unless the SMM code is executed within a guest container. The TSCRatio value does not affect the rate of the underlying TSC, MPERF, and MPerfReadOnly counters, nor the value that gets written to the TSC, MPERF, and MPerfReadOnly MSRs counters on a write by either the host or the guest. The TSC Ratio MSR specifies the TSCRatio value as a fixed-point binary number in 8.32 format, which is composed of 8 bits of integer and 32 bits of fraction. This number is the ratio of the desired P0 frequency to be presented to the guest relative to the P0 frequency of the core (See Section 17.1, “PState Control,” on page 657). The reset value of the TSCRatio is 1.0, which sets the guest P0 frequency to match the core P0 frequency. Note that: TSCFreq = Core P0 frequency * TSCRatio, so TSCRatio = (Desired TSCFreq) / Core P0 frequency. The TSC value read by the guest is computed using the TSC Ratio MSR along with the TSC_OFFSET field from the VMCB so that the actual value returned is: TSC Value (in guest) = (P0 frequency * TSCRatio * t) + VMCB.TSC_OFFSET + (Last Value Written to TSC) * TSCRatio Where t is time since the TSC was last written via the TSC MSR (or since reset if not written) 和Intel相比,AMD的TSC offset值是设置在VMCB中的,而TSC Scaling的倍率是基于MSR来实现的。实现的逻辑有区别并不重要,毕竟KVM会隔离掉不同平台的实现细节。重要的是,软硬件的协同配合,使得在虚拟化场景下,TSC可以作为一个高效的时钟源被VM使用。 性能测试 最后来看看相比于kvm-clock时钟源,使用tsc作为时钟源能够带来多大的性能提升吧。从红帽找到了一个测试时钟性能的例子: #include <time.h>main(){int rc;long i;struct timespec ts;for(i=0; i<500000000; i++) {rc = clock_gettime(CLOCK_MONOTONIC, &ts);}} 编译运行: # cat /sys/devices/system/clocksource/clocksource0/current_clocksourcetsc# time taskset -c 6 ./clock_timingreal 0m10.858suser 0m10.821ssys 0m0.000s# echo kvm-clock |sudo tee /sys/devices/system/clocksource/clocksource0/current_clocksourcekvm-clock# cat /sys/devices/system/clocksource/clocksource0/current_clocksourcekvm-clock# time taskset -c 6 ./clock_timingreal 0m13.530suser 0m13.482ssys 0m0.002s 同样是获取500000000次时间,tsc需要10.821s,而kvm-clock需要13.482s,差不多提升了20%,算是相当大的提升幅度了。

2024/12/17
articleCard.readMore

x86平台的TSC(TIME-STAMP COUNTER)

今天跟着Intel的开发手册,看看如何随着Intel对TSC不断的修改和增加新特性,让TSC从一个简单的性能计数器发展成当前Linux上x86平台最重要的时钟源之一。本文基本上可以看作是Intel® 64 and IA-32 Architectures Software Developer’s Manual Volume 3B: System Programming Guide, Part 2中17.15 TIME-STAMP COUNTER这章的翻译和总结。 在x86平台上,Linux系统里最常用的一个时钟源就是tsc,具体的,可以通过命令查看当前的时钟源和系统里可用的时钟源: # cat /sys/devices/system/clocksource/clocksource0/available_clocksourcetsc hpet acpi_pm# cat /sys/devices/system/clocksource/clocksource0/current_clocksourcetsc 那么TSC是个什么东西呢?我们可以跟着手册看一看。 TIME-STAMP COUNTER 从 Pentium 处理器开始,Intel 64 和 IA-32 架构定义了一种时间戳计数器机制(TSC),可以用于监控和识别处理器事件的相对时间。TSC包括以下组件: - TSC flag:用于标识TSC是否可用,当`CPUID.1:EDX.TSC[bit 4] = 1`时,TSC可用- IA32_TIME_STAMP_COUNTER MSR:对应TSC值的寄存器- RDTSC指令:读取TSC寄存器值的指令- TSD flag: 用于开关TSC功能,`CR4.TSD[bit 2] = 1`时开启TSC TSC从第一次在CPU中实现到如今很多年了,所以不同CPU上的一些表现还是有些区别的,但主要的特点是通用的,首先TSC是一个64bit大小的计数器,当CPU重置时,这个计数器也会被重置成0,重置后,即使CPU因为执行了HLT指令进入idle状态,这个寄存器也会持续的增加。 但是TSC会按什么样的频率增加呢?这里不同的CPU差异就比较大了。在一些比较老的CPU上(大概是07-08年之前),TSC是跟着CPU的运行频率增加的,比如当前CPU跑在2.4GHz,那TSC就每秒增加24000000,但是针对这些老的CPU,超频、以及CPU自身的睿频都会对TSC的计数产生影响。这其实对用户是不太友好的,毕竟睿频是硬件层面的,这个计数器就不那么“稳定”了。 所以后续的新CPU,Intel将这个行为修改成了以固定频率增加,只是这个频率具体是多少得看具体的配置,具体的TSC频率如何检测,手册里有专门的一章进行解释,这里我们不过多涉及。 在新CPU里,TSC以固定频率增长,所以这其实是一个TSC的特性,叫做Constant TSC,有了这个特性之后,TSC频率相对就稳定了,不会随着CPU睿频而随意改变频率,所以他也就可以成为一个稳定的时钟源了。因此这个特性会一直在后续的CPU中提供。 用户可以使用RDTSC这个CPU指令获取TSC的值,正常情况下(计数器没有溢出),Intel的CPU可以保证每次通过RDTSC读取的值都是单调递增的,同时可以保证在10年内计数器不会溢出。但是需要注意的是,这个指令它不是有序的(也就说他是有可能被CPU乱序执行的,所以可能需要加上memory barrier)。另外由于TSC是一个MSR,所以其实这个寄存器是可以通过RDMSR和WRMSR指令进行读写的,只是一些老CPU上只能写低32位(高32位此时会清0)。 Invariant TSC 前面提到TSC即使CPU处在halt状态,依然会持续的累加,但即使是这样,TSC依然不是100%可依赖最为一个时钟源的,为什么呢?因为halt状态只是CPU的C1状态,现代的CPU为了省电,引入了更多更深的C states,具体可以参考之前关于电源管理的内容再谈CPU的电源管理(如何做到稳定全核睿频?),当CPU进入到比较深的C states,比如C6,此时整个CPU的Core基本都被关闭了,那TSC自然也有可能不工作了。 为了解决这个问题呢,Intel又引入了一个新的特性增强,叫Invariant TSC,这个特性可以当CPUID.80000007H:EDX[8] == 1时,代表CPU支持这个特性。有这个特性的CPU,在任何的C states下,TSC都会持续运行,在Linux里,这个特性也会被称为NONSTOP_TSC(感觉这个更传神一些,不停止的TSC)。 可以看到引入Constant TSC和Invariant TSC之后,CPU先后解决了P-States(CPU频率变化)和C-States(CPU电源状态)对TSC频率的影响,逐渐将TSC设计成符合时钟源要求的样子,这也算是软硬件协同发展,螺旋上升的正面例子吧。 IA32_TSC_AUX Register and RDTSCP Support 看起来到这里已经万事大吉了?其实并没有,前面提到,RDTSC指令并不是有序的,意味着CPU硬件有可能对这个执行乱序执行,这可能并不是软件所预期的结果,举个例子,假如想通过基于类似RDTSC;other insturctions;RDTSC这样的指令顺序来计算other insturctions消耗的时间,在真正执行的时候,有可能就按RDTSC;RDTSCother insturctions的顺序了,这显然不是所期望的结果。 所以Intel针对这个问题,又增加了一个新的指令RDTSCP,使得可以原子地读取TSC。当执行RDTSCP指令时,会同时读取TSC和IA32_TSC_AUX两个寄存器的值。并且这个操作是原子的,不会出现上下文切换的问题。不过需要注意的是,只有当CPUID.80000001H:EDX[27] == 1时,CPU才支持RDTSCP指令。 针对这个乱序的问题,其实Linux内核里也是做了相应的处理的,我们可以从内核读取TSC的源码看出来,源码里的注释也是非常的详细: static __always_inline unsigned long long rdtsc_ordered(void){DECLARE_ARGS(val, low, high);/* * The RDTSC instruction is not ordered relative to memory * access. The Intel SDM and the AMD APM are both vague on this * point, but empirically an RDTSC instruction can be * speculatively executed before prior loads. An RDTSC * immediately after an appropriate barrier appears to be * ordered as a normal load, that is, it provides the same * ordering guarantees as reading from a global memory location * that some other imaginary CPU is updating continuously with a * time stamp. * * Thus, use the preferred barrier on the respective CPU, aiming for * RDTSCP as the default. */ //优先使用rdtscp,如果不支持,先执行lfence再执行rdtscasm volatile(ALTERNATIVE_2("rdtsc", "lfence; rdtsc", X86_FEATURE_LFENCE_RDTSC, "rdtscp", X86_FEATURE_RDTSCP): EAX_EDX_RET(val, low, high)/* RDTSCP clobbers ECX with MSR_TSC_AUX. */:: "ecx");return EAX_EDX_VAL(val, low, high);} Time-Stamp Counter Adjustment 最后的最后,还有一个问题需要解决,上面其实也提到了,TSC本质上是个MSR(IA32_TIME_STAMP_COUNTER MSR 地址10H),而这个MSR是可写的!这会存在一个问题,对于现代的多核系统,每个核都有自己的TSC MSR,如果某个核的MSR被修改了,这个修改怎么同步到其他核上去呢?很显然,不管是想计算出来被修改的核心的TSC的变化值,以及将这个值同步到其他的核上,都是不现实的。因为没办法在同一时刻在所有核上执行相同的指令。 但是多核之间同步TSC需求又是客观存在的,怎么办呢?Intel提供了一个新的MSR:IA32_TSC_ADJUST(地址3BH)来解决这个问题。首先和TSC一样每个核都有自己独立的IA32_TSC_ADJUST,当处理器重置时,IA32_TSC_ADJUST也会被置为0,当对IA32_TIME_STAMP_COUNTER进行写入时,比如加上(或者)一个X的值,那么对应核的IA32_TSC_ADJUST也会有对应的X值被加上(或者减去)。因此有了这个MSR之后,想计算某个核TSC的变化值,直接读取IA32_TSC_ADJUST里的值就行了,如果要把这个值同步到其他的核,就只需要把这个值写入到其他核的IA32_TSC_ADJUST里就行了。这就直接解决了多核之间TSC同步的问题,不过这个特性也不是所有CPU都支持,只有当CPUID.(EAX=07H, ECX=0H):EBX.TSC_ADJUST == 1时才支持。 到这里,TSC就变得真正可依赖了,首先有了Constant TSC,确保TSC按固定频率运行,然后有了Invariant TSC确保TSC一直运行,还有IA32_TSC_ADJUST确保当TSC被修改后依然能被同步回来。有了这些特性,TSC就可以成为系统中可信赖的时钟源。我们也可以通过Linux内核里的代码,看看内核是如何针对这种场景进行适配的: static void __init check_system_tsc_reliable(void){ // .../* * Disable the clocksource watchdog when the system has: * - TSC running at constant frequency * - TSC which does not stop in C-States * - the TSC_ADJUST register which allows to detect even minimal * modifications * - not more than two sockets. As the number of sockets cannot be * evaluated at the early boot stage where this has to be * invoked, check the number of online memory nodes as a * fallback solution which is an reasonable estimate. */ // 默认情况下Kernel假设TSC不稳定,所以会有个watchdog进行检测,当满足下面几个条件时,TSC足够稳定,watchdog也不需要运行了。if (boot_cpu_has(X86_FEATURE_CONSTANT_TSC) && boot_cpu_has(X86_FEATURE_NONSTOP_TSC) && boot_cpu_has(X86_FEATURE_TSC_ADJUST) && nr_online_nodes <= 4)tsc_disable_clocksource_watchdog();} 总结 好了,跟着文档的节奏,其实也可以看到TSC的发展历程,硬件不断的做出一些变化从而满足软件层面的需求,确定经历了相当长的时间。其实除此之外,TSC还有一些其他相关的特性,主要是和虚拟化相关,也是硬件为了更好的实现虚拟化做出的适配,这里就暂时不说了,期望下次可以继续聊聊虚拟化场景下TSC的一些特性。

2024/11/11
articleCard.readMore

一个UFO引发的惨案

首先需要解释一下标题,原谅我当了一回标题党,此UFO不是Unidentified flying object,而是在网络中的一个Oflload卸载技术UDP fragmentation offload。事情的起因是这样的,我们最近尝试将线上的虚拟机,从基于网卡SR-IOV+直通的方案,切换到基于DPDK+vhost-user的方案,以换取热迁移的效率提升。 从之前的模拟压测和线上灰度效果来看,新的DPDK方案的性能和稳定性都处于很好的水平,在我们的场景下可以很好地满足需求。 直到灰度到某个业务的时候,发生了一些问题,导致了虚拟机的网络中断。 我们通过热拔插方式进行网络切换,首先,会把当前直通的网卡从虚拟机中热拔出来,然后,再把一个vhost-user网卡热插到虚拟机中,从而实现网卡的切换。在切换过程中,大致会有3-5s左右的网络中断,但根据和业务的沟通,在单线程的操作情况下,这样的中断是没有问题的,不会影响业务。 为了保证业务的稳定,我们在网卡切换后,会持续ping 10s对应的虚拟机,确保网络正常后才会进行下一台的操作。 然后问题就发生了,在某些虚拟机切换网卡之后,大约5分钟内,网络是正常的,但是超过5分钟之后,突然网络就不通了,这个问题也是随机的,而对于网络不通的机器,通过重启DPDK进程的方式,网络又可以恢复几分钟,然后继续不通。这些现象确实在之前的测试中没有遇到过。从日志看,有少量的DPDK进程打印了这两条日志: VHOST_DATA: (/tmp/ens8f0-2.sock) failed to allocate memory for mbuf.VHOST_DATA: (/tmp/ens8f0-2.sock) failed to copy desc to mbuf. 从日志看,应该是给DPDK分配的内存不够了,导致DPDK从内存池里分配mbuf时无内存可用,但是DPDK使用的内存,是经过精确计算的呀?看看内存分配数量相关的代码: #define DEFAULT_IP_MTU (1500)#define L2_OVERHEAD (14 + 4 + 4)#define VF_RX_OFFSET (32)#define DEFAULT_MBUF_SIZE (DEFAULT_IP_MTU + L2_OVERHEAD + RTE_PKTMBUF_HEADROOM + VF_RX_OFFSET)#define MAX_VHOST_QUEUE_PAIRS 16/* rxq/txq descriptors numbers */#define RXQ_TXQ_DESC_1K 1024#define RXQ_TXQ_DESC_8K 8192/* relay mempool config */#define DEFAULT_NR_RX_QUEUE MAX_VHOST_QUEUE_PAIRS#define DEFAULT_NR_TX_QUEUE MAX_VHOST_QUEUE_PAIRS#define DEFAULT_NR_RX_DESCRXQ_TXQ_DESC_8K#define DEFAULT_NR_TX_DESCRXQ_TXQ_DESC_1K#define NUM_PKTMBUF_POOL(DEFAULT_NR_RX_DESC * DEFAULT_NR_RX_QUEUE + DEFAULT_NR_TX_DESC * DEFAULT_NR_TX_QUEUE + 4096)// ...mpool = rte_pktmbuf_pool_create(mp_name, n_mbufs, RTE_MEMPOOL_CACHE_MAX_SIZE, 0, DEFAULT_MBUF_SIZE, request_socket_id); 我们给每个VM分配了最多16个队列,MTU为1500,网卡侧同样支持16个发送队列+16个接收队列,其中每个接收队列设置ring buffer大小为8192,发送队列ring buffer大小1024,经过一系列的计算NUM_PKTMBUF_POOL这个值应该在所有场景都能满足需求,那为什么会出现内存不够的情况呢?我们再深入看一下DPDK相关的代码: 首先这个日志,在DPDK中有两个地方会打印,一个是在virtio_dev_tx_split函数中,另一个函数是vhost_dequeue_single_packed,这俩函数的功能是一致的,只是一个是用来处理老的virtio split ring的场景,另一个是处理packed ring的场景,而我们目前用的还是老的split ring,于是着重看下相关的代码: __rte_always_inlinestatic uint16_tvirtio_dev_tx_split(struct virtio_net *dev, struct vhost_virtqueue *vq,struct rte_mempool *mbuf_pool, struct rte_mbuf **pkts, uint16_t count,bool legacy_ol_flags){ // ...省略 // 一次循环,最多取32个数据count = RTE_MIN(count, MAX_PKT_BURST);count = RTE_MIN(count, avail_entries);VHOST_LOG_DATA(dev->ifname, DEBUG, "about to dequeue %u buffers\n", count);if (rte_pktmbuf_alloc_bulk(mbuf_pool, pkts, count))return 0;for (i = 0; i < count; i++) { // ...省略 // 拷贝Guest中的网络包到pkts(rte_mbuf)中err = desc_to_mbuf(dev, vq, buf_vec, nr_vec, pkts[i], mbuf_pool, legacy_ol_flags, 0, false);if (unlikely(err)) {if (!allocerr_warned) { // 拷贝失败了,打印了第二条日志,第一条日志在desc_to_mbuf打印了VHOST_LOG_DATA(dev->ifname, ERR, "failed to copy desc to mbuf.\n");allocerr_warned = true;}dropped += 1;i++;break;}} // ...省略return (i - dropped);}static __rte_always_inline intdesc_to_mbuf(struct virtio_net *dev, struct vhost_virtqueue *vq, struct buf_vector *buf_vec, uint16_t nr_vec, struct rte_mbuf *m, struct rte_mempool *mbuf_pool, bool legacy_ol_flags, uint16_t slot_idx, bool is_async){ // ...省略buf_addr = buf_vec[vec_idx].buf_addr;buf_iova = buf_vec[vec_idx].buf_iova;buf_len = buf_vec[vec_idx].buf_len;buf_offset = hdr_remain;buf_avail = buf_vec[vec_idx].buf_len - hdr_remain;PRINT_PACKET(dev,(uintptr_t)(buf_addr + buf_offset),(uint32_t)buf_avail, 0);mbuf_offset = 0;mbuf_avail = m->buf_len - RTE_PKTMBUF_HEADROOM;if (is_async) {pkts_info = async->pkts_info;if (async_iter_initialize(dev, async))return -1;}while (1) { // buf_avail是Guest中网络包的剩余长度,mbuf_avail是当前mbuf中剩余的容量 // 本次拷贝数据量是这两者的小值cpy_len = RTE_MIN(buf_avail, mbuf_avail); // 拷贝数据if (is_async) {if (async_fill_seg(dev, vq, cur, mbuf_offset, buf_iova + buf_offset, cpy_len, false) < 0)goto error;} else if (likely(hdr && cur == m)) {rte_memcpy(rte_pktmbuf_mtod_offset(cur, void *, mbuf_offset),(void *)((uintptr_t)(buf_addr + buf_offset)),cpy_len);} else {sync_fill_seg(dev, vq, cur, mbuf_offset, buf_addr + buf_offset, buf_iova + buf_offset, cpy_len, false);} // 计算拷贝后的结果mbuf_avail -= cpy_len;mbuf_offset += cpy_len;buf_avail -= cpy_len;buf_offset += cpy_len;/* This buf reaches to its end, get the next one */if (buf_avail == 0) { // 如果Guest里的数据都拷贝完了,直接break循环if (++vec_idx >= nr_vec)break;buf_addr = buf_vec[vec_idx].buf_addr;buf_iova = buf_vec[vec_idx].buf_iova;buf_len = buf_vec[vec_idx].buf_len;buf_offset = 0;buf_avail = buf_len;PRINT_PACKET(dev, (uintptr_t)buf_addr,(uint32_t)buf_avail, 0);}/* * This mbuf reaches to its end, get a new one * to hold more data. */ // 拷贝未结束,但是mbuf空间用完了,需要重新申请一个新的mbufif (mbuf_avail == 0) {cur = rte_pktmbuf_alloc(mbuf_pool); // 申请新的mbuf失败了,打印第一条日志,跳到error退出if (unlikely(cur == NULL)) {VHOST_LOG_DATA(dev->ifname, ERR,"failed to allocate memory for mbuf.\n");goto error;}prev->next = cur;prev->data_len = mbuf_offset;m->nb_segs += 1;m->pkt_len += mbuf_offset;prev = cur;mbuf_offset = 0;mbuf_avail = cur->buf_len - RTE_PKTMBUF_HEADROOM;}} // ...省略error:if (is_async)async_iter_cancel(async);return -1;} 从代码里可以看到确实是内存池不够用了,但是只有在Guest里数据包很大,超过我们预设的MTU的时候才会出现,什么时候Guest会发送超过MTU大小的包到网卡呢?很容易想到的一个点就是各种的Offload,特别是和网卡相关的分包Offload。于是就找了一台业务机器的现场看了一下网卡的特性开启情况: # ethtool -k eth0Features for eth0:rx-checksumming: on [fixed]tx-checksumming: on tx-checksum-ipv4: off [fixed] tx-checksum-ip-generic: on tx-checksum-ipv6: off [fixed] tx-checksum-fcoe-crc: off [fixed] tx-checksum-sctp: off [fixed]scatter-gather: on tx-scatter-gather: on tx-scatter-gather-fraglist: off [fixed]tcp-segmentation-offload: off tx-tcp-segmentation: off [fixed] tx-tcp-ecn-segmentation: off [fixed] tx-tcp6-segmentation: off [fixed]udp-fragmentation-offload: ongeneric-segmentation-offload: ongeneric-receive-offload: on# ...省略 再找一台没有问题的机器: # ethtool -k eth0Features for eth0:rx-checksumming: on [fixed]tx-checksumming: on tx-checksum-ipv4: off [fixed] tx-checksum-ip-generic: on tx-checksum-ipv6: off [fixed] tx-checksum-fcoe-crc: off [fixed] tx-checksum-sctp: off [fixed]scatter-gather: on tx-scatter-gather: on tx-scatter-gather-fraglist: off [fixed]tcp-segmentation-offload: off tx-tcp-segmentation: off [fixed] tx-tcp-ecn-segmentation: off [requested on] tx-tcp-mangleid-segmentation: off [fixed] tx-tcp6-segmentation: off [fixed]generic-segmentation-offload: on# ...省略 发现像TSO这种常用的Offload特性都关了,但是出问题的机器,比没出问题的机器,多了一个特性udp-fragmentation-offload: on,这个特性就是UFO,和TSO类似,TSO是TCP的offload,而UFO是UDP的offload,既然这里有不同,那是不是出问题里有应用会发送大的UDP包呢?再尝试抓包看下: # tcpdump -i eth0 udptcpdump: verbose output suppressed, use -v or -vv for full protocol decodelistening on eth0, link-type EN10MB (Ethernet), capture size 65535 bytes17:55:43.880517 IP localhost.29784 > 192.168.100.111.8333: UDP, length 908217:55:43.880526 IP localhost > 192.168.100.111: udp17:55:43.880528 IP localhost > 192.168.100.111: udp17:55:43.880539 IP localhost > 192.168.100.111: udp17:55:43.880541 IP localhost > 192.168.100.111: udp17:55:43.880542 IP localhost > 192.168.100.111: udp17:55:43.880543 IP localhost > 192.168.100.111: udp17:55:43.881681 IP localhost.29784 > 192.168.100.111.8333: UDP, length 907617:55:43.881684 IP localhost > 192.168.100.111: udp17:55:43.881686 IP localhost > 192.168.100.111: udp17:55:43.881687 IP localhost > 192.168.100.111: udp17:55:43.881689 IP localhost > 192.168.100.111: udp17:55:43.881690 IP localhost > 192.168.100.111: udp17:55:43.881692 IP localhost > 192.168.100.111: udp17:55:43.882214 IP localhost.17670 > 192.168.100.1.domain: 4442+ PTR? 192.168.100.1.in-addr.arpa. (42)17:55:43.882341 IP 192.168.100.1.domain > localhost.17670: 4442 NXDomain 0/1/0 (119)17:55:43.882828 IP localhost.29784 > 192.168.100.111.8333: UDP, length 904617:55:43.882835 IP localhost > 192.168.100.111: udp17:55:43.882837 IP localhost > 192.168.100.111: udp17:55:43.882850 IP localhost > 192.168.100.111: udp17:55:43.882852 IP localhost > 192.168.100.111: udp17:55:43.882853 IP localhost > 192.168.100.111: udp17:55:43.882854 IP localhost > 192.168.100.111: udp 没错,除了DNS的请求之外,业务还会发送一些超过9000长度的UDP包,这些UDP包会占用预期外的mbuf资源,知道了这个问题,那就尝试把ufo特性关闭一下看看效果: # ethtoo -K eth0 ufo off 关闭UFO之后,观察一段时间,虚拟机的网络恢复正常了。随后我们在测试环境也通过iperf复现了问题,便于后续的验证。 关闭UFO临时解决问题之余,又抛出来一个新的问题,为啥之前之前灰度的机器为什么没有出现同样的问题?为什么没有问题的机器里udp-fragmentation-offload: on这个特性直接消失了? 通过一些搜索,找到了答案,来着内核的官方文档: Segmentation Offloads: UDP Fragmentation Offload UDP fragmentation offload allows a device to fragment an oversized UDP datagram into multiple IPv4 fragments. Many of the requirements for UDP fragmentation offload are the same as TSO. However the IPv4 ID for fragments should not increment as a single IPv4 datagram is fragmented. UFO is deprecated: modern kernels will no longer generate UFO skbs, but can still receive them from tuntap and similar devices. Offload of UDP-based tunnel protocols is still supported. 原来UFO已经被废弃了,现代内核不会再发送大的UFO skbs,但是仍然允许从tuntap等类似设备上接收这些包。再看一下这俩机器的内核版本: #uname -r3.10.0-514.el7.x86_64 #uname -r5.14.0-284.25.1.el9_2.x86_64 确实差了不少。 最后呢,解决办法是将虚拟机的网卡配置调整了一下,修改成了: <interface type='vhostuser'> <mac address='{{.MACAddress}}'/> <source type='unix' path='{{.VhostPath}}' mode='server'/> <model type='virtio'/> <driver queues='16' rx_queue_size='1024' tx_queue_size='1024'> <host tso4='off' tso6='off' ufo='off' ecn='off' mrg_rxbuf='off'/> <guest tso4='off' tso6='off' ufo='off' ecn='off'/> </driver></interface> 其实TSO这个特性是在DPDK里关闭的,但是因为已经上线了不少业务,UFO在DPDK里关闭的话,会影响老虚拟机的热迁移,因此还是在qemu这侧关闭吧。

2024/4/1
articleCard.readMore

在RHEL系统中快速抓取火焰图

在之前的好几篇Blog里,都使用了火焰图来对业务进行性能优化,之前为了抓取火焰图,需要用到好几个工具进行组合,流程还是比较麻烦的。随着RHEL的版本更新,Redhat提供了一个更简单快速的方法实现了一键抓取火焰图的功能。 这个功能在RHEL 8.2及以上版本可以使用,当然RHEL对应的衍生版比如OracleLinux、AlmaLinux、RockyLinux等也是可以直接使用的。 首先需要安装perf和js-d3-flame-graph这两个软件包: # yum install js-d3-flame-graph perf -y 然后就可以通过perf script flamegraph -a -F 99 sleep 60命令抓取整个系统的火焰图了,其中-a参数表示需要记录整个系统的性能数据,-F参数指定每秒的收集频率,sleep 60表示收集60S的数据。 60秒后,命令自动退出并会在当前目录生成一个flamegraph.html文件,用任意浏览器打开这个文件,即可看到火焰图。 当然,如果要收集某个进程的火焰图,可以使用perf script flamegraph -a -F 99 -p PID1,PID2 sleep 60命令。

2023/10/18
articleCard.readMore

反序列化AWS/阿里云样式的基于Query的API请求

对于比较了解云计算的人来说,一定接触过AWS、阿里云的API接口,这两者的API调用方式很相似,当然具体谁参考谁这里就不深究了。以给EC2/ECS添加Tag这个接口为例: AWS: https://ec2.amazonaws.com/?Action=CreateTags&ResourceId.1=ami-1a2b3c4d&ResourceId.2=i-1234567890abcdef0&Tag.1.Key=webserver&Tag.1.Value=&Tag.2.Key=stack&Tag.2.Value=Production&AUTHPARAMS 阿里云: https://ecs.aliyuncs.com/?Action=TagResources&RegionId=cn-hangzhou&ResourceId.1=i-bp1j6qtvdm8w0z1o0****&ResourceId.2=i-bp1j6qtvdm8w0z1oP****&ResourceType=instance&Tag.1.Key=TestKey&Tag.1.Value=TestKey&<公共请求参数> 这种样式的接口设计,其实没有什么复杂的,相对比较特殊的地方在于,如果需要传入一个数组,则需要使用类似下标一样的Tag.N.Key这种格式进行传递,这个传递方式,和已有的一些诸如google/go-querystring的传递方式都不太相同,总之是个很特殊的设计。 如果需要写一个类似的服务,使用和这两家相同的API格式的话,针对这种数组格式的请求反序列化是个挺麻烦的事,而且找了一圈也没有类似的开源项目做这个。 今天借助ChatGPT写了一个反序列化函数,专门用来实现服务端对类似形态API的反序列化,通过这个函数可以很方便的将Query反序列化成一个对应的Struct: package mainimport ("encoding/json""fmt""net/url""reflect""strconv""strings")type TagRequest struct {Action string `query:"Action"`RegionID string `query:"RegionId"`ResourceIds []string `query:"ResourceId"`ResourceType string `query:"ResourceType"`Tags []Tag `query:"Tag"`}type Tag struct {Key string `query:"Key"`Value string `query:"Value"`}func Unmarshal(queryStr string, output interface{}) error {values, err := url.ParseQuery(queryStr)if err != nil {return err}return unmarshalData(values, output)}func unmarshalData(values url.Values, output interface{}) error {outputVal := reflect.ValueOf(output)if outputVal.Kind() != reflect.Ptr {return fmt.Errorf("output must be a pointer")}outputElem := outputVal.Elem()outputType := outputElem.Type()for i := 0; i < outputType.NumField(); i++ {field := outputType.Field(i)tag := field.Tag.Get("query")if tag == "" {continue}value := values.Get(tag)fieldVal := outputElem.FieldByName(field.Name)if field.Type.Kind() == reflect.Slice {elemType := field.Type.Elem()if elemType.Kind() != reflect.Struct {prefix := tag + "."arrIndex := 1for {currKey := prefix + fmt.Sprint(arrIndex)currValue := values.Get(currKey)if currValue == "" {break}currSliceVal := reflect.ValueOf(currValue)fieldVal.Set(reflect.Append(fieldVal, currSliceVal))arrIndex++}} else {prefix := tag + "."objIndex := 1outer := truefor outer {innerValues := make(url.Values)for innerKey, innerValue := range values {if strings.HasPrefix(innerKey, prefix+strconv.Itoa(objIndex)+".") {innerValues.Set(strings.TrimPrefix(innerKey, prefix+strconv.Itoa(objIndex)+"."), innerValue[0])}}if len(innerValues) == 0 {break}newStructPtr := reflect.New(elemType)err := unmarshalData(innerValues, newStructPtr.Interface())if err != nil {return err}fieldVal.Set(reflect.Append(fieldVal, newStructPtr.Elem()))objIndex++}}} else {fieldVal.Set(reflect.ValueOf(value))}}return nil}func main() {queryStr := "?Action=TagResources&RegionId=cn-hangzhou&ResourceId.1=i-bp1j6qtvdm8w0z1o0&ResourceId.2=i-bp1j6qtvdm8w0z1oP&ResourceType=instance&Tag.1.Key=TestKey&Tag.1.Value=TestValue&Tag.2.Key=TestKey&Tag.2.Value=TestValue"req := TagRequest{}err := Unmarshal(strings.TrimLeft(queryStr, "?"), &req)if err != nil {fmt.Println("Error:", err)return}jsonOutput, _ := json.MarshalIndent(req, "", " ")fmt.Println("Unmarshaled output:", string(jsonOutput))} 测试一下: % go run main.goUnmarshaled output: { "Action": "TagResources", "RegionID": "cn-hangzhou", "ResourceIds": [ "i-bp1j6qtvdm8w0z1o0", "i-bp1j6qtvdm8w0z1oP" ], "ResourceType": "instance", "Tags": [ { "Key": "TestKey", "Value": "TestValue" }, { "Key": "TestKey", "Value": "TestValue" } ]} 嗯,ChatGPT牛逼!为了方便大家使用,我创建了一个项目c0refast/aws-querystring,可以方便地作为库使用: package mainimport ("encoding/json""fmt""net/url""github.com/c0refast/aws-querystring/query")type TagRequest struct {Action string `query:"Action"`RegionID string `query:"RegionId"`ResourceIds []string `query:"ResourceId"`ResourceType string `query:"ResourceType"`Tags []Tag `query:"Tag"`}type Tag struct {Key string `query:"Key"`Value string `query:"Value"`}func main() {queryStr := "Action=TagResources&RegionId=cn-hangzhou&ResourceId.1=i-bp1j6qtvdm8w0z1o0&ResourceId.2=i-bp1j6qtvdm8w0z1oP&ResourceType=instance&Tag.1.Key=TestKey&Tag.1.Value=TestValue&Tag.2.Key=TestKey&Tag.2.Value=TestValue"urlValues, _ := url.ParseQuery(queryStr)req := TagRequest{}err := query.BindQuery(urlValues, &req)if err != nil {fmt.Println("Error:", err)return}jsonOutput, _ := json.MarshalIndent(req, "", " ")fmt.Println("Unmarshaled output:", string(jsonOutput))}

2023/7/13
articleCard.readMore

DIY一个8盘位全闪NAS

接上篇LSI RAID卡芯片和各个OEM对应卡型号列表里说的后续DIY NAS的想法,经过快3个月的时间,终于来更新整个DIY过程了,总结起来在整个过程中,收获的主要还是折腾的乐趣,要说折腾的尽头是白群晖,随着时间的推移,个人还是比较认同的,不过不得不说白群晖确实太贵了,都说群晖是买软件送硬件,但是这软件也太贵了点。 需求描述和分析 说起来,为啥会有个DIY NAS的需求呢?一个重要的原因是家里的小宝贝出生了,不知不觉也拍了好多的照片和视频,还是希望能更长久的把这些记忆保留下来。另外呢,之前更新自己的电脑,淘汰下来一套i5 6500 CPU加16G内存以及主板的准系统,买个机箱还有电源就直接可以用了,本着废物利用的原则,做个NAS也不亏,而且还多了很多可玩性。 其实单纯从保存数据来说,将数据存放到任何一个公有云的对象存储上,是个最终极的方案,因为目前各个厂商提供的对象存储数据持久性SLA都达到了11或者12个9(99.999999999%-99.9999999999%),这基本意味着几乎不存在数据丢失的可能性了。但是确实这个方案也是最贵的,毕竟每TB存储每月都需要消耗对应的存储费用,随着时间增长,即使是最便宜的冷归档类型,也依然是个不小的消耗。 那到底需要多少的存储容量呢?针对我个人而言,目前可预见的容量,应该不会超过10T,当前1-2年内所需求的容量更小,大概只需要1到2T的样子。 针对这个容量,其实已经可以考虑全SSD的存储方案了,其实相比于使用HDD的方案,纯SSD的NAS有以下几个好处:首先是噪音角度,相比HDD运行时的“炒豆子”声来说,SSD 0噪音,这可以直接解决夜间安静环境下HDD低频噪音对睡眠质量的影响;其次是稳定性和数据安全角度,根据我们公司数据中心有比较大规模的SSD和HDD的使用经验,同时参考backblaze提供的统计数据,可以看出SSD的稳定性远超过HDD,这带来了两个优势,一个是相比HDD,SSD损坏的概率低,这可以减少存储池修复的可能性,另外因为读写速度上SSD快很多,在坏盘的情况下,SSD也可以做到更坏的修复速度,从而可以提供更好的数据持久性。 当然SSD依然还是有缺点的,很明显当前SSD比HDD依然贵很多,以当前的价格来说,SSD成本大约0.4元/GB(大多数1T SATA SSD),HDD大概只有0.12元/GB(西数HC550 16T)。但是对于我目前的容量需求来说,使用SSD的成本相比HDD没有差距太大,多花的那部分成本,对于0噪音来说是相当值得的。 除了磁盘的选型,还有一些其他的需求,诸如盘位数量大于等于4,硬盘需要支持热拔插,存储池可以动态扩容,移动端、桌面端数据自动同步等等,不过这些也都算是比较基础的需求了。 硬盘笼选择 针对硬盘热拔插的需求,肯定还是要搞个硬盘笼的,不管怎么说,相比于直接把硬盘塞机箱里,有个热拔插硬盘笼一下子逼格就上来了。 所以一直花了不少精力去找合适的硬盘笼,主要还是集中于服务器的拆机件,这里给几个当时考虑的一些方案。 浪潮12盘位3.5寸硬盘笼 首先第一个选择是买浪潮的12盘位3.5寸硬盘笼,目前的价格大概150块钱的样子,还挺便宜,感觉应该都是当初Chia矿老板淘汰下来的,这些硬盘笼基本都有大4P的电源接口以及MiniSAS(SFF8087)接口,使用起来还是比较方便的,当然缺点是确实占地比较大,毕竟是适配的2U机箱,因为本来也一直坚持全闪的方案,所以3.5寸的硬盘位就没有必要了,即使很便宜,依然放弃了这个方案。 Intel 8盘位热插拔笼子(8 AnyBay) 这是Intel一个颜值和功能都超级能打的硬盘笼,具体的参数可以参考Intel的Spec文档(不得不说Intel的文档写的是真的好),甚至当前这个时间点,依然在量产状态,这个笼子一般来说都是在2U机箱上做竖插24盘位的组件的,这几乎是我心目中最理想的硬盘笼选择,8盘位AnyBay,支持SATA、SAS、U.2 NVMe接口的硬盘,特别是在现在咸鱼有大量的U.2接口的大容量企业级SSD,价格十分友好。最关键的是这个硬盘笼的尺寸非常完美,可以无缝的塞进两个5.25英寸光驱位中,网上也有这个硬盘笼搭配银欣(SilverStone)SG02-F机箱组NAS的方案:全网首发【8盘位热插拔NVMe SSD NAS】DIY指南简章,不得不说这个方案真的让人流口水,但确实不得不说成本太高了。 这个硬盘笼呢,什么都好,就是成本太高,不仅仅笼子本身(大约1100+)更重要的是其配套的组件,首先这个硬盘笼是MiniSAS HD(SFF‑8643)接口的,支持这个接口的HBA或者RAID卡也比较贵,其次如果说要支持8个NVMe硬盘的话,需要准备8个OcuLink接口,那PCIe转OcuLink接口的转接卡又是不小的支出。更重要的是,8个NVMe需要32个PCIe lane,这直接超出了当前大部分平台的能力,基本只有服务器平台的CPU+主板才能支持这么多的PCIe lane,成本很可观。最后还有一个不得不考虑的问题,U.2 NVMe硬盘一般来说功耗都比较高,很多盘能到10几20W往上,如果是8块硬盘,那整体功耗可能会超过100W,所以散热的问题就不得不考虑了,这个笼子如果插NVMe硬盘的话,需要额外的散热。 所以呢,东西虽好,但确实不符合我当前的需求和预算(流下贫穷的泪水)。据说H3C也有类似的笼子,价格要便宜1半,如果大家有类似的需求可以考虑。好像类似的8盘位AnyBay硬盘笼,各家OEM都有,但是好像好买且价格合适的不多。 Intel 8盘位热插拔笼子(4 AnyBay + 4 SATA/SAS) 这是另外一个Intel的硬盘笼子,大概可以算是上面笼子的低配版,文档看这里,这个笼子支持4个NVMe + 4个SATA/SAS硬盘,价格在淘宝也相对便宜,看到加上PCIe转接卡大概1000不到可以拿下,其实是个不错的选择,但是依然超出我的预算不少(继续流下贫穷的泪水)。 Intel 8盘位热插拔笼子(8 SATA/SAS) Intel还有一种硬盘笼(Intel出的好东西真不少啊),文档看这里,这个笼子支持8个SATA/SAS硬盘,使用两个MiniSAS(SFF8087)接口,是一开始我选中的方案,整体还是很平衡的,淘宝大概400不到,在我找到下面的硬盘笼之前,一度准备剁手入了。 HP热插拔380 G6/G7硬盘笼 这个是我最后选择的笼子,这个硬盘笼原本是给HP DL380G6/G7升级16盘位的套件,在HP那边的编号是:507690-001和516914-B21(这俩编号是一个东西),这个套件包含的几个组件和对应的物料编号如下: 硬盘笼子:463173-001 496074-001 硬盘背板:507690-001 451283-002 硬盘供电线:514217-001 硬盘SAS数据线:498425-001 493228-005 为什么我选择这个硬盘笼子呢,因为它真的便宜,笼子加上背板、送供电线和两根SAS线,只需要50块钱,当然是不带硬盘托架的,不过算上硬盘托架的价钱也只需要80。80块钱真买不了上当和吃亏。说实话它也有一些小问题,比如坑爹的HP不知道为啥要设计成横向的两个4盘位,尺寸比竖向8盘位相比宽了一点点,直接导致没办法塞进2个5.25英寸光驱位。另外电源接口的设计也比较恶心,是向外的,如果想塞进机箱,那突出来的电源线会成为一个大问题,另外HP的电源接口定义也是每代一改,虽然复用了10pin的接口,但是定义并不标准,导致我花了非常长的时间去寻找各个pin的定义,生怕弄错接口定义把背板烧了,最终还是在一个德语的论坛找到一个评论说了这个硬盘笼子的定义,需要说明的是这个背板不接3.3V的供电也没有影响,所以也是淘宝了两根大4P的电源线和10pin线,自己DIY了一个电源线,最终把这个硬盘盒的供电问题解决。总的来说,主要这个笼子实在是太便宜了,便宜到它的这几个缺点都可以忍受(还是流下贫穷的泪水)。 RAID/HBA卡选择 针对上面几个硬盘笼,特别是最终我选的硬盘笼,都使用了MiniSAS(SFF8087)接口,所以要想使用硬盘笼的话,需要有支持MiniSAS接口的SATA控制器或者RAID/HBA卡,根据之前的调查,大致有几种方案: 1. 可以使用MiniSAS转4SATA线实现,不过需要注意的是,这种线是区分正反的,正向线是MiniSAS口转4SATA,需要买反接线,将4SATA转换成MiniSAS口,从而实现将主板上的4个SATA口转成一个MiniSAS口。2. 市面上还有一些基于类似ASM1166(或者类似芯片)的PCI-E转SFF-8087转接卡,但ASM1166原生只支持6SATA Port,是否两个SFF-8087端口的8个端口都能使用,这个存疑。 另外还有也有一些M.2转8口SATA扩展卡也是用的两个SFF-8087转接卡,理论上也可以使用。3. 使用拆机的服务器HBA卡,比如基于LSI SAS 2008/2308的一众原厂或者OEM HBA卡。 最终我还是选择了相对成熟的HBA卡方案,一顿精挑细选,最终选了SuperMicro家的AOC-S2308L-L8I 9217-8i,基于SAS 2308芯片,PCIe 3.0 x8的接口,支持两个SFF-8087接口,这是一张OEM卡,对应的原厂卡型号是LSI SAS9217-8i,市面上除了SuperMicro之外,还有很多OEM也会出相同芯片的卡,比如浪潮、IBM等等,选择还是比较多的。我选的是2308的方案,这个芯片算是2008芯片的升级,其实区别不大,最主要的升级就是从PCIe 2.0 x8变成了PCIe 3.0 x8,整体带宽会高点。另外需要注意的一点是,这两个芯片有两种固件:IT(Initiator Target)模式和IR(Integrated RAID)模式,IT模式是类似HBA卡的直通模式,没办法组建RAID;IR模式是类似RAID卡的模式,可以组建简单的RAID0和RAID1。另外这两种固件是可以互刷的,区别只是在产品名字上是9207还是9217(9217是IR模式,9207是IT模式,所以我买的卡也是原厂IR模式刷的IT固件)。最后其实这个卡有个比较大的散热方面的问题,根据原厂的User Guide文档。这张卡默认情况下有接近10W(默认9.8W,最大16W)的功耗,且最低要求200 linear feet per minute的风量,在服务器环境下散热都不是问题,但是放到家用的机箱里,如果没有主动散热的情况下,这张卡会非常的烫手!所以最终我又找了一个12cm的风扇专门对着卡的散热片吹,从而解决散热问题。 硬盘选择 上面的这些组件搞定,最终就是买硬盘了,之前提到现在全新的SATA SSD大概0.4元/GB,一块1TB的SSD大概400左右,说实话还是不便宜的,所以我又勇敢的选择了大船货!其实现在二手拆机SSD,量最大,最划算的还是U.2的硬盘,不到1000块钱可以买到4T左右的企业级SSD,而且这些企业级SSD寿命极高,稳定性也相当好,而且相比于SATA接口,U.2因为是PCIe链路,可以做到传输层的数据保护,可惜的是咱们的硬盘笼不支持。于是就只能选择SATA接口的SSD,一下子可选范围就少了不少,最终还是选了当前比较火的Sandisk/闪迪云盘ECO,但是相比于更火的1.92T容量的版本,我最终买了960G容量的版本,相比于1.92T这个容量点,我猜960G容量用来做系统盘的概率更大点,说不准能抽奖抽到写入量超低的盘😁。相比于全新盘,这个拆机盘的价格就很实惠了。目前的价格是960G容量版本230块,不到0.24元/GB,属实是相当划算了。 NAS系统选择 所有硬件的问题搞定,最后就只剩下软件层面的选择了,到底该用什么系统呢?一开始想使用TrueNAS,主要是看中ZFS的能力,但是试用了一小段时间TrueNAS之后,感觉这系统是真的很难用,门槛太高了,虽然运维这玩意对我来说并不是太大的瓶颈,但是确实各个方面都不太好用,特别是相关的软件生态上差很多。 于是乎就又试了试黑群晖,一开始我其实是抵制黑群晖的,因为有点担心数据安全问题,不确定会不会哪天就崩了,但是试用了一下之后,觉得确实群晖的生态做的太好了,体验拉满。于是就去简单研究了一下黑群晖的实现原理,发现其实黑群晖的相关项目都是开源的,都放在了RedPill-TTG这个组织下,其中最关键的对群晖内核的hack都在RedPill-TTG/redpill-lkm这个项目里,大致翻了翻代码,基本上就是通过加载模块的方式,欺骗群晖的内核,让其认为自己是跑在群晖专有的硬件上。了解了之后,黑群晖在我心目中好感倍增,话说感觉群晖这绝对是套路满满,都说黑群晖的尽头是白群。估计群晖官方默许黑群晖行为的原因大概和Windows一样,反正最终都会买我。 不过相比于直接把群晖跑在裸机上,我采取了一个另外的方法,把群晖跑在了虚拟机里,这样做的目的主要也是为了方便以后迁移,对我来说当前的硬件平台只是个相对临时的解决方案,为了后续能更好的跨平台迁移,所以我选择将黑群晖跑在虚拟机里,而对于HBA卡来说,采取的是硬件直通的方式直通给虚拟机,从而确保SMART等功能的正常使用。这里分享一下我现在用的虚拟机的XML,有需要的可以参考一下: <domain type="kvm"> <name>Synology</name> <uuid>5ce24e3b-627b-468a-bcd5-53ff58d9731d</uuid> <memory>8388608</memory> <currentMemory>8388608</currentMemory> <memoryBacking> <hugepages/> </memoryBacking> <vcpu>4</vcpu> <os firmware="efi"> <firmware> <feature enabled='no' name='secure-boot'/> </firmware> <type arch="x86_64" machine="q35">hvm</type> <boot dev="hd"/> </os> <features> <acpi/> <apic/> </features> <cpu mode="host-passthrough"/> <clock offset="utc"> <timer name="rtc" tickpolicy="catchup"/> <timer name="pit" tickpolicy="delay"/> <timer name="hpet" present="no"/> </clock> <pm> <suspend-to-mem enabled="no"/> <suspend-to-disk enabled="no"/> </pm> <devices> <emulator>/usr/libexec/qemu-kvm</emulator> <disk type="file" device="disk"> <driver name="qemu" type="raw"/> <source file="/data0/Synology/boot.img"/> <target dev="sda" bus="usb"/> </disk> <interface type="bridge"> <source bridge="br0"/> <mac address="11:22:33:44:55:66"/> <model type="virtio"/> </interface> <console type="pty"/> <hostdev mode="subsystem" type="pci" managed="yes"> <source> <address domain="0" bus="1" slot="0" function="0"/> </source> </hostdev> <memballoon model="none"/> <graphics type='vnc' port='-1' autoport='yes' listen='0.0.0.0'> <listen type='address' address='0.0.0.0'/> </graphics> <video> <model type='vga' vram='16384' heads='1' primary='yes'/> </video> </devices></domain> 总结和后续 目前的这套NAS方案已经运行了2个多月,看起来还算稳定,这段时间内没出现过大的问题。如果折腾半天就只为了存点照片也有点浪费,所以除了存储功能之外,我又跑了个PCDN业务,目前收益也还不错,我是100M的上传带宽,目前每天大概能有个5块钱的收益,至少电费能覆盖了,跑着玩玩吧~ 想想当前这套还有什么不足,个人觉得一个比较大的缺点是占地,目前我是用了乔斯伯的V4做的机箱,但是硬盘笼是不太好直接放进机箱了,所以只是简单的放在了外壳上,整体相比于家用的NAS产品还是大了一圈,另外确实硬盘笼比双5.25寸光驱位大了点,即使后续换了机箱,可能也不太好直接放进去。 另外就是CPU这些硬件配置了,一方面当前这颗CPU TDP还是比较大的,65W,必须有主动散热,另外HBA卡也需要主动散热,所以离真正的0噪音还有点距离,理论上换用嵌入式的低功耗CPU+SATA转MiniSAS的方案,可以做到整机完全没有任何机械活动的部件,做到真正意义上的0噪音。 最后就是另一个方向,换到稍微低端点的数据中心CPU+主板,功耗会高一些,但是可扩展性会极大的增强,包括ECC内存以及U.2硬盘、IPMI这些都可以支持。具体往后该如何演进,还是等待当前套系统继续运行一段时间吧。

2023/1/26
articleCard.readMore

分享一个记录LSI RAID卡芯片和各个OEM对应卡型号列表的神贴

最近想买一张拆机的HBA卡组一个NAS玩玩,目前用SAS 2308的拆机OEM HBA卡(IT Mode,也可以刷固件刷成IR Mode从而直接变成RAID卡,但是只支持RAID 0, 1, 1E和10)只需要不到100块钱,非常划算,除了功耗高点(差不多10W)比较热之外,感觉没啥缺点。 于是就被各种原厂的或者OEM厂的各种型号搞晕了,因为基于这个芯片的各种OEM马甲卡实在太多了。于是乎就找到了一篇神贴,帖子里覆盖了几乎所有的LSI的RAID卡芯片以及大部分国外厂商对应的OEM卡型号,而且一直在更新,最新的一些SAS芯片也有记录,比较可惜的是国内的一些OEM厂商,特别是在淘宝保有量相当大的浪潮的OEM卡型号没有记录。 帖子的地址在:LSI RAID Controller and HBA Complete Listing Plus OEM Models。有需要的可以参考一下。 后续组NAS的经历我也会持续分享,敬请关注~

2022/11/5
articleCard.readMore

在移动硬盘盒上启用SSD的Trim功能

最近折腾了一小段时间的PCDN,家里刚好有一个闲置的JetsonNano和一块闲置的SSD,刚好可以跑跑PCDN,每天挣个宽带钱。具体跑的哪家,就不说了,说说在这过程中遇到的一个小问题:一般来说,PCDN或者类似的业务,对磁盘的写入压力还是比较大的,虽然可能平均的写入带宽并不高,但是也架不住每天读写的时间相当长,虽然我这块SSD是闲置的,但好歹是个传家宝,不管怎么说,还是有那么点点心疼的,肯定是不太希望哪天这SSD被写坏了。 在这种场景下,尽可能延长SSD的写入寿命就很重要了,而方法之一呢,就是想办法把SSD的Trim命令给用上。 用上Trim命令之前,可以先简单了解一下背后的逻辑,具体的可以参考Wiki,简单来说呢,因为SSD依赖垃圾回收机制来平衡NAND的磨损,但是呢具体到一整个LBA空间,只有文件系统知道哪些数据块是有效数据,所以就需要通过Trim命令,建立文件系统空闲空间和SSD底层数据块的关联,从而让SSD的主控更好的进行垃圾回收操作,一般来说,合理的使用Trim,可以有效的提高SSD的性能和寿命。当然了,Trim命令是ATA指令集里的,也就是SATA接口SSD才会有,对于SCSI以及SAS接口SSD,还有NVMe SSD来说,也有相应的UNMAP和Deallocate指令,作用都是一样的。 一般来说,在Linux下,一个设备是否支持Trim操作,可以通过lsblk --discard进行查看,当输出中的DISC-GRAN和DISC-MAX列不为0时,说明这个设备是支持Trim操作的: jetson-nano:chenfu:# lsblk --discardNAME DISC-ALN DISC-GRAN DISC-MAX DISC-ZEROsda 0 0B 0B 0mmcblk1 0 4M 76M 0└─mmcblk1p1 0 4M 76M 0 比如在我这个JetsonNano上,可以看到我外接的这块SSD硬盘,对应sda设备是不支持Trim的,但是mmcblk1这个设备,也就是装系统用的一个小的MicroSD卡是支持的。 那么问题来了,针对上面的输出,sda这块盘是不支持Trim的,那怎么样才能让他支持呢? 首先需要明确的是,因为这块盘是我通过一块USB移动硬盘盒转接到板子上的,也就意味着这块硬盘并没有用原生的SATA接口(当然这块开发版本身也不支持SATA接口)。而对于移动硬盘盒而言,将SATA口转换成USB口,会需要一个桥接芯片进行协议的转换,那么桥接芯片是否支持Trim命令的转换,就显得非常重要了。对于一些老的移动硬盘盒,大多使用的是Mass Storage Class Bulk-Only Transport (BOT)这个协议,但是对于一些比较新的桥接芯片,基本都会支持一个新的叫做USB Attached SCSI Protocol (UASP) 的新协议。所以我也查了一些资料,同样也是结合产品页的一些宣传,买了一个支持UAS协议的移动硬盘盒,根据评论看,这个硬盘盒是支持Trim的,但是大部分用户似乎都是在Windows下进行测试的,在Linux下是否真的支持,是否需要新版本内核或者驱动的支持还不知道。 等硬盘盒到手,插上之后系统lsusb看了一下: jetson-nano:~:% lsusbBus 002 Device 002: ID 174c:225c ASMedia Technology Inc. Ugreen Storage Device VendorId是0x174c,也就是ASMedia公司的桥接芯片,但是225c这个ProductId并没有在USB ID数据库里查到,不过从数据库里看0x1153这个ProductId对应ASM1153这款芯片来说,那225c应该是对应着ASM225CM这个芯片?从目前的资料看,这个芯片理论上是支持Trim的,至少可以通过刷新固件来解决支持的问题。 然而系统识别出sda之后,lsblk --discard依然提示不支持Trim。 于是又搜索了一些资料,终于在Arch的SSD Wiki里找到了一些信息: 其实现在一些USB转SATA芯片(如VL715、VL716等)以及在外接NVMe硬盘盒(如IB-1817M-C31)中使用的USB转PCIe芯片(如 智微(JMicron) JMS583 )支持类似TRIM的命令。这些命令可通过 USB Attached SCSI 驱动程序(在Linux下称为”uas”)发送。然而内核可能不会自动检测到并启用这一功能。 会不会是因为芯片是支持的,但是系统默认没有开启呢?于是按Wiki里的说法,使用sg_readcap -l /dev/sda命令读取设备的标志位: jetson-nano:chenfu:# sg_readcap -l /dev/sdaRead Capacity results: Protection: prot_en=0, p_type=0, p_i_exponent=0 Logical block provisioning: lbpme=0, lbprz=0 Last LBA=937703087 (0x37e436af), Number of logical blocks=937703088 Logical block length=512 bytes Logical blocks per physical block exponent=0 Lowest aligned LBA=0Hence: Device size: 480103981056 bytes, 457862.8 MiB, 480.10 GB 发现Logical block provisioning: lbpme=0, lbprz=0其中lbpme=0,因为LBPME位为0,所以内核默认是不会开启DISCARD的支持。针对这种情况,还需要继续通过sg_vpd -a /dev/sda命令查询设备支持的命令情况: jetson-nano:chenfu:# sg_vpd -a /dev/sdaSupported VPD pages VPD page: ...Unit serial number VPD page: Unit serial number: 704108E11D02Device Identification VPD page: Addressed logical unit: designator type: NAA, code set: Binary 0x5000000000000001Block limits VPD page (SBC): Write same non-zero (WSNZ): 0 ...Block device characteristics VPD page (SBC): Non-rotating medium (e.g. solid state) ...Logical block provisioning VPD page (SBC): Unmap command supported (LBPU): 1 Write same (16) with unmap bit supported (LBPWS): 0 Write same (10) with unmap bit supported (LBPWS10): 0 Logical block provisioning read zeros (LBPRZ): 0 Anchored LBAs supported (ANC_SUP): 0 Threshold exponent: 0 [threshold sets not supported] Descriptor present (DP): 0 Minimum percentage: 0 [not reported] Provisioning type: 0 (not known or fully provisioned) Threshold percentage: 0 [percentages not supported] 可以发现在Logical block provisioning VPD page (SBC)段下,有Unmap command supported (LBPU): 1,说明设备本身是支持Unmap指令的,因为前面说到,ATA中的Trim其实就是对应的SCSI中的UNMAP,所以支持UNMAP也就是支持了Trim,当然这中间的转换过程,应该是有硬盘盒的主控来完成。 那既然在物理上是支持Trim的,那剩下的就是逻辑上怎么启用的问题了,先看下目前内核识别的设备的provisioning_mode: jetson-nano:chenfu:# cat /sys/block/sda/device/scsi_disk/0:0:0:0/provisioning_modefull 可以发现输出是full,也就是说内核当前是没有检测到设备支持Trim特性,解决方法也比较简单,直接echo unmap到这个文件: jetson-nano:chenfu:# echo unmap > /sys/block/sda/device/scsi_disk/0:0:0:0/provisioning_modejetson-nano:chenfu:# lsblk --discardNAME DISC-ALN DISC-GRAN DISC-MAX DISC-ZEROsda 0 512B 4G 0mmcblk1 0 4M 76M 0└─mmcblk1p1 0 4M 76M 0 可以看到,强制指定provisioning_mode为unmap之后,lsblk --discard的输出已经提示sda设备支持Trim了。 最后,为了能让这个特性可以在插入硬盘盒的时候自动生效,可以手动编写一个Udev的规则文件: echo 'ACTION=="add|change", ATTRS{idVendor}=="174c", ATTRS{idProduct}=="225c", SUBSYSTEM=="scsi_disk", ATTR{provisioning_mode}="unmap"' >>/etc/udev/rules.d/10-uas-discard.rules 也就是说,当有idVendor为174c,idProduct为225c的设备(也就是我的这个硬盘盒)连接的时候,自动设置provisioning_mode为unmap。

2022/10/5
articleCard.readMore

SPDK的“Reduce”块压缩算法

本文是SPDK文档SPDK “Reduce” Block Compression Algorithm的翻译,在读SPDK的文档过程中,刚好看到了SPDK里bdev reduce模块实现背后的算法描述,于是就想着翻译一下,正好也借翻译的同时仔细理解一下背后算法的原理,当然本人的水平有限,如果译文有任何歧义,还请参考原文并以实际原文为准。 概述 SPDK的“reduce”块压缩方案使用SSD存储压缩后的块数据,同时将元数据存放到持久内存中。此元数据包含用户数据的逻辑块到压缩后的物理块的对应关系。本文档中描述的方案是通用的,不依赖于包括SPDK在内任何特定的块设备框架。该算法会在一个叫做libreduce的库中实现。更高层次的软件可以基于该模块创建和呈现特定的块设备。对于SPDK来说,bdev_reduce模块封装了libreduce库,从而在SPDK中提供一个bdev以实现压缩功能。 本方案仅仅解释压缩后的数据块和用于跟踪这些数据块的元数据的管理。它依赖于高层软件模块来执行压缩操作。对于SPDK,bdev_reduce模块利用DPDK compressdev框架执行压缩和解压缩。 (需要注意的是,在某些情况下,数据块可能是不可压缩的,或者无法压缩到足以实现空间节省的程度。在这些情况下,数据可能不经过压缩,直接存储在磁盘上。“压缩的存储块”包括这些不经压缩的块。) 一个压缩块存储设备是一个建立在拥有相似大小的后备存储设备之上的一个逻辑实体。其中的后备存储设备必须是精简置备(thin-provisioned)的从而才能真正意义上从后文描述的实现中获得空间节省。同样该算法除了一直使用后备存储设备上可用的编号最低的块之外,对后备存储设备的实现没有直接的了解。这保证了在精简配置的后备存储设备上使用此算法时,在实际需要空间之前不会分配对应空间。 后备存储的大小,必须考虑最坏情况,也就是所有数据都不可压缩的情况。在这种情况下,后备存储的大小和压缩块设备的大小是一致的。另外,本算法基于永远不会原地写这个前台来保证原子性,所以在更新元数据之前,可能还需要额外的一些后备存储空间来作为临时写缓存。 为了最佳性能考虑,所有后备存储设备都将以4KB为最小单位进行分配、读取和写入。这些4KB的单元被称作“后备IO单元”(backing IO units)。他们被一个称作“后备IO单元索引”(backing IO unit indices)的索引列表中以0到N-1编号进行索引。在一开始,这个完整的索引代表了“空闲后备IO单元列表”(free backing IO unit list)。 一个压缩块存储设备基于chunk进行压缩和解压操作,chunk大小至少是两个4K的后备IO单元,每个chunk所需要的后备IO单元数量,也同样表明了chunk的大小,这个数量或者大小需要在压缩块存储设备创建时指定。一个chunk,需要消耗至少1个,至多chunk大小个后备IO单元数量。举个例子,一个16KB的chunk,有可能消耗1,2,3,4个后备IO单元,最终消耗的数量取决于这个chunk的压缩率。磁盘blocks和chunk的对应关系,存储在持久内存中的一个chunk map里。每个chunk map包含了N个64-bit的值,其中N是每个chunk所包含的后备IO单元的数量。每个64-bit值表示一个后备IO单元的索引。一个特殊的值(举个例子,2^64-1)用来表示因为压缩节省而不需要使用实际的后备存储。chunk map的数量,等于压缩块设备的容量除以它的chunk大小,再加上少量用于保证原子写操作额外的一些chunk map。一开始所有的chunk map都表示“空闲chunk map列表”。 最后,压缩块设备的逻辑映射表通过“logical map”进行表示。这里的“logical map”指的是压缩块存储设备对于对于chunk map的偏移的对于关系。logical map里每个条目是一个64-bit的值,表示所关联的chunk map。一个特殊值(UINT64_MAX)表示没有对应关联的chunk map。映射是通过将字节偏移量除以块大小得到一个索引来确定的,该索引用作块映射条目数组的数组索引。 开始时,逻辑映射表中的所有条目都没有关联的块映射。 请注意,虽然对后备存储设备的访问以 4KB 为单位,但逻辑映射表可能允许以4KB或512B为单位进行访问。 一些例子 为了说明这个算法,我们将使用一个真实的非常小规模的例子。 压缩块设备的大小为64KB,chunk大小为16KB。 这会实现以下几点: “后备存储” 需要是一个80KB大小的精简置备(thin-provisioned)逻辑设备。这包括了64KB的压缩设备原始大小,以及为了在最坏情况下保证写原子性而额外分配的16KB大小。 “空闲后备IO单元列表”(free backing IO unit list)由一个0-19的索引组成,这些索引表示在后备存储里的20个4KB最小IO单元。 一个”chunk map”的大小是32字节, 对应每个chunk需要4个后备存储单元(16KB/4KB),以及每个存储单元需要8个字节(64bit)进行表示。 需要从持久内存中分配5个chunk map,共160B的空间。这包含了压缩块设备的4个chunk(64KB / 16KB)所对应的4个chunk map以及为了改写已有chunk时需要的额外1个chunk map “空闲后备IO单元列表”(Free chunk map list) 将由0 - 4(包含4)进行索引。 这些索引表示这5个被分配的chunk map “逻辑映射表”(logical map)需要在持久内存中分配32B空间,这包含了压缩块设备4个chunk的索引,每个索引需要8B(64bit)。 在下面的例子中,”X”符号代表上面所说的那个特殊值特殊值(2^64-1)。 创建初始化(Initial Creation) +--------------------+Backing Device | | +--------------------+Free Backing IO Unit List 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19 +------------+------------+------------+------------+------------+Chunk Maps | | | | | | +------------+------------+------------+------------+------------+Free Chunk Map List 0, 1, 2, 3, 4 +---+---+---+---+Logical Map | X | X | X | X | +---+---+---+---+ 在32KB偏移量处写入16KB(Write 16KB at Offset 32KB) 找到逻辑映射表(logical map)对应的index。32KB偏移量除以16KB的chunk size,得到index为2。 Logical map里的第2个单元是一个“X”,也就是说当前这16KB还没有被写入过。 在内存中分配16KB的buffer。 将写入的这16KB数据压缩后,存入到刚刚分配的buffer中。 假设数据被压缩到只剩6KB,那么就需要2个4KB的后备IO单元。 从空闲后备IO单元列表中分配2个block(编号0和1)。需要注意的是,永远都从空闲后备IO单元列表中最小的单元还是分配,这样可以保证在thin-provision情况下,不会用到多余的后端存储,从而节省容量。 将压缩后的6KB数据存储到后备存储的第0和第一个单元。 从空闲chunk map列表中分配一个chunk map 0。 将(0,1,X,X)存储到chunk map 0中。这表示只有2个后备IO单元被用来存储这16KB数据。 把chunk map的编号0,写到逻辑映射表(logical map)的第二个单元中。 +--------------------+Backing Device |01 | +--------------------+Free Backing IO Unit List 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19 +------------+------------+------------+------------+------------+Chunk Maps | 0 1 X X | | | | | +------------+------------+------------+------------+------------+Free Chunk Map List 1, 2, 3, 4 +---+---+---+---+Logical Map | X | X | 0 | X | +---+---+---+---+ 在8KB偏移量处写入4KB(Write 4KB at Offset 8KB) 在逻辑映射表中找到对应的index。 8KB偏移量,除以16KB的chunk size,得到index为0。 逻辑映射表中的0号条目是“X”,这表示这16KB还没有被写入过数据。 写入请求不是一个完整的16KB chunk大小,所以我们必须要先分配一个16KB的buffer用于暂存源数据。 把需要写入的4KB数据写入到这16KB buffer的8KB偏移处,并把buffer其他的地方填0。 再分配16KB的目标buffer。 把16KB的源数据,压缩后存入到目标buffer中。 假设数据被压缩到3KB,这将需要1个4KB的后备IO单元。 从空闲后备单元列表中分配一个block(编号2)。 把3KB数据写入到编号2的block中。 从空闲chunk map列表中分配一个空闲chunk map(编号1)。 把(2,X,X,X)写入到chunk map中。 将chunk map的索引(编号1)写入到逻辑映射表的第0个条目里。 +--------------------+Backing Device |012 | +--------------------+Free Backing IO Unit List 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19 +------------+------------+------------+------------+------------+Chunk Maps | 0 1 X X | 2 X X X | | | | +------------+------------+------------+------------+------------+Free Chunk Map List 2, 3, 4 +---+---+---+---+Logical Map | 1 | X | 0 | X | +---+---+---+---+ 在16K偏移量处读取16KB数据(Read 16KB at Offset 16KB) 16KB偏移量,在逻辑映射表中对应第1个条目。 逻辑映射表第1个条目是“X”,这说明这个16KB空间还没有被写入过任何数据。 由于这16KB的chunk还没有被写入过任何数据,所以直接这个读请求直接返回全0。 在4KB偏移量处写入4KB(Write 4KB at Offset 4KB) 4KB偏移量,在逻辑映射表中对应第0个条目。 逻辑映射表的第0个条目是“1”,由于我们并不是覆盖写整个chunk,所以我们必须要进行一个读-改-写(read-modify-write)操作。 chunk map 1仅仅指定了一个后备IO单元(编号2),分配一个16KB的buffer,并讲编号2的后备IO单元读入到这个buffer,这个buffer后面会被叫做压缩数据buffer。 需要注意的是,因为我们一下子分配了16KB的buffer而不是只分配4KB,我们可以继续使用这个buffer作为后面新压缩数据的存放地点。 再分配一个16KB的buffer用于存放解压后的数据。解压压缩数据buffer里的数据,并将数据存入刚分配的buffer里。 把需要写入的4KB数据写入到解压数据buffer的4KB偏移处。 把解压数据buffer里的数据压缩,并放到压缩数据buffer中。 假设新的数据压缩后大小5KB,这将需要2个4KB的后备IO单元。 从空闲后备IO单元列表里,分配编号为3和4的两个block。 将这个5KB数据写入到3和4这两个block中。 从空闲chunk map列表中分配编号2的chunk map。 将(3,4,X,X)写入到编号2的chunk map中。需要注意的是,到当前节点,这个chunk map还没有被逻辑映射表引用,如果此时出现了掉电,当前chunk的数据依然是保持完整的。 将chunk map的编号2写入到逻辑映射表中。 释放编号为1的chunk map,并放入到空闲chunk map列表中。 释放编号为2的后备IO单元,并将编号放入到空闲后备IO单元列表中。 +--------------------+Backing Device |01 34 | +--------------------+Free Backing IO Unit List 2, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19 +------------+------------+------------+------------+------------+Chunk Maps | 0 1 X X | | 3 4 X X | | | +------------+------------+------------+------------+------------+Free Chunk Map List 1, 3, 4 +---+---+---+---+Logical Map | 2 | X | 0 | X | +---+---+---+---+ 跨越多个chunks的请求(Operations that span across multiple chunks) 针对跨越多个chunks的请求,逻辑上这个请求会被分割成多个请求,每个请求关联一个chunk。 举例:在4KB偏移处写入20KB数据 在这个场景下,这个写请求会被分割成:一个在4KB偏移处写入12KB数据的请求(只影响逻辑映射表的第0个条目),以及一个在偏移量16KB处写入8KB的请求(只影响逻辑映射表的第1个条目)。 每个子请求都独立的基于上述的算法进行处理,直到这两个子请求全部完成时,原始的20KB写入操作才会返回。 Unmap操作(Unmap Operations) Unmap操作通过从逻辑映射表中删除对应的(如果有)chunk map条目来实现,对应的chunk map会被放回到空闲chunk map列表中,并且任何相关的后备IO单元也会被释放并放回到空闲后备IO单元列表中。 而对于针对chunk的某一部分进行Unmap的操作,相当于对chunk的这一部分写0,如果整个chunk在多次的操作中被整体Unmap掉了,那么未压缩的数据就变成全0了,这样就可以被检测出来,在这种情况下,整个chunk的映射条目也会从逻辑映射表里被移除。 当整个chunk都被Umap掉之后,后续针对该chunk的读操作都会返回全0,这个表现就和上述在16K偏移量处读取16KB数据(Read 16KB at Offset 16KB)的例子一致。 写0操作(Write Zeroes Operations) 写0操作的流程和Unmap操作类似,如果一个写0操作覆盖了整个chunk,我们也可以在逻辑映射表中完全移除整个chunk的对应条目,然后后续的读操作也会返回全0。 Restart 一个使用libreduce模块的应用程序,有可能需要定期退出并重新启动。当应用程序重新启动的时候,会重新加载压缩卷,从而恢复到应用程序退出之前的状态。 当压缩卷被加载的时候,空闲chunk map列表和空闲后备IO单元列表会通过扫描逻辑映射表的形式进行重建。逻辑映射表只会保存有效的chunk map索引,同样的,chunk map只会保存有效后备单元索引。 任何没有被引用的chunk map以及后备IO单元,都会被认为是空的,并加入到对应的空闲列表中。 这就保证了如果系统在一个写操作的中间状态下崩溃后(比如在chunk map被更新,但还没写入逻辑映射表的过程中崩溃)重启的过程中,所有未完成的写入操作都会被忽略。 对同一个chunk的并发操作(Overlapping operations on same chunk) 具体实现时,必须要考虑针对同一个chunk并发操作的情况。比如第一个IO需要对chunk A写入某些数据,同时又有第二个IO也需要对chunk A进行写入。在这种情况下,第二个IO必须等第一个IO完成之后才能开始。 针对类似情况的进一步优化,超出了本文档的描述范围。 精简置备的后备存储(Thin provisioned backing storage) 后备存储设备必须是精简置备的,从而才能在压缩场景下实现空间节约。本文的算法永远都会使用(重用)后备存储上最靠近偏移量0的后备IO单元。 这确保了即使后备存储的空间和压缩块设备的大小接近,但是直到确实需要后备IO单元的时候,才会真正从后备存储设备上分配存储空间。

2022/4/5
articleCard.readMore

实现一个Kubernetes的身份认证代理服务

最近接到一个需求:把K8s的认证和授权体系,整合到我们内部的系统中,使得我们内部系统的用户,可以无缝的直接访问K8s集群,同时也需要限制好用户对应namespace的权限。 对于需求的用户授权也就是authorization (authz)部分,实现思路还是比较简单的,毕竟K8s的RBAC实现相对来说还是非常完善的,而且RBAC对于我们目前的用户和组织权限管理理念十分的接近。所以只需要将目前系统里的用户权限和组织关系,对应到一系列的RBAC Role和RoleBinding里,就可以实现对于用户权限的精细化控制。 而对于用户的认证authentication (authn)部分,K8s提供了非常多的身份认证策略。但是如文档里明确的一点: Kubernetes 假定普通用户是由一个与集群无关的服务通过以下方式之一进行管理的: 负责分发私钥的管理员 类似 Keystone 或者 Google Accounts 这类用户数据库 包含用户名和密码列表的文件 有鉴于此,Kubernetes 并不包含用来代表普通用户账号的对象。 普通用户的信息无法通过API调用添加到集群中。 K8s并不自己管理用户实体,所以是没有办法像RBAC那样,通过创建一个“User”资源,来把某个用户添加到集群里的。 其实这个特点,对于系统集成来说,可能更是一个优点,因为这直接避免了第三方系统的用户属性和K8s“用户”属性可能存在的不兼容问题。 而对于目前的需求而言,需要做到以下几点: 最好是基于Token实现,并且这个Token由我们自己的系统生成,同一个Token,既可以调用原有的API,也可以调用K8s的API。 尽可能保证K8s兼容性,最好用户可以无缝的,不需要经过复杂的配置,直接使用kubectl访问到集群。 记录所有用户的访问记录以便于各种审计工作。 针对这几个需求,又通读了一遍文档之后,最终决定使用身份认证代理这个方式,怎么理解呢: K8s APIServer可以获取HTTP请求中的某些头部字段,根据头部字段的值来判断当前操作的用户。也就是说,如果实现一个反向代理服务器,由这个反向代理服务器实现Token的认证工作,确认用户请求的有效性,若用户请求有效,直接把用户的信息添加到HTTP请求头中,并代理到K8s Server,最终再由K8s中的RBAC规则,判断用户能否调用对应API。 这么做刚好能满足目前的需求,首先,Token的发放和验证完全和K8s没有关系,所以Token可以保持和原有系统保持不变;同样的,代理只是根据HTTP头进行验证并转发,也不会修改任何K8s API的调用方式和格式,所以也能保持很好的兼容性;又因为所有的用户请求都会经过代理服务器,所以代理服务器可以记录所有请求的详细信息,从而方便实现各种审计工作。 那么问题来了,K8s通过哪个HTTP Header获取用户信息呢? APIServer提供了几个命令行参数:--requestheader-username-headers、--requestheader-group-headers、--requestheader-extra-headers-prefix,通过这几个参数来配置HTTP头的字段名称。 其中,只有--requestheader-username-headers这个参数是必须的,由于目前场景下只需要配置这一个参数就可以了。比如:添加--requestheader-username-headers=X-Remote-User到APIServer启动参数,APIServer就会从请求中获取X-Remote-User这个头,并用对应的值作为当前操作的用户。 事情还没有结束,既然APIServer会从请求头中获取用户名,那么问题来了,如何确保这个请求是可信的?如何防止恶意用户,伪造请求,绕过身份认证代理服务器,直接用假冒的请求访问APIServer怎么办?这样是不是就可以模拟任何用户访问了?那一定不行,得需要有个办法来验证代理服务器的身份。不过K8s的开发者们显然考虑到了这个问题,所以APIServer提供了--requestheader-client-ca-file和--requestheader-allowed-names两个额外的参数,其中--requestheader-client-ca-file是必须的,用来指定认证代理服务器证书的CA位置,如果同时指定--requestheader-allowed-names,则在验证客户端证书发行者的同时,还会验证客户端证书的CN字段,确保不会有人用其他证书模仿代理服务器。 说到这里,整个解决方案的思路就已经比较清楚了:1.让用户带上token访问身份代理服务器;2.身份代理服务器解析token,确认用户身份后将用户名带入到请求X-Remote-User头,并转发给K8s,这里需要注意带上预先签好的客户端证书访问;3.K8s通过请求头部信息确认用户,并基于RBAC规则确认用户权限。 针对上面的方案,这里简单的使用openresty搭建了一个测试方案,主要也是因为目前的Token是jwt格式的,解析和验证也比较方便,这里贴一个比较简单的配置例子: server {listen 80;server_name test.k8sproxy.ichenfu.com;location / {access_by_lua '-- 因为token格式是jwt,且用户名是在jwt payload里的,所以需要依赖resty.jwt这个库-- 具体的安装方式这里不详细说明,可以查找其他资料local cjson = require("cjson")local jwt = require("resty.jwt")-- 拿到用户请求的Authorization头local auth_header = ngx.var.http_Authorizationif auth_header == nil then-- 禁止没有认证信息的请求ngx.exit(ngx.HTTP_UNAUTHORIZED)endlocal _, _, jwt_token = string.find(auth_header, "Bearer%s+(.+)")if jwt_token == nil then-- 禁止认证信息有误的请求ngx.exit(ngx.HTTP_UNAUTHORIZED)end-- secret,需要保密!local secret = "ichenfu-jwt-secret"local jwt_obj = jwt:verify(secret, jwt_token)if jwt_obj.verified == false then-- 如果验证失败,说明Token有问题,禁止ngx.exit(ngx.HTTP_UNAUTHORIZED)else-- 验证成功,设置X-Remote-User头为用户名(假设用户名存储在payload中的user字段)ngx.req.set_header("X-Remote-User", jwt_obj.user)end';proxy_ssl_certificate /usr/local/openresty/nginx/conf/ssl/auth-proxy.pem;proxy_ssl_certificate_key /usr/local/openresty/nginx/conf/ssl/auth-proxy-key.pem;proxy_pass https://test.k8scluster.ichenfu.com:6443;}} 说起来openresty确实很方便,十几行代码就搞定了一个K8s的认证代理服务器。不过在后续测试过程中,遇到了一个问题。基于上面的逻辑,用户可以拿着Token,使用kubectl访问集群,但是在实际测试过程中,发现即使在kubeconfig文件中添加了Token,甚至使用kubectl --token="xxxxxxxxx" get pods这种在命令行里,指定Token的方式,都会提示请求失败,找不到认证信息。一开始,以为是自己lua程序写的有问题,最后通过kubectl --token="xxxxxxxxx" get pods --v=10 2>&1把请求过程打印出来才发现,kubectl根本不会把Token带入到请求头中! 经过一番查找,找到了kubectl does not send Authorization header (or use specified auth plugin) over plain HTTP #744这个Issue。才发现原来kubectl在默认情况下,如果访问一个HTTP协议的API地址,就认为这个服务是不需要认证的,如果需要认证,那API地址必须是HTTPS协议。 所以,为了实现预期的结果,还需要修改一下nginx配置文件,把监听换成HTTPS: server {listen 443 ssl;server_name test.k8sproxy.ichenfu.com;ssl_certificate /usr/local/openresty/nginx/conf/ssl/kubernetes.pem;ssl_certificate_key /usr/local/openresty/nginx/conf/ssl/kubernetes-key.pem;#localtion配置保持不变#...} 最终,所有需求都完美实现!当然需求的实现方式肯定不止这一种,而且最终即使使用这种方式,可能也不太会选择openresty,但是整体实现和测试的过程还是非常有意思的,特别是“意外”地知道了kubectl对于服务器认证的相关处理,收获还是不少的。

2022/3/20
articleCard.readMore

使用Go-Ceph库编写一个更简单的RBD HTTP API

很多人看到这个标题会很奇怪,Ceph不是有一个RESTful API么,为什么又要造一遍轮子? 的确,Ceph的官方组件Dashboard,内置了一些非常强大的RESTful API,功能也是比较的全面。为啥又要自己写一个呢?在我们的环境里,有一个自己实现的类似Openstack的虚拟机管理平台。而这个平台对接Ceph RBD时,就是使用的Dashboard模块提供的API。个人觉得啊,官方的API,虽然功能全,但确实对于对接的用户来说,真的不是那么友好。这里举几个简单的点: 官方API基于Token进行鉴权,而Token又通过用户名和密码进行获取,并且有一个固定的过期时间,这就会有两个选择,一个暴力点的选择是不管发送什么请求,都会获取一个新的Token,这样可以保证基于新Token的请求都可以成功;或者,每次在请求之前请求auth/check接口,确认Token的有效性,如果失效了,那就重新获取;再或者,根据请求的返回值,如果出现401错误等等情况,再重新获取新的Token。但是无论是哪种方法,都会显得冗余和逻辑复杂,特别是在多线程等等环境下,还需要考虑使用单例等等。另外,这多出来的这些Token请求,确实也拖慢了整体的效率,毕竟Python写的API,确实不算快。 官方API是一个异步API,怎么理解呢?让我们先看下大部分接口的返回值,以创建RBD为例: 201 Created – Resource created. 202 Accepted – Operation is still executing. Please check the task queue. 400 Bad Request – Operation exception. Please check the response body for details. 401 Unauthorized – Unauthenticated access. Please login first. 403 Forbidden – Unauthorized access. Please check your permissions. 500 Internal Server Error – Unexpected error. Please check the response body for the stack trace. 对于一个创建请求,如果成功,则可能会有两种返回值:201表示RBD Image创建成功,可以直接使用;202表示创建任务已经被接受了,但是还没有创建成功,具体的结果,需要去队列里找结果。怎么理解呢?如果返回201,那么恭喜,这个Image可以直接被使用了。如果返回了202,那此时还不能直接使用这个Image,因为仅仅是添加了任务,必须等任务执行完成之后,Image才真正可用。那怎么去寻找这个任务结果呢?又需要我们去轮询调用Display Tasks API,然后从返回的一个列表里,自己匹配刚刚的请求,来确认什么时间任务被执行完成。这个动作实在是不太优雅,让人难受。 官方API确实也缺失了一些功能。因为我们是一个VM的环境,依赖Clone功能实现VM的OS卷分发,而Clone功能又依赖某个Image的某个Snapshot。但是翻遍了RBDSNAPSHOT章节的文档,也没有找到如何确认某个Image的某个名字Snapshot是否存在的接口,最后从获取Image详情API的返回结果里找到了Image所拥有的Snapshot列表。但是呢,除了Snapshot,这个接口也会返回所有基于该Snapshot创建的所有Clone的列表。如果像我们现在这样某个Snapshot会有成千上万个Clone(有很多VM的操作系统都是一样的)。那这个接口的返回Body就会变得无比之大,这对于Dashboard、以及客户端的解析,都会是一个不小的成本。 当然了,这些问题,也只是在我们这个特定环境下的痛点,是绝对不可以说Ceph本身的实现问题的,那这些问题,要么忍着,要么,也可以尝试改变一下。 要说到同样是一个虚拟机管理平台,Openstack是怎么面临这些问题的呢?是不是我们也可以参考一下Openstack的实现呢?很遗憾,在Openstack Cinder组件里,是直接通过librbd的Python binding实现的。可惜的是我们并没有使用Python进行开发,相对于Openstack来说,集成方式也有些区别。 不过好在Ceph官方也提供了librbd的Golang Bindinggo-ceph,原理和Python一样,也是直接基于librbd的C接口,那既然这样,我们也可以尝试基于这个库,实现一个我们自己的RBD HTTP API。不需要多么花哨的设计和功能,只需要满足最基本的功能就可以了。 实现之前,还是先整理一下我们的需求。到目前为止,需求并不复杂,当然未来可能会对接K8S或者类似的容器平台,还需要额外的其他接口,但在当前虚拟机这个场景下,我们需要的功能如下: 1. Image相关接口,包括创建Image,获取Image信息,扩容Image,设置Image QOS,删除Image2. Snapshot相关接口,包括针对Image创建Snapshot,根据Snapshot创建Clone,以及判断Image某个Snapshot是否存在(这个接口在上面提到官方API没有,但是librbd里是有相关接口的) 看起来还是比较简单的,这里举个创建Image接口的例子,顺便也算是提供了一个简单的go-ceph的使用文档,在这之前,go-ceph相关的文档确实不太好找,以至于我只能一遍看他的实现代码,一边看librbd的文档写代码: package mainimport ("fmt""log""github.com/ceph/go-ceph/rados""github.com/ceph/go-ceph/rbd")const PoolName = "test_rbd_pool"const ImageName = "test-image-name"const ImageSize uint64 = 100 * 1024 * 1024 * 1024 // 100GBfunc main() {conn, err := rados.NewConn()if err != nil {log.Fatal(err)}// 打开默认的配置文件(/etc/ceph/ceph.conf)if err := conn.ReadDefaultConfigFile(); err != nil {log.Fatal(err)}if err := conn.Connect(); err != nil {log.Fatal(err)}defer conn.Shutdown()ctx, err := conn.OpenIOContext(PoolName)if err != nil {log.Fatal(err)}defer ctx.Destroy()// 这里使用默认配置创建,也可以根据自己需求,指定image的featuresif err := rbd.CreateImage(ctx, ImageName, ImageSize, rbd.NewRbdImageOptions()); err != nil {log.Fatal(err)}// 获取或者修改Image时,需要先OpenImage,或者OpenImageReadOnlyrbdImage, err := rbd.OpenImageReadOnly(ctx, ImageName, rbd.NoSnapshot)if err != nil {if err == rbd.ErrNotFound {log.Println("image not found")}log.Fatal(err)} else {fmt.Println(rbdImage.GetId())}} 总的来说,开发起来还是挺简单的。最终我也把上面需求的这些功能,封装成了HTTP API,代码也放到了C0reFast/rbd-api。相对官方的API来说,简单、速度快、所有操作全部是同步的,希望有一天在类似的场景下能发挥一些作用。

2022/3/5
articleCard.readMore