kubernetes GPU 的困境和破局
kubernetes GPU 的困境和破局
随着人工智能与机器学习技术的快速发展,在 Kubernetes 上运行模型训练、图像处理类程序的需求日益增加,而实现这类需求的基础,就是 Kubernetes 对 GPU 等硬件加速设备的支持与管理。
kubernetes 调度 GPU-使用篇
Kubernetes 支持对节点上的 AMD 和 NVIDIA GPU (图形处理单元)进行管理,目前处于实验状态。
在 GPU 的支持上,最基本的诉求其实非常简单:我只要在 Pod 的 YAML 里面,声明某容器需要的 GPU 个数,那么 Kubernetes 为我创建的容器里就应该出现对应的 GPU 设备,以及它对应的驱动目录。
以 NVIDIA 的 GPU 设备为例,上面的需求就意味着当用户的容器被创建之后,这个容器里必须出现如下两部分设备和目录:
GPU 设备,比如
/dev/nvidia0
;GPU 驱动目录,比如
/usr/local/nvidia/*
。
其中,GPU 设备路径,正是该容器启动时的 Devices 参数;而驱动目录,则是该容器启动时的 Volume 参数。
所以,在 Kubernetes 的 GPU 支持的实现里,**kubelet 实际上就是将上述两部分内容,设置在了创建该容器的 CRI (Container Runtime Interface)参数里面。**这样,等到该容器启动之后,对应的容器里就会出现 GPU 设备和驱动的路径了。
不过,**Kubernetes 在 Pod 的 API 对象里,并没有为 GPU 专门设置一个资源类型字段,而是使用了一种叫作 Extended Resource(ER)的特殊字段来负责传递 GPU 的信息。**比如下面这个例子:
可以看到,在上述 Pod 的 limits 字段里,这个资源的名称是nvidia.com/gpu
,它的值是 1。也就是说,这个 Pod 声明了自己要使用一个 NVIDIA 类型的 GPU。
在 Kubernetes 的 GPU 支持方案里,你并不需要真正去做上述关于 Extended Resource 的这些操作。在 Kubernetes 中,对所有硬件加速设备进行管理的功能,都是由一种叫作 Device Plugin 的插件来负责的。这其中,当然也就包括了对该硬件的 Extended Resource 进行汇报的逻辑。
使用 Device Plugins
Kubernetes 实现了设备插件(Device Plugins) 以允许 Pod 访问类似 GPU 这类特殊的硬件功能特性。
作为集群管理员,你要在节点上安装来自对应硬件厂商的 GPU 驱动程序,并运行来自 GPU 厂商的对应的设备插件。
当以上条件满足时,Kubernetes 将暴露 amd.com/gpu
或 nvidia.com/gpu
为 可调度的资源。
你可以通过请求 <vendor>.com/gpu
资源来使用 GPU 设备,就像你为 CPU 和内存所做的那样。 不过,使用 GPU 时,在如何指定资源需求这个方面还是有一些限制的:
GPUs 只能设置在
limits
部分,这意味着:你可以指定 GPU 的
limits
而不指定其requests
,Kubernetes 将使用限制 值作为默认的请求值;你可以同时指定
limits
和requests
,不过这两个值必须相等。你不可以仅指定
requests
而不指定limits
。容器(以及 Pod)之间是不共享 GPU 的。GPU 也不可以过量分配(Overcommitting)。
每个容器可以请求一个或者多个 GPU,但是用小数值来请求部分 GPU 是不允许的。
和 CPU 资源不同的是,硬件加速设备类型有多种,比如说 GPUs、NICs、FPGAs,而且它们的厂商也不止一家,Kubernetes 要想挨个支持是不现实的,所以 Kubernetes 就把这些硬件加速设备统一当做
扩展资源
来处理。Kubernetes 在 Pod 的 API 对象里并没有提供像 CPU 那样的资源类型,它使用
扩展资源
资源字段来传递 GPU 信息。
要想使用上面 yaml 文件声明使用 GPU 设备,那么需要先在 Node 节点上安装设备插件Device Plugin
。
设备插件与设备厂商绑定,不同厂商提供了不同的 Device Plugin。
部署 AMD GPU 设备插件
官方的 AMD GPU 设备插件 有以下要求:
Kubernetes 节点必须预先安装 AMD GPU 的 Linux 驱动。
如果你的集群已经启动并且满足上述要求的话,可以这样部署 AMD 设备插件:
你可以到 RadeonOpenCompute/k8s-device-plugin 项目报告有关此设备插件的问题。
部署 NVIDIA GPU 设备插件
官方的 NVIDIA GPU 设备插件 有以下要求:
Kubernetes 的节点必须预先
安装了 NVIDIA 驱动
Kubernetes 的节点必须预先安装 nvidia-docker 2.0
Docker 的默认运行时必须设置为
nvidia-container-runtime
,而不是 runcNVIDIA 驱动版本 ~= 384.81
Kubernetes 版本 >= 1.10
准备 GPU 节点
具体参考 https://github.com/NVIDIA/k8s-device-plugin
需要在所有 GPU 节点上执行以下步骤。
请注意,您需要安装nvidia-docker2
软件包而不是nvidia-container-toolkit
. 这是因为新--gpus
选项还没有到达 Kubernetes。例子:
您需要启用 nvidia 运行时作为节点上的默认运行时。我们将编辑 docker daemon 配置文件,该文件通常位于/etc/docker/daemon.json
:
如果
runtimes
不存在,请前往nvidia-docker的安装页面
在 Kubernetes 中启用 GPU 支持
在集群中的所有 GPU 节点上配置上述选项后,您可以通过部署以下守护程序集来启用 GPU 支持:
**注意:**这是一个简单的静态守护程序集,旨在演示nvidia-device-plugin
.
在生产环境中部署插件时,请使用
helm
集群内存在不同类型的 GPU
如果集群内部的不同节点上有不同类型的 NVIDIA GPU,那么你可以使用 节点标签和节点选择器 来将 pod 调度到合适的节点上。
例如:
参考:https://kubernetes.io/zh/docs/tasks/manage-gpus/scheduling-gpus/
非 Device Plugin 插件
很多公司在使用时,并没有在 YAML 文件中指定 GPU 的个数,也没有在 Kubernetes 集群中安装 Device Plugin 插件,因为他们的程序以 DaemonSet 的方式运行,且每台机器上只有一块 GPU,这样相当于一个程序独占一个 GPU,至于把 GPU 设备及驱动加载到 Docker 容器内,可以通过在 YAML 文件中指定NVIDIA_DRIVER_CAPABILITIES
环境变量来实现:
kubernetes 调度 GPU-原理篇
Kubernetes 本身是通过插件扩展的机制来管理 GPU 资源的,具体来说这里有两个独立的内部机制。
第一个是
Extend Resources
,允许用户自定义资源名称。而该资源的度量是整数级别,这样做的目的在于通过一个通用的模式支持不同的异构设备,包括 RDMA、FPGA、AMD GPU 等等,而不仅仅是为 Nvidia GPU 设计的;Device Plugin Framework
允许第三方设备提供商以外置的方式对设备进行全生命周期的管理,而 Device Plugin Framework 建立 Kubernetes 和 Device Plugin 模块之间的桥梁。它一方面负责设备信息的上报到 Kubernetes,另一方面负责设备的调度选择。
Extended Resource 的上报
Extend Resources 属于 Node-level 的 api,完全可以独立于 Device Plugin 使用。而上报 Extend Resources,只需要通过一个 PACTH API 对 Node 对象进行 status 部分更新即可,而这个 PACTH 操作可以通过一个简单的 curl 命令来完成。这样,在 Kubernetes 调度器中就能够记录这个节点的 GPU 类型,它所对应的资源数量是 1。
这个 PATCH 操作,可以简单地使用 curl 命令来发起,如下所示:
PATCH 操作完成后,你就可以看到 Node 的 Status 变成了如下所示的内容:
这样在调度器里,它就能够在缓存里记录下 node-1 上的nvidia.com/gpu
类型的资源的数量是 1。
当然如果使用的是 Device Plugin,就不需要做这个 PACTH 操作,只需要遵从 Device Plugin 的编程模型
,在设备上报的工作中 Device Plugin 就会完成这个操作。
Device Plugin 工作机制
介绍一下 Device Plugin 的工作机制,整个 Device Plugin 的工作流程可以分成两个部分:
一个是启动时刻的资源上报;
另一个是用户使用时刻的调度和运行。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-bdUufJwW-1653290237483)(https://cdn.jsdelivr.net/gh/Fly0905/note-picture@main/imag/202205222220941.png)]
Kubernetes 的 Device Plugin 机制,我可以用如下所示的一幅示意图来和你解释清楚。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-fiXh9WMI-1653290237484)(https://cdn.jsdelivr.net/gh/Fly0905/note-picture@main/imag/202205230635508.png)]
Device Plugin 的开发非常简单。主要包括最关注与最核心的两个事件方法:
其中
ListAndWatch
对应资源的上报,同时还提供健康检查的机制。当设备不健康的时候,可以上报给 Kubernetes 不健康设备的 ID,让 Device Plugin Framework 将这个设备从可调度设备中移除;而
Allocate
会被 Device Plugin 在部署容器时调用,传入的参数核心就是容器会使用的设备 ID,返回的参数是容器启动时,需要的设备、数据卷以及环境变量。
资源上报和监控
对于每一个硬件设备,都需要它所对应的 Device Plugin 进行管理,这些 Device Plugin 以客户端的身份通过 GRPC 的方式对 kubelet 中的 Device Plugin Manager 进行连接,并且将自己监听的 Unix socket api 的版本号和设备名称比如 GPU,上报给 kubelet。
我们来看一下 Device Plugin 资源上报的整个流程。总的来说,整个过程分为四步,其中前三步都是发生在节点上,第四步是 kubelet 和 api-server 的交互。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-1gYzDHR6-1653290237485)(https://cdn.jsdelivr.net/gh/Fly0905/note-picture@main/imag/202205230642141.png)]
第一步是
Device Plugin 的注册
,需要 Kubernetes 知道要跟哪个 Device Plugin 进行交互。这是因为一个节点上可能有多个设备,需要 Device Plugin 以客户端的身份向 Kubelet 汇报三件事情。**我是谁?**就是 Device Plugin 所管理的设备名称,是 GPU 还是 RDMA;
**我在哪?**就是插件自身监听的 unix socket 所在的文件位置,让 kubelet 能够调用自己;
交互协议,即 API 的版本号。
第二步是
服务启动
,Device Plugin 会启动一个 GRPC 的 server。在此之后 Device Plugin 一直以这个服务器的身份提供服务让 kubelet 来访问,而监听地址和提供 API 的版本就已经在第一步完成了;第三步,当该 GRPC server 启动之后,
kubelet 会建立一个到 Device Plugin 的 ListAndWatch 的长连接, 用来发现设备 ID 以及设备的健康状态
。当 Device Plugin 检测到某个设备不健康的时候,就会主动通知 kubelet。而此时如果这个设备处于空闲状态,kubelet 会将其移除可分配的列表。但是当这个设备已经被某个 Pod 所使用的时候,kubelet 就不会做任何事情,如果此时杀掉这个 Pod 是一个很危险的操作。第四步,kubelet 会将这些设备暴露到 Node 节点的状态中,把设备数量发送到 Kubernetes 的 api-server 中。后续调度器可以根据这些信息进行调度。
**需要注意的是 kubelet 在向 api-server 进行汇报的时候,只会汇报该 GPU 对应的数量
。而 kubelet 自身的 Device Plugin Manager 会对这个 GPU 的 ID 列表进行保存,并用来具体的设备分配。而这个对于 Kubernetes 全局调度器来说,它不掌握这个 GPU 的 ID 列表,它只知道 GPU 的数量。**这就意味着在现有的 Device Plugin 工作机制下,Kubernetes 的全局调度器无法进行更复杂的调度。
比如说想做**两个 GPU 的亲和性调度,同一个节点两个 GPU 可能需要进行通过 NVLINK
通讯而不是 PCIe
通讯,才能达到更好的数据传输效果。**在这种需求下,目前的 Device Plugin 调度机制中是无法实现的。
Pod 的调度和运行的过程
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-WNs9XQFy-1653290237486)(https://cdn.jsdelivr.net/gh/Fly0905/note-picture@main/imag/202205230642572.png)]
第一步:Pod 想使用一个 GPU 的时候,它只需要像之前的例子一样,在 Pod 的 Resource 下 limits 字段中声明 GPU 资源和对应的数量 (比如 nvidia.com/gpu: 1)。Kubernetes 会找到满足数量条件的节点,然后将该节点的 GPU 数量减 1,并且完成 Pod 与 Node 的绑定。
第二步:绑定成功后,自然就会被对应节点的 kubelet 拿来创建容器。而当 kubelet 发现这个 Pod 的容器请求的资源是一个 GPU 的时候,**kubelet 就会委托自己内部的 Device Plugin Manager 模块,从自己持有的 GPU 的 ID 列表中选择一个可用的 GPU 分配给该容器。**此时
kubelet 就会向本机的 Device Plugin 发起一个 Allocate 请求,这个请求所携带的参数,正是即将分配给该容器的设备 ID 列表
。第三步:Device Plugin 收到 AllocateRequest 请求之后,它就会根据 kubelet 传过来的设备 ID,去寻找这个设备 ID 对应的设备路径、驱动目录以及环境变量,并且以 AllocateResponse 的形式返还给 kubelet。
第四步:AllocateResponse 中所携带的设备路径和驱动目录信息,一旦返回给 kubelet 之后,kubelet 就会根据这些信息执行为容器分配 GPU 的操作,这样 Docker 会根据 kubelet 的指令去创建容器,而这个容器中就会出现 GPU 设备。并且把它所需要的驱动目录给挂载进来,至此 Kubernetes 为 Pod 分配一个 GPU 的流程就结束了。
Device Plugin 生命周期总结
Device Plugin启动时
,以 grpc 的形式通过var/ib/kubelet/device-plugins/kubelet..sock
向 Kubelet 注册设备 id(比如 nvidia.com/gpu)Kubelet将会把设备数量以Node状态上报到APIServer中
,后续调度器会根据这些信息进行调度Kubelet 同时会
建立一个到Device Plugin的listAndWatch长连接
,当插件检测到某个设备不健康的时候,就会主动通知 Kubelet当用户的应用请求 GPU 资源后,Device Plugin 根据 Kubeleti 在 Allocate 请求分配好的设备 id 定位到对应的设备路径和驱动文件
Kubelet 根据 Device plugin 提供的信息创建对应容器
kubernetes 调度 GPU-社区加强篇
Device Plugin:可用但不好用
我们知道 Kubernetes 可以实现对宿主机的 CPU、内存、网络实现精细化的控制,但是到目前为止(2022.05),Kubernetes 尚未实现像管理 CPU 那样来管理 GPU,比如有如下限制:
Nvidia 贡献的调度方案,只支持按较粗粒度的调度,按 GPU 块数调度。Nvidia GPU Device Plugin
1. GPU 资源调度精细度不足
对于 GPU 资源只能设置
limit
,这意味着requests
不可以单独使用,要么只设置limit
、要么同时设置二者,但二者值必须相等,不可以只设置request
而不设置limit
。不允许以小数请求 GPU 资源分配。
2. GPU 资源共享能力不足
pod 及容器之间,不可以共享 GPU,且 GPU 也不可以过量分配(所以我们线上的程序采用
daemonSet
方式运行)。
3. 集群 GPU 资源缺少全局视角
没有直观方式可获取集群层面 GPU 信息,比如 Pod / 容器与 GPU 卡绑定关系、已使用 GPU 卡数 等
现有 Kubernetes 对 GPU 资源的分配调度是通过 extended resource 实现的,它是基于节点上卡数量的加减调度。用户如果想知道集群中 GPU 卡的分配情况,需要遍历节点,拿到并计算这些信息。并且由于这个资源是标量的,所以并无法拿到 Pod / 容器 与卡的绑定关系。这些问题在整卡模式下不是那么凸显,但在细粒度共享模式下,就尤为严重了。
问题在于 GPU 资源的调度工作,实际上都是在 kubelet 上完成的。
而作为全局的调度器对这个参与是非常有限的,作为传统的 Kubernetes 调度器来说,它只能处理 GPU 数量。一旦你的设备是异构的,不能简单地使用数目去描述需求的时候,比如我的 Pod 想运行在两个有 nvlink 的 GPU 上,这个 Device Plugin 就完全不能处理。
4. 不能很好支持多 GPU 后端
各种 GPU 技术(nvidia docker、qGPU、vCUDA、gpu share、GPU 池化)均需独立部署组件,无法统一调度和管理
社区 GPU 共享技术实践
由于资源隔离主要采用的是虚拟化技术
,并且 NVIDIA 提供的两种 GPU 虚拟化解决方案都没有开源,GPU 共享在资源隔离方面的实践资料相对较少,大多关注GPU资源的调度
。
资源隔离主要采用的是虚拟化的解决思路,目前 NVIDIA 有两种 GPU 虚拟化的解决方案:
GRID
: 模式更多用于虚拟机场景,基于驱动,隔离型会做的比较强,但不开源
,性能比较好。
MPS
: 应用到容器场景,基于软件的方式,隔离性比较弱,但也不开源
。
1.阿里 GPU 共享实践(⭐⭐⭐)
阿里云服务团队贡献的 GPU 共享的调度方案,其目的在于解决用户共享 GPU 调度的需求 Kubernetes GPU 共享实践gpushare-scheduler-extendergpushare-device-plugin
优点:
**能够
让更多的预测服务共享同一个GPU卡上
,**能够让使用者通过 API 描述对于一个可共享资源的申请, 并能实现该种资源的调度
缺点:
不支持该共享资源的隔离
前提条件:
延用 Kubernetes Extended Resource 定义,但是衡量维度最小单位从 1 个 GPU 卡变为 GPU 显存的 MiB
用户申请的 GPU 资源上限不会超过一张卡,也就是申请的资源上限为单卡
实现思路:依赖于 Kubernetes 的现有工作机制:
Extended Resource
定义Scheduler Extender
机制Device Plugin
机制
利用
kubernetes Extended Resource
机制,重新定义 GPU 资源,主要是对显存和 GPU 数量的定义。利用
Device Plugin
机制,在节点上将 GPU 资源总量上报给 kubelet,kubelet 进一步上报给 Kubernetes API Server。利用
k8s scheduler Extender
机制,扩展调度器功能,负责在全局调度器 Filter 和 Bind 的时候判断节点上单个 GPU 卡是否能够提供足够的 GPU 显存,并且在 Bind 的时刻将 GPU 的分配结果通过 annotation 记录到 Pod Spec 以供后续 Filter 检查分配结果。节点运行:当 Pod 和节点绑定的事件被 Kubelet 接收到后,Kubelet 就会在节点上创建真正的 Pod 实体,在这个过程中, Kubelet 会调用 GPU Share Device Plugin 的 Allocate 方法, Allocate 方法的参数是 Pod 申请的 gpu-mem。而在 Allocate 方法中,会根据 GPU Share Scheduler Extender 的调度决策运行对应的 Pod。
后期规划
集成 Nvidia MPS 作为隔离选项
由 kubeadm 部署的 Kubernetes 集群的自动化部署
调度器扩展器高可用性
适用于 GPU、RDMA 和其他设备的通用解决方案
2.华为 GPU 共享实践(⭐⭐⭐)
Volcano 提供调度层面的 GPU 资源共享,可以使多个 Pod 运行在同一块 GPU 卡上,从而提升集群 GPU 资源整体利用率。
优点:
调度层面是可以的多个容器共享一个 gpu 卡
目前允许填的是 memory 的显存大小。可以填一个 gpu 显存的一部分,也可以填多个 gpu 卡的显存量。
缺点:
隔离方面,本来打算做显存和算力隔离的,不过说 hack cuda api 或者 driver 会涉及法务风险,就没有继续了
直接指定 gpu num,还不行。这个 device plugin 当时是做 gpu 共享开发的。
Volcano GPU 共享设计
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ckOZqapw-1653290237486)(https://github.com/volcano-sh/volcano/raw/master/docs/images/gpu-share-flow.png)]
创建一个带有
volcano.sh/gpu-memory
资源请求的 pod,Volcano 并为 pod 分配 gpu 资源。添加以下注释
kubelet 监视绑定到自身的 pod,并在运行容器之前调用 allocate API 设置 env。
Volcano 通过 Kubernetes 自定义扩展资源机制定义了 GPU 相关的volcano.sh/gpu-memory
和volcano.sh/gpu-number
两种资源,其中volcano.sh/gpu-memory
用来描述节点 GPU 显存信息;volcano.sh/gpu-number
用来描述节点 GPU 卡的数量。
Volcano 通过 Kubernetes 提供的 Device plugin 实现如下功能:
收集集群中节点上
gpu-number
与显存gpu-memory
监控 GPU 健康状态
在集群中为申请 GPU 的 workload 挂载 GPU 资源
用户可以从 Volcano device plugin for Kubernetes 获取如何安装、使用 volcano GPU 插件的详细信息。
GPU 虚拟化:推理场景以及 GPU 开发的场景,GPU 使用率普遍偏低,Volcano 已实现多容器共享使用 GPU,未来将进一步增强算力、显存的隔离能力,保障在提升利用率的同时,降低业务间的干扰;
支持 GPU 节点多维度资源比例分片
“支持 GPU 节点多维度资源比例分片”是该版本具有重大意义的特性之一,主要用于解决 GPU 节点因 CPU 等其他维度资源过度使用引起 GPU 作业饥饿但 GPU 资源空闲浪费的问题。该特性由 Volcano 社区合作伙伴中科类脑贡献。在传统调度器中,GPU 等稀缺资源在进行分配时与 CPU 等资源离散考虑,即 CPU 型作业可直接分配到 GPU 节点而不会考虑 GPU 作业的 CPU、内存需求,不会为其预留资源。在该特性中,允许用户设置一个主导型资源(通常设置为 GPU),并可为它配置配套资源维度的预留比例(如 GPU:CPU:Memory=1:4:32)。调度器在工作时将会时刻保持 GPU 节点上 GPU、CPU、Memory 的空闲资源比例不低于该设定值,因此任何时刻符合该比例需求的 GPU 作业均可调度到该节点,而不会引起 GPU 浪费。这一方法较业界其他解决方案,如 GPU 节点分配独立调度器、CPU 型作业强制不允许调度到 GPU 节点等,更有利于提高节点资源利用率,使用也更加灵活。
特性设计和使用方式请参考:https://github.com/volcano-sh/volcano/blob/master/docs/design/proportional.md
https://github.com/elastic-ai/elastic-gpu-scheduler
2.腾讯 GPU 共享实践(⭐)
https://cloud.tencent.com/document/product/457/65734
https://www.infoq.cn/article/UyujBCyAeFb2o9Ncfr2L
实现思路:
常规 GPU 使用,按块调度
使用 Nvidia 提供的虚拟化技术
自研实现 GPU 半虚拟化: 基于驱动函数实现,改变了函数显存申请、释放和线程的发起函数。
总结
从社区、stackoverflow 以及上述各公司的实践来看,目前 GPU 共享主要共享的是 GPU 的显存,资源隔离性差,存在资源抢占等情况,是否需要开展 GPU 共享相关的开发工作需要视公司机器学习对 GPU 的使用场景来决定;
GPU 共享存在一定的限制:用户申请的资源限制为单卡
从显存共享的角度来说,单 GPU 卡共享具有可行性,主要实现步骤包括:
扩展资源定义
,重新定义 GPU 资源,主要是对显存和 GPU 数量的定义扩展调度器
,负责在全局调度器 Filter 和 Bind 的时候判断节点上单个 GPU 卡是否能够提供足够的 GPU 显存利用
Nvidia Device Plugi
n 插件机制,完成资源上报和分配
参考资料
Device Plugins: https://kubernetes.io/zh/docs/concepts/extend-kubernetes/compute-storage-net/device-plugins/
AMD - deploying-amd-gpu-device-plugin: https://kubernetes.io/zh/docs/tasks/manage-gpus/scheduling-gpus/#deploying-amd-gpu-device-plugin
NVIDIA - deploying-nvidia-gpu-device-plugin: https://kubernetes.io/zh/docs/tasks/manage-gpus/scheduling-gpus/#deploying-nvidia-gpu-device-plugin
k8s-device-plugin: https://github.com/RadeonOpenCompute/k8s-device-plugin
Kubernetes如何通过Device Plugins来使用NVIDIA GPU
版权声明: 本文为 InfoQ 作者【琦彦】的原创文章。
原文链接:【http://xie.infoq.cn/article/32b6e04e53419c35465acc699】。文章转载请联系作者。
评论