在 Introduction to eBPF 这篇文章中介绍了基于内核源码开发并加载 eBPF 代码的过程。本文将介绍基于 Go 和对应的库开发 eBPF 程序,文中所有涉及的代码可以在我的 Github 中找到。
当涉及到选择库和工具来与 eBPF 进行交互时,会让人有所困惑。在选择时,你必须在基于 Python 的 BCC 框架、基于 C 的 libbpf 和一系列基于 Go 的 Dropbox、Cilium、Aqua 和 Calico 等库中选择。
在大多数情况下,eBPF 库主要协助实现两个功能:
部分库也可以帮助你将 eBPF 程序附加到一个特定的钩子,尽管对于网络场景下,这可能很容易采用现有的 netlink API 库完成。
当涉及到 eBPF 库的选择时,仍然让人感到困惑(见[1], [2])。事实是每个库都有各自的范围和限制。
参考 使用 Go 语言管理和分发 ebpf 程序 可以看到 cilium/ebpf
更加活跃,本文也选择基于 cilium/ebpf
库来开发。cilium/ebpf
纯 Go 程序编写,从而实现了程序最小依赖;与此同时其还提供了 bpf2go
工具,可用来将 eBPF 程序编译成 Go 语言中的一部分,使得交付更加方便,后续如果配合 CO-RE 功能则威力大增。
eBPF 程序一般有两部分组成:
clang/llvm
编译成 elf
格式的文件,为内核中需要加载的程序;前置条件需要安装 clang/llvm
编译器:
1 | # 安装 llvm 编译器,至少要求 clang 9.0 版本以上 |
可以从我的 Github 下载代码,目录结构如下:
1 | [root@VM-4-27-centos demo]# tree |
1 | // +build ignore |
1 |
|
vmlinux.h
是使用工具生成的代码文件。它包含了系统运行 Linux 内核源代码中使用的所有类型定义。当我们编译 Linux 内核时,会输出一个称作 vmlinux
的文件组件,其是一个 ELF 的二进制文件,包含了编译好的可启动内核。vmlinux
文件通常也会被打包在主要的 Linux 发行版中。
内核中的 bpftool 工具其中功能之一就是读取 vmlinux
文件并生成对应的 vmlinux.h
头文件。vmlinux.h
会包含运行内核中所使用的每一个类型定义,因此该文件的比较大。
生成 vmlinux.h
文件的命令如下:
1 | $ bpftool btf dump file /sys/kernel/btf/vmlinux format c > vmlinux.h |
包含该 vmlinux.h
,就意味着我们的程序可以使用内核中使用的所有数据类型定义,因此 BPF 程序在读取相关的内存时,就可以映射成对应的类型结构按照字段进行读取。
例如,Linux 中的 task_struct 结构用于表示进程,如果 BPF 程序需要检查 task_struct
结构的值,那么首先就需要知道该结构的具体类型定义。
由于 vmlinux.h
文件是由当前运行内核生成的,如果你试图将编译好的 eBPF 程序在另一台运行不同内核版本的机器上运行,可能会面临崩溃的窘境。这主要是因为在不同的版本中,对应数据类型的定义可能会在 Linux 源代码中发生变化。
但是,通过使用 libbpf 库提供的功能可以实现 “CO:RE”(一次编译,到处运行)。libbpf 库定义了部分宏(比如 BPF_CORE_READ),其可分析 eBPF 程序试图访问 vmlinux.h
中定义的类型中的哪些字段。如果访问的字段在当前内核定义的结构中发生了移动,宏 / 辅助函数会协助自动找到对应字段。对于可能消失的字段,也提供了对应的辅助函数 bpf_core_field_exists。因此,我们可以使用当前内核中生成的 vmlinux.h
头文件来编译 eBPF 程序,然后在不同的内核上运行它【需要运行的内核也支持 BTF 内核编译选项】。
该注解使用 bpf2go
程序将 kprobe.c
文件编译成 bpfdemo_bpfeb.go
和 bpfdemo_bpfel.go
两个文件,分别为 bigendian
和 littleendian
两种平台的程序。
其中参数中的 BPFDemo
参数为 main.go
文件中函数调用的名称,例如 objs := BPFDemoObjects{}
和 LoadBPFDemoObjects(&objs, nil);
1 | // SPDX-License-Identifier: GPL-2.0-only |
1 | GO := go |
执行编译,可以看到生成了对应的 BPF 字节码 bpfdemo_bpfeb.o
和 bpfdemo_bpfel.o
,还有对应的 go 文件:
1 | [root@VM-4-27-centos demo]# make |
在我们编写的 Go 代码中,首先需要将编译好的 eBPF 代码加载进内核,调用的是 LoadBPFDemoObjects
1 | // Load pre-compiled programs and maps into the kernel. |
这里的 LoadBPFDemoObjects
和 BPFDemoObjects
都来自 bpf2go
自动生成的代码。
以 bpfdemo_bpfeb.go
为例,可以看到生成了很多辅助函数和结构体,其中:
LoadBPFDemo
将编译好的 ELF 格式的 BPF 代码加载进内存,然后调用 LoadAndAssign
实际调用 BPF 系统调用 load BPF 程序到内核。1 | // BPFDemoMaps contains all maps after they have been loaded into the kernel. |
实际查看 LoadAndAssign
可以看到它会加载 BPF Program 和 BPF Map 到内核
1 | // LoadAndAssign loads Maps and Programs into the kernel and assigns them |
这里的 loadProgram
会调用 newProgramWithOptions
,处理很多与 BTF 等其他内容后,最终调用 sys.ProgLoad(attr)
1 | func newProgramWithOptions(spec *ProgramSpec, opts ProgramOptions, handles *handleCache) (*Program, error) { |
此即调用了 BPF 的系统调用:
1 | func ProgLoad(attr *ProgLoadAttr) (*FD, error) { |
加载 map 也是类似,最终调用了 sys.MapCreate
1 | func MapCreate(attr *MapCreateAttr) (*FD, error) { |
kprobe可以对任何内核函数进行插桩,可以实时在生产环境中启用,不需要重启系统,也不需要以特殊方式重启内核。
现在有以下三种接口可以访问kprobes.
register_kprobe()
等,在 这篇文章中 介绍了其用法/sys/kernel/debug/tracing/kprobe_events
: 通过向这个文件写入字符串,可以配置开启和停止kprobes,在 这篇文章中 介绍了其用法perf_event_open()
: 与 perf 工具所使用的一样,现在BPF跟踪工具也开始使用这些函数对应到 main.go
中,在 LoadBPFDemoObjects
之后,我们还调用了 link.Kprobe
来
1 | // Open a Kprobe at the entry point of the kernel function and attach the |
1 | func Kprobe(symbol string, prog *ebpf.Program, opts *KprobeOptions) (Link, error) { |
这里创建了一个 kprobe
类型的 Perf Event,传入的追踪地址是 symbol
1 | // kprobe opens a perf event on the given symbol and attaches prog to it. |
最终调用了 PerfEventOpen
来开启一个 perf event,这个系统调用可以参考 这里
1 | // pmuProbe opens a perf event based on a Performance Monitoring Unit. |
通过 perf_event 的 ioctl 调用把 BPF 程序 attach 到 kprobe event
PERF_EVENT_IOC_SET_BPF
,表示允许 attach BPF 程序到 kprobe event 上,其中 ioctl 设置的第三个参数代表 bpf 系统调用的 fd。PERF_EVENT_IOC_ENABLE
,表示使能 event。1 | ioctl(perf_event_fd, PERF_EVENT_IOC_SET_BPF, bpf_prog_fd) |
attachPerfEvent
通过 perf_event 的 ioctl 调用把 BPF 程序 attach 到 kprobe event
1 | // attach the given eBPF prog to the perf event stored in pe. |
通过 ioctl 挂载 BPF 程序:
1 | func attachPerfEventIoctl(pe *perfEvent, prog *ebpf.Program) (*perfEventIoctl, error) { |
定期查看 eBPF map 的更新:
1 | // Read loop reporting the total amount of times the kernel |
1 | FROM ubuntu:20.04 |
eBPF Map 是用户空间和内核空间进行数据交换、信息传递的桥梁,它以 key/value
方式将数据存储在内核中,可以被任何知道它们的BPF程序访问。在内核空间的程序创建 BPF Map 并返回对应的 文件描述符,在用户空间运行的程序就可以通过这个文件描述符来访问并操作BPF Map。eBPF Map 支持多种数据结构类型,在 上一篇博客 中已经简单介绍过,本文将通过代码实例展示其使用方法,所有代码可以在我的 Github 中找到。
最初创建 BPF Map 的方式都是通过 bpf
系统调用函数,传入的第一个参数是BPF_MAP_CREATE
,在 上一篇博客 中已经介绍,此处不在详述。
1 | union bpf_attr my_map_attr { |
相对于直接使用 bpf
系统调用函数来创建BPF Map,在实际场景中常用的是基于 SEC("maps")
这个语法糖来做到声明即创建:
1 | struct bpf_map_def SEC("maps") my_bpf_map = { |
关键点就是SEC("maps")
,ELF convention
,它的工作原理是这样的:
SEC("maps")
bpf_load.c
扫描目标文件中所有 Section 信息,它会扫描目标文件里定义的 Section,其中就有用来创建BPF Map的SEC("maps")
,我们可以到相关代码里看到说明:1 | // https://elixir.bootlin.com/linux/v4.15/source/samples/bpf/bpf_load.h#L41 |
bpf_load.c
扫描到SEC("maps")
后,对BPF Map相关的操作是由load_maps
函数完成,其中的bpf_create_map_node()
和bpf_create_map_in_map_node()
就是创建BPF Map的关键函数BPF_MAP_CREATE
命令进行的系统调用。bpf_load.o
作为依赖库,并合并为最终的可执行文件中,这样在程序运行起来时,就可以通过声明SEC("maps")
即可完成创建BPF Map的行为了。从上面梳理的过程可以看到,这个简化版虽然使用了语法糖,但最后还是会去使用 bpf() 函数完成系统调用。
本小节将介绍 eBPF Map 的几种常见的数据结构,包括其使用场景和使用方法。
对于 BPF_MAP_TYPE_HASH
类型的 eBPF Map,其 key 和 value 都是可自定义的数据结构,使用方法如下所示:
1 | // define the struct for the key of bpf map |
对于 BPF_MAP_TYPE_ARRAY
类型的 eBPF Map,有以下特性:
pre-allocated
并且初始化未 0map_delete_elem()
函数会返回 EINVAL
,因为 Array 中的元素不能够被删除map_update_elem()
函数更新元素的时候是 non-atomic
的,并没有并发保护 BPF_MAP_TYPE_ARRAY
类型的 eBPF Map 主要用于以下两种情景:
全局变量:可以申请一个只有一个元素的 Array,key = 0,value 是一些全局变量的集合
aggregation of tracing events into fixed set of buckets
下面展示了使用 BPF_MAP_TYPE_ARRAY
作为全局变量的方法:
1 | struct globals { |
BPF_MAP_TYPE_PROG_ARRAY
类型的 eBPF Map 主要用于尾调用,尾调用执行涉及两个步骤:
BPF_MAP_TYPE_PROG_ARRAY
的 map,这个 map 可以从用户空间通过 key/value 操作bpf_tail_call()
如下所示,内核将这个辅助函数调用内联到一个特殊的 BPF 指令内。目前,这样的程序数组在用户空间侧是只写模式1 | long bpf_tail_call(void *ctx, struct bpf_map *prog_array_map, u32 index) |
内核根据传入的文件描述符查找相关的 BPF 程序,自动替换给定的 map slot 处的程序指针。如果没有找到给定的 key 对应的 value,内核会跳过(fall through)这一步 ,继续执行 bpf_tail_call()
后面的指令。
尾调用是一个强大的功能,它可以实现:
在 samples/bpf
中可以看到 BPF_MAP_TYPE_PROG_ARRAY
的使用示例:
1 | struct { |
eBPF 提供了两种特殊的 Map 类型,BPF_MAP_TYPE_ARRAY_OF_MAPS
和 BPF_MAP_TYPE_HASH_OF_MAPS
,实现了 map-in-map
,也就是 eBPF Map 中每一个 entry 的 Value 也是一个 Map,如下所示:
BPF_MAP_TYPE_ARRAY_OF_MAPS
和 BPF_MAP_TYPE_HASH_OF_MAPS
的区别在于,outer map
是一个 Array 还是 HashTable。
之前的常规 eBPF Map 是在 load time
创建的,对于 map-in-map
,我们需要定义一个 outer map
,inner map
是在 runtime
被用户创建并插入到 outer map
。outer map
定义如下所示:
1 | struct bpf_map_def SEC("maps") outer_map = { |
这里需要注意:
outer map
的 value_size
必须是 __u32
,这正好是 inner map id
的大小尽管你不需要在 BPF C 程序中定义 inner map
,verifier
需要在 load time
知道 inner map
的定义。所以,在调用 bpf_object__load
前,你必须创建一个 dummy inner map
并且 通过调用 bpf_map__set_inner_map_fd
设置它的 fd 到 outer map
。注意,verifier
要求 dummy inner map
的 fd 必须在 load 之后关闭。
1 | const char* outer_map_name = "outer_map"; |
插入到 outer map
步骤如下:
inner map
inner map
的 fd 作为 value 插入到 outer map
inner map fd
1 | int inner_map_fd = bpf_create_map_name( |
注意:
outer map
的每一项 entry 的 value 是 the id of an inner map
,但是调用 bpf_map_update_elem
API 时给的参数是 the fd of the inner map
inner map fd
以避免内存泄漏。如前所述,outer map
的每一项 entry 的 value 是 the id of an inner map
,而不是 the fd of the inner map
。即使我们在调用 bpf_map_update_elem
传递的参数是 inner map fd
,使用 bpf_map_lookup_elem
的时候我们的到的 value 是 inner map id
,为了获得 inner map fd
,可以调用 bpf_map_get_fd_by_id
。拿到 inner map fd
之后,就可以像之前一样操作 inner map
了。
1 | const __u32 outer_key = 42; |
注意,每次调用 bpf_map_get_fd_by_id
都会返回一个新的 fd,你必须在使用之后关闭它以避免内存泄露。
对于 inner map
的删除和常规 Map 一样,可以调用 bpf_map_delete_elem
:
1 | const __u32 outer_key = 42; |
有时候我们期望 eBPF 程序能够通知用户态程序数据准备好了,array、hash 类型的 eBPF map 不满足此类使用场景,这时候就轮到 BPF_MAP_TYPE_PERF_EVENT_ARRAY
了。与普通 hash、array 类型有些不同,它没有 bpf_map_lookup_elem()
方法,使用的是 bpf_perf_event_output()
向用户态传递数据。它的 value_size
只能是 sizeof(u32)
,代表的是 perf_event 的文件描述符;max_entries
则是 perf_event 的文件描述符数量。
有关源码如下:
1 | struct msg { |
Note:
- 这里的
seq
代表的是消息序列号- 若用户态不向内核态传递消息,PERFEVENT_ARRAY map 中的
max_entries
没有意义。该 map 向用户态传递的数据暂存在 perf ring buffer 中,而由max_entries
指定的 map 存储空间存放的是 perf_event 文件描述符,若用户态程序不向 map 传递 perf_event 的文件描述符,其值可以为 0。用户态程序使用bpf(BPF_MAP_UPDATE_ELEM)
将由sys_perf_event_open()
取得的文件描述符传递给 eBPF 程序,eBPF 程序再使用 `bpf_perf_event{read, readvalue}()` 得到该文件描述符。于此有关的用法见 linux kernel 下的 [sample/bpf/tracex6{user, kern.c}](https://github.com/torvalds/linux/blob/v5.10/samples/bpf/tracex6_kern.c)。
libbpf 提供了 PERF_EVENT_ARRAY map 在用户态开箱即用的 API,它使用了 epoll 进行封装,仅需调用 perf_buffer__new()
、perf_buffer__poll()
即可使用:
1 | static void print_bpf_output(void *ctx, int cpu, void *data, __u32 size) { |
现在我们就可以借助 BPF Map 来实现在内核空间收集网络包信息,主要包括源地址和目标地址,在用户空间展示这些信息。代码主要分两个部分:
请注意,该程序的编译运行是基于Linux内核代码中BPF示例环境,如果你还不熟悉,可以参考 上一篇博客。
下面首先介绍运行在内核空间的示例代码:
1 |
|
我们先来看运行在内核空间的BPF程序代码重点内容:
SEC("maps")
声明并创建了一个名为tracker_map 的BPF Map,它的类型是BPF_MAP_TYPE_HASH
,它的 key 和 value 都是自定义的struct,定义在了xdp_ip_tracker_common.h
头文件中,具体如下所示:parse_and_track
是对网络包进行分析和过滤,把源地址和目的地址联合起来作为BPF Map的key,把当前网络包的大小以 byte 单位记录下来,并联合网络包计数器作为BPF Map的value。对于连续的网络包,如果生成的key已经存在,就把value累加,否则就新增一对key-value存入BPF Map中。其中通过bpf_map_lookup_elem()
函数来查找元素,bpf_map_update_elem()
函数来新增元素。接下来是运行在用户空间的示例代码:
1 |
|
load_bpf_file()
函数(本质就是用BPF_PROG_LOAD
命令进行系统调用)加载对应内核空间的BPF程序编译出来的.o文件,这种通过编程加载BPF程序的方式,和我们之前通过命令行工具的方式相比,更具灵活性,适合实际场景中的产品分发。set_link_xdp_fd()
函数 attach 到目标hook上,看函数名就知道了,这是XDP network hook。它接受的两个主要的参数是:ifindex
,这个是目标网卡的序号(可以通过ip a
查看),我这里填写的是6,它是对应了一个docker容器的veth虚拟网络设备;prog_fd[0]
,这个是BPF程序加载到内存后生成的文件描述符fd。prog_fd
和 map_fd
得说明下:bpf_load.c
的全局变量;prog_fd
是一个数组,在加载内核空间BPF程序时,一旦fd生成后,就添加到这个数组中去;map_fd
也是一个数组,在运行上文提到的load_maps()
函数时,一旦完成创建BPF Map系统调用生成fd后,同样会添加到这个数组中去。 因此在bpf sample文件夹下的程序可以直接使用这两个变量,作为对于BPF程序和BPF Map的引用。bpf_map_get_next_key(map_fd[0], &lookup_key, &next_key)
函数,map_fd[0]
是你的目标BPF Map; lookup_key
是需要查找的BPF Map目标key,这个参数是要主动传入的,而next_key
是这个目标key相邻的下一个key,这个参数是被动赋值的。如果你想从头开始遍历BPF Map,就可以通过传入一个一定不存在的key作为lookup_key
,然后next_key
会被自动赋值为BPF Map中第一个key,key知道了,对应的value也就可以被读取了,直到bpf_map_get_next_key()
返回为-1,即next_key
没有可以被赋值的了,遍历也就完成了,这个函数工作起来是不是像一个iterator。还有一段非常陌生的代码,如下所示:
1 | struct rlimit r = {RLIM_INFINITY, RLIM_INFINITY}; |
rlimit
,全称是resource limit,顾名思义,它是控制应用进程能使用资源的限额。RLIM_INFINITY
看起来就是无限的意思,因此第一行代码就是定义了一个没有上限的资源配额。setrlimit()
,传入的第一个参数是一个资源规格名称——RLIMIT_MEMLOCK
,即内存;第二个参数是刚才定义的无限资源配额,可以猜出这行代码的意思就是为内存资源配置了无限配额,即没有内存上限。max_entries
,那么这个BPF程序一定会使用不少的内存。因此为了成功运行BPF程序,就把对于内存的限制放开成无限了。在Unix/Linux的世界,一切皆是文件,BPF Map也不例外。从上文看到我们是可以通过文件描述符fd来访问BPF Map内的数据,因此BPF Map创建是遵循Linux文件创建的过程。实现BPF_MAP_CREATE
系统调用命令的函数是map_create()
,即创建BPF Map的核心函数:
1 | static int map_create(union bpf_attr *attr) |
其中bpf_map_new_fd()
函数就是用来为BPF Map分配fd的,下面是其函数主体:
1 | // https://elixir.bootlin.com/linux/v4.15/source/kernel/bpf/syscall.c#L327 |
要说的是anon_inode_getfd()
这个函数,它不是一般的分配 fd 的方式,是一种特殊的匿名方式,它的inode没有被绑定到磁盘上的某个文件,而是仅仅在内存里。一旦fd关闭后,对应的内存空间就会被释放,相关数据,即我们的 BPF Map也就被删除了。它的comment doc写得非常好,详细大家可以自行了解。
也可以通过lsof
和cat /proc/[pid]/fd
命令看到BPF Map作为 anon_inode 的效果(其实普通的BPF程序也是这个type):
如果想看当前操作系统上面是否有正在使用BPF Map,可以使用BPF社区大力推荐的命令行工具——BPFtool,它是专门用来查看BPF程序和BPF Map的命令行工具,并且可以对它们做一些简单操作。BPFtool源码 被维护在Linux内核代码里,因此一般都是通过make命令自行编译出可执行文件,操作起来并不麻烦,如下所示:
1 | cd linux-source-code/tools |
需要注意的是,不同内核版本下的BPFtool代码有所差异,其功能也不一样,一般来说高版本内核下的BPFtool功能更多,也是向下兼容的。我使用的就是在5.6.6内核版本下编译出来的BPFtool,并且在内核版本是4.15.0操作系统上运行顺畅。
接下来给大家简单演示如何使用bpftool查看BPF Map信息,主要用两个命令进行查看:
1 | # command #1, list all the bpf map in the current node |
eBPF 源于 BPF,本质上是处于内核中的一个高效与灵活的虚类虚拟机组件,以一种安全的方式在许多内核 hook 点执行字节码。BPF 最初的目的是用于高效网络报文过滤,经过重新设计,eBPF 不再局限于网络协议栈,已经成为内核顶级的子系统,演进为一个通用执行引擎。开发者可基于 eBPF 开发性能分析工具、软件定义网络、安全等诸多场景。本文将介绍 eBPF 的前世今生,并构建一个 eBPF 环境进行开发实践,文中所有的代码可以在我的 Github 中找到。
BPF,是类 Unix 系统上数据链路层的一种原始接口,提供原始链路层封包的收发。1992 年,Steven McCanne 和 Van Jacobson 写了一篇名为 The BSD Packet Filter: A New Architecture for User-level Packet Capture 的论文。在文中,作者描述了他们如何在 Unix 内核实现网络数据包过滤,这种新的技术比当时最先进的数据包过滤技术快 20 倍。
BPF 在数据包过滤上引入了两大革新:
由于这些巨大的改进,所有的 Unix 系统都选择采用 BPF 作为网络数据包过滤技术,直到今天,许多 Unix 内核的派生系统中(包括 Linux 内核)仍使用该实现。tcpdump 的底层采用 BPF 作为底层包过滤技术,我们可以在命令后面增加 -d
来查看 tcpdump 过滤条件的底层汇编指令。
1 | $ tcpdump -d 'ip and tcp port 8080' |
2014 年初,Alexei Starovoitov 实现了 eBPF(extended Berkeley Packet Filter)。经过重新设计,eBPF 演进为一个通用执行引擎,可基于此开发性能分析工具、软件定义网络等诸多场景。eBPF 最早出现在 3.18 内核中,此后原来的 BPF 就被称为经典 BPF,缩写 cBPF(classic BPF),cBPF 现在已经基本废弃。现在,Linux 内核只运行 eBPF,内核会将加载的 cBPF 字节码透明地转换成 eBPF 再执行。
eBPF 新的设计针对现代硬件进行了优化,所以 eBPF 生成的指令集比旧的 BPF 解释器生成的机器码执行得更快。扩展版本也增加了虚拟机中的寄存器数量,将原有的 2 个 32 位寄存器增加到 10 个 64 位寄存器。由于寄存器数量和宽度的增加,开发人员可以使用函数参数自由交换更多的信息,编写更复杂的程序。总之,这些改进使 eBPF 版本的速度比原来的 BPF 提高了 4 倍。
维度 | cBPF | eBPF |
---|---|---|
内核版本 | Linux 2.1.75(1997年) | Linux 3.18(2014年)[4.x for kprobe/uprobe/tracepoint/perf-event] |
寄存器数目 | 2个:A, X | 10个: R0–R9, 另外 R10 是一个只读的帧指针 - R0 eBPF 中内核函数的返回值和退出值 - R1 - R5 eBF 程序在内核中的参数值 - R6 - R9 内核函数将保存的被调用者callee保存的寄存器 - R10 一个只读的堆栈帧指针 |
寄存器宽度 | 32位 | 64位 |
存储 | 16 个内存位: M[0–15] | 512 字节堆栈,无限制大小的 map 存储 |
限制的内核调用 | 非常有限,仅限于 JIT 特定 | 有限,通过 bpf_call 指令调用 |
目标事件 | 数据包、 seccomp-BPF | 数据包、内核函数、用户函数、跟踪点 PMCs 等 |
2014 年 6 月,eBPF 扩展到用户空间,这也成为了 BPF 技术的转折点。 正如 Alexei 在提交补丁的注释中写到:「这个补丁展示了 eBPF 的潜力」。当前,eBPF 不再局限于网络栈,已经成为内核顶级的子系统。
对比 Web 的发展,eBPF 与内核的关系有点类似于 JavaScript 与浏览器内核的关系,eBPF 相比于直接修改内核和编写内核模块提供了一种新的内核可编程的选项。eBPF 程序架构强调安全性和稳定性,看上去更像内核模块,但与内核模块不同,eBPF 程序不需要重新编译内核,并且可以确保 eBPF 程序运行完成,而不会造成系统的崩溃。
维度 | Linux 内核模块 | eBPF |
---|---|---|
kprobes/tracepoints | 支持 | 支持 |
安全性 | 可能引入安全漏洞或导致内核 Panic | 通过验证器进行检查,可以保障内核安全 |
内核函数 | 可以调用内核函数 | 只能通过 BPF Helper 函数调用 |
编译性 | 需要编译内核 | 不需要编译内核,引入头文件即可 |
运行 | 基于相同内核运行 | 基于稳定 ABI 的 BPF 程序可以编译一次,各处运行 |
与应用程序交互 | 打印日志或文件 | 通过 perf_event 或 map 结构 |
数据结构丰富性 | 一般 | 丰富 |
入门门槛 | 高 | 低 |
升级 | 需要卸载和加载,可能导致处理流程中断 | 原子替换升级,不会造成处理流程中断 |
内核内置 | 视情况而定 | 内核内置支持 |
eBPF 分为用户空间程序和内核程序两部分:
eBPF 整体结构图如下:
用户空间程序与内核中的 BPF 字节码交互的流程主要如下:
eBPF 技术虽然强大,但是为了保证内核的处理安全和及时响应,内核中的 eBPF 技术也给予了诸多限制,当然随着技术的发展和演进,限制也在逐步放宽或者提供了对应的解决方案。
eBPF 程序不能调用任意的内核参数,只限于内核模块中列出的 BPF Helper 函数,函数支持列表也随着内核的演进在不断增加。
eBPF 程序不允许包含无法到达的指令,防止加载无效代码,延迟程序的终止。
eBPF 程序中循环次数限制且必须在有限时间内结束,这主要是用来防止在 kprobes 中插入任意的循环,导致锁住整个系统;解决办法包括展开循环,并为需要循环的常见用途添加辅助函数。Linux 5.3 在 BPF 中包含了对有界循环的支持,它有一个可验证的运行时间上限。
eBPF 堆栈大小被限制在 MAX_BPF_STACK,截止到内核 Linux 5.8 版本,被设置为 512;参见 include/linux/filter.h,这个限制特别是在栈上存储多个字符串缓冲区时:一个char[256]缓冲区会消耗这个栈的一半。目前没有计划增加这个限制,解决方法是改用 bpf 映射存储,它实际上是无限的。
1 | /* BPF program can access up to 512 bytes of stack space. */ |
eBPF 字节码大小最初被限制为 4096 条指令,截止到内核 Linux 5.8 版本, 当前已将放宽至 100 万指令( BPF_COMPLEXITY_LIMIT_INSNS),参见:include/linux/bpf.h,对于无权限的BPF程序,仍然保留4096条限制 ( BPF_MAXINSNS );新版本的 eBPF 也支持了多个 eBPF 程序级联调用,虽然传递信息存在某些限制,但是可以通过组合实现更加强大的功能。
1 |
在深入介绍 eBPF 特性之前,让我们 Get Hands Dirty
,切切实实的感受 eBPF 程序到底是什么,我们该如何开发 eBPF 程序。随着 eBPF 生态的演进,现在已经有越来越多的工具链用于开发 eBPF 程序,在后文也会详细介绍:
系统环境如下,采用腾讯云 CVM,Ubuntu 20.04,内核版本 5.4.0
1 | $ uname -a |
首先安装必要依赖:
1 | sudo apt install -y bison build-essential cmake flex git libedit-dev pkg-config libmnl-dev \ |
一般情况下推荐采用 apt 方式的安装源码,安装简单而且只安装当前内核的源码,源码的大小在 200M 左右。
1 | # apt-cache search linux-source |
源码安装至 /usr/src/
目录下。
1 | $ ls -hl |
编译成功后,可以在 samples/bpf
目录下看到一系列的目标文件和二进制文件。
前面说到 eBPF 通常由内核空间程序和用户空间程序两部分组成,现在 samples/bpf
目录下有很多这种程序,内核空间程序以 _kern.c
结尾,用户空间程序以 _user.c
结尾。先不看这些复杂的程序,我们手动写一个 eBPF 程序的 Hello World。
内核中的程序 hello_kern.c
:
1 |
|
上述代码和普通的C语言编程有一些区别。
pragama __section("tracepoint/syscalls/sys_enter_execve")
指定的。argc, argv
, 它根据不同的 prog type 而有所差别。我们的例子中,prog type 是 BPF_PROG_TYPE_TRACEPOINT
, 它的入口参数就是 void *ctx
。#include <linux/bpf.h>
这个头文件的来源是kernel source header file 。它安装在 /usr/include/linux/bpf.h
中。
它提供了bpf 编程需要的很多symbol。例如
等等
来自libbpf ,需要自行安装。 我们引用这个头文件是因为调用了bpf_printk()。这是一个kernel helper function。
这里我们简单解读下内核态的 ebpf
程序,非常简单:
bpf_trace_printk
是一个 eBPF helper 函数,用于打印信息到 trace_pipe
(/sys/kernel/debug/tracing/trace_pipe),详见这里SEC
宏,并且定义了 GPL 的 License,这是因为加载进内核的 eBPF 程序需要有 License 检查,类似于内核模块用户态程序 hello_user.c
1 |
|
在用户态 ebpf
程序中,解读如下:
load_bpf_file
将编译出的内核态 ebpf 目标文件加载到内核read_trace_pipe
从 trace_pipe
读取 trace 信息,打印到控制台中修改 samples/bpf
目录下的 Makefile
文件,在对应的位置添加以下三行:
1 | hostprogs-y += hello |
重新编译,可以看到编译成功的文件
1 | $ make M=samples/bpf |
进入到对应的目录运行 hello
程序,可以看到输出结果如下:
1 | $ sudo ./hello |
前面提到 load_bpf_file
函数将 LLVM 编译出来的 eBPF 字节码加载进内核,这到底是如何实现的呢?
load_bpf_file
也是在 samples/bpf
目录下实现的,具体的参见 bpf_load.c
load_bpf_file
代码可以看到,它主要是解析 ELF 格式的 eBPF 字节码,然后调用 load_and_attach
函数load_and_attach
函数中,我们可以看到其调用了 bpf_load_program
函数,这是 libbpf 提供的函数。bpf_load_program
中的 license
、kern_version
等参数来自于解析 eBPF ELF 文件,prog_type 来自于 bpf 代码里面 SEC 字段指定的类型。1 | static int load_and_attach(const char *event, struct bpf_insn *prog, int size) |
eBPF 程序都是事件驱动的,它们会在内核或者应用程序经过某个确定的 Hook 点的时候运行,这些 Hook 点都是提前定义的,包括系统调用、函数进入/退出、内核 tracepoints
、网络事件等。
如果针对某个特定需求的 Hook 点不存在,可以通过 kprobe
或者 uprobe
来在内核或者用户程序的几乎所有地方挂载 eBPF 程序。
With great power there must also come great responsibility.
每一个 eBPF 程序加载到内核都要经过 Verification
,用来保证 eBPF 程序的安全性,主要包括:
要保证 加载 eBPF 程序的进程有必要的特权级,除非节点开启了 unpriviledged
特性,只有特权级的程序才能够加载 eBPF 程序
内核提供了一个配置项 /proc/sys/kernel/unprivileged_bpf_disabled
来禁止非特权用户使用 bpf(2)
系统调用,可以通过 sysctl
命令修改
比较特殊的一点是,这个配置项特意设计为一次性开关(one-time kill switch), 这意味着一旦将它设为 1
,就没有办法再改为 0
了,除非重启内核
一旦设置为 1
之后,只有初始命名空间中有 CAP_SYS_ADMIN
特权的进程才可以调用 bpf(2)
系统调用 。 Cilium 启动后也会将这个配置项设为 1:
1 | $ echo 1 > /proc/sys/kernel/unprivileged_bpf_disabled |
要保证 eBPF 程序不会崩溃或者使得系统出故障
要保证 eBPF 程序不能陷入死循环,能够 runs to completion
要保证 eBPF 程序必须满足系统要求的大小,过大的 eBPF 程序不允许被加载进内核
要保证 eBPF 程序的复杂度有限,Verifier
将会评估 eBPF 程序所有可能的执行路径,必须能够在有限时间内完成 eBPF 程序复杂度分析
Just-In-Time(JIT)
编译用来将通用的 eBPF 字节码翻译成与机器相关的指令集,从而极大加速 BPF 程序的执行:
x86
),JIT 做了很多特殊优化,目的是为给定的指令产生可能的最短操作码,以降低程序翻译过程所需的空间64 位的 x86_64
、arm64
、ppc64
、s390x
、mips64
、sparc64
和 32 位的 arm
、x86_32
架构都内置了 in-kernel eBPF JIT 编译器,它们的功能都是一样的,可以用如下方式打开:
1 | $ echo 1 > /proc/sys/net/core/bpf_jit_enable |
32 位的 mips
、ppc
和 sparc
架构目前内置的是一个 cBPF JIT 编译器。这些只有 cBPF JIT 编译器的架构,以及那些甚至完全没有 BPF JIT 编译器的架构,需要通过内核中的解释器(in-kernel interpreter)执行 eBPF 程序。
要判断哪些平台支持 eBPF JIT,可以在内核源文件中 grep HAVE_EBPF_JIT
:
1 | $ git grep HAVE_EBPF_JIT arch/ |
BPF Map 是驻留在内核空间中的高效 Key/Value store
,包含多种类型的 Map,由内核实现其功能,具体实现可以参考 我的这篇博文。
BPF Map 的交互场景有以下几种:
BPF_MAP_TYPE_PROG_ARRAY
类型的 map 来知道另一个BPF程序的指针,然后调用 tail_call()
的 helper function 来执行Tail call共享 map 的 BPF 程序不要求是相同的程序类型,例如 tracing 程序可以和网络程序共享 map,单个 BPF 程序目前最多可直接访问 64 个不同 map。
当前可用的 通用 map 有:
BPF_MAP_TYPE_HASH
BPF_MAP_TYPE_ARRAY
BPF_MAP_TYPE_PERCPU_HASH
BPF_MAP_TYPE_PERCPU_ARRAY
BPF_MAP_TYPE_LRU_HASH
BPF_MAP_TYPE_LRU_PERCPU_HASH
BPF_MAP_TYPE_LPM_TRIE
以上 map 都使用相同的一组 BPF 辅助函数来执行查找、更新或删除操作,但各自实现了不同的后端,这些后端各有不同的语义和性能特点。随着多CPU架构的成熟发展,BPF Map也引入了 per-cpu 类型,如BPF_MAP_TYPE_PERCPU_HASH
、BPF_MAP_TYPE_PERCPU_ARRAY
等,当你使用这种类型的BPF Map时,每个CPU都会存储并看到它自己的Map数据,从属于不同CPU之间的数据是互相隔离的,这样做的好处是,在进行查找和聚合操作时更加高效,性能更好,尤其是你的BPF程序主要是在做收集时间序列型数据,如流量数据或指标等。
当前内核中的 非通用 map 有:
BPF_MAP_TYPE_PROG_ARRAY
:一个数组 map,用于 hold 其他的 BPF 程序BPF_MAP_TYPE_PERF_EVENT_ARRAY
BPF_MAP_TYPE_CGROUP_ARRAY
:用于检查skb中的cgroup2成员信息BPF_MAP_TYPE_STACK_TRACE
:用于存储栈跟踪的MAPBPF_MAP_TYPE_ARRAY_OF_MAPS
:持有(hold) 其他 map 的指针,这样整个 map 就可以在运行时实现原子替换BPF_MAP_TYPE_HASH_OF_MAPS
:持有(hold) 其他 map 的指针,这样整个 map 就可以在运行时实现原子替换eBPF 程序不能够随意调用内核函数,如果这么做的话会导致 eBPF 程序与特定的内核版本绑定,相反它内核定义的一系列 Helper functions
。 Helper functions
使得 BPF 能够通过一组内核定义的稳定的函数调用来从内核中查询数据,或者将数据推送到内核。所有的 BPF 辅助函数都是核心内核的一部分,无法通过内核模块来扩展或添加。当前可用的 BPF 辅助函数已经有几十个,并且数量还在不断增加,你可以在 Linux Manual Page: bpf-helpers 看到当前 Linux 支持的 Helper functions
。
不同类型的 BPF 程序能够使用的辅助函数可能是不同的,例如:
lightweight tunneling
使用的封装和解封装辅助函数,只能被更低的 tc 层使用;而推送通知到用户态所使用的事件输出辅助函数,既可以被 tc 程序使用也可以被 XDP 程序使用所有的辅助函数都共享同一个通用的、和系统调用类似的函数方法,其定义如下:
1 | u64 fn(u64 r1, u64 r2, u64 r3, u64 r4, u64 r5) |
内核将辅助函数抽象成 BPF_CALL_0()
到 BPF_CALL_5()
几个宏,形式和相应类型的系统调用类似,这里宏的定义可以参见 include/linux/filter.h 。以 bpf_map_update_elem
为例,可以看到它通过调用相应 map 的回调函数完成更新 map 元素的操作:
1 | BPF_CALL_4(bpf_map_update_elem, struct bpf_map *, map, void *, key, |
这种方式有很多优点:
虽然 cBPF 允许其加载指令(load instructions)进行超出范围的访问(overload),以便从一个看似不可能的包偏移量(packet offset)获取数据以唤醒多功能辅助函数,但每个 cBPF JIT 仍然需要为这个 cBPF 扩展实现对应的支持。而在 eBPF 中,JIT 编译器会以一种透明和高效的方式编译新加入的辅助函数,这意味着 JIT 编 译器只需要发射(emit)一条调用指令(call instruction),因为寄存器映射的方式使得 BPF 排列参数的方式(assignments)已经和底层架构的调用约定相匹配了。这使得基于辅助函数扩展核心内核(core kernel)非常方便。所有的 BPF 辅助函数都是核心内核的一部分,无法通过内核模块(kernel module)来扩展或添加。
前面提到的函数签名还允许校验器执行类型检测(type check)。上面的
struct bpf_func_proto
用于存放校验器必需知道的所有关于该辅助函数的信息,这 样校验器可以确保辅助函数期望的类型和 BPF 程序寄存器中的当前内容是匹配的。参数类型范围很广,从任意类型的值,到限制只能为特定类型,例如 BPF 栈缓冲区(stack buffer)的
pointer/size
参数对,辅助函数可以从这个位置读取数据或向其写入数据。 对于这种情况,校验器还可以执行额外的检查,例如,缓冲区是否已经初始化过了。
尾调用的机制是指:一个 BPF 程序可以调用另一个 BPF 程序,并且调用完成后不用返回到原来的程序。
skb
的某些字段(例如 cb[]
)除了 BPF 辅助函数和 BPF 尾调用之外,BPF 核心基础设施最近刚加入了一个新特性:BPF to BPF calls
。在这个特性引入内核之前,典型的 BPF C 程序必须 将所有需要复用的代码进行特殊处理,例如,在头文件中声明为 always_inline
。当 LLVM 编译和生成 BPF 对象文件时,所有这些函数将被内联,因此会在生成的对象文件中重 复多次,导致代码尺寸膨胀:
1 |
|
之所以要这样做是因为 BPF 程序的加载器、校验器、解释器和 JIT 中都缺少对函数调用的支持。从 Linux 4.16
和 LLVM 6.0
开始,这个限制得到了解决,BPF 程序不再需要到处使用 always_inline
声明了。因此,上面的代码可以更自然地重写为:
1 |
|
BPF 到 BPF 调用是一个重要的性能优化,极大减小了生成的 BPF 代码大小,因此 对 CPU 指令缓存(instruction cache,i-cache)更友好。
BPF 辅助函数的调用约定也适用于 BPF 函数间调用:
r1
- r5
用于传递参数,返回结果放到 r0
r1
- r5
是 scratch registers,r6
- r9
像往常一样是保留寄存器8
当前,BPF 函数间调用和 BPF 尾调用是不兼容的,因为后者需要复用当前的栈设置( stack setup),而前者会增加一个额外的栈帧,因此不符合尾调用期望的布局。
BPF JIT 编译器为每个函数体发射独立的镜像(emit separate images for each function body),稍后在最后一通 JIT 处理(final JIT pass)中再修改镜像中函数调用的地址 。已经证明,这种方式需要对各种 JIT 做最少的修改,因为在实现中它们可以将 BPF 函数间调用当做常规的 BPF 辅助函数调用。
BPF map 和程序作为内核资源只能通过文件描述符访问,其背后是内核中的匿名 inode。这带来了很多优点:
但同时,文件描述符受限于进程的生命周期,使得 map 共享之类的操作非常笨重,这给某些特定的场景带来了很多复杂性。
例如 iproute2,其中的 tc 或 XDP 在准备环境、加载程序到内核之后最终会退出。在这种情况下,从用户空间也无法访问这些 map 了,而本来这些 map 其实是很有用的。例如,在 data path 的 ingress 和 egress 位置共享的 map(可以统计包数、字节数、PPS 等信息)。另外,第三方应用可能希望在 BPF 程序运行时监控或更新 map。
为了解决这个问题,内核实现了一个最小内核空间 BPF 文件系统,BPF map 和 BPF 程序 都可以 pin 到这个文件系统内,这个过程称为 object pinning
。BPF 相关的文件系统不是单例模式(singleton),它支持多挂载实例、硬链接、软连接等等。
相应的,BPF 系统调用扩展了两个新命令,如下图所示:
BPF_OBJ_PIN
:钉住一个对象BPF_OBJ_GET
:获取一个被钉住的对象为了避免代码被损坏,BPF 会在程序的生命周期内,在内核中将 BPF 解释器解释后的整个镜像(struct bpf_prog
)和 JIT 编译之后的镜像(struct bpf_binary_header
)锁定为只读的。在这些位置发生的任何数据损坏(例如由于某些内核 bug 导致的)会触发通用的保护机制,因此会造成内核崩溃而不是允许损坏静默地发生。
查看哪些平台支持将镜像内存(image memory)设置为只读的,可以通过下面的搜索:
1 | $ git grep ARCH_HAS_SET_MEMORY | grep select |
CONFIG_ARCH_HAS_SET_MEMORY
选项是不可配置的,因此平台要么内置支持,要么不支持,那些目前还不支持的架构未来可能也会支持。
为了防御 Spectre v2) 攻击,Linux 内核提供了 CONFIG_BPF_JIT_ALWAYS_ON
选项,打开这个开关后 BPF 解释器将会从内核中完全移除,永远启用 JIT 编译器:
x86_64
和 arm64
)上的 JIT 通常都建议打开这个开关 将 /proc/sys/net/core/bpf_jit_harden
设置为 1
会为非特权用户的 JIT 编译做一些额外的加固工作。这些额外加固会稍微降低程序的性能,但在有非受信用户在系统上进行操作的情况下,能够有效地减小潜在的受攻击面。但与完全切换到解释器相比,这些性能损失还是比较小的。对于 x86_64
JIT 编译器,如果设置了 CONFIG_RETPOLINE
,尾调用的间接跳转( indirect jump)就会用 retpoline
实现。写作本文时,在大部分现代 Linux 发行版上这个配置都是打开的。
当前,启用加固会在 JIT 编译时盲化(blind)BPF 程序中用户提供的所有 32 位和 64 位常量,以防御 JIT spraying攻击,这些攻击会将原生操作码作为立即数注入到内核。这种攻击有效是因为:立即数驻留在可执行内核内存(executable kernel memory)中,因此某些内核 bug 可能会触发一个跳转动作,如果跳转到立即数的开始位置,就会把它们当做原生指令开始执行。
盲化 JIT 常量通过对真实指令进行随机化(randomizing the actual instruction)实现 。在这种方式中,通过对指令进行重写,将原来基于立即数的操作转换成基于寄存器的操作。指令重写将加载值的过程分解为两部分:
rnd ^ imm
到寄存器rnd
进行异或操作(xor)这样原始的 imm
立即数就驻留在寄存器中,可以用于真实的操作了。这里介绍的只是加载操作的盲化过程,实际上所有的通用操作都被盲化了。下面是加固关闭的情况下,某个程序的 JIT 编译结果:
1 | $ echo 0 > /proc/sys/net/core/bpf_jit_harden |
加固打开之后,以上程序被某个非特权用户通过 BPF 加载的结果(这里已经进行了常量盲化):
1 | $ echo 1 > /proc/sys/net/core/bpf_jit_harden |
两个程序在语义上是一样的,但在第二种方式中,原来的立即数在反汇编之后的程序中不再可见。同时,加固还会禁止任何 JIT 内核符合(kallsyms)暴露给特权用户,JIT 镜像地址不再出现在 /proc/kallsyms
中。
BPF 网络程序,尤其是 tc 和 XDP BPF 程序在内核中都有一个 offload 到硬件的接口,这样就可以直接在网卡上执行 BPF 程序。
当前,Netronome 公司的 nfp
驱动支持通过 JIT 编译器 offload BPF,它会将 BPF 指令翻译成网卡实现的指令集。另外,它还支持将 BPF maps offload 到网卡,因此 offloaded BPF 程序可以执行 map 查找、更新和删除操作。
eBPF 提供了 bpf()
系统调用来对 BPF Map 或 程序进行操作,其函数原型如下:
1 |
|
函数有三个参数,其中:
cmd
指定了 bpf 系统调用执行的命令类型,每个 cmd 都会附带一个参数 attr
bpf_attr union
允许在内核和用户空间之间传递数据,确切的格式取决于 cmd
这个参数size
这个参数表示bpf_attr union
这个对象以字节为单位的大小cmd
可以为一下几种类型,基本上可以分为操作 eBPF Map 和操作 eBPF 程序两种类型:
BPF_MAP_CREATE
:创建一个 eBPF Map
并且返回指向该 Map 的文件描述符BPF_MAP_LOOKUP_ELEM
:在某个 Map 中根据 key 查找元素并返回其 valueBPF_MAP_UPDATE_ELEM
:在某个 Map 中创建或者更新一个元素 key/value 对BPF_MAP_DELETE_ELEM
:在某个 Map 中根据 key 删除一个元素BPF_MAP_GET_NEXT_KEY
:在某个 Map 中根据 key 查找元素然后返回下一个元素的 keyBPF_PROG_LOAD
:校验并加载 eBPF 程序,返回与该程序关联的文件描述符bpf_attr union
的结构如下所示,根据不同的 cmd
可以填充不同的信息。
1 | union bpf_attr { |
BPF_PROG_LOAD
命令用于校验和加载 eBPF 程序,其需要填充的参数 bpf_xattr
,下面展示了在 libbpf
中 bpf_load_program
的实现,可以看到最终是调用了 bpf
系统调用。
1 | int bpf_load_program(enum bpf_prog_type type, const struct bpf_insn *insns, |
和前面一样,查看 libbpf
中 bpf_create_map
的实现,可以看到最终也调用了 bpf 系统调用:
1 | int bpf_create_map(enum bpf_map_type map_type, int key_size, |
libbpf
中 bpf_map_lookup_elem
的实现:
1 | int bpf_map_lookup_elem(int fd, const void *key, void *value) |
libbpf
中 bpf_map_update_elem
的实现:
1 | int bpf_map_update_elem(int fd, const void *key, const void *value, |
libbpf
中 bpf_map_delete_elem
的实现:
1 | int bpf_map_delete_elem(int fd, const void *key) |
libbpf
中 bpf_map_get_next_key
的实现:
1 | int bpf_map_get_next_key(int fd, const void *key, void *next_key) |
注意,这里的 libbpf
函数和之前提到的 helper functions
还不太一样,你可以在 Linux Manual Page: bpf-helpers 看到当前 Linux 支持的 Helper functions
。以 bpf_map_update_elem
为例,eBPF 程序通过调用 helper function
,其参数如下:
1 | struct msg { |
这里的第一个参数来自于 SEC(".maps")
语法糖创建的 bpf_map
。
对于用户态程序,则其函数原型如下,其中通过 fd 来访问 eBPF map。
1 | int bpf_map_lookup_elem(int fd, const void *key, void *value) |
函数BPF_PROG_LOAD
加载的程序类型规定了四件事:
实际上,程序类型本质上定义了一个API。甚至还创建了新的程序类型,以区分允许调用的不同的函数列表(比如BPF_PROG_TYPE_CGROUP_SKB
对比 BPF_PROG_TYPE_SOCKET_FILTER
)。
bpf 程序会被hook到内核不同的hook点上。不同的hook点的入口参数,能力有所不同。因而定义了不同的 prog type。不同的prog type 的bpf程序能够调用的kernel function 集合也不一样。当bpf 程序加载到内核时,内核的verifier程序会根据bpf prog type,检查程序的入口参数,调用了哪些 helper function。
目前内核支持的eBPF程序类型列表如下所示:
BPF_PROG_TYPE_SOCKET_FILTER
:一种网络数据包过滤器BPF_PROG_TYPE_KPROBE
:确定kprobe是否应该触发BPF_PROG_TYPE_SCHED_CLS
:一种网络流量控制分类器BPF_PROG_TYPE_SCHED_ACT
:一种网络流量控制动作BPF_PROG_TYPE_TRACEPOINT
:确定 tracepoint是否应该触发BPF_PROG_TYPE_XDP
:从设备驱动程序接收路径运行的网络数据包过滤器BPF_PROG_TYPE_PERF_EVENT
:确定是否应该触发perf事件处理程序BPF_PROG_TYPE_CGROUP_SKB
:一种用于控制组的网络数据包过滤器BPF_PROG_TYPE_CGROUP_SOCK
:一种由于控制组的网络包筛选器,它被允许修改套接字选项BPF_PROG_TYPE_LWT_*
:用于轻量级隧道的网络数据包过滤器BPF_PROG_TYPE_SOCK_OPS
:一个用于设置套接字参数的程序BPF_PROG_TYPE_SK_SKB
:一个用于套接字之间转发数据包的网络包过滤器BPF_PROG_CGROUP_DEVICE
:确定是否允许设备操作随着新程序类型的添加,内核开发人员同时发现也需要添加新的数据结构。
举个例子BPF_PROG_TYPE_SCHED_CLS bpf prog , 能够访问哪些bpf helper function呢?让我们来看看源代码是如何实现的。
每一种prog type 会定义一个 struct bpf_verifier_ops
结构体。当 prog load 到内核时,内核会根据它的 type,调用相应结构体的get_func_proto 函数。
1 | const struct bpf_verifier_ops tc_cls_act_verifier_ops = { |
对于 BPF_PROG_TYPE_SCHED_CLS 类型的 BPF 代码,verifier 会调用 tc_cls_act_func_proto
,以检查程序调用的helper function 是否都是合法的。
每一种 prog type 的调用时机都不同。
BPF_PROG_TYPE_SCHED_CLS 的调用过程如下。
egress 方向上,tcp/ip 协议栈运行之后,有一个hook点。这个hook点可以attach BPF_PROG_TYPE_SCHED_CLS type 的 egress 方向的bpf prog。 在这段bpf 代码执行之后,才会运行qos,tcpdump, xmit 到网卡driver的代码。在这段 bpf 代码中你可以修改报文里面的内容,地址等。修改之后,通过 tcpdump可以看到,因为tcpdump代码在此之后才执行。
1 | static int __dev_queue_xmit(struct sk_buff *skb, struct net_device *sb_dev) |
ingress 方向上,在 deliver to tcp/ip 协议栈之前,在 tcpdump 之后,有一个hook点。这个hook点可以attach BPF_PROG_TYPE_SCHED_CLS type 的ingress 方向的bpf prog。在这里你也可以修改报文。但是修改之后的结果在tcpdump中是看不到的。
1 | static int __netif_receive_skb_core(struct sk_buff **pskb, bool pfmemalloc, |
无论 egress还是ingress 方向,真正执行bpf 指令的入口都是 cls_bpf_classify。它遍历 tcf_proto中的bpf prog link list, 对每一个bpf prog 执行BPF_PROG_RUN(prog->filter, skb)
1 | static int cls_bpf_classify(struct sk_buff *skb, const struct tcf_proto *tp, |
BPF_PROG_RUN 会执行 JIT compile 的bpf 指令,如果内核不支持JIT,则会调用解释器执行bpf的byte code。
BPF_PROG_RUN 传给bpf prog的入口参数是skb,其类型是 struct sk_buff
, 定义在文件include/linux/skbuff.h中。
但是在bpf 代码中,为了安全,不能直接访问 sk_buff
。bpf中是通过访问 struct __sk_buff
来访问struct sk_buff的。__sk_buff
是 sk_buff
的一个子集,是sk_buff面向bpf 程序的接口。bpf代码中对 __sk_buff
的访问会在verifier程序中翻译成对sk_buff相应fileds的访问。
在加载bpf prog的时候,verifier会调用上面 tc_cls_act_verifier_ops
结构体里面的tc_cls_act_convert_ctx_access的钩子。它最终会调用下面的函数修改ebpf的指令,使得对 __sk_buff
的访问变成对 struct sk_buff
的访问。
一种 type 的bpf prog 可以挂到内核中不同的hook点,这些不同的hook点就是不同的attach type。
其对应关系在 下面函数 中定义了。
1 | attach_type_to_prog_type(enum bpf_attach_type attach_type) |
当bpf prog 通过系统调用bpf() attach到具体的hook点时,其入口参数中就需要指定attach type。
有趣的是,BPF_PROG_TYPE_SCHED_CLS 类型的 bpf prog 不能通过bpf系统调用来attach,因为它没有定义对应的 attach type。故它的 attach 需要通过netlink interface 额外的实现,还是非常复杂的。
内核中的 prog type 目前有30种。每一种type 能做的事情有所差异,这里只讲讲我平时工作用过的几种。
理解一种prog type的最好的方法是
1 | include/uapi/linux/bpf.h |
是第一个被添加到内核的程序类型。当你attach一个bpf程序到socket上,你可以获取到被socket处理的所有数据包。socket过滤不允许你修改这些数据包以及这些数据包的目的地。仅仅是提供给你观察这些数据包。在你的程序中可以获取到诸如protocol type类型等。
以 tcp 为 example,调用的地点是 tcp_v4_rcv->tcp_filter->sk_filter_trim_cap 作用是过滤报文,或者trim报文。udp, icmp中也有相关的调用。
在 tcp 协议 event 发生时调用的bpf 钩子,定义了15种event。这些event的 attach type 都是BPF_CGROUP_SOCK_OPS。不同的调用点会传入不同的enum, 比如:
主要作用:tcp 调优,event 统计等。
BPF_PROG_TYPE_SOCK_OPS 这种程序类型,允许你当数据包在内核网络协议栈的各个阶段传输的时候,去修改套接字的链接选项。他们attach到cgroups上,和BPF_PROG_TYPE_CGROUP_SOCK以及BPF_PROG_TYPE_CGROUP_SKB很像,但是不同的是,他们可以在整个连接的生命周期内被调用好多次。你的bpf程序会接受到一个op的参数,该参数代表内核将通过套接字链接执行的操作。因此,你知道在链接的生命周期内何时调用该程序。另一方面,你可以获取ip地址,端口等。你还可以修改链接的链接的选项以设置超时并更改数据包的往返延迟时间。
举个例子,Facebook 使用它来为同一数据中心内的连接设置短恢复时间目标(RTO)。RTO是一种时间,它指的是网络在出现故障后的恢复时间,这个指标也表示网络在受到不可接受到情况下的,不能被使用的时间。Facebook认为,在同一数据中心中,应该有一个很短的RTO,Facebook修改了这个时间,使用bpf程序。
它对应很多attach type,一般在bind, connect 时调用, 传入 sock 的地址。
主要作用:例如 cilium中 clusterip 的实现,在主动 connect 时,修改了目的ip地址,就是利用这个。
BPF_PROG_TYPE_CGROUP_SOCK_ADDR,这种类型的程序使您可以在由特定cgroup控制的用户空间程序中操纵IP地址和端口号。 在某些情况下,当您要确保一组特定的用户空间程序使用相同的IP地址和端口时,系统将使用多个IP地址.当您将这些用户空间程序放在同一cgroup中时,这些BPF程序使您可以灵活地操作这些绑定。 这样可以确保这些应用程序的所有传入和传出连接均使用BPF程序提供的IP和端口。
BPF_PROG_TYPE_SK_MSG, These types of programs let you control whether a message sent to a socket should be delivered.当内核创建了一个socket,它会被存储在前面提到的map中。当你attach一个程序到这个socket map的时候,所有的被发送到那些socket的message都会被filter。在filter message之前,内核拷贝了这些data,因此你可以读取这些message,而且可以给出你的决定:例如,SK_PASS和SK_DROP。
调用点:tcp sendmsg 时会调用。
主要作用:做sock redir 用的。
BPF_PROG_TYPE_SK_SKB,这类程序可以让你获取socket maps和socket redirects。socket maps可以让你获得一些socket的引用。当你有了这些引用,你可以使用相关的helpers,去重定向一个incoming 的packet ,从一个socket去另外一个scoket.这在使用BPF来做负载均衡时是非常有用的。你可以在socket之间转发网络数据包,而不需要离开内核空间。Cillium和facebook的Katran 广泛的使用这种类型的程序去做流量控制。
调用点:getsockopt, setsockopt
类似 ftrace 的kprobe,在函数出入口的 hook 点,debug 用的。
类似 ftrace 的 tracepoint。
如上面的例子
网卡驱动收到packet时,尚未生成 sk_buff 数据结构之前的一个hook点。
BPF_PROG_TYPE_XDP 允许你的 bpf 程序,在网络数据包到达 kernel 很早的时候。在这样的bpf程序中,你仅仅可能获取到一点点的信息,因为kernel还没有足够的时间去处理。因为时间足够的早,所以你可以在网络很高的层面上去处理这些 packet。
XDP定义了很多的处理方式,例如
BPF_PROG_TYPE_CGROUP_SKB 允许你过滤整个cgroup的网络流量。在这种程序类型中,你可以在网络流量到达这个 cgoup 中的程序前做一些控制。内核试图传递给同一 cgroup 中任何进程的任何数据包都将通过这些过滤器之一。同时,您可以决定 cgroup 中的进程通过该接口发送网络数据包时该怎么做。其实,你可以发现它和 BPF_PROG_TYPE_SOCKET_FILTER 的类型很类似。最大的不同是cgroup_skb是attach到这个cgroup中的所有进程,而不是特殊的进程。在container的环境中,bpf是非常有用的。
在sock create, release, post_bind 时调用的。主要用来做一些权限检查的。
BPF_PROG_TYPE_CGROUP_SOCK,这种类型的 bpf 程序允许你,在一个cgroup中的任何进程打开一个 socket 的时候,去执行你的Bpf程序。这个行为和 CGROUP_SKB 的行为类似,但是它是提供给你 cgoup 中的进程打开一个新的 socket 的时候的情况,而不是给你网络数据包通过的权限控制。这对于为可以打开套接字的程序组提供安全性和访问控制很有用,而不必分别限制每个进程的功能。
BCC 是 BPF 的编译工具集合,前端提供 Python/Lua API,本身通过 C/C++ 语言实现,集成 LLVM/Clang 对 BPF 程序进行重写、编译和加载等功能, 提供一些更人性化的函数给用户使用。
虽然 BCC 竭尽全力地简化 BPF 程序开发人员的工作,但其“黑魔法” (使用 Clang 前端修改了用户编写的 BPF 程序)使得出现问题时,很难找到问题的所在以及解决方法。必须记住命名约定和自动生成的跟踪点结构 。且由于 libbcc 库内部集成了庞大的 LLVM/Clang 库,使其在使用过程中会遇到一些问题:
随着 BPF CO-RE 的落地,我们可以直接使用内核开发人员提供的 libbpf 库来开发 BPF 程序,开发方式和编写普通 C 用户态程序一样:一次编译生成小型的二进制文件。Libbpf 作为 BPF 程序加载器,接管了重定向、加载、验证等功能,BPF 程序开发者只需要关注 BPF 程序的正确性和性能即可。这种方式将开销降到了最低,且去除了庞大的依赖关系,使得整体开发流程更加顺畅。
性能优化大师 Brendan Gregg 在用 libbpf + BPF CO-RE 转换一个 BCC 工具后给出了性能对比数据:
As my colleague Jason pointed out, the memory footprint of opensnoop as CO-RE is much lower than opensnoop.py. 9 Mbytes for CO-RE vs 80 Mbytes for Python.
我们可以看到在运行时相比 BCC 版本,libbpf + BPF CO-RE 版本节约了近 9 倍的内存开销,这对于物理内存资源已经紧张的服务器来说会更友好。
关于 BCC 可以参考 我的这篇文章介绍
bpftrace is a high-level tracing language for Linux eBPF and available in recent Linux kernels (4.x). bpftrace uses LLVM as a backend to compile scripts to eBPF bytecode and makes use of BCC for interacting with the Linux eBPF subsystem as well as existing Linux tracing capabilities: kernel dynamic tracing (kprobes), user-level dynamic tracing (uprobes), and tracepoints. The bpftrace language is inspired by awk, C and predecessor tracers such as DTrace and SystemTap.
RDMA,即 Remote Direct Memory Access
,是一种绕过远程主机 OS kernel
访问其内存中数据的技术,概念源自于 DMA
技术。在 DMA 技术中,外部设备(PCIe 设备)能够绕过 CPU 直接访问 host memory
;而 RDMA 则是指外部设备能够绕过 CPU,不仅可以访问本地主机的内存,还能够访问另一台主机上的用户态内存。由于不经过操作系统,不仅节省了大量 CPU 资源,同样也提高了系统吞吐量、降低了系统的网络通信延迟,在高性能计算和深度学习训练中得到了广泛的应用。本文将介绍 RDMA 的架构与原理,并讲解 RDMA 网络使用方法,测试代码在 Github 上可以找到。
计算机网络通信中最重要两个衡量指标主要是 带宽 和 延迟,通信延迟主要是指:
Transmission Delay:
Propagation delay
After the packet is transmitted to the transmission medium, it has to go through the medium to reach the destination. Hence the time taken by the last bit of the packet to reach the destination is called propagation delay.
计算方法:$Delay_p = Distance / Velocity$,其中 Distance 是传输链路的距离,Velocity 是物理介质传输速度
1 | Velocity =3 X 108 m/s (for air) |
Queueing delay
Processing delay
现实计算机网络中的通信场景中,主要是以发送小消息为主,因此处理延迟是提升性能的关键。
传统的 TCP/IP 网络通信,数据需要通过用户空间发送到远程机器的用户空间,在这个过程中需要经历若干次内存拷贝:
在高速网络条件下,传统的 TPC/IP 网络在主机侧数据移动和复制操作带来的高开销限制了可以在机器之间发送的带宽。为了提高数据传输带宽,人们提出了多种解决方案,这里主要介绍下面两种:
在主机通过网络进行通信的过程中,CPU 需要耗费大量资源进行多层网络协议的数据包处理工作,包括数据复制、协议处理和中断处理。当主机收到网络数据包时,会引发大量的网络 I/O 中断,CPU 需要对 I/O 中断信号进行响应和确认。为了将 CPU 从这些操作中解放出来,人们发明了TOE(TCP/IP Offloading Engine)技术,将上述主机处理器的工作转移到网卡上。TOE 技术需要特定支持 Offloading 的网卡,这种特定网卡能够支持封装多层网络协议的数据包。
为了消除传统网络通信带给计算任务的瓶颈,我们希望更快和更轻量级的网络通信,由此提出了RDMA技术。RDMA利用 Kernel Bypass 和 Zero Copy技术提供了低延迟的特性,同时减少了CPU占用,减少了内存带宽瓶颈,提供了很高的带宽利用率。RDMA提供了给基于 IO 的通道,这种通道允许一个应用程序通过RDMA设备对远程的虚拟内存进行直接的读写。
RDMA 技术有以下几个特点:
下面是 RDMA 整体框架架构图,从图中可以看出,RDMA在应用程序用户空间,提供了一系列 Verbs 接口操作RDMA硬件。RDMA绕过内核直接从用户空间访问RDMA 网卡。RNIC网卡中包括 Cached Page Table Entry,用来将虚拟页面映射到相应的物理页面。
目前RDMA有三种不同的硬件实现,它们都可以使用同一套API来使用,但它们有着不同的物理层和链路层:
时间回退到二十世纪的最后一年,随着 CPU 性能的迅猛发展,早在 1992 年 Intel 提出的 PCI 技术已经满足不了人民群众日益增长的 I/O 需求,I/O 系统的性能已经成为制约服务器性能的主要矛盾。尽管在 1998 年,IBM 联合 HP 、Compaq 提出了 PCI-X 作为 PCI 技术的扩展升级,将通信带宽提升到 1066 MB/sec,人们认为 PCI-X 仍然无法满足高性能服务器性能的要求,要求构建下一代 I/O 架构的呼声此起彼伏。经过一系列角逐,Infiniband 融合了当时两个竞争的设计 Future I/O 和 Next Generation I/O,建立了 Infiniband 行业联盟,也即 BTA (InfiniBand Trade Association),包括了当时的各大厂商 Compaq、Dell、HP、IBM、Intel、Microsoft 和 Sun。在当时,InfiniBand 被视为替换 PCI 架构的下一代 I/O 架构,并在 2000 年发布了 1.0 版本的 Infiniband 架构 Specification,2001 年 Mellanox 公司推出了支持 10 Gbit/s 通信速率的设备。
然而好景不长,2000 年互联网泡沫被戳破,人们对于是否要投资技术上如此跨越的技术产生犹豫。Intel 转而宣布要开发自己的 PCIe 架构,微软也停止了 IB 的开发。尽管如此,Sun 和 日立等公司仍然坚持对 InfiniBand 技术的研发,并由于其强大的性能优势逐渐在集群互联、存储系统、超级计算机内部互联等场景得到广泛应用,其软件协议栈也得到标准化,Linux 也添加了对于 Infiniband 的支持。进入2010年代,随着大数据和人工智能的爆发,InfiniBand 的应用场景从原来的超算等场景逐步扩散,得到了更加广泛的应用,InfiniBand 市场领导者 Mellanox 被 NVIDIA 收购,另一个主要玩家 QLogic 被 Intel 收购,Oracle 也开始制造自己的 InfiniBand 互联芯片和交换单元。到了 2020 年代,Mellanox 最新发布的 NDR 理论有效带宽已经可以达到 单端口 400 Gb/s,为了运行 400 Gb/s 的 HCA 可以使用 PCIe Gen5x16 或者 PCIe Gen4x32。
InfiniBand 架构为系统通信定义了多种设备:channel adapter、switch、router、subnet manager,它提供了一种基于通道的点对点消息队列转发模型,每个应用都可通过创建的虚拟通道直接获取本应用的数据消息,无需其他操作系统及协议栈的介入。
在一个子网中,必须有至少每个节点有一个 channel adapter,并且有一个 subnet manager 来管理 Link。
可安装在主机或者其他任何系统(如存储设备)上的网络适配器,这种组件为数据包的始发地或者目的地,支持 Infiniband 定义的所有软件 Verbs
Switch 包含多个 InfiniBand 端口,它根据每个数据包 LRH 里面的 LID,负责将一个端口上收到的数据包发送到另一个端口。除了 Management Packets,Switch 不产生或者消费任何 Packets。它包含有 Subnet Manager 配置的转发表,能够响应 Subnet Manager 的 Management Packets。
Router 根据 L3 中的 GRH,负责将 Packet 从一个子网转发到另一个子网,当被转到到另一子网时,Router 会重建数据包中的 LID。
Subnet Manager 负责配置本地子网,使其保持工作:
InfiniBand 有着自己的协议栈,从上到下依次包括传输层、网络层、数据链路层和物理层:
对应着不同的层,数据包的封装如下,下面将对每一层的封装详细介绍:
物理层定义了 InfiniBand 具有的电气和机械特性,InfiniBand 支持光纤和铜作为传输介质。在物理层支持不同的 Link 速度,每个 Link 由四根线组成(每个方向两条),Link 可以聚合以提高速率,目前绝大多数的系统采用 4 Link。
以 QDR 为例,线上的 Signalling Rate
为 10 Gb/s,由于采用 8b/10b
编码,实际有效带宽单 Link 为 10 Gb/s * 8/10 = 8 Gb/s,如果是 4 Link,则带宽可以达到 32 Gb/s。因为是双向的,所以 4 Link 全双工的速率可以达到 64 Gb/s。
Link Layer 是 InfiniBand 架构的核心,包含以下部分:
网络层负责将 Packet 从一个子网路由到另一个子网:
下面是 GRH 报头的格式,长40字节,可选,用于组播数据包以及需要穿越多个子网的数据包。它使用 GID 描述了源端口和目标端口,其格式与 IPv6 报头相同。
传输层负责 Packet 的按序传输、根据 MTU 分段和很多传输层的服务(reliable connection, reliable datagram, unreliable connection, unreliable datagram, raw datagram)。InfiniBand 的传输层提供了一个巨大的提升,因为所有的函数都是在硬件中实现的。
按照连接和可靠两个标准,可以划分出下图四种不同的传输模式:
每种模式中可用的操作如下表所示,目前的RDMA硬件提供一种数据报传输:不可靠的数据报(UD),并且不支持memory verbs。
下面是传输层的 Base Transport Header 的结构,长度为 12 字节,指定了源 QP 和 目标 QP、操作、数据包序列号和分区。
P_Key
表,每个 QP 都与这个表中的一个 P_Key
索引相关联。只有当两个 QP 相关联的 P_Key
键值相同时,它们才能互相收发数据包。根据传输层的服务类别和操作,有不定长度的扩展传输报头(Extended Transport Header,ETH),比如下面是进行时候的 ETH:
下面是 RDMA ETH,面向于 RDMA 操作:
下面是 Datagram ETH,面向与 UD 和 RD 类型的服务:
下面是 Reliable Datagram ETH,面向于 RC 类型的服务,其中有 End2End Context 字段:
InfiniBand 架构获得了极好的性能,但是其不仅要求在服务器上安装专门的 InfiniBand 网卡,还需要专门的交换机硬件,成本十分昂贵。而在企业界大量部署的是以太网络,为了复用现有的以太网,同时获得 InfiniBand 强大的性能,IBTA 组织推出了 RoCE(RDMA over Converged Ethernet)。RoCE 支持在以太网上承载 IB 协议,实现 RDMA over Ethernet,这样一来,仅需要在服务器上安装支持 RoCE 的网卡,而在交换机和路由器仍然使用标准的以太网基础设施。网络侧需要支持无损以太网络,这是由于 IB 的丢包处理机制中,任意一个报文的丢失都会造成大量的重传,严重影响数据传输性能。
RoCE 与 InfiniBand 技术有相同的软件应用层及传输控制层,仅网络层及以太网链路层存在差异,如下图所示:
RoCE 协议分为两个版本:
0x8915
标识 RoCE 报文。 iWARP 从以下几个方面降低了主机侧网络负载:
由于 TCP 协议能够提供流量控制和拥塞管理,因此 iWARP 不需要以太网支持无损传输,仅通过普通以太网交换机和 iWARP 网卡即可实现,因此能够在广域网上应用,具有较好的扩展性。
RDMA有两种基本操作,包括 Memory verbs
和 Messaging verbs
:
Memory verbs
:包括read、write和atomic操作,属于单边操作,只需要本端明确信息的源和目的地址,远端应用不必感知此次通信,数据的读或存都通过远端的DMA在RNIC与应用buffer之间完成,再由远端RNIC封装成消息返回到本端。Messaging verbs
:包括send和receive操作,属于双边操作,即必须要远端的应用感知参与才能完成收发。RDMA Consortium 和 IBTA 主导了RDMA,RDMAC是IETF的一个补充,它主要定义的是iWRAP和iSER,IBTA是infiniband的全部标准制定者,并补充了RoCE v1 v2的标准化。应用和RNIC之间的传输接口层(software transport interface)被称为Verbs。IBTA解释了RDMA传输过程中应具备的特性行为,而并没有规定Verbs的具体接口和数据结构原型。这部分工作由另一个组织OFA(Open Fabric Alliance)来完成,OFA提供了RDMA传输的一系列Verbs API。OFA开发出了OFED(Open Fabric Enterprise Distribution)协议栈,支持多种RDMA传输层协议。
OFED中除了提供向下与RNIC基本的队列消息服务,向上还提供了ULP(Upper Layer Protocols),通过ULPs,上层应用不需要直接到Verbs API对接,而是借助于ULP与应用对接,常见的应用不需要做修改,就可以跑在RDMA传输层上。
SR 定义了数据的发送量、从哪里、发送方式、是否通过 RDMA、到哪里。
结构 ibv_send_wr 用来描述 SR。
1 | struct ibv_send_wr { |
RR 定义用来放置通过 RDMA 操作接收到的数据的缓冲区。如没有定义缓冲区,并且有个传输者尝试执行一个发送操作或者一个带即时数的 RDMA 写操作,那么接收者将会发出接收未就绪的错误(RNR)。
结构 ibv_recv_wr
用来描述 RR。
1 | struct ibv_recv_wr { |
RDMA提供了基于消息队列的点对点通信,每个应用都可以直接获取自己的消息,无需操作系统和协议栈的介入。消息服务建立在通信双方本端和远端应用之间创建的Channel-IO连接之上。当应用需要通信时,就会创建一条Channel连接,每条Channel的首尾端点是两对Queue Pairs(QP)。每对QP由Send Queue(SQ)和Receive Queue(RQ)构成,这些队列中管理着各种类型的消息。QP会被映射到应用的虚拟地址空间,使得应用直接通过它访问RNIC网卡。除了QP描述的两种基本队列之外,RDMA还提供一种队列Complete Queue(CQ),CQ用来知会用户WQ上的消息已经被处理完。
RDMA提供了一套软件传输接口,方便用户创建传输请求Work Request(WR),WR中描述了应用希望传输到Channel对端的消息内容,WR 通知QP中的某个队列Work Queue(WQ)。在 WQ 中,用户的 WR 被转化为Work Queue Element(WQE)的格式,等待RNIC的异步调度解析,并从WQE指向的Buffer中拿到真正的消息发送到 Channel 对端。
1 | struct ibv_qp { |
发送到 SQ 和 RQ 的工作请求都被视为未完成,工作请求未完成期间,它指向的内存缓冲区的内容是不确定的。CQ 包含了发送到工作队列(WQ)中已完成的工作请求(WR)。每次完成表示一个特定的 WR 执行完毕(包括成功完成的 WR 和不成功完成的 WR)。完成队列是一个用来告知应用程序已经结束的工作请求的信息(状态、操作码、大小、来源)的机制。
CQ有n个完成队列实体(CQE),CQE 的数量在CQ创建时指定。当一个CQE被 轮询 到,它就从CQ中被删除。CQ是一个CQE的 FIFO 队列。CQ能服务于发送队列、接收队列或者同时服务于这两种队列。多个不同QP中的工作请求(WQ)可联系到同一个CQ上。
结构 ibv_cq
用来描述CQ。
1 | struct ibv_cq { |
RDMA 设备访问的每一个内存缓冲区都必须注册,在注册过程中,将对内存缓冲区执行如下操作:
注册成功后,内存有两个键:
lkey
:供本地工作请求用来访问内存的 keyrkey
:供远程机器通过 RDMA 访问内存的 key在工作请求中,将使用这些 key 来访问内存缓冲区,同一内存缓冲区可以被多次注册(甚至设置不同的操作权限),并且每次注册都会生成不同的 key。
结构 ibv_mr
用来描述内存注册。
1 | struct ibv_mr { |
启用远程内存访问的方式有以下两种:
这两种方式都将创建一个 rkey,可用来访问制定的内存。然而,如果想要这个rkey 无效,以禁止访问该内存时。采用注销内存区的方式实现起来比较繁琐。而使用内存窗口,并根据需要进行绑定和解除绑定,对于启动和禁用运城内存访问简单灵活得多。
内存窗口作用于以下场景:
内存窗口和内存注册之间的关联操作叫做绑定。不同的MW可以做用于同一个MR,即使有不同的访问权限。
地址向量用来描述本地节点到远程节点的路由。在QP的每个UC/RC中,都有一个地址向量存在于QP的上下文中。在UD的QP中,每个提交的发送请求(SR)中都应该定义地址向量。
结构 ibv_ah
用来描述地址向量。
GRH用于子网之间的路由。当用到RoCE时,GRH用于子网内部的路由,并且是强制使用的,强制使用GRH是为了保证应用程序即支持IB又支持RoCE。当全局路由用在给予UD的QP时,在接受缓冲区的前40自己会包含有一个GRH。这个区域专门存储全局路由信息,为了回应接收到的数据包,会产生一个合适的地址向量。如果向量用在UD中,接收请求RR应该总是有额外的40字节用来GRH。
结构 ibv_grh
用来描述GRH。
保护域是一种集合,它的内部元素只能与集合内部的其它元素相互作用。这些元素可以是AH、QP、MR、和SRQ。保护域用于QP与内存注册和内存窗口相关联,这是一种授权和管理网络适配器对主机系统内存的访问。PD也用于将给予不可靠数据报(UD)的QP关联到地址处理(AH),这是一种对UD目的端的访问控制。
1 | struct ibv_pd { |
首先必须检查得到本机可用的IB设备列表,列表中的每个设备都包含一个名字和GUID。
1 | /* 1 获取设备列表 */ |
遍历设备列表,通过设备的GUID或者名字选择并打开它,获取一个上下文:
1 | /* 2 打开设备,获取设备上下文 */ |
一般在这里需要释放设备列表占用的资源
1 | /* 3 释放设备列表占用的资源 */ |
设备的工作能力能使用户了解已打开设备支持的特性和能力 ibv_port_attr
。
1 | /* 4 查询设备端口状态 */ |
保护域(PD)允许用户限制哪些组件只能相互交互。这个组件可以是AH、QP、MR、MW、和SRQ。
1 | /* 5 创建PD(Protection Domain) */ |
一个CQ包含完成的工作请求(WR),每个WR将生成放置在CQ中的完成队列实体CQE,CQE将表明WR是否成功完成:
1 | /* 6 创建CQ(Complete Queue) */ |
在注册过程中,用户设置内存权限并接收 lkey
和 rkey
,稍后将使用这些秘钥来访问此内存缓冲区:
1 | /* 7 注册MR(Memory Region) */ |
创建 QP 还将创建关联的发送队列和接收队列:
1 | /* 8 创建QP(Queue Pair) */ |
可以通过 Socket 或者 RDMA_CM API 来交换控制信息,这里演示的是使用 Socket 交换信息:
1 | /* 9 交换控制信息 */ |
QP 有一个状态机,用于指定 QP 在各种状态下能够做什么:
1 | /* 10 转换QP状态 */ |
1 | /* 11 创建发送任务ibv_send_wr */ |
ibv_post_send
ibv_post_recv
1 | rc = ibv_post_send(res->qp, &sr, &bad_wr); |
1 | /* 13 轮询任务结果 */ |
单边操作传输方式是RDMA与传统网络传输的最大不同,提供直接访问远程的虚拟地址,无须远程应用的参与,这种方式适用于批量数据传输。
READ和WRITE是单边操作,只需要本端明确信息的源和目的地址,远端应用不必感知此次通信,数据的读或写都通过RDMA在RNIC与应用Buffer之间完成,再由远端RNIC封装成消息返回到本端。
对于单边操作,以存储网络环境下的存储为例,数据的流程如下:
对于单边操作,以存储网络环境下的存储为例,数据的流程如下:
双边操作与传统网络的底层buffer pool类似,收发双方的参与过程并无差别,区别在零拷贝、kernel bypass,实际上传统网络中一些高级的网络SOC 已经实现类似功能。对于RDMA,这是一种复杂的消息传输模式,多用于传输短的控制消息。
RDMA 中 SEND/RECEIVE 是双边操作,即必须要远端的应用感知参与才能完成收发。在实际中,SEND/RECEIVE多用于连接控制类报文,而数据报文多是通过READ/WRITE来完成的。对于双边操作为例,主机 A 向主机 B 发送数据的流程如下:
1 | ServerA:ib_read_bw -a -d mlx4_0 |
示例如下:
1 | # Server A |
1 | ServerA: ib_write_bw -a -d mlx4_0 |
1 | ServerA: ib_send_bw -a -d mlx4_0 |
延迟测试也有三个命令,使用方法与上类似:
ib_read_lat
ib_write_lat
ib_send_lat
以 ib_read_lat
为例,测试结果如下:
1 | # Server A |
Xid Message
由 NVIDIA 驱动报告的错误信息,一般卸载操作系统的内核日志或者是事件日志中。Xid消息表明发生了一般的GPU错误,通常是由于驱动程序对GPU的编程不正确或发送给GPU的命令损坏所致。这些消息可能表示硬件问题、NVIDIA软件问题或用户应用程序问题。
Xid Message 的产生可能有以下三种:
Xid Message 可以用作错误诊断,辅助调试报告的错误。在所有不同版本的NVIDIA驱动中,Xid Message 的含义保持一致。
在 Linux 中,Xid Error 的信息在 /var/log/messages
中,可以看到错误信息。下图展示的是 XID 14 的错误信息:
1 | $ grep "NVRM: Xid" /var/log/messages |
在 NVIDIA 提供的 NVML 库中可以监听 GPU 的 Xid Error,下面是 Go 监听的示例代码:
1 | eventSet := nvml.NewEventSet() |
XID 13 号错误是通用的用户进程的错误,一般是用户访问数组越界、或者非法指令、非法寄存器的问题。这种问题在很少的情况下才会是硬件问题或者内核驱动的问题,基本上是用户进程的问题。
当这种问题发生时,NVIDIA 推荐如下步骤:
XID 31 号错误是由 MMU 报告的错误,比如当一个用户进程对一个非法地址访问的时候。一般来说,这是用户程序级别的bug,也有可能是驱动或者硬件bug。
当这种问题发生时,NVIDIA 推荐如下步骤:
XID 32 号错误是由 DMA Controller 上报的,DMA Controller 负责在 NVIDIA 驱动和 GPU之前通过 PCIe总线进行通信。
一般来说,这种问题是由 PCI 的质量问题导致,一般也不是由用户程序造成的。
XID 43 号错误发生在当探测到用户程序可能因此故障,这时候必须终止用户程序。这种情况下,GPU还是处于健康的状态。
在大多数情况,这种问题是用户进程导致的,而不是驱动的bug
XID 45 号错误发生在 用户进程 Abort 了,这时候内核驱动需要终止在GPU上运行的GPU Application。Ctrl-C
、CPU Reset、Sigkill 都是这种场景。
大多数情况下,这种问题是用户进程导致的,而不是驱动的bug
XID 48 号错误发生在当 GPU 探测到GPU上有一个不可纠正的错误,这个错误也会报告给用户进程。这种情况下,可要 GPU Reset 或者 Node 重启来修复这个问题。nvidia-smi
工具会提供一个ECC错误的总结。
下表展示了所有的Xid Error信息:
XID | Failure | Causes | ||||||
---|---|---|---|---|---|---|---|---|
HW Error | Driver Error | User App Error | System Memory Corruption | Bus Error | Thermal Issue | FB Corruption | ||
1 | Invalid or corrupted push buffer stream | X | X | X | X | |||
2 | Invalid or corrupted push buffer stream | X | X | X | X | |||
3 | Invalid or corrupted push buffer stream | X | X | X | X | |||
4 | Invalid or corrupted push buffer stream | X | X | X | X | |||
GPU semaphore timeout | X | X | X | X | X | |||
5 | Unused | |||||||
6 | Invalid or corrupted push buffer stream | X | X | X | X | |||
7 | Invalid or corrupted push buffer address | X | X | X | ||||
8 | GPU stopped processing | X | X | X | X | |||
9 | Driver error programming GPU | X | ||||||
10 | Unused | |||||||
11 | Invalid or corrupted push buffer stream | X | X | X | X | |||
12 | Driver error handling GPU exception | X | ||||||
13 | Graphics Engine Exception | X | X | X | X | X | X | |
14 | Unused | |||||||
15 | Unused | |||||||
16 | Display engine hung | X | ||||||
17 | Unused | |||||||
18 | Bus mastering disabled in PCI Config Space | X | ||||||
19 | Display Engine error | X | ||||||
20 | Invalid or corrupted Mpeg push buffer | X | X | X | X | |||
21 | Invalid or corrupted Motion Estimation push buffer | X | X | X | X | |||
22 | Invalid or corrupted Video Processor push buffer | X | X | X | X | |||
23 | Unused | |||||||
24 | GPU semaphore timeout | X | X | X | X | X | X | |
25 | Invalid or illegal push buffer stream | X | X | X | X | X | ||
26 | Framebuffer timeout | X | ||||||
27 | Video processor exception | X | ||||||
28 | Video processor exception | X | ||||||
29 | Video processor exception | X | ||||||
30 | GPU semaphore access error | X | ||||||
31 | GPU memory page fault | X | X | |||||
32 | Invalid or corrupted push buffer stream | X | X | X | X | X | ||
33 | Internal micro-controller error | X | ||||||
34 | Video processor exception | X | ||||||
35 | Video processor exception | X | ||||||
36 | Video processor exception | X | ||||||
37 | Driver firmware error | X | X | X | ||||
38 | Driver firmware error | X | ||||||
39 | Unused | |||||||
40 | Unused | |||||||
41 | Unused | |||||||
42 | Video processor exception | X | ||||||
43 | GPU stopped processing | X | X | |||||
44 | Graphics Engine fault during context switch | X | ||||||
45 | Preemptive cleanup, due to previous errors — Most likely to see when running multiple cuda applications and hitting a DBE | X | ||||||
46 | GPU stopped processing | X | ||||||
47 | Video processor exception | X | ||||||
48 | Double Bit ECC Error | X | ||||||
49 | Unused | |||||||
50 | Unused | |||||||
51 | Unused | |||||||
52 | Unused | |||||||
53 | Unused | |||||||
54 | Auxiliary power is not connected to the GPU board | |||||||
55 | Unused | |||||||
56 | Display Engine error | X | X | |||||
57 | Error programming video memory interface | X | X | X | ||||
58 | Unstable video memory interface detected | X | X | |||||
EDC error – clarified in printout | X | |||||||
59 | Internal micro-controller error(older drivers) | X | ||||||
60 | Video processor exception | X | ||||||
61 | Internal micro-controller breakpoint/warning(newer drivers) | |||||||
62 | Internal micro-controller halt(newer drivers) | X | X | X | ||||
63 | ECC page retirement recording event | X | X | X | ||||
64 | ECC page retirement recording failure | X | X | |||||
65 | Video processor exception | X | X | |||||
66 | Illegal access by driver | X | X | |||||
67 | Illegal access by driver | X | X | |||||
68 | Video processor exception | X | X | |||||
69 | Graphics Engine class error | X | X | |||||
70 | CE3: Unknown Error | X | X | |||||
71 | CE4: Unknown Error | X | X | |||||
72 | CE5: Unknown Error | X | X | |||||
73 | NVENC2 Error | X | X | |||||
74 | NVLINK Error | X | X | X | ||||
75 | Reserved | |||||||
76 | Reserved | |||||||
77 | Reserved | |||||||
78 | vGPU Start Error | X | ||||||
79 | GPU has fallen off the bus | X | X | X | X | X | ||
80 | Corrupted data sent to GPU | X | X | X | X | X | ||
81 | VGA Subsystem Error | X | ||||||
82 | Reserved | |||||||
83 | Reserved | |||||||
84 | Reserved | |||||||
85 | Reserved | |||||||
86 | Reserved | |||||||
87 | Reserved | |||||||
88 | Reserved | |||||||
89 | Reserved | |||||||
90 | Reserved | |||||||
91 | Reserved | |||||||
92 | High single-bit ECC error rate | X | X |
在使用GPU进行深度学习相关的训练与推理时,需要查看当前集群中GPU的使用情况:
为了获得GPU的监控数据,NVIDIA 提供了以下三种方法:
nvidia-smi
命令即是基于此实现的对比这三种工具的特点:
remote/local
两种方式运行本文后续将主要介绍 DCGM。
下图展示了 DCGM
在集群中运行的方式,DCGM
以 Agent 的形式部署在计算节点上,管理节点上的工具可以通过 DCGM
提供的API管理和监控GPU。
DCGM
提供了一下四种关键特性:
DCGM 需要单独下载安装,在NVIDIA官网NVIDIA下载对应的安装包,这里选择下载rpm包即可,下载完成后:
1 | # 卸载可能已安装的旧版本DCGM |
/usr/lib64
目录/usr/local/dcgm/bindings
目录DCGM 是一个面向集群管理的工具,所以在实际使用前,需要先在目标机器启动一个agent,nv-hostengine
,具体启动命令如下
1 | # 启动 nv-hostengine |
其中,--port
--bind-interface
两个参数分别用来设置监听的端口和绑定的IP地址。同时也支持使用 UNIX_SOCKET
通信
在启动 nv-hostengine
之后,我们就可以使用 dcgmi
来操作
和NVML不同,DCGM 的大部分功能都是面向组的,所以在使用DCGM之前,首先需要创建组,然后才能使用DCGM提供的各种功能。
1 | # 获取设备列表后,可以用如下命令创建组 |
注意:group和设备之间是多对多关系
当有一个Job需要通过GPU加速计算的时候,我们想知道:
1 | # 当前 Group 3 如下 |
DCGM 可以更改GPU设置, 具体支持的设置项如下,查看原有设置:
1 | $ dcgmi config --host 127.0.0.1:39999 -g 3 --get |
1 | # 具体参数说明 |
注意,使用DCGM更改设置时,运作模式是一种面向声明的模式,用户通过dcgmi指定需要的目标设置,同时nv-hostengine自动调整设置,使当前设置对齐目标设置
dcgm 的提供了policy 功能,policy 本质上是类似于一种Watch机制,首先设定一个违反
条件,然后可以根据违反
条件设置对应的处理策略。一般而言,可以设置一个条件,然后注册listener,等待dcgm通知。
例如
1 | # 通过如下命令设置最大温度50度的条件 |
参数设置
1 | --set actn,val (OR required) Set the current violation policy. |
DCGM
的健康检查是无侵入式的检查,提供了实时监控和聚合的健康数据,其运行机制是
dcgmi health
命令查询当前发现的错误1 | $ dcgmi health --check -g 1 |
诊断是主动检查的模式,提供了三个级别的检查,每次运行时会根据运行级别,运行对应的测试程序,来发现问题。
运行命令如下
1 | $ dcgmi diag --host 127.0.0.1:39999 -g 3 -r 1 |
profile功能可以用较小的性能消耗获取GPU卡的利用率数据以及进程的性能数据,profile功能对于驱动版本和卡的类型有一些强制要求,具体是
可以获取的性能指标有
指标 | 说明 | FIELD_NAME |
---|---|---|
Graphics Engine Activity | Ratio of time the graphics engine is active. The graphics engine is active if a graphics/compute context is bound and the graphics pipe or compute pipe is busy. PROF_GR_ENGINE_ACTIVE (ID: 1001) | |
SM Activity | The ratio of cycles an SM has at least 1 warp assigned (computed from the number of cycles and elapsed cycles) | PROF_SM_ACTIVE (ID: 1002) |
SM Occupancy | The ratio of number of warps resident on an SM. (number of resident warps as a percentage of the theoretical maximum number of warps per elapsed cycle) | PROF_SM_OCCUPANCY (ID: 1003) |
Tensor Activity | The ratio of cycles the tensor (HMMA) pipe is active (off the peak sustained elapsed cycles) | PROF_PIPE_TENSOR_ACTIVE (ID: 1004) |
Memory BW Utilization | The ratio of cycles the device memory interface is active sending or receiving data. | PROF_DRAM_ACTIVE (ID: 1005) |
Engine Activity | Ratio of cycles the fp64 /fp32 / fp16 / HMMA / IMMA pipes are active. | PROF_PIPE_FPXY_ACTIVE (ID: 1006 (FP64); 1007 (FP32); 1008 (FP16)) |
NVLink Activity | The number of bytes of active NVLink rx or tx data including both header and payload. | DEV_NVLINK_BANDWIDTH_L0 |
PCIe Bandwidth pci_bytes{rx, tx} | The number of bytes of active pcie rx or tx data including both header and payload. | PROFPCIE[TR]X_BYTES (ID: 1009 (TX); 1010 (RX)) |
系统监控通常需要有以下几个组件:
Prometheus 作为云原生时代优秀的解决方案,其结合 Grafana 和 Alert Manager 等组件实现了 k8s 集群的系统监控,下面是其组件架构,更多内容可以参考我的另一篇博文。
同样,为了获得 GPU 的监控数据,NVIDIA 推出了 dcgm-exporter
,它封装了 DCGM
,类似于 node-exporter
将 GPU 的数据暴露给 Prometheus:
dcgm-exporter
作为 DaemonSet
运行在每一个装有GPU的Node上,为了使得 Prometheus 能够采集到它收集的数据,同时创建了 Service
。
1 | apiVersion: apps/v1 |
这一步之后,可以获取每个Node上的 Metrics:
1 |
部署完成后,需要在Prometheus的配置中,给 scrape_configs
添加 gpu-metrics
的 job,通过 kubernetes_sd_configs
的服务发现机制找到 dcgm-exporter
对应的服务。
1 | - job_name: gpu-metrics |
NVIDIA 提供了专用于 GPU 监控的 Grafana 面板 ,在Grafana 导入面板后,即可看到对应的GPU监控面板:
OpenFalcon 是小米开源的一套监控系统解决方案,其架构如下图所示。在每个节点上会有一个 falcon-agent
的 daemon 进程,负责对每个节点进行数据采集。
为了支持GPU监控,OpenFalcon 有专门的 GPU 监控插件,它依赖于 DCGM
获得监控指标,下面是一些常用的指标:
1 | GPUUtils GPU 使用率 (%) |
与 OpenFalcon
不同,GPU Manager 使用的是 NVML
库开发,获得对于 GPU Pod 级的监控数据。
1 | func (disp *Display) getDeviceUsage(pidsInCont []int, deviceIdx int) *displayapi.DeviceInfo { |
对于 k8s 的 GPU 监控,我们到底需要那些指标:
流量控制是指对系统流量的管控,包括了对网格入口的流量、网格出口的流量以及在网格内部微服务间相互调用流量的控制。在 Istio 入门 中我们知道,Istio 架构在逻辑上分为 Control plane 和 Data plane,Control plane 负责整体管理和配置代理, Data plane 负责网格内所有微服务间的网络通信,同时还收集报告网络请求的遥测数据等。流量控制是在 Data plane 层实现。
Istio 为了控制服务请求,引入了服务版本(version)的概念,可以通过版本这一标签将服务进行区分。版本的设置是非常灵活的,以下是几种典型的设置方式:
通过版本标签,Istio 就可以定义灵活的路由规则来控制流量,上面提到的金丝雀发布这类应用场景就很容易实现了。
下图展示了使用服务版本实现路由分配的例子。服务版本定义了版本号(v1.5、v2.0-alpha)和环境(us-prod、us-staging)两种信息。服务 B 包含了 4 个 Pod,其中 3 个是部署在生产环境的 v1.5 版本,而 Pod4 是部署在预生产环境的 v2.0-alpha 版本。运维人员可以根据服务版本来指定路由规则,使 99% 的流量流向 v1.5 版本,而 1% 的流量进入 v2.0-alpha 版本。
除了上面介绍的服务间流量控制外,还能控制与网格边界交互的流量。可以在系统的入口和出口处部署 Sidecar 代理,让所有流入和流出的流量都由代理进行转发。负责入和出的代理就叫做入口网关和出口网关,它们把守着进入和流出网格的流量。下图展示了 Ingress 和 Egress 在请求流中的位置,有了他们俩,也就可以控制出入网格的流量了。
Istio 还能设置流量策略。比如可以对连接池相关的属性进行设置,通过修改最大连接等参数,实现对请求负载的控制。还可以对负载均衡策略进行设置,在轮询、随机、最少访问等方式之间进行切换。还能设置异常探测策略,将满足异常条件的实例从负载均衡池中摘除,以保证服务的稳定性。
Istio 的流量路由规则可以让您很容易的控制服务之间的流量和 API 调用。Istio 在服务层面提供了断路器,超时,重试等功能,通过这些功能可以简单地实现 A/B 测试,金丝雀发布,基于百分比的流量分割等,此外还提供了开箱即用的故障恢复功能,用于增加应用的健壮性,以应对服务故障或网络故障。这些功能都可以通过 Istio 的流量管理 API 添加流量配置来实现。
跟其他 Istio 配置一样,流量管理 API 也使用 CRD 指定。本小节主要介绍下面几个典型的流量管理 API 资源,以及这些 API 的功能和使用示例。
VirtualService 由一组 路由规则 组成,描述了 用户请求的目标地址 到 服务网格中实际工作负载 之间的映射。在这个映射中,VirtualService提供了丰富的配置方式,可以为发送到这些 Workloads 的流量指定不同的路由规则。对应于具体的配置,用户请求的目标地址用 hosts
字段来表示,网格内的实际负载由每个 route
配置项中的 destination
字段指定。
graph LRsubgraph VirtualServiceClientRequests -- DifferentTrafficRoutingRules --> DestinationWorkloadsHosts -- DifferentTrafficRoutingRules --> RouteDestinationend
VirtualService 通过解耦 用户请求的目标地址 和 真实响应请求的目标工作负载,为服务提供了合适的统一抽象层,而由此演化设计的配置模型为管理这方面提供了一致的环境。对于原生 Kubernetes 而言,只有在 Ingress 处有这种路由规则的定义,对于集群内部不同Service的不同版本之间,并没有类似 VirtualService 的定义。
使用 VirtualService,可以为一个或多个主机名指定流量行为。在 VirtualService 中使用路由规则,告诉 Envoy如何发送 VirtualService 的流量到适当的目标。路由目标可以是相同服务的不同版本,或者是完全不同的服务。
一个典型的应用场景是将流量发送到被指定为服务子集的服务的不同版本。客户端将 VirtualService 视为一个单一实体,将请求发送至 VirtualService 主机,然后 Envoy 根据 VirtualService 规则把流量路由到不同的版本中。
这种方式可以方便地创建一种金丝雀的发布策略实现新版本流量的平滑比重升级。流量路由完全独立于实例部署,这意味着实现新版本服务的实例可以根据流量的负载来伸缩,完全不影响流量路由。相比之下,类似 Kubernetes 的容器调度平台仅支持基于部署中实例扩缩容比重的流量分发,那样会日趋复杂化。关于使用VirtualService实现金丝雀部署,可以参考 Canary 。
VirtualService 也提供了如下功能。
monolith.com
的 URLs 跳转至 microservice A
中”。在一些应用场景中,由于指定服务子集,需要配置 DestinationRule 来使用这些功能。在不同的对象中指定服务子集以及其他特定的目标策略可以帮助您在不同的 VirtualService 中清晰地复用这些功能。
下面的 VirtualService 根据是否来自于特定用户路由请求到不同的服务版本中(如果请求来自用户 jason
,则访问 v2
版本的 reviews
,否则访问 v3
版本):
1 | apiVersion: networking.istio.io/v1alpha3 |
下面对这些字段依次解释:
用来配置 Downstream 访问的可寻址地址,也就是用户请求的目标地址。
1 | hosts: |
*
前缀,创建一组匹配所有服务的路由规则hosts
实际上不必是 Istio 服务注册的一部分,它只是虚拟的目标地址。这可以为没有路由到网格内部的虚拟主机建模。http
字段用来配置路由规则,通常情况下配置一组路由规则,当请求到来时,自上而下依次进行匹配,直到匹配成功后跳出匹配。它可以对请求的 uri、method、authority、headers、port、queryParams 以及是否对 uri 大小写敏感等进行配置。
1 | http: |
我们推荐在每个 VirtualService 中配置一条默认「无条件的」或者基于权重的规则以确保 VirtualService 至少有一条匹配的路由。
路由片段的 destination
字段指定符合匹配条件的流量目标地址。这里不像 VirtualService 的 hosts
,Destination 的 host
必须是存在于 Istio 服务注册中心的实际目标地址,否则 Envoy 不知道该将请求发送到哪里。这个目标地址可以是代理的网格服务或者作为服务入口加入的非网格服务。下面的场景中我们运行在 Kubernetes 平台上,主机名是 Kubernetes 的服务名。
1 | route: |
1 *Note for Kubernetes users*: When short names are used (e.g. "reviews" instead of "reviews.default.svc.cluster.local"), Istio will interpret the short name based on the namespace of the rule, not the service. A rule in the "default" namespace containing a host "reviews will be interpreted as "reviews.default.svc.cluster.local", irrespective of the actual namespace associated with the reviews service. To avoid potential misconfiguration, it is recommended to always use fully qualified domain names over short names.
路由规则是将特定流量子集路由到特定目标地址的强大工具。可以在流量端口、header
字段、 URL 等内容上设置匹配条件。例如,下面的VirtualService 使用户发送流量到两个独立的服务,ratings and reviews, 就好像它们是 http://bookinfo.com/
这个更大的 VirtualService 的一部分。VirtualService 规则根据请求的 URL 和指向适当服务的请求匹配流量。
1 | apiVersion: networking.istio.io/v1alpha3 |
对于匹配条件,您可以使用确定的值,一条前缀、或者一条正则表达式。
您可以使用 AND
向同一个 match
块添加多个匹配条件, 或者使用 OR
向同一个规则添加多个 match
块。对于任意给定的 VirtualService ,您可以配置多条路由规则。这可以使您的路由条件在一个单独的 VirtualService 中基于业务场景的复杂度来进行相应的配置。可以在 HTTPMatchRequest 参考中查看匹配条件字段和他们可能的值。
再者进一步使用匹配条件,您可以使用基于“权重”百分比分发流量。这在 A/B 测试和金丝雀部署中非常有用。
1 | spec: |
您也可以使用路由规则在流量上执行一些操作,例如
headers
DestinationRule
是 Istio 流量路由功能的重要组成部分。一个 VirtualService
可以看作是如何将流量分发到给定的目标地址,然后调用 DestinationRule
来配置分发到该目标地址的流量。DestinationRule
在 VirtualService
的路由规则之后起作用(即在 VirtualService
的 match
-> route
-> destination
之后起作用,此时流量已经分发到真实的 Service
上),应用于真实的目标地址。
特别地,可以使用 DestinationRule
来指定命名的服务子集,例如根据版本对服务的实例进行分组,然后通过 VirtualService
的路由规则中的服务子集将控制流量分发到不同服务的实例中。
DestinationRule
允许在调用完整的目标服务或特定的服务子集(如倾向使用的负载均衡模型,TLS 安全模型或断路器)时自定义 Envoy流量策略。Istio 默认会使用轮询策略,此外 Istio 也支持如下负载均衡模型,可以在 DestinationRule
中使用这些模型,将请求分发到特定的服务或服务子集。
下面的 DestinationRule
使用不同的负载均衡策略为 my-svc 目的服务配置了3个不同的 Subset
1 | apiVersion: networking.istio.io/v1alpha3 |
每个子集由一个或多个 labels
定义,对应 Kubernetes 中的对象(如 Pod
)的 key/value 对。这些标签定义在 Kubernetes 服务的 deployment 的 metadata 中,用于标识不同的版本。
除了定义子集外,DestinationRule
还定义了该目的地中所有子集的默认流量策略,以及仅覆盖该子集的特定策略。默认的策略定义在 subset
字段之上,为 v1
和 v3
子集设置了随机负载均衡策略,在 v2
策略中使用了轮询负载均衡。
Gateway 用于管理进出网格的流量,指定可以进入或离开网格的流量。Gateway 配置应用于网格边缘的独立的 Envoy代理上,而不是服务负载的 Envoy 代理上。
与其他控制进入系统的流量的机制(如 Kubernetes Ingress API)不同,Istio gateway 允许利用 Istio 的流量路由的强大功能和灵活性。Istio 的 gateway 资源仅允许配置 4-6 层的负载属性,如暴露的端口,TLS 配置等等,但结合 Istio 的 VirtualService
,就可以像管理 Istio 网格中的其他数据面流量一样管理 Gateway 的流量。
Gateway 主要用于管理 Ingress 流量,但也可以配置 Egress Gateway。通过 Egress Gateway 可以配置流量离开网格的特定节点,限制哪些服务可以访问外部网络,或通过 Egress 安全控制来提高网格的安全性。Gateway 可以用于配置为一个纯粹的内部代理。
Istio (通过 istio-ingressgateway
和 istio-egressgateway
参数)提供了一些预配置的 Gateway 代理,default
profile 下仅会部署 Ingress Gateway。Gateway 可以通过部署文件进行部署,也可以单独部署。
下面是 default
profile 默认安装的 Ingress
1 | $ kubectl get gw |
可以看到该 ingress 就是一个普通的 Pod
,该 Pod
仅包含一个 Istio-proxy 容器
1 | $ kubectl get pod -n istio-system |grep ingress |
下面是一个 Gateway 的例子,用于配置外部 HTTPS 的 ingress 流量:
1 | apiVersion: networking.istio.io/v1alpha3 |
上述 Gateway 配置允许来自 ext-host.example.com
流量进入网格的 443 端口,但没有指定该流量的路由。(此时流量只能进入网格,但没有指定处理该流量的服务,因此需要与 VirtualService
进行绑定)
为了为 Gateway 指定路由,需要通过 VirtualService
的 Gateway
字段,将 Gateway
绑定到一个 VirtualService
上,将来自 ext-host.example.com
流量引入一个 VirtualService
,hosts
可以是通配符,表示引入匹配到的流量。
1 | apiVersion: networking.istio.io/v1alpha3 |
Egress Gateway 提供了对网格的出口流量进行统一管控的功能,在安装 Istio 时默认是不开启的。可以使用以下命令查看是否开启。
1 | kubectl get pod -l istio=egressgateway -n istio-system |
若没有开启,使用以下命令添加。
1 | $ istioctl manifest apply --set values.global.istioNamespace=istio-system \ |
Egress Gateway 的一个简单示例如下:
1 | apiVersion: networking.istio.io/v1alpha3 |
可以看出,与 Ingress Gateway 不同,Egress Gateway 使用有 istio: egressgateway
标签的 Pod 来代理流量,实际上这也是一个 Envoy 代理。当网格内部需要访问 edition.cnn.com
这个地址时,流量将会统一先转发到 Egress Gateway 上,再由 Egress Gateway 将流量转发到 edition.cnn.com
上。
Istio 支持对接 Kubernetes、Consul 等多种不同的注册中心,控制平面Pilot
启动时,会从指定的注册中心获取 Service Mesh
集群的服务信息和实例列表,并将这些信息进行处理和转换,然后通过 xDS 下发给对应的数据平面,保证服务之间可以互相发现并正常访问。
同时,由于这些服务和实例信息都来源于服务网格内部,Istio 无法从注册中心直接获取网格外的服务,导致不利于网格内部与外部服务之间的通信和流量管理。为此,Istio 引入 ServiceEntry 实现对外通信和管理。
使用 ServiceEntry 可以将外部的服务条目添加到 Istio 内部的服务注册表中,以便让网格中的服务能够访问并路由到这些手动指定的服务。ServiceEntry 描述了服务的属性(DNS 名称、VIP、端口、协议、端点)。这些服务可能是位于网格外部(如,web APIs),也可能是处于网格内部但不属于平台服务注册表中的条目(如,需要和 Kubernetes 服务交互的一组虚拟机服务)。
对于网格外部的服务,下面的 ServiceEntry 示例表示网格内部的应用通过 https 访问外部的 API。
1 | apiVersion: networking.istio.io/v1alpha3 |
对于在网格内部但不属于平台服务注册表的服务,使用下面的示例可以将一组在非托管 VM 上运行的 MongoDB 实例添加到 Istio 的注册中心,以便可以将这些服务视为网格中的任何其他服务。
1 | apiVersion: networking.istio.io/v1alpha3 |
结合上面给出的示例,这里对 ServiceEntry 涉及的关键属性解释如下:
hosts
: 表示与该 ServiceEntry 相关的主机名,可以是带有通配符前缀的 DNS 名称。address
: 与服务相关的虚拟 IP 地址,可以是 CIDR 前缀的形式。ports
: 和外部服务相关的端口,如果外部服务的 endpoints 是 Unix socket 地址,这里必须只有一个端口。location
: 用于指定该服务属于网格内部(MESH_INTERNAL)还是外部(MESH_EXTERNAL)。resolution
: 主机的服务发现模式,可以是 NONE、STATIC、DNS。endpoints
: 与服务相关的一个或多个端点。exportTo
: 用于控制 ServiceEntry 跨命名空间的可见性,这样就可以控制在一个命名空间下定义的资源对象是否可以被其他命名空间下的 Sidecar
、Gateway 和 VirtualService 使用。目前支持两种选项,”.” 表示仅应用到当前命名空间,”*” 表示应用到所有命名空间。Istio 提供了三种访问外部服务的方法:
Sidecar
将请求传递到未在网格内配置过的任何外部服务。使用这种方法时,无法监控对外部服务的访问,也不能利用 Istio 的流量控制功能。Sidecar
。仅当出于性能或其他原因无法使用 Sidecar
配置外部访问时,才建议使用该配置方法。这里,我们重点讨论第 2 种方式,也就是使用 ServiceEntry 完成对网格外部服务的受控访问。
对于 Sidecar
对外部服务的处理方式,Istio 提供了两种选项:
ALLOW_ANY
:默认值,表示 Istio 代理允许调用未知的外部服务。上面的第一种方法就使用了该配置项。REGISTRY_ONLY
:Istio 代理会阻止任何没有在网格中定义的 HTTP 服务或 ServiceEntry 的主机。可以使用下面的命令查看当前所使用的模式:
1 | $ kubectl get configmap istio -n istio-system -o yaml | grep -o "mode: ALLOW_ANY" |
如果当前使用的是 ALLOW_ANY
模式,可以使用下面的命令切换为 REGISTRY_ONLY
模式:
1 | $ kubectl get configmap istio -n istio-system -o yaml | sed 's/mode: ALLOW_ANY/mode: REGISTRY_ONLY/g' | kubectl replace -n istio-system -f - |
在 REGISTRY_ONLY
模式下,需要使用 ServiceEntry 才能完成对外部服务的访问。当创建如下的 ServiceEntry 时,服务网格内部的应用就可以正常访问 httpbin.org 服务了。
1 | apiVersion: networking.istio.io/v1alpha3 |
使用 ServiceEntry 可以使网格内部服务发现并访问外部服务,除此之外,还可以对这些到外部服务的流量进行管理。结合 VirtualService 为对应的 ServiceEntry 配置外部服务访问规则,如请求超时、故障注入等,实现对指定服务的受控访问。
下面的示例就是为外部服务 httpbin.org 设置了超时时间,当请求时间超过 3s 时,请求就会直接中断,避免因外部服务访问时延过高而影响内部服务的正常运行。由于外部服务的稳定性通常无法管控和监测,这种超时机制对内部服务的正常运行具有重要意义。
1 | apiVersion: networking.istio.io/v1alpha3 |
同样的,我们也可以为 ServiceEntry 设置故障注入规则,为系统测试提供基础。下面的示例表示为所有访问 httpbin.org
服务的请求注入一个403错误。
1 | apiVersion: networking.istio.io/v1alpha3 |
在默认的情况下,Istio 中所有 Pod 中的 Envoy 代理都是可以被寻址的。然而在某些场景下,我们为了做资源隔离,希望只访问某些 Namespace 下的资源。这个时候,我们就可以使用 Sidecar配置来实现。下面是一个简单的示例:
1 | apiVersion: networking.istio.io/v1alpha3 |
该示例就规定了在命名空间为 bookinfo 下的所有服务仅可以访问本命名空间下的服务以及 istio-system
命名空间下的服务。
除了最核心的路由和流量转移功能外,Istio 还提供了一定的弹性功能,目前支持超时、重试和熔断。
如果程序请求长时间无法返回结果,则需要设置超时机制,超过设置的时间则返回错误信息。这样做既可以节约等待时消耗的资源,也可以避免由于级联错误引起的一系列问题。
设置超时的方式也有很多种,比如通过修改代码在应用程序侧设置请求超时时间,但是这样很不灵活,也容易出现遗漏的现象,而 Istio 则可以在基础设施层解决这一问题。在 Istio 里添加超时非常简单,只需要在路由配置里添加 timeout
这个关键字就可以实现。
1 | apiVersion: networking.istio.io/v1alpha3 |
在网络环境不稳定的情况下,会出现暂时的网络不可达现象,这时需要重试机制,通过多次尝试来获取正确的返回信息。重试逻辑可以写业务代码中,比如 Bookinfo 应用中的productpage
服务就存在硬编码重试,而 Istio 可以通过简单的配置来实现重试功能,让开发人员无需关注重试部分的代码实现,专心实现业务代码。在 Istio 里添加超时和重试都非常简单,只需要在路由配置里添 retry
这个关键字就可以实现。
1 | apiVersion: networking.istio.io/v1alpha3 |
熔断是一种非常有用的过载保护手段,可以避免服务的级联失败。在熔断器中,设置一个对服务中的单个主机调用的限制,例如并发连接的数量或对该主机调用失败的次数。一旦限制被触发,熔断器就会“跳闸”并停止连接到该主机。使用熔断模式可以快速失败而不必让客户端尝试连接到过载或有故障的主机。熔断适用于在负载均衡池中的“真实”网格目标地址,可以在 DestinationRule 中配置熔断器阈值,让配置适用于服务中的每个主机。
Istio 里面的熔断需要在自定义资源 DestinationRule
的 TrafficPolicy
里进行设置。下面的示例将 v1 子集的reviews
服务工作负载的并发连接数限制为 100:
1 | apiVersion: networking.istio.io/v1alpha3 |
Istio 还提供了对流量进行调试的能力,包括故障注入和流量镜像。对流量进行调试可以让系统具有更好的容错能力,也方便我们在问题排查时通过调试来快速定位原因所在。
在一个微服务架构的系统中,为了让系统达到较高的健壮性要求,通常需要对系统做定向错误测试。比如电商中的订单系统、支付系统等若出现故障那将是非常严重的生产事故,因此必须在系统设计前期就需要考虑多样性的异常故障并对每一种异常设计完善的恢复策略或优雅的回退策略,尽全力规避类似事故的发生,使得当系统发生故障时依然可以正常运作。而在这个过程中,服务故障模拟一直以来是一个非常繁杂的工作,于是在这样的背景下就衍生出了故障注入技术手段,故障注入是用来模拟上游服务请求响应异常行为的一种手段。通过人为模拟上游服务请求的一些故障信息来检测下游服务的故障策略是否能够承受这些故障并进行自我恢复。
Istio 提供了一种无侵入式的故障注入机制,让开发测试人员在不用调整服务程序的前提下,通过配置即可完成对服务的异常模拟。Istio 1.5 仅支持网络层的故障模拟,即支持模拟上游服务的处理时长、服务异常状态、自定义响应状态码等故障信息,暂不支持对于服务主机内存、CPU 等信息故障的模拟。他们都是通过配置上游主机的 VirtualService 来实现的。当我们在 VirtualService 中配置了故障注入时,上游服务的 Envoy代理在拦截到请求之后就会做出相应的响应。
目前,Istio 提供两种类型的故障注入,abort 类型与 delay 类型。
实际上,Istio 的故障注入正是基于 Envoy的 config.filter.http.fault.v2.HTTPFault 过滤器实现的,它的局限性也来自于 Envoy故障注入机制的局限性。对于 Envoy的 HttpFault 的详细介绍请参考 Envoy 文档。对比 Istio 故障注入的配置项与 Envoy故障注入的配置项,不难发现,Istio 简化了对于故障控制的手段,去掉了 Envoy中通过 HTTP header 控制故障注入的配置。
如下的配置表示对 v1
版本的 ratings.prod.svc.cluster.local
服务访问的时候进行故障注入,0.1
表示有千分之一的请求被注入故障, 400
表示故障为该请求的 HTTP 响应码为 400
。
1 | apiVersion: networking.istio.io/v1alpha3 |
1h/1m/1s/1ms
, 不能小于 1ms
。percentage
配置功能一样,已经被 percentage
代替。如下的配置表示对 v1
版本的 reviews.prod.svc.cluster.local
服务访问的时候进行延时故障注入,0.1
表示有千分之一的请求被注入故障,5s
表示reviews.prod.svc.cluster.local
延时 5s
返回。
1 | apiVersion: networking.istio.io/v1alpha3 |
流量镜像(Mirroring / traffic-shadow),也叫作影子流量,就是通过复制一份请求并把它发送到镜像服务,从而实现流量的复制功能。流量镜像的主要应用场景有以下几种:最主要的就是进行线上问题排查。
一般情况下,因为系统环境,特别是数据环境、用户使用习惯等问题,我们很难在开发环境中模拟出真实的生产环境中出现的棘手问题,同时生产环境也不能记录太过详细的日志,因此很难定位到问题。有了流量镜像,我们就可以把真实的请求发送到镜像服务,再打开 debug 日志来查看详细的信息。除此以外,还可以通过它来观察生产环境的请求处理能力,比如在镜像服务进行压力测试。也可以将复制的请求信息用于数据分析。流量镜像在 Istio 里实现起来也非常简单,只需要在路由配置中通添加mirror
关键字即可。
很多情况下,当我们对服务做了重构,或者我们对项目做了重大优化时,怎么样保证服务是健壮的呢?在传统的服务里,我们只能通过大量的测试,模拟在各种情况下服务的响应情况。虽然也有手工测试、自动化测试、压力测试等一系列手段去检测它,但是测试本身就是一个样本化的行为,即使测试人员再完善它的测试样例,无法全面的表现出线上服务的一个真实流量形态。往往当项目发布之后,总会出现一些意外,比如你服务里收到客户使用的某些数据库不认识的特殊符号,再比如用户在本该输入日期的输入框中输入了 “—” 字样的字符,又比如用户使用乱码替换你的 token 值批量恶意攻击服务等等,这样的情况屡见不鲜。数据的多样性,复杂性决定了开发人员在开发阶段根本是无法考虑周全的。
而流量镜像的设计,让这类问题得到了最大限度的解决。流量镜像讲究的不再是使用少量样本去评估一个服务的健壮性,而是在不影响线上坏境的前提下将线上流量持续的镜像到我们的预发布坏境中去,让重构后的服务在上线之前就结结实实地接受一波真实流量的冲击与考验,让所有的风险全部暴露在上线前夕,通过不断的暴露问题,解决问题让服务在上线前夕就拥有跟线上服务一样的健壮性。由于测试坏境使用的是真实流量,所以不管从流量的多样性,真实性,还是复杂性上都将能够得以展现,同时预发布服务也将表现出其最真实的处理能力和对异常的处理能力。运用这种模式,一方面,我们将不会再跟以前一样在发布服务前夕内心始终忐忑不安,只能祈祷上线之后不会出现问题。另一方面,当大量的流量流入重构服务之后,开发过程中难以评估的性能问题也将完完整整的暴露出来,此时开发人员将会考虑它服务的性能,测试人员将会更加完善他们的测试样例。通过暴露问题,解决问题,再暴露问题,再解决问题的方式循序渐进地完善预发布服务来增加我们上线的成功率。同时也变相的促进我们开发测试人员技能水平的提高。
当然,流量镜像的作用不仅仅只是解决上面这样的场景问题,我们可以根据它的特性,解决更多的问题。比如,假如我们在上线后突然发现一个线上问题,而这个问题在测试坏境中始终不能复现。那么这个时候我们就能利用它将异常流量镜像到一个分支服务中去,然后我们可以随意在这个分支服务上进行分析调试,这里所说的分支服务,可以是原服务的只用于问题分析而不处理正式业务的副本服务,也可以是一个只收集镜像流量的组件类服务。又比如突然需要收集某个时间段某些流量的特征数据做分析,像这种临时性的需求,使用流量镜像来处理非常合适,既不影响线上服务的正常运转,也达到了收集分析的目的。
实际上在 Istio 中,服务间的通讯都是被 Envoy代理拦截并处理的, Istio 流量镜像的设计也是基于 Envoy特性实现的。它的流量转发如下图所示。可以看到,当流量进入到Service A
时,因为在Service A
的 Envoy代理上配置了流量镜像规则,那么它首先会将原始流量转发到v1
版本的 Service B
服务子集中去 。同时也会将相同的流量复制一份,异步地发送给v2
版本的Service B
服务子集中去,可以明显的看到,Service A
发送完镜像流量之后并不关心它的响应情况。
在很多情况下,我们需要将真实的流量数据与镜像流量数据进行收集并分析,那么当我们收集完成后应该怎样区分哪些是真实流量,哪些是镜像流量呢? 实际上,Envoy团队早就考虑到了这样的场景,他们为了区分镜像流量与真实流量,在镜像流量中修改了请求标头中 host
值来标识,它的修改规则是:在原始流量请求标头中的 host
属性值拼接上“-shadow”
字样作为镜像流量的 host
请求标头。
为了能够更清晰的对比出原始流量与镜像流量的区别,我们使用以下的一个示例来说明:
如下图所示,我们发起一个http://istio.gateway.xxxx.tech/serviceB/request/info
的请求,请求首先进入了istio-ingressgateway
,它是一个 Istio 的 Gateway 资源类型的服务,它本身就是一个 Envoy代理。在这个例子里,就是它对流量进行了镜像处理。可以看到,它将流量转发给v1
版本Service B
服务子集的同时也复制了一份流量发送到了v2
版本的Service B
服务子集中去。
在上面的请求链中,请求标头数据有什么变化呢?下图收集了它们请求标头中的所有信息,可以明显的对比出正式流量与镜像流量请求标头中host
属性的区别(部分相同的属性值过长,这里只截取了前半段)。从图中我们可以看出,首先就是host属性值的不同,而区别就是多了一个“-shadow”
的后缀。再者发现x-forwarded-for
属性也不相同,x-forwarded-for
协议头的格式是:x-forwarded-for: client1, proxy1, proxy2
, 当流量经过 Envoy代理时这个协议头将会把代理服务的 IP 添加进去。实例中10.10.2.151
是我们云主机的 IP,而10.10.2.121
是isito-ingressgateway
所对应Pod
的 IP 。从这里也能看到,镜像流量是由istio-ingressgatway
发起的。除了这两个请求标头的不同,其他配置项是完全一样的。
上面我们介绍了流量镜像的原理及使用场景,接下来我们再介绍下流量的镜像如何配置才能生效。在 Istio 架构里,镜像流量是借助于 VirtualService 这个资源中的 HTTPRoute
配置项的mirror
与mirrorPercent
这两项子配置项来实现的,这两个配置项的定义也是非常的简单。
0~100
,如果配置成0
则表示不发送镜像流量。下面的例子就是我们在示例中使用到的Service B
的镜像流量配置,其中,mirror.host
配置项是配置一个域名或者在Istio 注册表中注册过的服务名称,可以看到,该配置指定了镜像流量需要发送的目标服务地址为serviceB
。mirror.subset
配置项配置一个Service B
服务的服务子集名称 ,指定了要将镜像流量镜像到v2
版本的Service B
服务子集中去。mirror_percent
配置将100%
的真实流量进行镜像发送。所以下面的配置整体表示当流量到来时,将请求转发到v1
版本的service B
服务子集中,再以镜像的方式发送到v2
版本的service B
服务上一份,并将真实流量全部镜像。
1 | apiVersion: networking.istio.io/v1alpha3 |
service B
服务对应的 DestinationRule 配置如下 :
1 | apiVersion: networking.istio.io/v1alpha3 |
Istio 是一个完全开源的服务网格,以透明的方式构建在现有的分布式应用中。它也是一个平台,拥有可以集成任何日志、遥测和策略系统的 API 接口。Istio 多样化的特性使你能够成功且高效地运行分布式微服务架构,并提供保护、连接和监控微服务的统一方法。
微服务应用最大的痛点就是处理服务间的通信,而这一问题的核心其实就是流量管理。首先我们来看看传统的微服务应用在没有 Service Mesh 介入的情况下,是如何完成诸如金丝雀发布这样的路由功能的。我们假设不借助任何现成的第三方框架,一个最简单的实现方法,就是在服务间添加一个负载均衡(比如 Nginx)做代理,通过修改配置的权重来分配流量。这种方式使得对流量的管理和基础设施绑定在了一起,难以维护。
而使用 Istio 就可以轻松的实现各种维度的流量控制。下图是典型的金丝雀发布策略:根据权重把 5% 的流量路由给新版本,如果服务正常,再逐渐转移更多的流量到新版本。
Istio 中的流量控制功能主要分为三个方面:
关于流量控制的更多内容,参考 Istio流量控制
安全对于微服务这样的分布式系统来说至关重要。与单体应用在进程内进行通信不同,网络成为了服务间通信的纽带,这使得它对安全有了更迫切的需求。比如为了抵御外来攻击,我们需要对流量进行加密;为保证服务间通信的可靠性,需要使用mTLS的方式进行交互;为控制不同身份的访问,需要设置不同粒度的授权策略。作为一个服务网格,Istio 提供了一整套完整的安全解决方案。它可以以透明的方式,为我们的微服务应用添加安全策略。
Istio 中的安全架构是由多个组件协同完成的。Citadel 是负责安全的主要组件,用于密钥和证书的管理;Pilot 会将安全策略配置分发给 Envoy 代理;Envoy 执行安全策略来实现访问控制。下图展示了 Istio 的安全架构和运作流程。
关于安全管理的更多内容,参考 Istio安全管理
面对复杂的应用环境和不断扩展的业务需求,即使再完备的测试也难以覆盖所有场景,无法保证服务不会出现故障。正因为如此,才需要“可观察性”来对服务的运行时状态进行监控、上报、分析,以提高服务可靠性。具有可观察性的系统,可以在服务出现故障时大大降低问题定位的难度,甚至可以在出现问题之前及时发现问题以降低风险。具体来说,可观察性可以:
而在微服务治理之中,随着服务数量大大增加,服务拓扑不断复杂化,可观察性更是至关重要。Istio 自然也不可能缺少对可观察性的支持。它会为所有的服务间通信生成详细的遥测数据,使得网格中每个服务请求都可以被观察和跟踪。开发人员可以凭此定位故障,维护和优化相关服务。而且,这一特性的引入无需侵入被观察的服务。
Istio 一共提供了三种不同类型的数据从不同的角度支撑起其可观察性:
关于可观测行的更多内容,参考 Istio可观测性
Istio的架构由控制平面和数据平面两个部分组成。
控制平面是 Istio 在原有服务网格产品上,首次提出的架构,实现了对于数据平面的统一管理。
Pilot
组件的主要功能是将路由规则等配置信息转换为 sidecar 可以识别的信息,并下发给数据平面。可以把它简单的理解为是一个配置分发器(dispatcher),并辅助 sidecar 完成流量控制相关的功能。它管理sidecar代理之间的路由流量规则,并配置故障恢复功能,如超时、重试和熔断。
上图显示了Pilot的基本架构,它主要由以下几个部分组成:
为了实现对不同服务注册中心 (Kubernetes、consul) 的支持,Pilot 需要对不同的输入来源的数据有一个统一的存储格式,也就是抽象模型。抽象模型中定义的关键成员包括 HostName(Service名称)、Ports(Service端口)、Address(Service ClusterIP)、Resolution (负载均衡策略) 等。
借助平台适配器 Pilot 可以实现服务注册中心数据到抽象模型之间的数据转换。例如 Pilot 中的 Kubernetes 适配器通过 Kubernetes API 服务器得到 Kubernetes 中 Service 和 Pod 的相关信息,然后翻译为抽象模型提供给 Pilot 使用。通过平台适配器模式,Pilot 还可以从 Consul 等平台中获取服务信息,还可以开发适配器将其他提供服务发现的组件集成到 Pilot 中。
Pilot 使用了一套起源于 Envoy 项目的标准数据面 API 来将服务信息和流量规则下发到数据面的 sidecar 中。这套标准数据面 API,也叫 xDS。Sidecar 通过 xDS API 可以动态获取 Listener (监听器)、Route (路由)、Cluster (集群)及 Endpoint (集群成员)配置:
Pilot 还定义了一套用户 API, 用户 API 提供了面向业务的高层抽象,可以被运维人员理解和使用。
运维人员使用该 API 定义流量规则并下发到 Pilot,这些规则被 Pilot 翻译成数据面的配置,再通过标准数据面 API 分发到 sidecar 实例,可以在运行期对微服务的流量进行控制和调整。
通过运用不同的流量规则,可以对网格中微服务进行精细化的流量控制,如按版本分流、断路器、故障注入、灰度发布等。
关于 Pilot 的具体实现,可以参考 Istio Pilot 模块分析
Citadel
是 Istio 中专门负责安全的组件,内置有身份和证书管理功能,可以实现较为强大的授权和认证等操作,在1.5 版本之后取消了独立进程,作为一个模块被整合在 istiod 中。
总体来说,Istio 在安全架构方面主要包括以下内容:
Istio 的身份标识模型使用一级服务标识来确定请求的来源,它可以灵活的标识终端用户、工作负载等。在平台层面,Istio 可以使用类似于服务名称来标识身份,或直接使用平台提供的服务标识。比如 Kubernetes 的 ServiceAccount,AWS IAM 用户、角色账户等。
在身份和证书管理方面,Istio 使用 X.509 证书,并支持密钥和证书的自动轮换。从 1.1 版本开始,Istio 开始支持安全发现服务器(SDS),随着不断的完善和增强,1.5 版本 SDS 已经成为默认开启的组件。Citadel 以前有两个功能:将证书以 Secret 的方式挂载到命名空间里;通过 SDS gRPC 接口与 nodeagent(已废弃)通信。目前 Citadel 只需要完成与 SDS 相关的工作,其他功能被移动到了 istiod 中。
关于Citadel的更多内容,参考 Istio安全管理
Galley
是 Istio 1.1 版本中新增加的组件,其目的是将 Pilot
和底层平台(如 Kubernetes)进行解耦。它分担了原本 Pilot
的一部分功能,主要负责配置的验证、提取和处理等功能。
Istio 数据平面核心是以 sidecar 模式运行的智能代理。Sidecar 模式将数据平面核心组件部署到单独的流程或容器中,以提供隔离和封装。Sidecar 应用与父应用程序共享相同的生命周期,与父应用程序一起创建和退出。Sidecar 应用附加到父应用程序,并为应用程序提供额外的特性支持。
如下图所示,数据平面的 sidecar 代理可以调节和控制微服务之间所有的网络通信,每个服务 Pod 启动时会伴随启动 istio-init
和 proxy 容器。
istio-init
容器主要功能是初始化 Pod 网络和对 Pod设置 iptable 规则,设置完成后自动结束。istio-agent
以及网络代理组件istio-agent
的作用是同步管理数据,启动并管理网络代理服务进程,上报遥测数据数据平面真正触及到对网络数据包的相关操作,是上层控制平面策略的具体执行者。
Envoy 是 Istio 中默认的数据平面 Sidecar 代理,关于 Sidecar 是如何实现自动注入和流量劫持,以及Sidecar的流量路由机制如何实现,更多可参考 Envoy系列文章 。
这里介绍在 Kubernetes 环境下安装 Istio,在开始之前,你需要有一个 Kubernetes 运行环境。
从 Istio v1.7 版本开始,Istio官方推荐使用 istioctl 安装。下面是安装步骤:
1 | $ curl -L https://raw.githubusercontent.com/istio/istio/release-1.7/release/downloadIstioCandidate.sh | sh - |
安装目录内容:
目录 | 包含内容 |
---|---|
bin | 包含 istioctl 的客户端文件 |
manifests | 包含 各种部署的 manifests |
samples | 包含示例应用程序 |
tools | 包含用于性能测试和在本地机器上进行测试的脚本 |
istioctl
客户端路径加入 $PATH
中,从而可以使用 istioctl 命令行工具1 | $ export PATH=$PATH:$(pwd)/bin |
demo
配置1 | $ istioctl install --set profile=demo |
1 | $ kubectl label namespace default istio-injection=enabled |
Bookinfo 是 Istio 社区官方推荐的示例应用之一。它可以用来演示多种 Istio 的特性,并且它是一个异构的微服务应用。该应用由四个单独的微服务构成。 这个应用模仿了在线书店,可以展示书店中书籍的信息。例如页面上会显示一本书的描述,书籍的细节( ISBN、页数等),以及关于这本书的一些评论。
Bookinfo 应用分为四个单独的微服务, 这些服务对 Istio 并无依赖,但是构成了一个有代表性的服务网格的例子:它由多个不同语言编写的服务构成,并且其中有一个应用会包含多个版本。
productpage
会调用 details
和 reviews
两个微服务,用来生成页面。details
中包含了书籍的信息。reviews
中包含了书籍相关的评论。它还会调用 ratings
微服务。ratings
中包含了由书籍评价组成的评级信息。reviews
微服务有 3 个版本,可用来展示各服务之间的不同的调用链路:
ratings
服务。ratings
服务,并使用 1 到 5 个黑色星形图标来显示评分信息。ratings
服务,并使用 1 到 5 个红色星形图标来显示评分信息。下图展示了这个应用的端到端架构:
1 | $ kubectl apply -f samples/bookinfo/platform/kube/bookinfo.yaml |
1 | $ kubectl get svc |
1 | $ kubectl exec "$(kubectl get pod -l app=ratings -o jsonpath='{.items[0].metadata.name}')" -c ratings -- curl -s productpage:9080/productpage | grep -o "<title>.*</title>" |
到现在,Bookinfo 应用已经成功部署,我们在集群内部也已经可以访问,但是在集群外部还不能够访问。为了使得外部能够访问应用程序,我们需要创建一个Istio Ingress Gateway。
1 | $ kubectl apply -f samples/bookinfo/networking/bookinfo-gateway.yaml |
1 | $ istioctl analyze |
通过下面的命令来设置 INGRESS_HOST
和 INGRESS_PORT
环境变量。
1 | kubectl get svc istio-ingressgateway -n istio-system |
这里显示 EXTERNAL_IP
已经变设置,表明当前环境下有一个可以使用的外部负载均衡器。
1 | $ export INGRESS_HOST=$(kubectl -n istio-system get service istio-ingressgateway -o jsonpath='{.status.loadBalancer.ingress[0].ip}') |
1 | $ export GATEWAY_URL=$INGRESS_HOST:$INGRESS_PORT |
http://<GATE_WAYURL>/productpage
来访问Bookinfo应用Istio集成了 一些 遥测应用,他们可以帮助你对你的服务网格有直观的认识、展示网格的拓扑、分析网格的健康状态
1 | $ kubectl apply -f samples/addons |
官方教程指示使用 istioctl dashboard kiali
命令来打开浏览器访问 Kiali服务,但是我的 Kubernetes 集群在服务器上,这样显然不行,不要将 Kiali 服务暴露给外部。因为之前集群已经安装了 Traefik ,所以可以使用 Ingress来暴露。
1 | apiVersion: extensions/v1beta1 |
在命令行创建Ingress,打开浏览器访问 http://<NodeIP>:<TraefikWebNodePort>/kiali
即可访问Kiali
在左侧导航栏点击Graph,选择default的命名空间,可以看到 Bookinfo
应用中各个服务间的关系:
到此为止,你的Istio和相关的服务已经在集群中完好的部署,关于其具体功能演示,参照 Istio流量控制。
Service Mesh 是一个基础设施层,用于处理服务到服务间的网络通信。云原生应用有着复杂的服务拓扑,Service Mesh负责在这些网络拓扑中实现请求的可靠传递。在实践中,Service Mesh通常实现为一组轻量级的网络代理,它们与应用程序部署在一起,但是对应用保持透明。
本文作为 「Service Mesh」系列开篇,将理清 Service Mesh 的前世今生,通过对其概念与原理的理解,开始上手 Service Mesh的工作。与此同时,我们也会讨论 Service Mesh 在业界当前的应用现状,探讨其落地的难点与痛点。
随着行业需求的推动,互联网服务从最早的仅有少数几台的大型服务器演变到成百上千的小型服务,服务架构也从最早期的单体式(Monolithic)到分布式(Distributed),再到微服务(Microservices)、容器化(Containerization)、容器编排(Container Orchestration),最后到服务网格(Service Mesh)、无服务器(Serverless)。
总结分布式系统的演进过程,我们可以看到一种通用的发展规律:
接下来我们会回顾从早期TCP/IP协议栈的广泛应用,到微服务时代从容器编排到服务网格的演进过程,并再次体会上述规律。
从多台计算机开始通信以来,服务间通信是应用最为广泛的模式。以下图为例,ServiceA 和 ServiceB 可以是我们提供应用的服务端与客户端。在开发者开发这些服务的时候,需要借助底层的网络硬件和协议进行通信。这张图只是一个简化的师徒,省略了在代码操作的数据和通过线路发送接收的电信号之间转换的很多层。
更加具体一点,把底层的网络协议栈加入,我们会看到下图:
从上世纪50年代起,上述的模型就一直在使用。最开始,由于计算机系统规模相对较小,每个节点之间的链路协议都是经过专门设计和维护的。随着计算机规模的迅速扩大,很多个小的网络系统开始连接起来。在这个过程中,不同主机间如何找到彼此,跨网络间如何路由转发,如何实现流量控制等问题,成了摆在网络系统设计人员面前亟需解决的难题。
为了实现各个网络节点的路由转发,屏蔽链路层协议,人们发明了IP网络协议。然而,IP网络协议还不能够解决流量控制的问题。这里的流量控制,值得是防止一台服务器发送过多的数据包,超出下游服务器的处理能力。在最开始,编写网络服务和应用程序的开发者来负责处理上述流量控制的问题。这就意味着在编写应用程序过程中,网络处理的逻辑和应用自身的业务逻辑被耦合在一起,如下图所示。
然而,这种每个开发人员都要去考虑流量处理等传输层的问题太过复杂,程序开发的成本太高。随着技术的快速发展,流量处理和其他网络问题相关的解决方案被整合到网络协议栈,TCP/IP席卷了世界,成为互联网事实上的协议标准。流量控制等网络问题的代码仍在,但是你不再需要自己去开发与维护这段代码,而是直接调用系统提供的网络协议栈。
确定于上世界80年代的TCP/IP网络协议栈和通用的网络模型对于互联网的发展发挥了巨大的作用,极大了促进了互联网应用的繁荣。网络应用的功能逐渐复杂起来,人们把所有的组件都集中在一个应用当中,这即是单体应用 Monolithic
。单体应用基于相同技术栈开发、访问共享的数据库、共同部署运维和扩容。同时,组件之间的通信也趋于频繁和耦合,所有的交互都是以函数调用的形式来实现。
然而,随着互联网的迅猛发展,网络应用中需要添加越来越多的功能,应用的复杂度不断提升,参与软件开发的协作人数也越来越多,单体应用开始爆发出其固有局限性。在这种背景下,微服务的思潮降临,让软件开发重新变得小而美:
然而,微服务也不是银弹,在微服务落地的过程中,也产生了很多的问题,其中主要的问题就是服务间通信:
如何找到服务的提供⽅?
微服务通讯必须走远程过程调用(HTTP/REST本质上也属于RPC),当其中一个应用需要消费另一个应用的服务时,无法再像单体应用一样通过简单的进程内机制(e.g. Spring的依赖注入)就能获取到服务实例;你甚至都不知道有没有这个服务方。
如何保证远程调⽤的可靠性?
既然是RPC,那必然要走IP网络,而我们都知道网络(相比计算和存储)是软件世界里最不可靠的东西。虽然有TCP这种可靠传输协议,但频繁丢包、交换机故障甚至电缆被挖断也常有发生;即使网络是好的,如果对方机器宕机了,或者进程负载过高不响应呢?
如何降低服务调⽤的延迟?
网络不只是不可靠,还有延迟的问题。虽然相同系统内的微服务应用通常都部署在一起,同机房内调用延迟很小;但对于较复杂的业务链路,很可能一次业务访问就会包括数十次RPC调用,累积起来的延迟就很可观了。
如何保证服务调⽤的安全性?
网络不只是不可靠和有延迟,还是不安全的。互联网时代,你永远不知道屏幕对面坐的是人还是狗;同样,微服务间通讯时,如果直接走裸的通讯协议,你也永远不知道对端是否真的就是自己人,或者传输的机密信息是否有被中间人偷听。
就像历史总是会重演,为了解决上述微服务引入的问题,最早需要工程师独立去完成对应的服务,在业务逻辑中实现下列逻辑:
然而,随着分布式程度的增加,这些服务的复杂度也越来越高,一些问题不得不考虑:
为了解决重复造轮子的问题,集成了服务通信中各种问题的Library开始变得十分流行,包括 Apache Dubbo(手动置顶)、Spring Cloud、Netflix OSS、gRPC 等等。
这些可复用的类库和框架,确确实实带来了质量和效率上的大幅提升,但是也存在着下列问题:
像网络协议栈发展的过程一样,将大规模分布式服务所需要的功能剥离出来集成到底层平台是一个众望所归的选择。人们通过应用层的协议(例如HTTP)写出了很多复杂的应用程序和服务,甚至不用考虑TCP是如何控制数据包在网络上传输的。这就是我们微服务所需要的,从事服务开发的工程师们可以专注于业务逻辑的开发,避免浪费时间去编写服务基础设施代码或者管理这些库和框架。
在这个想法下,我们可以得到类似于如下的图:
不幸的是,更改协议栈来增加微服务的功能不是一个可行的方案,许多开发者是通过一组代理来实现此功能。这里的设计思想是服务不需要和下游服务直连,所有的流量都通过该代理透明的来实现对应的功能。这里的透明代理,通过一种叫做 Sidecar
的模式来运行,Sidecar将上述类库和框架要干的事情从应用中彻底剥离了出来,并统一下沉到了基础设施层,这其中的典型代表就是 Linkerd 和 Envoy。
在这种模型中,每个服务都会有一个配套的代理SideCar。考虑到服务之间的通信仅仅通过SideCar代理,我们最终得到如下的部署图:
Buoyant的CEO William Morgan ,发现了各个SideCar代理之间互联组成了一个网状网络,2017初,William为这个网状的平台起了一个“Service Mesh”的定义。
Service Mesh是一个用于服务和服务之间通信的专用基础设施层。它负责服务请求能够在复杂的服务拓扑(组成了云原生应用)中可靠的进行投递。在实践中,Serivce Mesh的典型实现是作为轻量级网络代理阵列,部署在应用程序旁边,不需要业务进程感知到。
William关于Service Mesh的定义中,最有说服力的一点是,他不再将SideCar代理视为一个独立组件,而是承认了它们组成的网络像它们自身一样是有价值的
随着很多公司将它们的微服务部署到更复杂的系统运行环境中,例如Kubernetes和Mesos,人们开始使用这些平台提供的工具来实现合适的Serivce Mesh的想法。它们将独立的SideCar代理从独立的工作环境中转移到一个适当的,有集中的控制面。
看下我们的鸟瞰图,服务之间的流量仍然是通过SideCar代理来进行转发,但是控制平面知道每个SideCar实例。控制平面能够让代理实现例如访问控制,指标收集等需要协作完成的事情。Istio是这个模型的典型实现。
Service Mesh 的主流实现包括:
Linkerd
项目。现在(2020.09.08) Linkerd
已经发展到 2.8 版本,由控制面和数据面组成,详情可以参考 这里
Envoy是一个高性能的Service Mesh软件,现在主要被用于数据面作为 Sidecar 代理,详情可以参考 这里
Istio是第二代 Service Mesh,第一次提出控制面的概念,详情可以参考 这里
Service Mesh 最基础的功能毕竟是 sidecar proxy. 提到 proxy 怎么能够少了 nginx? 我想nginx自己也是这么想的吧 毫不意外,nginx也推出了其 service mesh 的开源实现:nginMesh.
不过,与 William Morgan 的死磕策略不同,nginMesh 从一开始就没有想过要做一套完整的第二代Service Mesh 开源方案,而是直接宣布兼容Istio, 作为Istio的 sidecar proxy. 由于 nginx 在反向代理方面广泛的使用,以及运维技术的相对成熟,nginMesh在sidecar proxy领域应该会有一席之地。
下图展示的是 Kubernetes 与 Service Mesh 中的的服务访问关系:
kube-proxy
组件,该组件会与 Kubernetes API Server 通信,获取集群中的 Service
信息,然后设置 iptables 规则,直接将对某个 Service
的请求发送到对应的 Endpoint(属于同一组 Service
的 Pod
)上。Service
多个 Pod
实例间的负载均衡,但是如何对这些 Service
间的流量做细粒度的控制,比如按照百分比划分流量到不同的应用版本(这些应用都属于同一个 Service
,但位于不同的 deployment 上),做金丝雀发布(灰度发布)和蓝绿发布?Kubernetes 社区给出了 使用 Deployment 做金丝雀发布的方法,该方法本质上就是通过修改 Pod
的 label 来将不同的 Pod
划归到 Deployment 的 Service
上。kube-proxy
的设置都是全局生效的,无法对每个服务做细粒度的控制,而 Service Mesh
通过 Sidecar
proxy 的方式将 Kubernetes 中对流量的控制从 Service
一层抽离出来,可以做更多的扩展。
kube-proxy
只能路由 Kubernetes 集群内部的流量,而我们知道 Kubernetes 集群的 Pod
位于 CNI 创建的外网络中,集群外部是无法直接与其通信的,因此 Kubernetes 中创建了 Ingress 这个资源对象,它由位于 Kubernetes 边缘节点(这样的节点可以是很多个也可以是一组)的 Ingress controller 驱动,负责管理 南北向流量,Ingress 必须对接各种 Ingress Controller 才能使用,比如 nginx ingress controller、traefik。
Service
、port、HTTP 路径等有限字段匹配来路由流量,这导致它无法路由如 MySQL、Redis 和各种私有 RPC 等 TCP 流量。Service
的 LoadBalancer 或 NodePort,前者需要云厂商支持,后者需要进行额外的端口管理。Service
来暴露,Ingress 本身是不支持的,例如 nginx ingress controller,服务暴露的端口是通过创建 ConfigMap 的方式来配置的。Istio
Gateway 的功能与 Kubernetes Ingress 类似,都是负责集群的南北向流量。Istio
Gateway
描述的负载均衡器用于承载进出网格边缘的连接。该规范中描述了一系列开放端口和这些端口所使用的协议、负载均衡的 SNI 配置等内容。Gateway 是一种 CRD 扩展,它同时复用了 Sidecar
proxy 的能力,详细配置请参考 Istio 官网。
服务网格的出现带来的变革:
第一,微服务治理与业务逻辑的解耦。服务网格把 SDK 中的大部分能力从应用中剥离出来,拆解为独立进程,以 Sidecar 的模式进行部署。服务网格通过将服务通信及相关管控功能从业务程序中分离并下沉到基础设施层,使其和业务系统完全解耦,使开发人员更加专注于业务本身。
注意,这里提到了一个词“大部分”,SDK 中往往还需要保留协议编解码的逻辑,甚至在某些场景下还需要一个轻量级的 SDK 来实现细粒度的治理与监控策略。例如,要想实现方法级别的调用链追踪,服务网格则需要业务应用实现 trace ID 的传递,而这部分实现逻辑也可以通过轻量级的 SDK 实现。因此,从代码层面来讲,服务网格并非是零侵入的。
第二,异构系统的统一治理。随着新技术的发展和人员更替,在同一家公司中往往会出现不同语言、不同框架的应用和服务,为了能够统一管控这些服务,以往的做法是为每种语言、每种框架都开发一套完整的 SDK,维护成本非常之高,而且给公司的中间件团队带来了很大的挑战。有了服务网格之后,通过将主体的服务治理能力下沉到基础设施,多语言的支持就轻松很多了。只需要提供一个非常轻量级的 SDK,甚至很多情况下都不需要一个单独的 SDK,就可以方便地实现多语言、多协议的统一流量管控、监控等需求。
此外,服务网格相对于传统微服务框架,还拥有三大技术优势:
Service Mesh
,可以为服务提供智能路由(蓝绿部署、金丝雀发布、A/B test)、超时重试、熔断、故障注入、流量镜像等各种控制能力。而以上这些往往是传统微服务框架不具备,但是对系统来说至关重要的功能。例如,服务网格承载了微服务之间的通信流量,因此可以在网格中通过规则进行故障注入,模拟部分微服务出现故障的情况,对整个应用的健壮性进行测试。由于服务网格的设计目的是有效地将来源请求调用连接到其最优目标服务实例,所以这些流量控制特性是“面向目的地的”。这正是服务网格流量控制能力的一大特点。服务网格带来了巨大变革并且拥有其强大的技术优势,被称为第二代“微服务架构”。然而就像之前说的软件开发没有银弹,传统微服务架构有许多痛点,而服务网格也不例外,也有它的局限性。
Sidecar
代理和其它组件引入到已经很复杂的分布式环境中,会极大地增加整体链路和操作运维的复杂性。Istio
之类的服务网格,通常需要运维人员成为这两种技术的专家,以便充分使用二者的功能以及定位环境中遇到的问题。展望未来,Kubernetes 正在爆炸式发展,它已经成为企业绿地应用的容器编排的首选。如果说 Kubernetes 已经彻底赢得了市场,并且基于 Kubernetes 的应用程序的规模和复杂性持续增加,那么就会有一个临界点,而服务网格则将是有效管理这些应用程序所必需的。随着服务网格技术的持续发展,其实现产品(如 Istio
)的架构与功能的不断优化,服务网格将完全取代传统微服务架构,成为大小企业微服务化和上云改造的首选架构。
MIG,也就是 Multi-Instance GPU
是 NVIDIA 在 NVIDIA GTC 2020
发布的最新 Ampere 架构的 NVIDIA A100 GPU
推出的新特性。当配置为 MIG 运行状态时,A100 可以通过分出最多 7 个核心来帮助供应商提高 GPU 服务器的利用率,无需额外投入。MIG 提供了一种多用户使用隔离的GPU资源、提高GPU资源使用率的新的方式,特别适合于云服务提供商的多租户场景,保证一个租户的运行不干扰另一个租户。本文将介绍 MIG 的新特性和使用方法,以及在容器和 k8s 中使用 MIG 的方案。
随着深度学习的广泛应用,使用GPU加速训练和推理越来越普遍。然而,高昂的GPU价格在这里成为了不可忽视的成本,有时候单个GPU并没有得到充分的利用,在多租户之间如何能够共享GPU并且互不干扰成为了一个重要课题,尤其是在云服务环境使用GPU的场景下。针对这个问题,有很多种解决方案,分别是软件级虚拟化GPU和硬件级虚拟化GPU,而 MIG 即是硬件级虚拟化GPU的一种方式:
Data center managers aim to keep resource utilization high, so an ideal data center accelerator doesn’t just go big- it also efficiently accelerates many smaller workloads.
MIG主要技术特点
首先看一下传统GPU的内部架构,MIG的目的是使虚拟的每个GPU实例都拥有上面类似的架构。
MIG对资源的划分可以分为两级,分别是GPU Instance、Compute Instance
MIG功能可以将单个GPU划分为多个GPU分区,称为 GPU Insance
。创建GPU实例可以认为是将一个大GPU拆分为多个较小的GPU,每个GPU实例都具有专用的计算和内存资源。
每个GPU实例的行为就像一个较小的,功能齐全的独立GPU,其中包括:
注意:在MIG操作模式下,每个GPU实例中的单个GPC启用了7个TPC(14个SM),这使所有GPU切片具有相同的一致计算性能。
memory controllers
和 cache
,粗略来说一个 GPU Memory Slice 大致是总的GPU Memory资源的 1/8,包括memory的 capacity 和 bandwidth。GPU SM Slice:一个 GPU SM Slice 是 A100 GPU SMs 的一个最小片段,粗略来说一个 GPU SM Slice 大致是总的GPU SM资源的 1/7
GPU Slice:一个 GPU Slice 是 A100 GPU 中集合一个 GPU Memory Slice
和 一个 GPU SM Slice
的最小片段
一个 GPU Instance 可以被划分为多个 Compute Instance,多个Compute Instance之间共享Memory和Engine,它包含了原来GPU Instance里面 GPU SM slices
和 GPU Engines
的一个子集(DMAs, NVDECs, etc.):
pre-A100 GPU每个用户独占SM、Frame Buffer、L2 Cache。
A100 MIG将GPU进行物理切割,每个虚拟GPU instance具有独立的SM、L2 Cache、DRAM。
下面是MIG 配置多个独立的GPU Compute workloads。每个GPC分配固定的CE和DEC。A100中有5个decoder。
当1个GPU instance中包含2个Compute instance时,2个Compute instance共享CE、DEC和L2、Frame Buffer。
Compute instance使多个上下文可以在GPU实例上同时运行。
和上一代Volta MPS技术的对比
MPS was designed for sharing the GPU among applications from a single user, but not for multi-user or multi-tenant use cases.
解决了MPS存在的memory system resources were shared across all the applications问题,同时继承了Volta MPS所有功能
对比项 | MPS | MIG |
---|---|---|
Partition Type | Logical | Physical |
Max Partitions | 48 | 7 |
SM Performance Isolation | Yes (by percentage, not partitioning) | Yes |
Memory Protection | Yes | Yes |
Memory Bandwidth QoS | No | Yes |
Error Isolation | No | Yes |
Cross-Partition Interop | IPC | Limited IPC |
Reconfigure | Process Launch | When Idle |
每个 GI 包括的资源不是随意定义的,NVIDIA 提供了 一系列的 GPU Instance Profiles
,用户在创建 GI 时必须按照这个 Profile 来切割。我们知道,A100 总共有 8 个 GPU Memory Slice 和 7 个 SM Slice,那么切分总共有5种 Profile:
Profile Name | Fraction of Memory | Fraction of SMs | Hardware Units | Number of Instances Available |
---|---|---|---|---|
MIG 1g.5gb | 1/8 | 1/7 | 0 NVDECs | 7 |
MIG 2g.10gb | 2/8 | 2/7 | 1 NVDECs | 3 |
MIG 3g.20gb | 4/8 | 3/7 | 2 NVDECs | 2 |
MIG 4g.20gb | 4/8 | 4/7 | 2 NVDECs | 1 |
MIG 7g.40gb | Full | 7/7 | 5 NVDECs | 1 |
注意:这里对于 A100-SXM4-40GB
总的 Memory大小是40GB,所以最小单位是 1g.5gb
,如果对于 A100-SXM4-80GB
,则最小单位是 1g.10gb
。
也就是说,这几种 Profile 确定了 A100 GPU 可以被切分的方式,如下图,所有可以切分的方式只是下图从左到右选择不同的Profile,并且两个Profile上下不重叠。唯一的例外是,现在 NVIDIA 不支持 (4 memory, 4 compute) 和 (4 memory, 3 compute) 的组合:
下图就是组合的一种方式:A100 GPU 被切割成了3个GPU Instance,分别的大小是
下图也是组合的一种可能:
前面提到, 硬件上 NVIDIA 不支持 (4 memory, 4 compute) 和 (4 memory, 3 compute) 的组合,但是支持两个 (4 memory, 3 compute) 的组合,这里左边的一个 (4 memory, 3 compute) 是将 (4 memory, 4 compute) 示例化为一个 (4 memory, 3 compute)。如下图就将 A100 切分成两个 GPU Instance,每个GPU Instance都有 (4 memory, 3 compute)
或者切分成3个GPU Instance:
也可以切分成下面这种4个GPU Instance:
总的来说,一共有 18 种切分方法:
注意,下图中的两种切分并不相同,因为每个切分的Instance 的 physical layout
也很重要:
具体到A100卡,实际实现有两个型号,分别是
本次调研中使用的卡是108 SM版本
1 | $ nvidia-smi |
1 | $ tree /dev/ |
查询是否开启MIG
1 | $ nvidia-smi -i 0 --query-gpu=pci.bus_id,mig.mode.current --format=csv |
对于指定卡开启mig,只有在卡空闲时才能更改mig enable 设置
1 | $ nvidia-smi -i 0 -mig 1 |
If you are using MIG inside a VM with GPU passthrough, then you may need to reboot the VM to allow the GPU to be in MIG mode as in some cases, GPU reset is not allowed via the hypervisor for security reasons. This can be seen in the following example:
重启之后
1 | $ nvidia-smi -i 0 --query-gpu=pci.bus_id,mig.mode.current --format=csv |
1 | # nvidia-smi mig -lgip |
1 | # nvidia-smi mig -lgipp |
1 | # nvidia-smi mig -cgi 9,14,19 |
1 | # nvidia-smi mig -lgi |
创建CI前,首先需要查询对应的GI支持Profile列表,可以发现上文创建的ID为2的GI可以进一步分为3种类型的CI
1 | # nvidia-smi mig -lcip -gi 2 |
然后进一步将ID为2的GI划分为两个CI,Profile分别是1c.3g.20gb,2c.3g.20gb,具体命令如下
1 | # nvidia-smi mig -cci 0,1 -gi 2 |
1 | # nvidia-smi mig -lci -gi 2 |
执行 nvidia-smi
也可以看到如下输出
1 | # nvidia-smi |
执行nvidia-smi -L
可以列出每个设备的UUID,供后续计算时使用
1 | # nvidia-smi -L |
可以使用如下命令删除gi实例1上的ci实例0
1 | nvidia-smi mig -dci -ci 0 -gi 1 |
暂时没有拿到 bare metal 的 A100 机器,TODO
1 | # docker run --runtime=nvidia -e NVIDIA_VISIBLE_DEVICES=MIG-GPU-ed92375c-b61c-7a27-2611-bc72ad3ea181/2/1 nvidia/cuda nvidia-smi |
怀疑是 NVIDIA Docker Toolkit 版本太老
1 | # /usr/bin/nvidia-container-runtime -v |
安装新版本的 NVIDIA Docker Toolkit
1 | Dependencies Resolved |
环境配置好后,即可通过 docker
运行容器使用GPU:
1 | $ docker run --runtime=nvidia -e NVIDIA_VISIBLE_DEVICES=MIG-GPU-61148488-f8ba-c817-b2e8-18f59e2b66b1/2/0 nvidia/cuda nvidia-smi |
确认 Node 上的 MIG 特性开启,此时没有创建任何GI:
1 | Thu Jan 14 16:35:34 2021 |
启动 Device Plugin
,此时 mig-strategy
是 none
:
1 | kind: DaemonSet |
可以看到 Node
上可以用 nvidia.com/gpu
资源数目:
1 | Capacity: |
部署 Pod
:
1 | $ kubectl run -it --rm \ |
确认 Node 上的MIG特性开启后,创建大小相同的7个GI,每个GI对应着一个CI:
1 | $ nvidia-smi mig -cgi 19,19,19,19,19,19,19 -C |
部署 Device Plugin
:
1 | apiVersion: apps/v1 |
这时候可以看到 Node 上面的标记 nvidia.com/gpu
变成了 7 个:
1 | Capacity: |
部署 discovery
1 | apiVersion: apps/v1 |
运行 Pod 申请GPU:
1 | $ for i in $(seq 7); do |
确认 Node 上的MIG特性开启后,创建不同大小的3个GI,每个GI对应着一个CI:
1 | $ nvidia-smi mig -cgi 9,14,19 -C |
启动 Device Plugin
:
1 | apiVersion: apps/v1 |
启动 Device Plugin
之后,可以看到 Node 上的有MIG的resource type
:
1 | Capacity: |
这时候启动 gpu-feature-discovery
,启动策略是 mixed
:
1 | apiVersion: apps/v1 |
这时候查看 Node 的 label,可以看到 MIG 相关的 label 已经打上 ?
1 | $ kubectl get node -o json | \ |
使用 kubectl
启动 Pod:
1 | $ kubectl run -it --rm \ |
1 | # TKE GPU Node查看到 Driver 信息 |
If you are using MIG inside a VM with GPU passthrough, then you may need to reboot the VM to allow the GPU to be in MIG mode as in some cases, GPU reset is not allowed via the hypervisor for security reasons. This can be seen in the following example:
1 | $ sudo nvidia-smi -i 0 -mig 1 |
测试项目 | 实测性能 | 官方标准性能 |
---|---|---|
FP32MAD | 19.436TF | 19.5 TF |
FP64MAD | 9.690TF | 9.7 TF |
int32mad | 19.446TF | - |
int32add | 18.906TF | - |
FP32GEMMTensor (矩阵大小满足最佳性能要求) | 158.426TF | 156 TF |
FP32GEMMTensor (不满足最佳性能要求) | 68.054 TF | - |
FP32GEMM | 19.047 TF | - |
备注
为了测试各个CI和GI的性能,对3g.20gb GI进行进一步划分,分为 2c.3g.20gb, 1c.3g.20gb,另外两个GI不做进一步划分,直接在GI基础上创建CI。
至此一块GPU卡被分为四个CI分别是
MIG 1c.3g.20gb
测试项目 | 实测性能(OPS) |
---|---|
FP32MAD | 2.523 T |
FP64MAD | 1.261 T |
INT32MAD | 2.524 T |
INT32ADD | 2.455 T |
FP32GEMMTensor (矩阵大小满足最佳性能要求) | 23.081 T |
FP32GEMMTensor (不满足最佳性能要求) | 8.940 T |
FP32GEMM | 2.476 T |
MIG 2c.3g.20gb
测试项目 | 实测性能(OPS) |
---|---|
FP32MAD | 5.046 T |
FP64MAD | 2.521 T |
INT32MAD | 5.049 T |
INT32ADD | 4.908 T |
FP32GEMMTensor (矩阵大小满足最佳性能要求) | 44.941 T |
FP32GEMMTensor (不满足最佳性能要求) | 18.920 T |
FP32GEMM | 4.909 T |
MIG 2g.10gb
测试项目 | 实测性能(OPS) |
---|---|
FP32MAD | 5.046 T |
FP64MAD | 2.521 T |
INT32MAD | 5.049 T |
INT32ADD | 4.908 T |
FP32GEMMTensor (矩阵大小满足最佳性能要求) | 40.151 T |
FP32GEMMTensor (不满足最佳性能要求) | 17.514 T |
FP32GEMM | 4.909 T |
MIG 1g.5gb
测试项目 | 实测性能(OPS) |
---|---|
FP32MAD | 2.523 T |
FP64MAD | 1.261 T |
INT32MAD | 2.524 T |
INT32ADD | 2.454 T |
FP32GEMMTensor (矩阵大小满足最佳性能要求) | 16.453 T |
FP32GEMMTensor (不满足最佳性能要求) | 8.261 T |
FP32GEMM | 2.476T |
备注
在串行执行FP32MAD任务时,1c.3g.20gb,1g.5gb的测试任务时保持在14.3%,2c.3g.20gb,2g.10gb的测试任务时保持在28.6%附近
统一执行FP32MAD
测试项目 | 实测性能(OPS) |
---|---|
1c.3g.20gb | 2.523 T |
2c.3g.20gb | 5.044 T |
2g.10gb | 5.046 T |
1g.5gb | 2.523 T |
统一执行FP32GEMMTensor
测试项目 | 实测性能(OPS) |
---|---|
1c.3g.20gb | 20.450 T |
2c.3g.20gb | 41.194 T |
2g.10gb | 39.773 T |
1g.5gb | 16.336 T |
备注
在并行执行FP32MAD任务时,SmActivity,SmOccupancy,FP32Activity三项监控指标保持在85.7%附近
分别执行不同类型的计算
测试项目 | 实测性能(OPS) |
---|---|
1c.3g.20gb FP32MAD | 2.523 T |
2c.3g.20gb FP64MAD | 2.521 T |
2g.10gb INT32MAD | 5.048 T |
1g.5gb INT32ADD | 2.454 T |
备注
在并行执行不同计算任务时,SmActivity,SmOccupancy,FP64Activity,FP32Activity分别为85.7%, 78.1%, 28.5%, 57.0%
根据测试结果,验证了CI,GI隔离的有效性,具体结论如下
原生的 k8s 基于 Device Plugin
和 Extended Resource
机制实现了在容器中使用GPU,但是只支持GPU的独占使用,不允许在Pod间共享GPU,这大大降低了对集群中GPU的利用率。为了在集群层面共享GPU,我们需要实现GPU资源的隔离与调度,本文将依次介绍阿里的 GPUShare 与腾讯的 GPUManager,分析其实现机制。
阿里的 GPUShare 基于 Nvidia Docker2 和他们的 gpu sharing design 设计而实现的,为了使用阿里的GPUShare,首先需要配置Node上的 Docker Runtime 并安装 NVIDIA Docker 2
,具体过程可以参考 在Docker中使用GPU。
gpuTotalMemory
和 gpuTotalCount
算出Node上每张卡的GPU Memory设计里定义了两种 Extended Resource
:
aliyun.com/gpu-mem
: 单位从 number of GPUs
变更为 amount of GPU memory in MiB
,如果一个Node有多个GPU设备,这里计算的是总的GPU Memoryaliyun.com/gpu-count
:对应于Node上的GPU 设备的数目下图是整个设计的核心组件:
Filter
和Bind
阶段,用于决定某个Node上的一个GPU设备是否可以提供足够的GPU Memory,并将GPU分配的结果记录到Pod Spec 的 Annotation中GPU Share Device Plugin
基于 nvml
库来查询每个Node上GPU设备的数目和每个GPU设备的GPU Memory。
这些资源状况被通过 ListAndWatch()
汇报给 Kubelet,然后 kubelet 会上报给 APIServer,这时候执行 kubectl get node
可以看到在 status
看到相关的Extended Resource
字段:
1 | apiVersion: v1 |
用户申请GPU的时候,在 Extended Resource 中只填写 gpu-mem
,下面部署一个单机版的Tensorflow:
1 | apiVersion: apps/v1 |
当kube-scheduler运行完所有的Filter函数后,就会调用 GPU Share Extender
的 Filter 函数。在原生的过滤中,kube-scheduler会计算是否有足够的Extended Resource(算的是总共的GPU Memory),但是不能知道是否某个GPU设备有足够的资源,这时候就需要调度器插件来实现。以下图为例:
GPU Share Extender
的过滤中,他需要找到有单个GPU能够满足用户申请的资源,当检查到N2节点的时候,发现虽然总的GPU Memory有8138MiB,但是每个GPU设备都只剩4096MiB了,不能满足单设备8138的需求,所以N2被过滤掉这里有一个问题:当一个Node上有多张卡的时候,Scheduler Extender是如何知道每张卡当前可用的Capacity的呢?
我们看一下Extender在 Filter 阶段执行的函数,对于要创建的Pod,当前Node检查自己拥有的所有可用GPU,一旦有一个GPU的可用显存大于申请的显存,那么当前Node是可以被调度的。
1 | // check if the pod can be allocated on the node |
接下来的一个问题是,每个Node可用的GPU显存是如何得到的呢?我们进入到 getAvailableGPUs
继续看:
1 | func (n *NodeInfo) getAvailableGPUs() (availableGPUs map[int]uint) { |
这里可以看到,Scheduler Extender
内部维护了当前Node上所有的GPU显存状态和已经用了的GPU显存状态信息:
1 | // device index: gpu memory |
关于 GetUsedGPUMemory
,是Scheduler Extender
内部维护的 DeviceInfo
所记录的,这里的 d.podMap
会在每次Extender执行 Bind
的时候,将对应的Pod添加到对应的Node上的 DeviceInfo
中:
1 | func (d *DeviceInfo) GetUsedGPUMemory() (gpuMem uint) { |
再总结总结,本质上是 Scheduler Extender
维护了一个 devs
这么一个数据结构,使得它可以知道当前Node上每个GPU设备的显存状态。
1 | // NodeInfo is node level aggregated information. |
那么问题来了,我们通过ApiServer,只能知道对应Node上的 gpuCount
和 gpuTotalMemory
,而不知道每张卡各自的显存的。这个 devs
是怎么初始化得到每张卡的显存信息呢的呢?继续看代码:
1 | // Create Node Level |
可以看到,这里在初始化的时候,默认设定每张GPU卡的显存大小一样,通过平均得到每张卡的心存信息。
GPU Share Extender
需要做两件事情:binpack
原则找到Node上对应的GPU设备,并将 GPU Device ID记录到 Pod的 Annotation中 ALIYUN_GPU_ID
。他也会将Pod使用的GPU Memory记录到Pod Annotation中:ALIYUN_COM_GPU_MEM_POD
和 ALIYUN_COM_GPU_MEM_ASSUME_TIME
以下图为例,N1中有4个GPU,其中GPU0(12207),GPU1(8138)、GPU2(4069)和GPU3(16276), GPU2因为资源不够被过滤掉,剩下的3个GPU根据 Binpack 原则,我们选用GPU1(图里面 Annotation错了,不是0,而是1)
我们看一看在找GPU设备的时候是如何操作的,可以看到这里通过 candidateGPUMemory > availableGPU
这里实现了 binpack
。
1 | // allocate the GPU ID to the pod |
接下来由Kubelet在创建container前调用 GPU Share Device Plugin
的 Allocate
函数,参数是申请的GPU Memory的数量。
Pod运行成功后,执行 kubectl get pod
可以看到:
1 | apiVersion: v1 |
Device Plugin 从 k8s apiserver 拿到所有Pending的Pod中属于GPU Share的Pod,并且按照 AssumedTimestamp排序
选择符合Allocation传入的GPU Memory的Pod,如果有多个,选择最早的那个Pod
标记 ALIYUN_COM_GPU_MEM_ASSIGNED
为 True
把 DeviceID 作为下NVIDIA_VISIBLE_DEVICES环境变量告诉 Nvidia Docker2,并且创建容器
这里问题是device plugin的allocate接口参数是什么,是否包含pod信息,是否包含pod annotation?
查看 Device Plugin 的代码,这一个申请的GPU Memory的数量让我很疑惑,为何要这么算?
1 | for _, req := range reqs.ContainerRequests { |
继续看 Device Plugin
的 DeviceIDs
是如何生成的。这里调用了 nvml library
可以探测到本Node上拥有的GPU有多少个,每个显存是多少。接下来 Device Plugin
会创建一系列的 FakeDeviceID
,并将这个DeviceIDs返回给 Kubelet,这就解释了为什么要通过上面的方法计算申请的 GPU Memory,这里的Memory以MiB为单位。
1 | func getDevices() ([]*pluginapi.Device, map[string]uint) { |
我们看一下 Device Plugin
是如何找到对应的Pod的,可以看到一旦碰到有Pod申请的GPU显存与Kubelet传入的显存大小一致,那么则找到对应的Pod了。
1 | pods, err := getCandidatePods() |
这里的 getCandidatePods
就是List所有Pending的Pod中 Assume Memory的,并且按照时间排序:
1 | // pick up the gpushare pod with assigned status is false, and |
那么这里有一个问题:如果在同一个Node有两个Pod
,都申请了相同的GPU显存大小,比如3G,那么kubelet是在创建容器的时候,是如何保证两个Pod不混淆的呢?混淆会有问题吗,kubelet建Pod的时候到底是怎么搞的?是谁触发了kubelet创建容器?
GPU Manager 提供一个 All-in-One 的 GPU 管理器,基于 Kubernetes DevicePlugin 插件系统实现,该管理器提供了分配并共享 GPU、GPU 指标查询、容器运行前的 GPU 相关设备准备等功能,支持用户在 Kubernetes 集群中使用 GPU 设备。
/metrics
路径,可以为 Prometheus 提供 GPU 指标的收集功能,访问 /usage
路径可以进行可读性的容器状况查询。设计里定义了两种 Extended Resource
:
tencent.com/vcuda-core
: vcuda-core
对应的是使用率,单张卡有100个coretencent.com/vcuda-memory
:vcuda-memory
是显存,每个单位是256MB的显存tencent.com/vcuda-core
填写50,tencent.com/vcuda-memory
填写成30GaiaGPU的实现主要分为两个部分:Kubernetes 部分 和 vCUDA 部分
nvidia-docker2
,使用的是原生的 runc
ListAndWatch
返回给Kubelet的也不是实际的GPU设备,而是 a list of vGPUs
,vmemory
devicevprocessor
devices,每个 vprocessor
占有 1%的GPU利用率1 | apiVersion: v1 |
下面看具体代码,首先是向 kubelet
注册:
1 | func (m *managerImpl) RegisterToKubelet() error { |
这里有一个 m.bundleServer
,分别是 vcore
和 vmemory
的 gRPC Server。
1 | func (m *managerImpl) setupGRPCService() { |
接下来看 ListAndWatch
的实现,对于两种资源,它会去检查 capacity()
里面包含对应 resourceName
的:
1 | //ListAndWatchWithResourceName send devices for request resource back to server |
那么这里的 ta.capicity()
是如何得到的呢?这里维护了一个拓扑树,树根是物理的Host,树叶是物理的GPU。这里根据树叶上GPU的数目和总的显存大小,构建了 vcore
设备 和 vmemory
设备,命名以各自的资源名为前缀。
1 | func (ta *NvidiaTopoAllocator) capacity() (devs []*pluginapi.Device) { |
GPU Quota Admission
作为调度器插件,实现了更细粒度的quota调度准入维度。用户通过配置一个 ConfigMap
,对每个 Namespace
可用的GPU卡的配额做规划,同时也定义了资源池,这样在调度的时候就可以实现按照资源池及GPU型号进行策略调度。
1 | { |
具体在调度的时候,对每一个Pod,根据Namespace可以筛选出一系列含有GPU的Pods,然后当前Namespace下,对于某种GPU Model(比如P100),计算已经使用了的GPU大小,根据 ConfigMap
定义的配额,找到没超出。通过这个,得到所有没超出Quota的Models。
1 | type NamespaceQuota struct { |
接下来在 Filter阶段,根据上面的可用 GPU Models
和定义的 Quota Pool
,
1 | func (gpuFilter *GPUFilter) filterNodes(nodes []corev1.Node, gpuModels, pools []string) (filteredNodes []corev1.Node, failedNodesMap schedulerapi.FailedNodesMap, err error) { |
到这一步,也就是实现了细粒度的Quota调度准入控制。
为此我们增加了GPU predicate controller来尽可能的降低系统默认调度策略带来的碎片化问题。
我们看看它是如何实现的,首先在 deviceFilter
的入口里面,拿到当前Node上存在的所有Pod:
1 | pods, err := gpuFilter.ListPodsOnNode(node) |
接下来构建一个 NodeInfo
结构体,里面包含有当前Node的所有信息,这里记录了Node上所有的GPU显存和GPU设备数目。这个是通过Node Status里面两个扩展资源计算出来的。GPU Manager 方案也是认为每台机器上的GPU的不同卡的显存大小是相同的,这样可以算出每张卡的显存大小。
1 | type NodeInfo struct { |
NodeInfo
里面还有一个 DeviceInfo
的map,用于记录每张卡的使用情况。这里在初始化这个 NodeInfo
数据结构的时候也会根据传入的 pods
信息更新 DeviceInfo
的设备使用情况。
1 | type DeviceInfo struct { |
接下来就是每个 Allocate
函数的实现,对于Pod里面的每一个容器,都会分配得到一个 devIDs
列表,然后得到对Pod打上Annotation:
1 | func (alloc *allocator) Allocate(pod *v1.Pod) (*v1.Pod, error) { |
接下来的问题就是,这里的 AllocateOne
是如何实现的呢?对于每个容器,根据其申请的GPU资源,可以分为GPU是共享模式还是独占模式,然后调用 Evaluate
去得到 devs
。
1 | func (alloc *allocator) AllocateOne(container *v1.Container) ([]*device.DeviceInfo, error) { |
以共享模式为例,这里拿到当前Node的所有 Device
,分别根据最少可用的cores
和可用的memory
来排序,如果有满足用户需要的设备,则加入到 devs
里面,最后将这个 list
返回给用户。
1 | func (al *shareMode) Evaluate(cores uint, memory uint) []*device.DeviceInfo { |
可以看到这里在调度过程中,选择最先满足的那个,一旦满足则跳出选择。这是因为这里的 devs
已经按照最少可用的资源来匹配了,通过这种方式可以减少碎片化。
用户创建Pod之后,经过调度找到对应的Node,这时候Kubelet向DevicePlugin执行Allocate函数。因为Kubelet看到的是虚拟的Devices,这里需要有一个从虚拟Device到实际GPU Device的映射,这里就是上图中GPU Manager做的事情,然后发送一个Request给GPU Scheduler,根据拓扑关系选择最合适的GPU,然后GPU Manager将 AllocateResponse返回给Kubelet。
我们先看 Allocate
的实现,这段代码比较长,但是实现的逻辑也不难:
deviceIDs
这样里一个List,里面只有 vcore
这种设备 (代码是这样的,需要进一步看一看 kubelet)vcore
相同的容器1 | func (ta *NvidiaTopoAllocator) Allocate(_ context.Context, reqs *pluginapi.AllocateRequest) (*pluginapi.AllocateResponse, error) { |
找到这样的一个容器之后,拿到容器申请的 vmemory
,每一个虚拟的 vmemory
作为一个设备加入到 req.DevicesIDs
中,继续调用 allocateOne
:
1 | if found { |
具体的 Allocate
实现在 allocateOne
里面,根据Pod计算出其申请的 needCores
和 needMemory
之后,根据三种情况有不同的分配策略。注意这里还是在拓扑树上面操作,拓扑树树根是物理的Host,树叶是物理的GPU
1 | switch { |
这里的 Evaluate
返回的是 NvidiaNode
这样的 GPU 节点,通过这个结构可以构建一个拓扑树:
1 | //NvidiaNode represents a node of Nvidia GPU |
关于这里具体的分配算法此处就不再详述了,抓住主脉络。
接下来构建 pluginapi.ContainerAllocateResponse
,这里会分别设置环境变量,挂载的目录,找到的设备,以及Annotation
:
1 | ctntResp := &pluginapi.ContainerAllocateResponse{ |
首先是 Devices
字段:
1 | allocatedDevices := sets.NewString() |
这里还有一些控制设备:
1 | // Append control device |
接着是 Annotations
字段:
1 | ctntResp.Annotations[types.VDeviceAnnotation] = vDeviceAnnotationStr(nodes) |
然后是 Envs
字段
1 | // LD_LIBRARY_PATH |
最后是 Mounts
字段,这里给GPU容器配置一个volume挂载点来提供CUDA Library以及配置环境变量LD_LIBRARY_PATH
告诉应用哪里去找到 CUDA Library
。
1 | if shareMode { |
vGPU Manager
作为 GPU Manager
这个 DaemonSet
的一部分,负责下发容器配置和监控容器分配的vGPU。上一步在拓扑分配器确定好每个容器的资源配置之后,vGPU Manager
负责为每个容器在 host 上创建一个独立的目录,这个目录以容器的名称命名,并且会被包括在 AllocateResponse
中返回给 kubelet,对就是上面那段代码做的事情。
vGPU Manager
会维护一个使用了GPU的并且仍然活着的容器列表,还会去周期性的检查他们。一旦有容器挂掉,就会将这个容器移出列表并且删去目录。
1 | // Host | Container |
论文中的 vGPU Library
,具体实现为 vcuda-controller ,它运行在容器中用于管理部署在容器中的GPU资源。这个 vGPU Library
本质上就是自己封装了 CUDA Library
,劫持了 memory-related
API 和 computing-related
API,下表显示了劫持的API。
vCUDA
在调用相应API时检查:
这里对于其具体实现按下不表。
一个令人疑惑的问题是,在GPU Manager中,用户的容器是如何能够使用这个动态库的呢?具体有两个问题:
GPU Manager
作为 DaemonSet
会在其Image中将我们自定义的库打包进去,然后挂载到Node上的一个目录。LD_LIBRARY_PATH
,将其指向这个自定义的动态库的地址。这部分代码还没有看。
我们在 GPU 与 CUDA 编程入门 这篇博客中初步介绍了如何Linux上使用GPU的方法,随着容器和k8s的迅猛发展,人们对于在容器中使用GPU的需求越发强烈。本文将基于前文,继续介绍如何在容器中使用GPU,进一步地,介绍在Kubernetes中如何调度GPU,并以Tensorflow为例,介绍如何基于Docker搭建部署了GPU的深度学习开发环境。
容器最早是用于无缝部署基于CPU的应用,它们对于硬件和平台是无感知的,但是显然这种使用场景对于GPU并不适用。对于不同的GPU,需要机器安装不同的硬件驱动,这极大限制了在容器中使用GPU。为了解决这个问题,最早的一种使用方法是在容器中完全重新安装一次NVIDIA驱动,然后将在容器启动的时候将GPU以字符设备 /dev/nvidia0
的方式传递给容器。然而这种方法要求容器中安装的驱动版本与Host上的驱动版本完全一致,同一个Docker Image不能在各个机器上复用,这极大的限制了容器的扩展性。
为了解决上述问题,容器必须对于 NVIDIA 驱动是无感知的,基于此 NVIDIA 推出了 NVIDIA Container Toolkit:
如上图所示, NVIDIA 将原来 CUDA 应用依赖的API环境划分为两个部分:
libcuda.so.major.minor
动态库和内核module提供支持,图中表示为CUDA Driverlibcuda.so.major.minor
这2个文件就必须同时升级到同一个版本,这样原有的程序才能正常工作,libcublas.so
等用户空间级别的API组成,图中表示为CUDA Toolkit为了让使用GPU的容器更具可扩展性,关于非驱动级的API被 NVIDIA 打包进了 NVIDIA Container Toolkit,因此在容器中使用GPU之前,每个机器需要先安装好NVIDIA驱动,之后配置好 NVIDIA Container Toolkit之后,就可以在容器中方便使用GPU了。
NVIDIA 的容器工具包本质是使用一个nvidia-runc
的方式来提供GPU容器的创建, 在用户创建出来的OCI spec上补上几个hook函数,来达到GPU设备运行的准备工作。具体包括以下几个组件,从上到下展示如图:
nvidia-docker2
nvidia-container-runtime
nvidia-container-toolkit
libnvidia-container
下面对这几个组件依次介绍:
libnvidia-container
libnvidia-container
提供了一个 library 和一个配置 GNU/Linux 的 Container 使用 NVIDIA GPU 的 client nvidia-container-cli
。libnvidia-container
的实现依赖于 kernel primitives
,并且是对于 container runtime 是无关的。
nvidia-container-cli
的主要作用就是将 NVIDIA GPU 注入到容器中,包括 /dev/nvidia0 设备挂载等操作。下面是抓到的日志信息,可以看到其主要操作包括:
1 | I0301 09:23:38.589710 4693 nvc_mount.c:218] zz: mounting /dev/nvidiactl at /var/lib/docker/overlay2/09af6a668c457545500c0bc7e152195750c2ccfe948daeae6d8a573a1e738ba0/merged/dev/nvidiactl |
到 libnvidia-container
代码中查看,可以看到
1 | if (xmount(err, src, dst, NULL, MS_BIND, NULL) < 0) |
具体就是将 /dev/nvidia0
设备 bind mount 到 container roofs 的 /dev/nvidia0
上。
下面是详细日志信息:
1 | -- WARNING, the following logs are for debugging purposes only -- |
nvidia-container-toolkit
nvidia-container-toolkit
被 runC
在 PreStart Hook
的时候调用,此时 Container 已经被创建,但是还没有启动。nvidia-container-toolkit
的主要作用是搜集信息(比如 container 的 roofs 路径),搜集在 config.json 的信息,拼凑起来 nvidia-container-cli
的参数,并调用 nvidia-container-cli
,调用参数为:
1 | /usr/bin/nvidia-container-cli |
nvidia-container-runtime
在执行 docker run
的时候,加上 --runtime=nvidia
参数,就会将 docker 的 runtime 从 runC 变成 nvidia-container-runtime
。nvidia-docker-runtime
本质上就是对 runC
的一个简单封装,它把 runC
Spec 当作输入,将 nvidia-container-toolkit
作为 PreStart
Hook,然后调用 runC
。
1 | func addNVIDIAHook(spec *specs.Spec) error { |
注意,这里的 nvidia-container-runtime-hook
实际上就是执行 /usr/bin/nvidia-container-toolkit
的软链接。
当安装了 nvidia-container-runtime
之后,需要修改 Docker 的 daemon.json
来使其生效,或者显示制定 --runtime
参数。
1 | /etc/docker/daemon.json |
nvidia-docker2
nvidia-docker2
是整个 NVIDIA Container Toolkit 中唯一与 docker 相关的包,它的作用在用户 docker run/create
的时候,添加 --runtime=nvidia
的参数,然后调用上面的 nvidia-container-runtime
进行后面的一系列操作,将 GPU 注入到容器中。它也支持设置 NV_GPU
参数来指定哪一个 GPU 来注射到 容器中。
nvidia-docker
本质上就是一个 Shell 脚本,内容如下所示:
1 |
|
这里仍然基于腾讯云的 CentOS 7机器为例演示如何在安装配置 NVIDIA Container Toolkit
,对于更多的平台可以参考其官方文档。
1 | $ curl https://get.docker.com | sh \ |
Setup the stable
repository and the GPG key:
1 | $ distribution=$(. /etc/os-release;echo $ID$VERSION_ID) \ |
Install the nvidia-docker2
package (and dependencies) after updating the package listing:
1 | $ sudo apt-get update |
1 | $ sudo apt-get install -y nvidia-docker2 |
Restart the Docker daemon to complete the installation after setting the default runtime:
1 | $ sudo systemctl restart docker |
At this point, a working setup can be tested by running a base CUDA container:
1 | $ sudo docker run --rm --gpus all nvidia/cuda:11.0-base nvidia-smi |
This should result in a console output shown below:
1 | +-----------------------------------------------------------------------------+ |
To register the nvidia
runtime, use the method below that is best suited to your environment. You might need to merge the new argument with your existing configuration. Three options are available:
1 | $ sudo mkdir -p /etc/systemd/system/docker.service.d |
1 | $ sudo tee /etc/systemd/system/docker.service.d/override.conf <<EOF |
1 | $ sudo systemctl daemon-reload \ |
The nvidia
runtime can also be registered with Docker using the daemon.json
configuration file:
1 | $ sudo tee /etc/docker/daemon.json <<EOF |
1 | sudo pkill -SIGHUP dockerd |
You can optionally reconfigure the default runtime by adding the following to /etc/docker/daemon.json
:
1 | "default-runtime": "nvidia" |
Use dockerd
to add the nvidia
runtime:
1 | $ sudo dockerd --add-runtime=nvidia=/usr/bin/nvidia-container-runtime [...] |
为了在 k8s 中管理和使用GPU,我们除了需要配置 NVIDIA Container Toolkit
,还需要安装NVIDIA推出的 NVIDIA/k8s-device-plugin,具体安装可以参考 我的这篇博文。上面的步骤加起来显得还是有些繁琐,如果你直接使用腾讯云 TKE 的话,在集群添加装有GPU的Node时候,就会自动帮你安装配置好 NVIDIA Container Toolkit
和 NVIDIA/k8s-device-plugin
,十分方便。接下来我们以Tensorflow为例,演示在 k8s 环境运行有GPU的Tensorflow。
单机版的Tensorflow,执行 kubectl apply -f tensorflow.yaml
来运行 Jupiter Notebook
。
1 | apiVersion: apps/v1 |
我们看到容器很快运行起来,根据 http:<nodeIP>:<nodePort>
可以访问到 Jupiter Notebook
,但是显示需要token:
查看 Tensorflow
日志,可以获得 token:aa06c9f12d80adac1a6288b97bf8030522cecc92202dbb20
1 | [root@VM-1-14-centos single]# kubectl get pods |
登陆之后即可看到 Jupiter Notebook
:
新建Notebook,运行命令如下:
可以看到,TensorFlow 支持在GPU上的运算
"/device:GPU:0"
:TensorFlow 可见的机器上第一个 GPU 的速记表示法。"/job:localhost/replica:0/task:0/device:GPU:0"
:TensorFlow 可见的机器上第一个 GPU 的完全限定名称。Kubernetes 原生支持对于CPU和内存资源的发现,但是有很多其他的设备 kubelet不能原生处理,比如GPU、FPGA、RDMA、存储设备和其他类似的异构计算资源设备。为了能够使用这些设备资源,我们需要进行各个设备的初始化和设置。按照 Kubernetes 的 OutOfTree
的哲学理念,我们不应该把各个厂商的设备初始化设置相关代码与 Kubernetes 核心代码放在一起。与之相反,我们需要一种机制能够让各个设备厂商向 Kubelet 上报设备资源,而不需要修改 Kubernetes 核心代码。这即是 Device Plugin
这一机制的来源,本文将介绍 Device Plugin 的实现原理,并介绍其使用。
Device Plugin 实际上是一个 gPRC server,Device 插件一般推荐使用 DaemonSet 的方式部署,并将 /var/lib/kubelet/device-plugins
以 Volume 的形式挂载到容器中。当然,也可以手动运行的方式来部署,但这样就没有失败自动恢复的功能了。
为了能够使用某个厂商的特定设备,一般有两步:
kubectl create -f http://vendor.com/device-plugin-daemonset.yaml
kubectl describe nodes
的时候,相关设备会出现在node status中:vendor-domain/vendor-device
当 Device Plugin 向 kubelet 注册后,kubelet 就通过 RPC 与 Device Plugin 交互:
ListAndWatch()
:让 kubelet 发现设备资源和对应属性,并且在设备资源发生变动的时候接收通知Allocate()
:kubelet 在创建容器前通过 Allocate来申请相关设备资源为了向 kubelet 告知 Device Plugin 的存在,Device Plugin 必须向 kubelet 发出注册请求,这之后 kubelet 才会和 Device Plugin 通过 gRPC
交互,具体过程如下:
RegisterRequest
的请求RegisterRequest
请求后,返回一个 RegisterResponse
,如果Kubelet碰到任何错误,会把错误附在Response中插件启动后要持续监控 Kubelet 的状态,并在 Kubelet 重启后重新注册自己。比如,Kubelet 刚启动后会清空 /var/lib/kubelet/device-plugins/
目录,所以插件作者可以监控自己监听的 unix socket 是否被删除了,并根据此事件重新注册自己
Device Plugin 和 Kubelet 通过在一个 Unix Socket上使用 gRPC 交互,当启动 gRPC server的时候,Device Plugin 将会在 /var/lib/kubelet/device-plugins/
这个 HostPath 创建一个 UnixSocket,比如 /var/lib/kubelet/device-plugins/nvidiaGPU.sock
。
在实现 Device 插件时需要注意
/var/lib/kubelet/device-plugins/kubelet.sock
向 Kubelet 注册,同时提供插件的 Unix Socket 名称、API 的版本号和插件名称(格式为 vendor-domain/resource
,如 nvidia.com/gpu
)。Kubelet 会将这些设备暴露到 Node 状态中,方便后续调度器使用1 | // Registration is the service advertised by the Kubelet |
插件启动时,以grpc的形式通过/var/lib/kubelet/device-plugins/kubelet.sock向Kubelet注册,同时提供插件的监听Unix Socket,API版本号和设备名称(比如nvidia.com/gpu)。Kubelet将会把这些设备暴露到Node状态中,以Extended Resource的要求发送到API server中,后续Scheduler会根据这些信息进行调度。
插件启动后,Kubelet会建立一个到插件的listAndWatch长连接,当插件检测到某个设备不健康的时候,就会主动通知Kubelet。此时如果这个设备处于空闲状态,Kubelet就会将其挪出可分配列表;如果该设备已经被某个pod使用,Kubelet就会将该Pod杀掉
插件启动后可以利用Kubelet的socket持续检查Kubelet的状态,如果Kubelet重启,插件也会相应的重启,并且重新向Kubelet注册自己
NVIDIA 提供了一个基于 Device Plugins 接口的 GPU 设备插件 NVIDIA/k8s-device-plugin。
部署
1 | kubectl apply -f https://raw.githubusercontent.com/NVIDIA/k8s-device-plugin/master/nvidia-device-plugin.yml |
创建 Pod 时请求 GPU 资源
1 | apiVersion: v1 |
注意:使用该插件时需要配置 nvidia-docker 2.0,并配置 nvidia
为默认运行时 (即配置 docker daemon 的选项 --default-runtime=nvidia
)。nvidia-docker 2.0 的安装方法为(以 Ubuntu Xenial 为例,其他系统的安装方法可以参考 这里):
整个Kubernetes调度GPU的过程如下:
ListAndWatch
接口,上报注册节点的GPU信息和对应的DeviceID。 nvidia.com/gpu
的GPU Pod创建出现,调度器会综合考虑GPU设备的空闲情况,将Pod调度到有充足GPU设备的节点上。ListAndWatch
接口收到的Device信息,选取合适的设备,DeviceID 作为参数,调用GPU DevicePlugin的 Allocate
接口NVIDIA_VISIBLE_DEVICES
环境变量,返回kubeletgpu-container-runtime
调用 gpu-containers-runtime-hook
gpu-containers-runtime-hook
根据容器的 NVIDIA_VISIBLE_DEVICES
环境变量,转换为 --devices
参数,调用 nvidia-container-cli prestart
nvidia-container-cli
根据 --devices
,将GPU设备映射到容器中。 并且将宿主机的Nvidia Driver Lib 的so文件也映射到容器中。 此时容器可以通过这些so文件,调用宿主机的Nvidia Driver。在前面 API Specification
中,通过 Protobuf
定义了 DevicePlugin
应该提供的服务,在 Kubelet
中会调用 DevicePluginClient
来使用对应的服务,这里的 DevicePluginClient
即是通过 Protobuf
自动生成的代码。
1 | type DevicePluginClient interface { |
在 NVIDIA/k8s-device-plugin
中,我们可以看到上面不同服务的具体实现:
1 | func (m *NvidiaDevicePlugin) GetDevicePluginOptions(context.Context, *pluginapi.Empty) (*pluginapi.DevicePluginOptions, error) |
对 NVIDIA/k8s-device-plugin
来说,这里的关键数据结构为 NvidiaDevicePlugin
,它实现了 Device Plugin
架构定义的API:
1 | type NvidiaDevicePlugin struct { |
下面根据 Device Plugin
的生命周期,依次分析每个部分的实现机制。
NVIDIA
的 k8s-device-plugin
启动之后逻辑如下,总的来说干了三件事:
gRPC server
Kubelet
注册给定的 resourceName
unhealthy
的 channel 中1 | func (m *NvidiaDevicePlugin) Start() error { |
Serve
监听在/var/lib/kubelet/device-plugins/nvidia-gpu.sock
这 个 Unix Socket
,并且启动了 gRPC server
,其他的就是启动失败重试的逻辑了。
1 | func (m *NvidiaDevicePlugin) Serve() error { |
Register
通过和 /var/lib/kubelet/device-plugins/kubelet.sock
这个 Unix Socket
向 Kubelet
注册,传递了 DevicePlugin
的 Unix Socket
的 Endpoint、资源的名称、API的版本号等信息。
1 | func (m *NvidiaDevicePlugin) Register() error { |
这里调用了 nvml.NewEventSet
来监听 GPU 是否发生变化的事件,并且将 unhealthy Device
传递给 m.health
这个channel
。
1 | func checkHealth(stop <-chan interface{}, devices []*Device, unhealthy chan<- *Device) { |
1 | type ManagerImpl struct { |
Device Manager
在 kubelet
启动时的 NewContainerManager
中创建,属于 containerManager
的子模块。
1 | func NewContainerManager(mountUtil mount.Interface, cadvisorInterface cadvisor.Interface, nodeConfig NodeConfig, failSwapOn bool, devicePluginEnabled bool, recorder record.EventRecorder) (ContainerManager, error) { |
具体创建 DeviceManager
的代码如下:
1 | func newManagerImpl(socketPath string, numaNodeInfo cputopology.NUMANodeInfo, topologyAffinityStore topologymanager.Store) (*ManagerImpl, error) { |
其中除了构建 DeviceManager
相关的结构之外,另外做的一个事情就是注册了一个 callback
,用来处理对应 devices
的add
,delete
,update
事件。
1 | func (m *ManagerImpl) genericDeviceUpdateCallback(resourceName string, devices []pluginapi.Device) { |
接下来到了 DeviceManager
启动的方法,它读取了 checkpoint file
中的数据,恢复 ManagerImpl
中的相关数据,包括:
然后将 /var/lib/kubelet/device-plugins/
下面的除了 checkpiont文件
的所有文件清空,也就是清空所有的socket文件,包括自己的 kubelet.sock
,以及其他所有之前的 DevicePlugin
的socket文件。最后创建 kubelet.sock
并启动 gRPC Server
对外提供gRPC服务,其中 Register()
用于 DevicePlugin
调用进行插件注册。
1 | func (m *ManagerImpl) Start(activePods ActivePodsFunc, sourcesReady config.SourcesReady) error { |
DeviceManager
接收到 DevicePlugin
的 RegisterRequest请求,其结构体如下
1 | type RegisterRequest struct { |
检查注册的device Name、version是否符合 Extended Resource
的规则,Name不能属于kubernetes.i o,得有自己的domain,比如nvidia.com
根据 endpoint
信息创建 EndpointImpl
对象,即根据 endpoint
建立 socket
连接:
1 | func (m *ManagerImpl) RegisterPlugin(pluginName string, endpoint string, versions []string) error { |
下面是 endPointsImpl
的具体实现:
1 | type endpointImpl struct { |
执行 EndpointImpl
对象的 run()
,在 run
方法中:
1 | func (e *endpointImpl) run() { |
DevicePlugin
的ListAndWatch gRPC
接口,通过长连接持续获取 ListAndWatch gRPC stream
stream
流中获取的devices详情列表然后调用Endpoint的 callback
,也就是 ManagerImpl
注册的callback方法genericDeviceUpdateCallback
进行Device Manager的缓存更新并写到checkpoint文件中看一下 DevicePlugin
实现的 ListAndWatch
,先是立马返回device详情列表,然后开启协程,一旦感知device的健康状态发生变化了,更新 device
详情列表再次返回给 deviceManager
。回想起健康检查,DevicePlugin
的 CheckHealth
就就会将设备的健康状态传递给 m.health
这个 channel
。
1 | func (m *NvidiaDevicePlugin) ListAndWatch(e *pluginapi.Empty, s pluginapi.DevicePlugin_ListAndWatchServer) error { |
那么问题来了,DevicePlugin
是如何知道有多少 Device
的呢?我们看看 apiDevices
的实现:
1 | func (m *NvidiaDevicePlugin) apiDevices() []*pluginapi.Device { |
这里的 cachedDevices
是通过 ResourceManager
获得的 Device
信息,其具体通过 GpuDeviceManager
结构来实现,可以看到它们是调用了 nvml
库而实现的。这里还有一个 MigDeviceManager
本质上相同,不再概述。
1 | func (g *GpuDeviceManager) Devices() []*Device { |
kubelet
接收到被调度到本节点的pods后
当 Node 上的 Kubelet
监听到有新的 Pod
创建时,会调用 HandlerPodAdditions
来处理 Pod
创建的事件。
1 | func (kl *Kubelet) syncLoopIteration(configCh <-chan kubetypes.PodUpdate, handler SyncHandler, |
接下来进一步看下 HandlerPodAdditions
的实现,对于传入的每一个 Pod
,如果它没有被 terminate
,则通过 canAdmitPod
检查是否可以允许该 Pod
创建。
1 | func (kl *Kubelet) HandlePodAdditions(pods []*v1.Pod) { |
canAdmitPod
里面,Kubelet
将会依次执行每一个 admit handler
来看 Pod 能否通过。
1 | // "pod" is new pod, while "pods" are all admitted pods |
admitHandlers
是一个 PodAdmitHandler
的切片,其接口如下:
1 | type PodAdmitHandler interface { |
Kubelet
在创建的时候会添加一系列的 PodAdmitHandler
用于检查,对pod的资源做一些准入判断,比如:
evictionAdmitHandler
:当节点有内存压力时,拒绝创建best effort的pod,还有其它条件先略过TopologyPodAdmitHandler
:拒绝创建因为Topology locality冲突而无法分配资源的pod1 | klet.admitHandlers.AddPodAdmitHandler(evictionAdmitHandler) |
与我们 DevicePlugin
相关的则是 containerManager
的 resourceAllocator
,这里会分别调用 DeviceManager
和 CpuManager
的 Allocate
函数,看是否能够申请到相关的资源。这里会对 Pod 的每一个 InitContainer
和 Container
检查,看能否申请到。
1 | func (m *resourceAllocator) Admit(attrs *lifecycle.PodAdmitAttributes) lifecycle.PodAdmitResult { |
接下来我们看 ManagerImpl
的 Allocate
函数实现。
allocateContainerResources为
Pod中的regular container分配devices,并更新deviceManager中PodDevices缓存healthyDevices
中随机分配对应数量的devices给该Pod,并注意更新allocatedDevices,否则会导致一个device被分配给多个Pod。DevicePlugin
的 Allocate
方法,DevicePlugin
返回 ContainerAllocateResponse
(包括注入的环境变量、挂载信息、Annotations),deviceManager
pod uuid
和 container name
将返回的信息存入 podDevices
缓存,更新 podDevices
缓存信息,并将deviceManager
中缓存数据更新到 checkpoint
文件中。1 | func (m *ManagerImpl) Allocate(pod *v1.Pod, container *v1.Container) error { |
接下来我们看 allocateContainerResource
的实现,因为扩展资源是DevicePlugin
所发现的,而扩展资源不允许过量提交,因此要求容器中的 Request
与 Limits
相等,并且 DevicePlugin
会遍历所有的 Limits
保证资源是充足的。
1 | func (m *ManagerImpl) allocateContainerResources(pod *v1.Pod, container *v1.Container, devicesToReuse map[string]sets.String) error { |
我们看到,这里通过 resp, err := eI.e.allocate(devs)
执行 RPC 调用,进入到了 DevicePlugin
的逻辑。这里有一个问题,RPC
远程调用中的 deviceIDs
参数是怎么来的呢?我们看到这里有一个 devicesToAllocate
的调用。这里的主要逻辑如下:
resuableDevices
结构中拿到可以使用的设备列表,如果可用的足够则返回,否则继续从 healthyDevices
中找healthyDevices
去掉已经在使用的设备,然后检查是否足够,如果不够则报错1 | func (m *ManagerImpl) devicesToAllocate(podUID, contName, resource string, required int, reusableDevices sets.String) (sets.String, error) { |
RPC
调用成功后,会将对应的 Response
记录到 m.podDevices
中。
1 | func (pdev podDevices) insert(podUID, contName, resource string, devices sets.String, resp *pluginapi.ContainerAllocateResponse) { |
Allocate
接口给容器加上 NVIDIA_VISIBLE_DEVICES
环境变量,设置了相关的 DeviceSpec
参数,将 Response
返回给 Kubelet
。
1 | func (m *NvidiaDevicePlugin) Allocate(ctx context.Context, reqs *pluginapi.AllocateRequest) (*pluginapi.AllocateResponse, error) { |
前面我们提到, Nvidia的 gpu-container-runtime
根据容器的 NVIDIA_VISIBLE_DEVICES
环境变量,会决定这个容器是否为GPU容器,并且可以使用哪些GPU设备。 而Nvidia GPU device plugin做的事情,就是根据kubelet 请求中的GPU DeviceId, 转换为 NVIDIA_VISIBLE_DEVICES
环境变量返回给kubelet, kubelet收到返回内容后,会自动将返回的环境变量注入到容器中。当容器中包含环境变量,启动时 gpu-container-runtime
会根据 NVIDIA_VISIBLE_DEVICES
里声明的设备信息,将设备映射到容器中,并将对应的Nvidia Driver Lib 也映射到容器中。
在kubelet的 GetResource
中,会调用 DeviceManager
的 GetDeviceRunContainerOptions
,并将这些 options
添加到kubecontainer.RunContainerOptions
中。RunContainerOptions
包括 Envs
、Mounts
、Devices
、PortMappings
、Annotations
等信息。kubelet调用 GetResources()
为启动container
获取启动参数 runtimeapi.ContainerConfig{Args...}
1 | func (cm *containerManagerImpl) GetResources(pod *v1.Pod, container *v1.Container) (*kubecontainer.RunContainerOptions, error) { |
GetDeviceRunContainerOptions()
根据 pod uuid
和 container name
从 podDevices
缓存(device的分配过程中会设置缓存数据)中取出Envs、Mounts、Devices、PortMappings、Annotations等信息,另外对于一些PreStartRequired为true的 DevicePlugin
,deviceManager需要在启动container之前调用 DevicePlugin
的 PreStartContainer
grpc接口,做一些device的初始化工作,超时时间限制为30秒。
1 | func (m *ManagerImpl) GetDeviceRunContainerOptions(pod *v1.Pod, container *v1.Container) (*DeviceRunContainerOptions, error) { |
device的状态管理涉及到以下3个部分:
kubelet_node_status.go调用deviceManager的GetCapacity()获取device的状态,将device状态添加到node info并通过kube-apiserver存入etcd,GetCapacity()返回device server含有的所有device、已经分配给pod使用的device、pod不能使用的device即no-active的device kubelet_node_status.go根据返回的数据更新node info
1 | type PodDevicesEntry struct { |
只要device的状态发生了变化(如注册新device、device被分配、device的健康状态发生变化、device被删除),就要将podDevices存入kubelet_internal_checkpoint 文件。kubelet在启动或重启时,都需要读取kubelet_internal_checkpoint 文件里的数据,并以podDevices格式存入podDevices缓存。
DevicePlugin
上报device状态在device的注册部分已经讲解过,归纳为deviceManager
注册完 DevicePlugin
后,会跟 DevicePlugin
建立长连接,持续获取 DevicePlugin
的ListAndWatch结果,持续更新device状态;deviceManager
断开连接,将device设置为不健康的状态;DevicePlugin
默认会重启重新注册,重新上报device的状态随着近年来深度学习的爆发,原来被用于图形渲染的GPU被大量用于并行加速深度学习的模型训练中,在这个过程中 CUDA 作为 NVIDIA 推出的基于GPU的一个通用并行计算平台和编程模型也得到了广泛的使用。或许你已经十分了解 现代CPU的体系架构,但是对于GPU还不甚清晰,GPU的体系架构到底和CPU有何区别,CUDA模型是什么,我们该如何使用 CUDA 实现并行计算,本文将为你扫盲祛魅,本文中使用到的所有代码可以在我的 Github 中找到。
如前所述,GPU (Graphics Processing Unit)最开始只是用于游戏、视频中的图形渲染,而现在最热门的一个应用领域是在深度学习的加速计算上。为什么需要 GPU 来加速计算呢?我们知道,随着摩尔定律的发展,在过去五十年间CPU的性能获得了巨大的提升,不论是从芯片上晶体管数目,还是时钟频率,到后来的从单核处理器发展到后来的多核多处理器。
下图是过去五十年间各款CPU处理器上晶体管数目的变化,基本上满足每18个月提升一倍的规律,虽然现在看起来50十年后摩尔定律对CPU来说有停滞的迹象(这是另一个话题,此处不表)
在 CPU 算力快速提升的这五十年,人们需要的计算量也同时在迅猛发展着,从最开始的桌面互联网,到后来的移动互联网,以及5年前爆发的深度学习,无一不需要庞大的计算力。在这个过程中,仅仅依靠CPU的算力开始力有不逮,这个过程中像GPU、FPGA、DSP等异构计算单元开始得到广泛的应用。下面,我回归计算的本质,以GPU为例来分析为什么我们需要这些异构计算单元。
无论是 CPU 还是 GPU,我们可以把计算模型抽象为下面这张图,这也是典型的冯诺伊曼体系架构。
影响计算能力的4个主要因素如下:
对于CPU,依次分析这几个因素:
尽管现在CPU的能力还在发展,但是以上的问题极大的限制了其算力的提高,当前仅靠CPU已经不能够满足人们对庞大算力的需求了。因此我们需要其他的专用芯片来帮助CPU一起计算,这就是异构计算的来源。GPU等专用计算单元虽然工作频率较低,但具有更多的内核数和并行计算能力,总体性能/芯片面积比和性能/功耗比都很高。随着人工智能时代的降临,GPU从游戏走进了人们的视野。
无论是CPU还是GPU,在进行计算时都需要用核心(Core)来做算术逻辑运算。核心中有ALU(逻辑运算单元)和寄存器等电路。在进行计算时,一个核心只能顺序执行某项任务。CPU作为通用计算芯片,不仅仅做算术逻辑计算,其很重要的一部分功能是做复杂的逻辑控制,一般而言CPU上的Core数目相对较少,数据中心的服务器一般也就40左右个CPU核心。但是GPU动辄有上千个核心,这些核心可以独立的进行算术逻辑计算,大大提高了并行计算处理能力。
GPU时代的最大获益者是NVIDIA,当然AMD他们家也有GPU产品,但是因为AMD并没有形成CUDA这样的软件生态导致深度学习中主要用的都是NVIDIA的GPU,后面的分析都将基于NVIDIA的GPU产品。NVIDIA 不同时代产品的芯片设计不同,每代产品背后有一个架构代号,架构均以著名的物理学家为名,以向先贤致敬,对于消费者而言,英伟达主要有两条产品线:
GPU并不是一个独立运行的计算平台,而是需要与CPU的协同工作,可以看作是CPU的协处理器,因此当我们说GPU并行计算的时候,实质上是指的 CPU+GPU
的异构计算架构。由于CPU和GPU是分开的,在NVIDIA的设计理念里,CPU和主存被称为 Host,GPU和显存被称为 Device。Host 和 Device 概念会贯穿整个NVIDIA GPU编程。
基于 CPU + GPU 的异构计算平台可以优势互补,CPU负责处理逻辑复杂的串行程序,GPU重点处理数据密集型的并行计算程序,从而发挥最大功效。CUDA 程序中既包含 Host 程序,又包含 Device 程序,它们分别在CPU和GPU上运行。
同时, Host 与 Device 之间通过PCIe总线交互进行数据拷贝,典型的 CUDA 程序的执行流程如下:
GPU核心在做计算时,只能直接从显存中读写数据,程序员需要在代码中指明哪些数据需要从内存和显存之间相互拷贝。这些数据传输都是在总线上,因此总线的传输速度和带宽成了部分计算任务的瓶颈。当前最新的总线技术是NVLink,IBM的 Power CPU 和 NVIDIA 的高端显卡可以通过NVLink直接通信,Intel 的 CPU目前不支持NVLink,只能使用PCIe技术。同时,单台机器上的多张英伟达显卡也可以使用NVLink相互通信,适合多GPU卡并行计算的场景。
在 NVIDIA 的设计里,一张GPU卡有多个Streaming Multiprocessor(SM),每个 SM 中有多个计算核心,SM 是运算和调度的基本单元。下图为当前计算力最强的显卡Tesla V100,密密麻麻的绿色小格子就是GPU小核心,多个小核心一起组成了一个SM。
将 SM 放大,单个SM的结构如图所示:
可以看到一个SM中包含了计算核心和存储部分,SM的核心组件包括CUDA核心,共享内存,寄存器等,SM可以并发地执行数百个线程,并发能力就取决于SM所拥有的资源数。
具体而言,SM中的FP32进行32位浮点加乘运算,INT进行整型加乘运算,SFU(Special Functional Unit)执行一些倒数和三角函数等运算。Tensor Core是 NVIDIA 新的微架构中提出的一种混合精度的计算核心。我们知道,当前深度神经网络中使用到最频繁的矩阵运算是: $ D = A \times B + C $。Tensor Core可以对 $ 4 \times 4 $ 的矩阵做上述运算。其中:
Tensor Core是在 Volta 架构开始提出的,使用Volta架构的V100在深度学习上的性能远超Pascal架构的P100。
前面提到,NVIDIA 相对于 AMD 的一个巨大优势是它的 CUDA 软件生态,下图是 NVIDIA GPU 编程的软件栈,从底层的GPU驱动和CUDA 工具包,上面还提供了科学计算所必需的cuBLAS线性代数库,cuFFT快速傅里叶变换库以及cuDNN深度神经网络加速库,当前常见的 TensorFlow 和 PyTorch 深度学习框架底层大多都基于 cuDNN 库。
在进一步学习 CUDA 编程模型之前,我们首先配置好 CUDA 的运行环境,跑通 Hello World
从而对 CUDA 编程有一个直观的认识,这里使用的是腾讯云的 GPU 服务器,机器安装的是 CentOS 7 系统,CUDA 环境配置可以参考 CUDA Installation Guide Linux 。
根据上图的 NVIDIA GPU 软件栈,有了一个插上了 GPU 的服务器之后,我们首先查看机器上的 GPU,可以看到当前机器上装GPU是 Tesla P40
:
1 | $ lspci | grep -i nvidia |
接下来在 这里下载 CUDA Toolkit,这里选择的是 rpm local
的安装方式:
1 | $ wget https://developer.download.nvidia.com/compute/cuda/11.1.1/local_installers/cuda-repo-rhel7-11-1-local-11.1.1_455.32.00-1.x86_64.rpm |
执行上面的安装操作之后,我们可以看到在 /usr/lib64/
看到 libcuda.so
:
1 | $ ls /usr/lib64 -al | grep cuda |
下面是一些我们会经常用到的 CUDA 工具,你需要通过配置环境变量来使用他们:
1 | 编译器:nvcc (C/C++) |
设置环境变量如下:
1 | $ export PATH=/usr/local/cuda-11.1/bin${PATH:+:${PATH}} |
除此之外,对于 64 位系统,需要设置 LD_LIBRARY_PATH
:
1 | $ export LD_LIBRARY_PATH=/usr/local/cuda-11.1/lib64${LD_LIBRARY_PATH:+:${LD_LIBRARY_PATH}} |
这个时候可以确认驱动的版本:
1 | $ cat /proc/driver/nvidia/version |
可以使用nvidia-smi
命令查看显卡情况,比如这台机器上几张显卡,CUDA版本,显卡上运行的进程等。
1 | $ nvidia-smi |
CUDA
自己提供了一系列的代码示例,可以通过下面的方法安装:
1 | $ cuda-install-samples-11.1.sh <dir> |
在对应目录下,我们可以看到 CUDA
提供的源代码:
1 | $ ls NVIDIA_CUDA-11.1_Samples |
直接在这个目录下执行 make
,可以在 bin
目录下得到所有代码的二进制程序,选择其中的 deviceQuery
执行:
1 | $ ./deviceQuery |
到现在,CUDA Toolkit
安装完毕,接下来通过编写一个简单的 hello world
来直观感受 CUDA 编程:
1 |
|
可以看到,CUDA 程序基本上和标准 C 语言程序一样,主要的区别在于 __global__
限定词 和 <<<... >>>
符号。其中 __global__
标记用来告诉编译器这段代码会运行在 Device (GPU)上,它会被运行在 Host 上的代码调用,也被称作是在 Device 上线程中并行执行的核函数(Kernel),是在 Device 上线程中并行执行的函数。
当一个核函数被调用时,需要通过 <<<grid, block>>>
符号 来设置核函数执行时的配置,在 CUDA 的术语中,这称作 kernel lauch
,在后面我们将深入介绍这部分。
hello world
程序写完,我们以 hello.cu
这样的后缀名来保存,接下来使用 nvcc
来编译,整体上用法与 gcc
几乎一样:
1 | $ nvcc hello.cu -o hello |
可以看到,来自 CPU 的 Hello World
执行了一次,来自 GPU 的 Hello World
执行了8次。
上文提到,为了实现 GPU 并行加速计算,我们需要在 Host 上执行 kernel launch
,让 核函数 在 Device 上的多个线程并发执行。具体的方式就是在调用核函数的时候通过 <<<grid, block>>>
来指定核函数要执行的线程数量N,之后GPU上的N个Core会并行执行核函数,并且每个线程会分配一个唯一的线程号threadID,这个ID值可以通过核函数的内置变量threadIdx
来获得。
CUDA将核函数所定义的运算称为线程(Thread),多个线程组成一个块(Block),多个块组成网格(Grid)。这样一个Grid可以定义成千上万个线程,也就解决了并行执行上万次操作的问题。 <<<grid, block>>>
中括号中第一个数字表示整个Grid有多少个Block,括号中第二个数字表示一个Block有多少个Thread。前面 Hello World
用 2 个Block,每个Block中有4个Thread,所以总共执行了8次。
实际上,线程(Thread)是一个编程上的软件概念。从硬件来看,Thread运行在一个CUDA核心上,多个Thread组成的Block运行在Streaming Multiprocessor(SM),多个Block组成的Grid运行在一个GPU显卡上。当一个 kernel
被执行时,它的gird中的线程块被分配到SM上,一个线程块只能在一个SM上被调度。SM一般可以调度多个线程块,这要看SM本身的能力。那么有可能一个 kernel
的各个线程块被分配多个SM,所以grid只是逻辑层,而SM才是执行的物理层。
grid
和 block
都是定义为dim3
类型的变量,dim3
可以看成是包含三个无符号整数(x,y,z)成员的结构体变量,在定义时,缺省值初始化为1。因此 grid
和 block
可以灵活地定义为 1-dim
,2-dim
以及3-dim
结构,对于上图中结构(主要水平方向为x轴),定义的 grid
和 block
如下所示, kernel
在调用时也必须通过执行配置<<<grid, block>>>
来指定 kernel
所使用的线程数及结构。
1 | dim3 grid(3, 2); |
所以,一个线程需要两个内置的坐标变量(blockIdx,threadIdx)
来唯一标识,它们都是dim3
类型变量,其中blockIdx指明线程所在grid中的位置,而threaIdx指明线程所在block中的位置,如图中的 Thread (1,1)
满足:
1 | threadIdx.x = 1 |
不同的执行配置会影响GPU程序的速度,一般需要多次调试才能找到较好的执行配置,在实际编程中,执行配置<<<grid, block>>>
应参考下面的方法:
block
(执行配置中第二个参数)。一个Block中的Thread数最好是32、128、256的倍数。注意,限于当前硬件的设计,Block大小不能超过1024。grid
(执行配置中第一个参数),即一个Grid中Block的个数可以由总次数N
除以block
,并向上取整。例如,我们想并行启动1000个Thread,可以将blockDim设置为128,1000 ÷ 128 = 7.8
,向上取整为8。使用时,执行配置可以写成gpuWork<<<8, 128>>>()
,CUDA共启动8 * 128 = 1024
个Thread,实际计算时只使用前1000个Thread,多余的24个Thread不进行计算。
这几个变量比较容易混淆,再次明确一下:block
是Block中Thread的个数,一个Block中的threadIdx
最大不超过block
;grid
是Grid中Block的个数,一个Grid中的blockIdx
最大不超过grid
。
这几个变量比较容易混淆,再次明确一下:block
是Block中Thread的个数,一个Block中的threadIdx
最大不超过block
;grid
是Grid中Block的个数,一个Grid中的blockIdx
最大不超过grid
。
kernel
的这种线程组织结构天然适合vector,matrix等运算,我们将在后面实现向量加法和矩阵乘法。如我们将利用上图2-dim结构实现两个矩阵的加法,每个线程负责处理每个位置的两个元素相加,代码如下所示。线程块大小为(16, 16),然后将 $ N*N $ 大小的矩阵均分为不同的线程块来执行加法运算。
SM采用的是SIMT (Single-Instruction, Multiple-Thread,单指令多线程)架构,基本的执行单元是 线程束(wraps),线程束包含32个线程,这些线程同时执行相同的指令,但是每个线程都包含自己的指令地址计数器和寄存器状态,也有自己独立的执行路径。
当线程块被划分到某个SM上时,它将进一步划分为多个线程束,因为这才是SM的基本执行单元,但是一个SM同时并发的线程束数是有限的。这是因为资源限制,SM要为每个线程块分配共享内存,而也要为每个线程束中的线程分配独立的寄存器。所以SM的配置会影响其所支持的线程块和线程束并发数量。由于SM的基本执行单元是包含32个线程的线程束,所以block大小一般要设置为32的倍数。(16, 16)
的二维Block是一个常用的配置,共256个线程。之前也曾提到过,每个Block的Thread个数最好是128、256或512,这与GPU的硬件架构高度相关。
1 | // Kernel定义 |
线程块中的线程数是有限制的,现代GPUs的线程块可支持的线程数可达1024个。有时候,我们要知道一个线程在 blcok
中的全局ID,此时就必须还要知道 block
的组织结构,这是通过线程的内置变量 blockDim
来获得。它获取线程块各个维度的大小。
2-dim
的block $ (D_x, D_y) $ ,线程 $ (x, y) $ 的ID值为 $ (x + y * D_x) $ 3-dim
的block $ (D_x, D_y, D_z) $,线程 $(x, y, z)$ 的ID值为 $ (x + y D_z + z D_z * D_y) $ 另外线程还有内置变量 gridDim
,用于获得网格块各个维度的大小。
此外这里简单介绍一下CUDA的内存模型,如下图所示。可以看到,
下面简单介绍一下CUDA编程中内存管理常用的API。首先是在 Device 上分配内存的 cudaMalloc
、cudaFree
和 cudaMemcpy
函数,分别对应C语言中的 malloc
、free
和 memcpy
函数:
1 | // 在 Device 上申请一定字节大小的显存,其中 `devPtr` 是指向所分配内存的指针 |
知道了CUDA编程基础,接下来我们以两个向量的加法为例,介绍如何利用CUDA编程来实现GPU加速计算。
我们首先来看利用 CPU 来计算向量加法该如何编程:
1 |
|
我们将 CPU 的向量加法转换成 CUDA 程序,使用 GPU 来计算,下面这段代码演示了如何使用 CUDA 编程规范来编写程序。实际上仍然只是使用一个 core
来进行计算,不仅没有提高并行度,反而还增加了数据拷贝的成本,显然相比原来的计算是会更慢的,这里主要作为演示。
1 |
|
为了提高并行度,我们设置一个 Block
多个 Thread
同时进行计算,如下图所示总共有256个Thread
,每个 Thread 负责处理 Vector 中的一部分。每一次迭代中,256个Thread分别计算 Vector 的这256个数,然后在下一次迭代中每个Thread往后推进256个数,继续计算。
1 |
|
相比 CPU 程序,这里的并行度显著提高,GPU 计算的时间也大大减小。
在上一个方案中,我们的256个Thread仍然需要计算多个数字,如果我们将并行度继续扩大,让每个Thread只需要计算Vector中的一个数,那么计算消耗时间将会更短。如下图所示,我们使用多个Block多个Thread,其中每个Block还是256个Thread,但是我们现在的Grid有多个Block,Block数字由Vector的长度除以BlockSize得到。
1 |
|
在上面的实现中,我们需要单独在 Host 和 Device 上进行内存分配,并且要进行数据拷贝,这是很容易出错的。好在CUDA 6.0引入统一内存(Unified Memory)来避免这种麻烦,简单来说就是统一内存使用一个托管内存来共同管理 Host 和 Device 中的内存,并且自动在 Host 和 Device 中进行数据传输。CUDA中使用cudaMallocManaged函数分配托管内存:
1 | cudaError_t cudaMallocManaged(void **devPtr, size_t size, unsigned int flag=0); |
利用统一内存,可以将上面的程序简化如下:
1 |
|
相比之前的代码,使用统一内存更简洁了,值得注意的是 kernel
执行是与 Host 异步的,由于托管内存自动进行数据传输,这里要用cudaDeviceSynchronize()
函数保证 Device 和 Host 同步,这样后面才可以正确访问 kernel
计算的结果。
「政治坐标系」的概念来源于著名的political compass
,用于表明一个人的政治倾向。这里是我的政治坐标测试,其中「中国政治坐标测试」最早是 2007 年北大未名 BBS 的同学们讨论制作的,并在后期根据中国实际情况进行了订正和修改,在 这里 可以看到目前的版本。令我感到惊讶的是,居然在这个帖子下面看到了木遥的踪迹,世界真小。
需要强调说明的是,这个测试初始并且唯一的目标在于给使用者提供一个自我思考和认同的提示器。
「公共政治议题讨论的阙失和长期的无限夸大式的政治宣传方式,使得很多人几乎是凭着脑海中浮现的口号来作出自己的选择,而完全不曾在理性上真正确认过自己的立场。」这是我对现实的悲观理解。这个问卷如此流行,足以反过来说明政治观点的分歧和相关观点在意识层面上(而非政策层面上)的讨论和争锋如何构成了公众生活的禁忌。网上关于这个测试的很多评论都反映出很多人并不习惯于拥有自己的观点,更不用说是在如此广泛的层面上。我相信这并非出自天性,而只是长期的怠惰使然。
与此同时,我也附上了来自英文「政治指南针」网站的西方政治坐标测试,这份测试系统建立于西方政治价值体系基础之上,某些问题强烈的依赖于具体的西方社会环境,未必能够充分反映中国国情。 不管怎样,倒也可以提供一个自我思考的提示器。
整个测试有 50 道题,分别从政治、经济、文化三个方面界定。这里列出了我在今天的选择,具体打分可到原网页进行测试。
三个维度的最大区间均为 [-2,2]。
本测试系统建立于中国政治价值体系基础之上,试图充分反映中国的特殊国情与政治文化。请注意,很多问题反映的是中国现实语境中的「左与右」,而非严格意义上的西方政治语汇中的「左与右」。
整个测试做完,我的得分如下:
1 | 政治立场坐标: 0.4 |
什么意思呢?也就是说,我政治观念偏自由主义,社会文化观念偏自由主义,经济观念偏集体主义。这个测试结果和我在 IDRlabs上面的政治观点测试大体类似,整体上政治文化偏自由,但是很明显经济方面自己的不确定性太大,整体上属于温和中间派。
倒也不是说通过这个测试就对我的左右进行了划分,把我划分成左派或右派。左右意识形态的纠葛在过去一百多年给人类社会带来撕扯与分裂,以至于对于左和右的定义国内国外都不太一样。一直以来,我的观点就是搁置意识形态上的争论,踏踏实实的讨论实际问题。但是搁置争论并不等于没有自己的观点,并不等于不去思考这些问题,而这个测试恰恰提供了这样的机会。
关于这五十个问题,做的时候有的并不是百分百的确定,很多问题涉及到经济问题。经济基础决定上层建筑,经济问题是可以用数学来解释的,过段时间等对于经济问题有了更多的理解后,再来做这个测试,或许答案又不一样了。
下面是我的测试结果,经济上偏自由,政治上偏自由。
横坐标反映经济观念,负值为左(Communism, Collectivism),正值为右(Neo-Liberalism, Libertaranism)。纵坐标反映政治社会观念,负值为自由(Anarchism, Libertarian),正值为专制或保守(Facism, Authoritarian)。
本测试系统建立于西方政治价值体系基础之上,某些问题强烈的依赖于具体的西方社会环境,未必能够充分反映中国国情。根据周围人群的实验结果,中国人的测试结果普遍位于第三象限(即两坐标均为负值),平均值位于(-2,-2)附近。为了区分中国人习惯意义上的「左与右」,可以以(-2,-2)为坐标原点重新划分坐标平面,即经济坐标小于-2为左,反之为右。政治坐标小于-2为自由,反之为保守或专制。
下面是著名政治人物的坐标位置以供参考:
还是原来的观点,这个测试结果并不一定代表什么,但是可以作为参考。最重要的是,给自己提供了一个思考的机会。很多问题选择不够坚决,说明很多时候对这方面的思考欠缺。这个测试不应该是一次性的测试,随着人的动态变化,观点也在发生改变。在以后的时间,可以回头再看这些问题。
]]>虚拟化的本质是抽象,虚拟化技术本质就是资源管理与优化技术。通过将计算机的各种物理资源,比如 CPU、内存以及磁盘空间、网络适配器等其他 I/O 设备,进行抽象转换,呈现出一个可供分割并且可以任意组合的多个计算机的配置环境。通过虚拟化技术,计算、网络、存储等计算机硬件资源得到更好的利用,而这些资源的虚拟形式将不受现有架设方式、地域或物理配置所限制。
在计算机领域,研究的一切问题都是 可计算问题(Computational Problem)。
A computational problem is collection of questions that computers might be able to solve.
通过对问题可计算的判定,我们知道不管计算机的存储和计算能力有多强,有些问题总是不能够被解决的。对于那些可计算的问题,怎么解决呢?1936年,图灵在现代计算领域奠基性论文 「论可计算数及其在判定性问题上的应用」On Computable Numbers, with an Application to the Entscheidungsproblem 中提出 图灵机 这一纸带和读写头表示的数学模型,并且证明了假设上述模型里所说的功能都能被以某种形式物理实现,那么 任意可计算问题都可以被解决
。
二战极大促进了电子计算机的诞生,为了帮助美国陆军的弹道研究实验室(BRL)计算火炮的火力表, ENIAC 在 1946 年被设计了出来。ENIAC 并不是二战中第一个被设计出来的计算机,机械和电子计算机器从19世纪就开始出现了,但是20世纪40年代被看作是现代计算机时代的开端。
对比这些几乎同时期独立的计算机,ENIAC有以下特点:
最初的计算机都是串行运行的,一次只能录入并执行一个程序,当程序进行缓慢的 IO 操作时,CPU 只好空转等待。这不仅造成了 CPU 的浪费,也造成了其他计算机硬件资源的浪费。那时的计算机科学家们都在思考着要如何能够提高 CPU 的利用率,直到有人提出了多道程序设计(Multiprogramming,多任务处理的前身)。
在整个上世纪 50-60 年代,多道程序设计的讨论非常流行。它令 CPU 一次性读取多个程序到内存,先运行第一个程序直到它出现了 IO 操作,此时 CPU 切换到运行第二个程序。
即,第 n+1 个程序得以执行的条件是第 n 个程序进行 IO 操作或已经运行完毕。
多道程序设计的特征就是:多道程序、宏观上并行、微观上串行。有效的提高了 CPU 的利用率,也充分发挥着其他计算机系统部件的并行性。
但多道程序设计存在一个问题, 就是它并不会去考虑分配给各个程序的时间是否均等,很可能第一个程序运行了几个小时而不出现 IO 操作,故第二个程序没有运行。最初,这个问题是令人接受的,那时的必须多个程序之间的执行顺序更加关心程序的执行结果。直到有人提出了新的需求:多用户同时使用计算机。应需而生的正是时间共享,或者称之为 “分时” 的概念(Time Sharing)。
所谓 “分时” 的含义是将 CPU 占用切分为多个极短(1/100sec)的时间片,每个时间片都执行着不同的任务。分时系统中允许几个、几十个甚至几百个用户通过终端机连接到同一台主机,将处理机时间与内存空间按一定的时间间隔,轮流地切换给各终端用户的程序使用。由于时间间隔很短,每个用户感觉就像他独占了计算机一样。分时系统达到了多个程序分时共享计算机硬件和软件资源的效果,本质就是一个多用户交互式操作系统。
分时系统与多道程序设计虽然类似,却也有着底层实现细节的不同
1959 年,牛津大学的计算机教授,Christopher Strachey 发表了一篇名为 Time sharing in large fast computers 的学术报告,他在文中首次提出了 “虚拟化” 的基本概念,还论述了什么是虚拟化技术。
Time sharing, in the sense of causing the main computer to interrupt its program to perform the arithmetic and control operations required by external or peripheral equipment, has been used on a limited scale for a long time. this paper explores the possibility of applying time sharing to a large fast computer on a very extensive scale.
本质上,Strachey 是在讨论如何将分时的概念融入到多道程序设计当中,从而实现一个可多用户操作(CPU 执行时间切片),又具有多程序设计效益(CPU 主动让出)的虚拟化系统。可见,虚拟化概念最初的提出就是为了满足多用户同时操作大型计算机,并充分利用大型计算机各部件资源的现实需求。而对这一需求的实现与演进,贯穿了整个大型机与小型机虚拟化技术的发展历程。
1961年 MIT 在 IBM7094 型机器上实现了首个分时系统CTSS(Compatible Time-Sharing System,相容分时系统)
1962 年 12 月 7 日,第一台 Atlas 超级计算机在英国诞生,Atlas 是第二代晶体管计算机,被认为是当时世界上最强大的计算机。Atlas 开创了许多沿用至今的软件概念:
1964 年的 IBM M44/44X 被认为是世界上第一个支持虚拟化的系统。它采用专门的硬件和软件,能够在一台物理机器上虚拟多个当时流行的 IBM 7044 大型机。它使用的虚拟化方法是非常原始的:像分时系统一样,在每个时间片,一个 IBM 7044 大型机独占所有硬件资源来运行。
值得一提的是,这个研究用的原型系统不仅开启了虚拟化技术的时代,M44/44X 实现了多个具有突破性的虚拟化概念,包括部分硬件共享(Partial Hardware Sharing)、分时(Time Sharing)、内存分页(Memory Paging)以及虚拟内存(Virtual Memory)。M44/44X 项目首次使用了 “Virtual Machine” 这一术语,所以被认为是世界上第一个支持虚拟机的计算机系统。虽然 M44/44X 只实现了部分的虚拟化功能,但其最大的成功在于证明了虚拟机的运行效率并不一定比传统的方式更低
在那个 “进程” 概念尚未被发明的年代,多任务操作系统和虚拟化技术事实上是难以分开的,因为 “虚拟机” 就是一个任务,而且当时还没有 Intel x86 这种霸主地位的体系结构,各家的大型机各自为政,也谈不上兼容别家的体系结构。这种 “任务级” 或者说 “进程级” 虚拟化,从概念上延续到今天,就是以 LXC 和 OpenVZ 为代表的操作系统级虚拟化。
1964 年,IBM推出了著名的 System/360 大型计算机系统,整个研发过程投资巨大,其出货时间也不断延迟。但最终,取得了巨大的商业成功。当时的项目经理 Frederick P. Brooks
事后根据这项计划的开发经验写出了同样著名的《人月神话:软件项目管理之道》(“The Mythical Man-Month: Essays on Software Engineering”),记述了人类工程史上一项里程碑式的大型复杂软件系统的开发经验。
虚拟化技术的应用和发展源于大型机对分时系统的需求。这种通过硬件的方式来生成多个可以运行独立操作系统软件的虚拟机实例,解决了早期大型计算机只能单任务处理而不能分时多任务处理的问题。由于这种虚拟化技术是基于硬件设备来实现的,故被称为硬件虚拟化(Hardware virtualization)。但需要注意的是,这一定义在后来被进一步细分为了狭义的硬件虚拟化技术,现今更加被公认的硬件虚拟化定义是:一种对计算机或操作系统的虚拟化,能够对用户隐藏真实的计算机硬件,表现出另一个抽象的计算平台。
MULTICS,全名 MULTiplexed Information and Computing System
,是1964年由贝尔实验室、麻省理工学院及美国通用电气公司所共同参与研发的,是一套安装在大型主机上多人多任务的操作系统,是连接1000部终端机,支持300的用户同时上线。
MULTICS 是一个伟大的实验,得意于第一代分时系统 CTSS 的成功,它在开发之初就提出了很高的要求:
然而,由于当时编写 MULTICS 的 PL/I 语言并没有很成熟,无力肩负编写操作系统这样的重担。而且整个开发过程中求大求全,多个单位参与,进展过慢,贝尔实验室退出此计划。
1969年,在 AT&T 的Bell Labs,Ken Thompson
为了一项名为Space Travel
的游戏,需要一个操作系统。他找了一台闲置的PDP-7 小型机,独自经过 4 个星期的奋斗,以汇编语言写出了一组内核程序,同时包括一些内核工具程序,以及一个小的文件系统,这就是伟大的 UNIX 操作系统的原型。
UNIX 系统本质上是对 MULTICS 系统的简化,当时开发者 Brian Kernighann
开玩笑地戏称这个不完善系统MULTICS其实是 UNiplexed Information and Computing System
,缩写为UNICS
。后来,大家取其谐音这个名字被改为UNIX
。
1973 年,贝尔实验室的Dennis Ritchie
以 B 语言为基础开发了一种称为 C 的编程语言。C 语言的设计原则就是好用,非常自由、弹性很大。Ken Thompson
和Dennis Ritchie
使用 C 语言完全重写了 UNIX,此后 UNIX 就真正成为了可移植的操作系统,那时已是 1977 年。
1979 年,Unix 的第 7 个版本引入了 chroot 机制,意味着第一个操作系统虚拟化(OS-level virtualization)诞生了。chroot 是直到现在我们依然在使用的一个系统调用,这个系统调用会让一个进程把指定的目录作为根目录,它的所有文件系统操作都只能在这个指定目录中进行,本质是一种文件系统层的隔离。
1974 年,Gerald J. Popek
和 Robert P. Goldberg
在合作论文《可虚拟第三代架构的规范化条件》(“Formal Requirements for Virtualizable Third Generation Architectures”)中提出了一组称为虚拟化准则的充分条件,又称波佩克与戈德堡虚拟化需求(Popek and Goldberg virtualization requirements),即:虚拟化系统结构的三个基本条件。满足这些条件的控制程序才可以被称为虚拟机监控器(Virtual Machine Monitor,简称 VMM):
该论文尽管基于简化的假设,但上述条件仍为评判一个计算机体系结构是否能够有效支持虚拟化提供了一个便利方法,也为设计可虚拟化的计算机架构给出了指导原则。同时,Gerald J. Popek 和 Robert P. Goldberg 还在论文中介绍了两种 Hypervisor 类型。
由于技术的原因,早期的 VMM 产品大多实现的是寄居式,例如:VMware 5.5 以前的版本、Xen 3.0 以前的版本。随着技术的成熟,主要是硬件虚拟化技术的诞生,几乎所有的 VMM 产品都转向了裸金属 Hypervisor 实现。例如:VMware 5.5 及以后版本、Xen 3.0 及以后版本以及 KVM。
2001,Fabrice Bellard 发布了目前最流行的、采用了动态二进制翻译(Binary Translation)技术的开源虚拟化软件 QEMU(Quick EMUlator)。QEMU 可以模拟 x86、x86_64、ARM、MIPS、SPARC、PowerPC 等多种处理器架构,无修改地运行这些架构上的操作系统。
软件辅助虚拟化 是通过 优先级压缩(Ring Compression)和 二进制代码翻译(Binary Translation)这两个技术来完成的。RC 基于 CPU 特权级的原理。也就是 guest、VMM 和 host 分别处于不同的特权级上,guest 要访问 host 就属于越级访问,会抛异常,这时 VMM 会截获这个异常,并模拟出其可能的行为,从而进行相应处理。
以我们最熟悉的 Intel x86 架构为例,分为四个特权级 0~3。一般情况下,操作系统内核(特权代码)运行在 ring 0(最高特权级),而用户进程(非特权代码)运行在 ring 3(最低特权级)。
使用了虚拟机之后,Guest OS 运行在 ring 1,VMM 运行在 ring 0。比如在 Windows 上装个 Linux 虚拟机,Windows 内核运行在 ring 0,而被虚拟的 Linux 内核运行在 ring 1,Linux 系统里的应用程序则运行在 ring 3。当虚拟机系统需要执行特权指令时,VMM 就会立即捕获它(谁让 ring 0 比 ring 1 的特权级高呢!)并模拟执行这条特权指令,再返回到虚拟机系统。
为了提高系统调用、中断处理的性能,有时会利用动态二进制翻译的技术,在运行前把这些特权指令替换成调用虚拟机管理器 API 的指令。如果所有特权指令都模拟得天衣无缝,虚拟机系统就像运行在物理机器上一样,完全不能发现自己运行在虚拟机里。
2003 年,英国剑桥大学的一位讲师发布了开源虚拟化项目 Xen 1.0,通过半虚拟化技术为 x86-64 提供虚拟化支持。
既然动态二进制翻译的难点和性能瓶颈在于模拟执行那些杂七杂八的特权指令,我们能不能修改虚拟机系统的内核,把那些特权指令改得好看些?毕竟在多数情况下,我们并不需要对虚拟机刻意 “隐瞒” 虚拟化层的存在,而是要在虚拟机之间提供必要的隔离,同时又不造成太多性能开销。
Paravirtualization 这个单词的前缀是 para-,即 “with” “alongside” 之意。也就是虚拟机系统与虚拟化层(主机系统)不再是严格的上下级关系,而是互信合作的关系,虚拟化层要在一定程度上信任虚拟机系统。在 x86 架构中,虚拟化层(Virtualization Layer)和虚拟机系统的内核(Guest OS)都运行在 ring 0。
虚拟机系统的内核需要经过特殊修改,把特权指令改成对虚拟化层 API 的调用。在现代操作系统中,由于这些体系结构相关的特权操作都被封装起来了(例如 Linux 内核源码中的 arch/ 目录),比起二进制翻译需要考虑各种边角情况,这种对虚拟机内核源码的修改就简单一些了。
相比使用二进制翻译的全虚拟化(full virtualization),半虚拟化是牺牲了通用性来换取性能,因为任何操作系统都可以无修改地运行在全虚拟化平台上,而每个半虚拟化的操作系统内核都要经过人肉修改。
2006 年,Intel 和 AMD 等厂商相继将对虚拟化技术的支持加入到 x86 体系结构的CPU中(AMD-V,Intel VT-x/d),使原来纯软件实现的各项功能可以用借助硬件的力量实现提速,此即 硬件辅助的虚拟化。
Xen这种将 Guest OS 中的特权指令改成对虚拟化层 API 的调用方式并不通用,要去改 Guest OS 的代码,只能看作是一种定制。为了能够通用,又能够提高性能,就只能从硬件上去做文章了。通过对硬件本身加入更多的虚拟化功能,就可以截获更多的敏感指令,填补上漏洞。所以后来,以 Intel 的 VT-x 和 AMD 的 AMD-V 为主的硬件辅助的 CPU 虚拟化就被提出来(Intel VT 包括 VT-x (支持 CPU 虚拟化)、EPT(支持内存虚拟化)和 VT-d(支持 I/O 虚拟化))。
CPU 硬件辅助虚拟化在 Ring 模式的基础上引入了一种新的模式,叫 VMX 模式。它包括根操作模式(VMX Root Operation)和非根操作模式(VMX Non-Root Operation)。
引入这种模式的好处就在于,Guest OS 运行在 Ring 0 上,就意味着它的核心指令可以直接下达到硬件层去执行,而特权指令等敏感指令的执行则是由硬件辅助,直接切换到 VMM 执行,这是自动执行的,应用程序是感知不到的,性能自然就提高了。
这种切换 VT-x 定义了一套机制,称为 VM-entry 和 VM-exit。从非根模式切换到根模式,也就是从 Guest 切换到 Host VMM,称为 VM-exit,反之称为 VM-entry。
VMCAL
指 令调用 VMM 服务的时候(类似于系统调用),硬件自动挂起 Guest OS,切换到根模式,VMM 开始执行。VMLAUNCH
或 VMRESUME
指令切换到非根模式,硬件自动加载 Guest OS 的上下文,Guest OS 开始执行。2007 年 2 月,Linux Kernel 2.6.20 合入了 KVM 内核模块,使用 KVM 的前提是 CPU 必须要支持虚拟化技术。
一般 KVM 只负责 CPU 和内存的虚拟化,I/O 的虚拟化则由另外一个技术来完成,即 QEMU。
KVM 是一种硬件辅助的虚拟化技术,支持 Intel VT-x 和 AMD-v 技术,怎么知道 CPU 是否支持 KVM 虚拟化呢?可以通过如下命令查看:
1 | # grep -E '(vmx|svm)' /proc/cpuinfo |
如果输出是 vmx 或 svm,则表明当前 CPU 支持 KVM,Intel 是 vmx,AMD 是svm。
从本质上看,一个 KVM 虚拟机对应 Host 上的一个 qemu-kvm 进程,它和其他 Linux 进程一样被调度,而 qemu-kvm 进程中的一个线程就对应虚拟机的虚拟 CPU (vCPU),虚拟机中的任务线程就被 vCPU 所调度。
比如下面这个例子,Host 机有两个物理 CPU,上面起了两个虚拟机 VM1 和 VM2,VM1 有两个 vCPU,VM2 有 3 个 vCPU,VM1 和 VM2 分别有 2 个 和 3 个线程在 2 个物理 CPU 上调度。VM1 和 VM2 中又分别有 3 个任务线程在被 vCPU 调度。
所以,这里有两级的 CPU 调度,Guest OS 中的 vCPU 负责一级调度,Host VMM 负责另一级调度,即 vCPU 在物理 CPU 上的调度。
我们也可以看到,vCPU 的个数,可以超过物理 CPU 的个数,这个叫 CPU 「超配」,这正是 CPU 虚拟化的优势所在,这表明了虚拟机能够充分利用 Host 的 CPU 资源,进行相应的业务处理,运维人员也可以据此控制 CPU 资源使用,达到灵活调度。
The Google File System
,讲述了一种可扩展的分布式文件系统MapReduce: Simplified Data Processing on Large Clusters
,讲述了大数据的分布式计算方式,即将任务分解然后在多台处理能力较弱的计算节点中同时处理,然后将结果合并从而完成大数据处理。Bigtable: A Distributed Storage System for Structured Data
,讲述了用于存储和管理结构化数据的分布式存储系统,其建立在 GFS、MapReduce 等基础之上。该论文启发了后期的很多的 NoSQL 数据库,包括 Cassandra、HBase 等。在 Google 的三篇论文发布之后,大数据时代宣告到来,于此同时,Hadoop 生态开始建立。
2006 年,Amazon Web Services 开始以 Web 服务的形式向企业提供 IT 基础设施服务,包括弹性计算网云(EC2)、简单储存服务(S3)、简单数据库(SimpleDB)等,现在通常称为云计算。尽管云计算最早是由谷歌CEO Eric Schmidt
,真正第一个吃螃蟹的人却是 Amazon。
2008 年 6 月,Linux Container(LXC) 发布 0.1.0 版本,其可以提供轻量级的虚拟化,用来隔离进程和资源,是 Docker 最初使用的容器技术支撑。
很多时候,我们并不是想在虚拟机里运行任意的操作系统,而是希望在不同的任务间实现一定程度的隔离。前面提到的虚拟化技术,每个虚拟机都是一个独立的操作系统,有自己的任务调度、内存管理、文件系统、设备驱动程序等,还会运行一定数量的系统服务(如刷新磁盘缓冲区、日志记录器、定时任务、ssh 服务器、时间同步服务),这些东西都会消耗系统资源(主要是内存),而且虚拟机和虚拟机管理器的两层任务调度、设备驱动等也会增加时间开销。能不能让虚拟机共享操作系统内核,又保持一定的隔离性呢?
chroot 的文件系统隔离给我们带来部分的思路,但是要成为一个真正的虚拟化解决方案,只有文件系统隔离是不够的。另外两个重要的方面是:
上述两件事情就是 BSD 和 Linux 社区在进入 21 世纪以来逐步在做的。在 Linux 中,命名空间的隔离叫做用户命名空间,在创建进程时,通过指定 clone 系统调用的参数来创建新的命名空间;资源的限制和审计是 cgroups 做的,它的 API 位于 proc 虚拟文件系统中。
这种虚拟机里运行一个或多个进程、虚拟机与主机共享一个内核的虚拟化方案,被称为 操作系统级虚拟化 或 任务级虚拟化。由于 Linux Containers(LXC)从 Linux 3.8 版本开始被纳入内核主线,操作系统级虚拟化又被称为 “容器”(container)。为了与虚拟机是一个完整的操作系统的虚拟化方案相区分,被隔离执行的进程(进程组)往往不称为 “虚拟机”,而称为 “容器”。由于没有多余的一层操作系统内核,容器比虚拟机更加轻量,启动更快,内存开销、调度开销也更小,更重要的是访问磁盘等 I/O 设备不需要经过虚拟化层,没有性能损失。
2010 年 7 月,NASA 和 Rackspace 联合发起了 OpenStack 云操作系统开源项目。
OpenStack 要对云上的各种资源进行虚拟化:
除了最核心的虚拟化管理器 Nova,OpenStack 还有虚拟机镜像管理器 Glance、对象存储 Swift、块存储 Cinder、虚拟网络 Neutron、身份认证服务 Keystone、控制面板 Horizon 等众多组件。
2014 年 6 月,Docker 基于 LXC 发布了第一个正式版本 v1.0。
Docker 是为系统运维而生,它大大降低了软件安装、部署的成本。软件的安装之所以是个麻烦事,是因为
软件之间存在依赖关系。比如,Linux 上依赖标准 C 库 glibc,依赖密码学库 OpenSSL,依赖 Java 运行环境;Windows 上依赖 .NET Framework,依赖 Flash 播放器。如果每个软件都带上它所有的依赖,那就太臃肿了,如何找到并安装软件的依赖,是一门大学问,也是各个 Linux 发行版的特色所在。
软件之间存在冲突。比如,程序 A 依赖 glibc 2.13,而程序 B 依赖 glibc 2.14;甲脚本需要 Python 3,乙脚本需要 Python 2;Apache 和 Nginx 两个 Web 服务器都想要监听 80 端口。互相冲突的软件安装在同一个系统里,总是容易带来一些混乱,比如 Windows 早期的 DLL Hell。解决软件冲突之道就是隔离,让多个版本在系统里共存,并提供方法来找到匹配的版本。
我们看看 Docker 如何解决这两个问题:
Docker 最开始基于 LXC 实现,后来则是基于 libcontainer。libcontainer 和 LXC 事实上都是基于 Linux 内核提供的 cgroups 资源审计、chroot 文件系统隔离、命名空间隔离等机制。
2015 年 7 月 21 日:Kubernetes v1.0 发布!进入云原生时代。
实际上,上述从二十世纪四十年代以来的发展历程,主要说的是计算虚拟化的事情,也就是 CPU 虚拟化。CPU 虚拟化固然是核心中的核心,但是计算机其他组件的虚拟化也不容忽视,比如内存的虚拟化,包括存储、网络等在内的 I/O 虚拟化。
前面讲虚拟化的鼻祖 IBM M44/44X 的时候,提到它提出了 “分页” 的概念。也就是每个任务(虚拟机)似乎独占所有内存空间,分页机制负责把不同任务的内存地址映射到物理内存。如果物理内存不够了,操作系统就会把不常用的任务的内存交换到磁盘之类的外部存储,等那个不常用任务需要执行时再加载回来(当然,这种机制是后来才发明的)。这样,程序的开发者就不需要考虑物理内存空间有多大,也不需要考虑不同任务的内存地址是否会冲突。
现在我们用的计算机都有分页机制,应用程序(用户态进程)看到的是一片广阔无涯的虚拟内存(Virtual Memory),似乎整台机器都被自己独占;操作系统负责设置用户态进程的虚拟内存到物理内存的映射关系;CPU 中的 MMU(Memory Management Unit)负责在用户态程序运行时,通过查询映射关系(所谓的页表),把指令中的虚拟地址翻译成物理地址。
这里要说的不是这种虚拟内存,而是基于虚拟机的内存虚拟化,它们本质上是一样的,通过对虚拟内存的理解,再去理解内存虚拟化就比较容易了。
内存虚拟化也分为基于软件的内存虚拟化和硬件辅助的内存虚拟化,其中,常用的基于软件的内存虚拟化技术为「影子页表」技术,硬件辅助内存虚拟化技术为 Intel 的 EPT(Extended Page Table,扩展页表)技术。
内存软件虚拟化的目标就是要将虚拟机的虚拟地址(Guest Virtual Address, GVA)转化为 Host 的物理地址(Host Physical Address, HPA),中间要经过虚拟机的物理地址(Guest Physical Address, GPA)和 Host 虚拟地址(Host Virtual Address)的转化,即:
其中前两步由虚拟机的系统页表完成,中间两步由 VMM 定义的映射表(由数据结构 kvm_memory_slot 记录)完成,它可以将连续的虚拟机物理地址映射成非连续的 Host 机虚拟地址,后面两步则由 Host 机的系统页表完成。如下图所示。
这样做得目的有两个:
我们可以看到,传统的内存虚拟化方式,虚拟机的每次内存访问都需要 VMM 介入,并由软件进行多次地址转换,其效率是非常低的。因此才有了影子页表技术和 EPT 技术。
影子页表简化了地址转换的过程,实现了 Guest 虚拟地址空间到 Host 物理地址空间的直接映射。
要实现这样的映射,必须为 Guest 的系统页表设计一套对应的影子页表,然后将影子页表装入 Host 的 MMU 中,这样当 Guest 访问 Host 内存时,就可以根据 MMU 中的影子页表映射关系,完成 GVA 到 HPA 的直接映射。而维护这套影子页表的工作则由 VMM 来完成。
由于 Guest 中的每个进程都有自己的虚拟地址空间,这就意味着 VMM 要为 Guest 中的每个进程页表都维护一套对应的影子页表,当 Guest 进程访问内存时,才将该进程的影子页表装入 Host 的 MMU 中,完成地址转换。
我们也看到,这种方式虽然减少了地址转换的次数,但本质上还是纯软件实现的,效率还是不高,而且 VMM 承担了太多影子页表的维护工作,设计不好。
为了改善这个问题,就提出了基于硬件的内存虚拟化方式,将这些繁琐的工作都交给硬件来完成,从而大大提高了效率。
下图是 EPT 的基本原理图示,EPT 在原有 CR3 页表地址映射的基础上,引入了 EPT 页表来实现另一层映射,这样,GVA->GPA->HPA 的两次地址转换都由硬件来完成。
这里举一个小例子来说明整个地址转换的过程。假设现在 Guest 中某个进程需要访问内存,CPU 首先会访问 Guest 中的 CR3 页表来完成 GVA 到 GPA 的转换,如果 GPA 不为空,则 CPU 接着通过 EPT 页表来实现 GPA 到 HPA 的转换(实际上,CPU 会首先查看硬件 EPT TLB 或者缓存,如果没有对应的转换,才会进一步查看 EPT 页表),如果 HPA 为空呢,则 CPU 会抛出 EPT Violation 异常由 VMM 来处理。
如果 GPA 地址为空,即缺页,则 CPU 产生缺页异常,注意,这里,如果是软件实现的方式,则会产生 VM-exit,但是硬件实现方式,并不会发生 VM-exit,而是按照一般的缺页中断处理,这种情况下,也就是交给 Guest 内核的中断处理程序处理。
在中断处理程序中会产生 EXIT_REASON_EPT_VIOLATION,Guest 退出,VMM 截获到该异常后,分配物理地址并建立 GVA 到 HPA 的映射,并保存到 EPT 中,这样在下次访问的时候就可以完成从 GVA 到 HPA 的转换了。
有人也许会担心增加的一级映射关系会减慢内存访问速度,事实上不论是否启用二级内存翻译(SLAT),页表高速缓存(Translation Lookaside Buffer,TLB)都会存储虚拟地址(VA)到机器地址(MA)的映射。如果 TLB 的命中率较高,则增加的一级内存翻译不会显著影响内存访问性能。
EPT转换跟SPT相比有两点优化:
首先我们来回顾一下 I/O 模型:
下图是 QEMU 以纯软件方式模拟 I/O 设备的示意图:
需要注意的是:
优缺点
VM-Entry
、VM-Exit
发生,需要多次上下文切换,也需要多次数据复制,因此性能较差半虚拟化方式需要借助 virtio
实现,在 GuestOS 中需要安装前端驱动(块设备驱动、网络设备驱动、PCI设备驱动等),QEMU中集中调用后端驱动,两者之间通信通过 virtio-ring 实现。这种方案无需频繁切换上下文,减少了内存拷贝次数,I/O效率较高,目前是公有云虚拟机选择的主流方案。
Virtio 分为了前端驱动和后端驱动:
virtio_blk
、virtio_net
等在前后端驱动之间,还定义了两层来支持客户机和 QEMU 之间的通信:
例如:
virtio_net
网络驱动程序使用两个虚拟队列(接收/发送),而virtio_blk
驱动仅使用一个虚拟队列。
虚拟队列实际上被实现为客户机操作系统和 Hypervisor 之间的衔接点,但它可以通过任意方式实现,前提是客户机操作系统和 virtio 后端程序都遵循一定的标准,以相互匹配的方式实现它。
这样做就可以根据约定实现批量处理而不是客户机中每次 I/O 请求都需要处理一次,从而提高了客户机与 Hypervisor 之间信息交换的效率
优缺点
以virtio为标准的半虚拟化在其追寻性能的道路上也历经了三个演进方案:virtio-net、vhost-net和vhost-user。
如下图所示,KVM负责为程序提供虚拟化硬件的内核模块,QEMU利用KVM模拟VM运行环境,包括处理器和外设等;Tap是内核中的虚拟以太网设备,可以理解为内核bridge。
当客户机发送报文时,它会利用消息通知机制通知KVM,并退出到用户空间的QEMU进程,然后由QEMU对Tap设备进行读写(需要说明的是,QEMU是VM运行的主进程,因此才有退出这一说)。 在该模型中,宿主机、客户机和QEMU存在大量的上下文切换,以及频繁的数据拷贝、CPU特权级切换,因此性能差强人意。其函数调用路径如下:
两次报文拷贝导致性能瓶颈,另外消息机制处理过程太长:报文到达Tap时内核通知QEMU,QEMU利用IOCTL向KVM请求中断,KVM发送中断到客户机。
针对virtio-net的优化是把QEMU从消息队列的处理中解放出来,直接在宿主机实现了一个vhost-net内核模块,专门做virtio的后端,以此减少上下文切换和数据包拷贝。其结构如下图所示,以报文接收过程为例。数据通路直接从Tap设备接收数据报文,通过vhost-net内核模块把报文拷贝到虚拟队列中的数据区,从而使客户机接收报文。消息通路是当报文从Tap设备到达vhost-net时,通过KVM向客户机发送中断,通知客户机接收报文。
在数据通路层面,vhost-net减少了内存拷贝,但是由于其后端运行在内核态,仍然存在性能瓶颈。
vhost-user是采用DPDK用户态后端实现的高性能半虚拟化网络I/O。其实现机理与vhost-net类似,但是整个后端包括ovs(openvswitch) datapath全部置于用户空间,更好的利用DPDK加速。然而由于OVS进程是用户态进程,无权限访问客户机内存,因此需要使用共享内存技术,提前通过socket通信在客户机启动时,告知OVS自己的内存布局和virtio中虚拟队列信息等。这样OVS建立起对每个VM的共享内存,便可以在用户态实现上述vhost-net内核模块的功能。
在DPDK加速的vhost-user方案中,还有一次内存拷贝。半虚拟化中仅剩的性能瓶颈也就在这一次拷贝中,intel推出了一款硬件解决方案,直接让网卡与客户机内的virtio虚拟队列交互,把数据包DMA到客户机buffer内,在支持了virtio标准的基础上实现了真正意义上的零拷贝。
在18.05以后的DPDK版本中,已经有支持vDPA的feature供选择了。
除了全虚拟化和准虚拟化,还有一种直接操作硬件的方式,无需KVM参与,如 Intel 的 VT-d,AMD 的 AMD-V。运行在 VT-d 平台上的 QEMU/KVM,可以分配网卡、磁盘控制器、USB控制器、VGA 显卡等设备供客户机直接使用。
对于性能的追求是永无止境的,除了上述全虚拟化、半虚拟化两种I/O虚拟化以外,还有一种非常极端的做法。让物理设备穿过宿主机、虚拟化层,直接被客户机使用,这种方式通常可以获取近乎native的性能。
这种方式主要缺点是: 1.硬件资源昂贵且有限。 2.动态迁移问题,宿主机并不知道设备的运行的内部状态,状态无法迁移或恢复。
DPDK针对这两点问题都做了一定程度的解决。另外还提供了一种基于硬件的PF(物理功能)转VF(虚拟功能),这相当于在网卡层面上就已经有了虚拟化的概念,把一个网卡的PF虚拟成几十上百个VF,这样可以把不同的VF透传给不同的虚拟机,这就是我们最熟悉的SR-IOV。
对于I/O透传在虚拟化环境中最严重的问题不是性能了,而是灵活性。客户机和网卡之间没有任何软件中间层过度,也就意味着不存在负责交换转发功能的I/O栈,也就不会有软件交换机。那么如果要想有一台server内部的软件交换功能如何实现呢。业界的主要做法是把交换功能完全下沉到网卡,直接在智能网卡上实现虚拟交换功能。这又带来了另一个问题,成本和性能的权衡。
而DPDK 18.05以后的版本似乎也解决了这一灵活性问题,为了充分发掘标准网卡(区别于智能网卡)在flow(流)层面上的功能,推出了VF representer。可以直接将OVS上的流表规则下发到网卡上,实现网卡在VF之间的交换功能,这样就实现了高效灵活的虚拟化网络配置。
本文是对虚拟化概览,也是作为 虚拟化技术系列 的第一篇。开篇概览对整体有了基本的认识,毋庸置疑,里面涉及到的技术细节凡凡总总。掌握了大的方向,后续本系列可以继续扩展,拓展到网络虚拟化、存储虚拟化、GPU 虚拟化等等。不管细节如何,我们做的都是抽象。
纵观虚拟化技术的发展历史,可以看到它始终如一的目标就是实现对 IT 资源的充分利用。虚拟化本质是对 IT 资源的抽象,沿着虚拟化的道路继续发展,我们看到了云计算的开花结果,实现了更上层的对企业业务能力的抽象。抽象之外,我们也可以在这个过程中不断的看到软硬件结合与替代的思路,做一件事软件与硬件只是不同的路径,到底路该怎么走,就得看我们想到哪了。
本文整理了作为程序员应该知道的关键数字,封面图源自 伯克利每年更新的动态图表 ,可视化的展示了每年各种操作的耗时变化,非常形象。
这里是 2020 年的具体数据:
1 | 1 ns - CPU L1 CACHE reference |
根据伯克利每年的数据,可以总结出:
1 | 1 ns L1 cache |
下面从定性角度来理解这些数据。
内存、SSD、磁盘、网络 之间速度的巨大差别了,粗略地讲:
最开始为了提高计算机速度,选择将 CPU 的频率提高,后来计算机的频率到达 3GHz 之后,很难再提高了,所以访问 Cache 和内存的速度也基本不再变化了。
1 | Core i7 Xeon 5500 Series Data Source Latency (approximate) [Pg. 22] |
网卡的速度越来越快,从最早的万兆网卡,到现在100Gb的网卡。
网络带宽越大,传输延时越小。
roundtrip in same datacenter 和 packet roundtrip CA to Netherlands 耗时没有任何变化,一致保持 500us 和 150ms,原因很好理解,毕竟信号在光纤中以近似光速传播,该时间由物理规律决定,这里说的是传播延时。
SSD 的随机读取速度从 1990 年到 2019 年变化不同,不过从 19us 提升到 16us,但顺序读取速度却从 50ms 提升到 49us,提升巨大。
从 2006 年开始,前两列操作的数值不再变化,只有后两列在变化,说明近十年来存储介质的速度有较大提升。
Mutex的lock或unlock操作代价是17 ns。(所以加锁解锁的操作不耗费时间,锁的大量竞争才耗费,思路降低锁粒度,每个锁对象只保护一小部分数据)
所以,根据以上法则,要时刻考虑你的数据大小和数据结构,并且要以批量的思想来做,批量写和批量读。
没错,这里是最近新开的另一个专栏「资本不眠」,这个专栏会总结股票市场的交易笔记,比如第一期聊到的 MACD
;也会聊在资本世界里面各种有意思的事情,比如这一期就是通过 The Big Short
这部电影对 2007 年 到 2008 年那次由 次贷危机
引发的全球经济危机进行的梳理复盘。如果以后有机会的话,我会专门在这个专栏复盘自己在股市中的每日操作(当然,我是十分期待自己能够开这个坑的,如果我对自己每次操作都能够知其所以然的话)。
哦对了,最近 A 股的半导体和新能源等科技股都炒疯了。呵,愚蠢的人类。
让我们再回顾一下那一年的大崩盘
这是一场席卷全球的金融风暴,它承接过去一百年内发生的种种,在 2008 年达到了高潮,彻底改变了全世界成千上万人们的生命轨迹。尽管已经过去了十二年,我们还是能够感受这场风暴的余波:英国脱欧、特朗普当选莫不如是,甚至你都能从最近奥斯卡最佳电影 寄生虫
感受到那场危机带来的影响。
那么今天,我会从头梳理,在那场危机中到底发生了什么?是什么导致了这场危机?它对我们到底造成了那些影响?
在 2000 年代,经过多轮的兼并重组,美国的金融行业被几家巨型公司所主宰,下面是我梳理的这场金融风暴中的主要玩家:
本博文是我对计算机系统中的NUMA 架构做的备忘笔记,参考资料来自于互联网。
那么两者之间的主要区别是什么呢? 总结下来有这么几点,
SMP和AMP的深入介绍很多经典文章书籍可参考,此处不再赘述。现今主流的x86多处理器服务器都是SMP架构的, 而很多嵌入式系统则是AMP架构的。
NUMA(Non-Uniform Memory Access) 非均匀内存访问架构是指多处理器系统中,内存的访问时间是依赖于处理器和内存之间的相对位置的。 这种设计里存在和处理器相对近的内存,通常被称作本地内存;还有和处理器相对远的内存, 通常被称为非本地内存。
UMA(Uniform Memory Access) 均匀内存访问架构则是与NUMA相反,所以处理器对共享内存的访问距离和时间是相同的。
由此可知,不论是NUMA还是UMA都是SMP架构的一种设计和实现上的选择。
阅读文档时,也常常能看到ccNUMA(Cache Coherent NUMA),即缓存一致性NUMA架构。 这种架构主要是在NUMA架构之上保证了多处理器之间的缓存一致性。降低了系统程序的编写难度。
x86多处理器发展历史上,早期的多核和多处理器系统都是UMA架构的。这种架构下, 多个CPU通过同一个北桥(North Bridge)芯片与内存链接。北桥芯片里集成了内存控制器(Memory Controller),
下图是一个典型的早期 x86 UMA 系统,四路处理器通过 FSB (前端系统总线, Front Side Bus) 和主板上的内存控制器芯片 (MCH, Memory Controller Hub) 相连,DRAM 是以 UMA 方式组织的,延迟并无访问差异。
注:
- PCH(Platform Controller Hub),Intel 于 2008 年起退出的一系列晶片组,用于取代以往的 I/O Controller Hub(ICH)
在 UMA 架构下,CPU 和内存控制器之间的前端总线 (FSB) 在系统 CPU 数量不断增加的前提下, 成为了系统性能的瓶颈。因此,AMD 在引入 64 位 x86 架构时,实现了 NUMA 架构。之后, Intel 也推出了 x64 的 Nehalem 架构,x86 终于全面进入到 NUMA 时代。x86 NUMA 目前的实现属于 ccNUMA。
从 Nehalem 架构开始,x86 开始转向 NUMA 架构,内存控制器芯片被集成到处理器内部,多个处理器通过 QPI 链路相连,从此 DRAM 有了远近之分。 而 Sandybridge 架构则更近一步,将片外的 IOH 芯片也集成到了处理器内部,至此,内存控制器和 PCIe Root Complex 全部在处理器内部了。 下图就是一个典型的 x86 的 NUMA 架构:
一个NUMA Node内部是由一个物理CPU和它所有的本地内存(Local Memory)组成的。广义得讲, 一个NUMA Node内部还包含本地IO资源,对大多数Intel x86 NUMA平台来说,主要是PCIe总线资源。 ACPI规范就是这么抽象一个NUMA Node的。
一个CPU Socket里可以由多个CPU Core和一个Uncore部分组成。每个CPU Core内部又可以由两个CPU Thread组成。 每个CPU thread都是一个操作系统可见的逻辑CPU。对大多数操作系统来说,一个八核HT打开的CPU会被识别为16个CPU。 下面就说一说这里面相关的概念,
Socket
一个Socket对应一个物理CPU。 这个词大概是从CPU在主板上的物理连接方式上来的,可以理解为 Socket 就是主板上的 CPU 插槽。处理器通过主板的Socket来插到主板上。 尤其是有了多核(Multi-core)系统以后,Multi-socket系统被用来指明系统到底存在多少个物理CPU。
Node
NUMA体系结构中多了Node的概念,这个概念其实是用来解决core的分组的问题。每个node有自己的内部CPU,总线和内存,同时还可以访问其他node内的内存,NUMA的最大的优势就是可以方便的增加CPU的数量。通常一个 Socket 有一个 Node,也有可能一个 Socket 有多个 Node。
Core
CPU的运算核心。 x86的核包含了CPU运算的基本部件,如逻辑运算单元(ALU), 浮点运算单元(FPU), L1和L2缓存。 一个Socket里可以有多个Core。如今的多核时代,即使是Single Socket的系统, 也是逻辑上的SMP系统。但是,一个物理CPU的系统不存在非本地内存,因此相当于UMA系统。
Uncore
Intel x86物理CPU里没有放在Core里的部件都被叫做Uncore。Uncore里集成了过去x86 UMA架构时代北桥芯片的基本功能。 在Nehalem时代,内存控制器被集成到CPU里,叫做iMC(Integrated Memory Controller)。 而PCIe Root Complex还做为独立部件在IO Hub芯片里。到了SandyBridge时代,PCIe Root Complex也被集成到了CPU里。 现今的Uncore部分,除了iMC,PCIe Root Complex,还有QPI(QuickPath Interconnect)控制器, L3缓存,CBox(负责缓存一致性),及其它外设控制器。
Threads
这里特指CPU的多线程技术。在Intel x86架构下,CPU的多线程技术被称作超线程(Hyper-Threading)技术。 Intel的超线程技术在一个处理器Core内部引入了额外的硬件设计模拟了两个逻辑处理器(Logical Processor), 每个逻辑处理器都有独立的处理器状态,但共享Core内部的计算资源,如ALU,FPU,L1,L2缓存。 这样在最小的硬件投入下提高了CPU在多线程软件工作负载下的性能,提高了硬件使用效率。 x86的超线程技术出现早于NUMA架构。
在Intel x86平台上,所谓本地内存,就是CPU可以经过Uncore部件里的iMC访问到的内存。而那些非本地的, 远程内存(Remote Memory),则需要经过QPI的链路到该内存所在的本地CPU的iMC来访问。 曾经在Intel IvyBridge的NUMA平台上做的内存访问性能测试显示,远程内存访问的延时时本地内存的一倍。
可以假设,操作系统应该尽量利用本地内存的低访问延迟特性来优化应用和系统的性能。
如前所述,Intel自从SandyBridge处理器开始,已经把PCIe Root Complex集成到CPU里了。 正因为如此,从CPU直接引出PCIe Root Port的PCIe 3.0的链路可以直接与PCIe Switch或者PCIe Endpoint相连。 一个PCIe Endpoint就是一个PCIe外设。这就意味着,对某个PCIe外设来说,如果它直接于哪个CPU相连, 它就属于哪个CPU所在的NUMA Node。
与本地内存一样,所谓本地IO资源,就是CPU可以经过Uncore部件里的PCIe Root Complex直接访问到的IO资源。 如果是非本地IO资源,则需要经过QPI链路到该IO资源所属的CPU,再通过该CPU PCIe Root Complex访问。 如果同一个NUMA Node内的CPU和内存和另外一个NUMA Node的IO资源发生互操作,因为要跨越QPI链路, 会存在额外的访问延迟问题。
其它体系结构里,为降低外设访问延迟,也有将IB(Infiniband)总线集成到CPU里的。 这样IB设备也属于NUMA Node的一部分了。
可以假设,操作系统如果是NUMA Aware的话,应该会尽量针对本地IO资源低延迟的优点进行优化。
在Intel x86上,NUMA Node之间的互联是通过 QPI((QuickPath Interconnect) Link的。 CPU的Uncore部分有QPI的控制器来控制CPU到QPI的数据访问。
下图就是一个利用 QPI Switch 互联的 8 NUMA Node 的 x86 系统,
NUMA Affinity(亲和性)是和NUMA Hierarchy(层级结构)直接相关的。对系统软件来说, 以下两个概念至关重要,
CPU NUMA Affinity
CPU NUMA的亲和性是指从CPU角度看,哪些内存访问更快,有更低的延迟。如前所述, 和该CPU直接相连的本地内存是更快的。操作系统如果可以根据任务所在CPU去分配本地内存, 就是基于CPU NUMA亲和性的考虑。因此,CPU NUMA亲和性就是要尽量让任务运行在本地的NUMA Node里。
Device NUMA Affinity
设备NUMA亲和性是指从PCIe外设的角度看,如果和CPU和内存相关的IO活动都发生在外设所属的NUMA Node, 将会有更低延迟。这里有两种设备NUMA亲和性的问题,
DMA Buffer NUMA Affinity
大部分PCIe设备支持DMA功能的。也就是说,设备可以直接把数据写入到位于内存中的DMA缓冲区。 显然,如果DMA缓冲区在PCIe外设所属的NUMA Node里分配,那么将会有最低的延迟。 否则,外设的DMA操作要跨越QPI链接去读写另外一个NUMA Node里的DMA缓冲区。 因此,操作系统如果可以根据PCIe设备所属的NUMA node分配DMA缓冲区, 将会有最好的DMA操作的性能。
Interrupt NUMA Affinity
设备DMA操作完成后,需要在CPU上触发中断来通知驱动程序的中断处理例程(ISR)来读写DMA缓冲区。 很多时候,ISR触发下半部机制(SoftIRQ)来进入到协议栈相关(Network,Storage)的代码路径来传送数据。 对大部分操作系统来说,硬件中断(HardIRQ)和下半部机制的代码在同一个CPU上发生。 因此,DMA缓冲区的读写操作发生的位置和设备硬件中断(HardIRQ)密切相关。假设操作系统可以把设备的硬件中断绑定到自己所属的NUMA node, 那之后中断处理函数和协议栈代码对DMA缓冲区的读写将会有更低的延迟。
由于NUMA的亲和性对应用的性能非常重要,那么硬件平台就需要给操作系统提供接口机制来感知硬件的NUMA层级结构。 在x86平台,ACPI规范提供了以下接口来让操作系统来检测系统的NUMA层级结构。
ACPI 5.0a规范的第17章是有关NUMA的章节。ACPI规范里,NUMA Node被第9章定义的Module Device所描述。 ACPI规范里用Proximity Domain对NUMA Node做了抽象,两者的概念大多时候等同。
SRAT(System Resource Affinity Table)
主要描述了系统boot时的CPU和内存都属于哪个Proximity Domain(NUMA Node)。 这个表格里的信息时静态的,如果是启动后热插拔,需要用OSPM的_PXM方法去获得相关信息。
SLIT(System Locality Information Table)
提供CPU和内存之间的位置远近信息。在SRAT表格里,只能告诉给定的CPU和内存是否在一个NUMA Node。 对某个CPU来说,不在本NUMA Node里的内存,即远程内存们是否都是一样的访问延迟取决于NUMA的拓扑有多复杂(QPI的跳数)。 总之,对于不能简单用远近来描述的NUMA系统(QPI存在0,1,2等不同跳数), 需要SLIT表格给出进一步的说明。同样的,这个表格也是静态表格,热插拔需要使用OSPM的_SLI方法。
DSDT(Differentiated System Description Table)
从Device NUMA角度看,这个表格给出了系统boot时的外设都属于哪个Proximity Domain(NUMA Node)。
ACPI规范OSPM(Operating System-directed configuration and Power Management) 和OSPM各种方法就是操作系统里的ACPI驱动和ACPI firmware之间的一个互动的接口。 x86启动OS后,没有ACPI之前,firmware(BIOS)的代码是无法被执行了,除非通过SMI中断处理程序。 但有了ACPI,BIOS提前把ACPI的一些静态表格和AML的bytecode代码装载到内存, 然后ACPI驱动就会加载AML的解释器,这样OS就可以通过ACPI驱动调用预先装载的AML代码。 AML(ACPI Machine Language)是和Java类似的一种虚拟机解释型语言,所以不同操作系统的ACPI驱动, 只要有相同的虚拟机解释器,就可以直接从操作系统调用ACPI写好的AML的代码了。 所以,前文所述的所有热插拔的OSPM方法,其实就是对应ACPI firmware的AML的一段函数代码而已。 (关于ACPI的简单介绍,这里给出两篇延伸阅读:1 和2。)
本博文是我对计算机系统中的缓存做的备忘笔记,参考资料来自于互联网。
众所周知,对于不同的存储设备,更高的性能意味着更高的成本和更小的容量。随着 CPU 越做越快,CPU 和主存之间的速度差距正在不断扩大。好在,软件的局部性原理 拯救了这一切,在现代计算机体系中通过 Memory Hierarchy
的设计,使得系统在性能、成本和制造工艺之间作出取舍,从而达到了一个平衡。
下图是现在可以看到的常见的存储器层次机构:
程序访问的局部性原理
指的是,内存中某个地址被访问后,短时间内还有可能继续访问这块地址。内存中的某个地址被访问后,它相邻的内存单元被访问的概率也很大。
程序访问的局部性包含两种:
出现这种情况的原因很简单,因为程序是指令和数据组成的,指令在内存中按顺序存放且地址连续,如果运行一段循环程序或调用一个方法,又或者再程序中遍历一个数组,都有可能符合上面提到的局部性原理。
那既然在执行程序时,内存的某些单元很可能会经常的访问或写入,那可否在CPU和内存之间,加一个缓存,CPU在访问数据时,先看一下缓存中是否存在,如果有直接就读取缓存中的数据即可。如果缓存中不存在,再从内存中读取数据。
事实证明利用这种方式,程序的运行效率会提高90%以上,这个缓存也叫做高速缓存Cache。
在深入了解 Cache 的技术细节之前,我们可以先看看关于 Cache 在现代计算机系统中的 big picture。如下图所示,ALU 不是直接和主存相连,所有的 load 和 store 通过 Cache 完成,Cache 是 CPU 芯片组成的一部分。
随着计算机系统的发展,Cache 不仅仅只有一层,可能被分为多层。于此同时,人们发现,将指令的 Cache 和 数据的 Cache 分开可以获得更大的系统增益。而且,CPU也从单核单处理器逐渐发展到多核多处理器,所以一个现代的计算机系统中,Cache 的组成方式可能如下图所示:
在这个图中,只显示了一个处理器(Processor),处理器中有四个核(Core),每个 Core 会有自己的L1数据缓存和L1指令缓存,也有自己统一的 L2 缓存。四个核之间会共享 L3 缓存,L3 缓存和主存直接沟通。
Cache 是以 缓存行(Cache Line)
为基本组织单位的,下图是一个通用的缓存组织结构。
假设内容容量是 M,内存物理地址为 m
个bit。CPU 在访问缓存时,物理地址会被解析成如下的格式
这里,有如下的关系
参数具体意义如下:一个 Cache 被分成 S 个 set
,每个 set 有 E 路 Cache Line。在一个 Cache Line 中,有 B 个字节的存储单元。所以,在一个内存地址中,中间的 s 个 bit 决定了该寻址单元被映射到哪个 set,而最低的 b 个 bit 决定了该单元在一个缓存行中的偏移量。tag
是内存地址的高 t 个 bit,因为可能有多个内存地址映射到同一个 Cache Line 中,所以用 tag 来校验该 Cache Line 是否是 CPU 要访问的内存单元。
上面是从内存地址的角度看访问 Cache 时候的地址参数解析,对应到实际的 Cache Line 的组成,可以看第一张图。可以看到,对于每一个 Cache Line,除了 tag 用来校验是否是 CPU 要访问的内存单元,还有一个 valid bit
来确认该缓存行是否有效,然后就是一个含有 $B = 2^b$ 个字节的 Cache Block
。在目前的 x86 CPU 的 Cache Line 中,一般都是 64 字节的。
当 tag 和 valid 校验成功时,我们称为 Cache Hit
,这时就可以将cache中的单元取出,放入到 CPU 中的寄存器。
当 tag 或 valid 校验失败时,说明要访问的内存单元并不在cache中,需要去内存中或者下一级的 Cache 中取出,这就是 Cache Miss
。当不命中的情况发生时,系统就会从内存中或者下一级缓存中取得该单元,将其装入cache中,与此同时也放入CPU寄存器中,等待下一步处理。
下图是一个典型的 Cache Read 的流程
根据上面参数 E 的不同选择,可以把 Cache 到 Memory 的映射分为以下几种类型
全相联把内存方位两个字段,tag
和 offset
,没有了 set index 的字段。
在访问数据时,直接根据内存地址中的 tag
,去遍历对比每一个缓存行,直到找到 tag
一致的缓存行,然后访问其中的数据。
如果遍历完所有的缓存行之后,没有找到一致的tag
, 那么就会从内存中获取数据,然后找到空闲的缓存行,直接写入tag
和 数据即可。
全相联意味着主存中的数据块可以出现在任意一个缓存行中。这种方式下替换算法(Replacement Policy)有最大的灵活度,也意味着可以有最低的 Cache Miss Rate
。但是因为没有索引可以使用,检查一个缓存行是否命中需要在整个 Cache 范围内搜索,这带来了查找电路的大量延时。因此只有在缓存极小的情况下才有可能使用这种映射方式。
直接映射是一种多对一
的映射关系。在这种映射下,主存中的每一个数据块只能有一个缓存行与之对应。可能有多个主存中的数据块被映射到统一个缓存行,但是每一个数据块只能被映射到确定的缓存行。
在 1990 年代初期,直接映射是当时最流行的机制。但是随着 CPU 主频的提高,直接映射机制正在逐渐退出舞台。
直接映射最大的问题在于,每个数据块在哪个缓存行是确定的,没有替换策略(Replacement Policy)。如果两个数据块被映射到同一个缓存行时,它们会不停的把对方替换出去。由于严重的冲突,频繁刷新 Cache 会造成大量的延时,而且未能有效利用程序运行期所具有的时间局部性。这样导致了缓存命中率(cache miss rate)明显提高。
下图是一个Memory 为 16Kbytes, Cache Line 为 4bytes 的直接映射缓存例子。
组相联映射结合了以上两种映射方式的优点。具体的方法就是
Cache容量比内存小,所以内存数据映射到Cache时,必然会导致Cache满的情况,那之后的内存映射要替Cache中的哪些行呢?这就需要制定一种策略。
常见的替换算法有如下几种:
现实使用最多的是最近最少使用算法(LRU)进行Cache行的替换方案,这种方案使得缓存的命中率最高。
可以通过如下的方式查看。
1 | ----- 如果启动时间比较短,可以通过如下方式查看 |
另外,由专门针对 x86 信息的程序,也就是 x86info
,可以直接安装对应的包。
注意,现在多数的 CPU 采用的是超线程,也就是说对于一个物理核来说,对于内核看到的是两个,而实际的物理核是一个。
另外,在 /sys/devices/system/cpu/
中包含了一些相关的指标,例如。
1 | ----- 查看cpu0的一级缓存中的有多少组 |
通过类似 lscpu 查看到对应 CPU0 的一级缓存大小是 32K ,包含了 64 个组 (sets),每组有 8 ways,则可以算出每一个 way (也就是 Cache Line) 的大小是 32*1024/(64*8)=64
。
可以通过如下方式查看。
1 | cat /sys/devices/system/cpu/cpu0/cache/index0/coherency_line_size |
这里是 Intel Core i7 L1数据缓存的实际例子
试想,如果CPU想要修改某个内存的数据,这块内存的数据刚好在Cache中存在,那么是不是要同时更新Cache中的数据?对于写入的数据,如何保证Cache和内存数据的一致性?
关于缓存一致性的问题,可以参考我的另一篇博文 Cache Coherency,此处不再赘述。
针对缓存的这种特殊的结构,作为程序猿,如果一不小心,可能会带来重大的性能问题。
一些典型的案例可以参考我的另一篇博文 Cache Friendly Code,此处不再赘述。