接受范围|初级
为了应对集群节点高负载、负载不均衡等问题,需要动态平衡各个节点之间的资源使用率,因此需要基于节点的相关监控指标,构建集群资源视图,从而为下述两种治理方向奠定实现基础:
在 Pod 调度阶段,加入优先将 Pod 调度到资源实际使用率低的节点的节点Score插件
在集群治理阶段,通过实时监控,在观测到节点资源率较高、节点故障、Pod 数量较多等情况时,可以自动干预,迁移节点上的一些 Pod 到利用率低的节点上
针对方向一,可以通过赋予Kubernetes调度器感知集群实际负载的能力,计算资源分配和实际资源利用之间的差距,优化调度策略。
针对方向二,社区给出了Descheduler方案,Descheduler 可以根据一些规则和策略配置来帮助再平衡集群状态,当前项目实现了十余种策略。
注意:Descheduler等方案存在一些与主调度策略不一致的可能性
本文将针对方向二的实现进行详细说明,原理及优化在下一篇,方向一已在前文进行了相关介绍Trimaran: 基于实际负载的K8s调度插件。
需求背景
Kubernetes提供了声明式的资源模型,核心组件(调度器、kubelet和控制器)的实现能够满足QoS需求。然而,由于下述一些原因,该模型会导致集群资源使用的不均衡:
用户很难准确评估应用程序的资源使用情况,因而对于Pod的资源配置,无从谈起
用户可能不理解资源模型,从而直接使用Kubernetes默认调度插件(Score)(默认插件不考虑实际节点利用率值)。
虽然调度器可以依托实时资源使用情况,以调度pod,降低集群管理的成本,提高集群的利用率,但集群资源使用的情况是动态变化的,随时会出现不均衡状态,比如某些节点过热,某些节点负载过点的情况,为了能够调节负载的均衡性,可以通过Descheduler对Pod进行迁移,从而达到节点资源的某种均衡,Descheduler使用以下典型场景:
Pod利用率变化导致节点负载过点或者过高
节点的上下线
节点标签变动导致Pod的亲和性或反亲和性结果的改变
节点的taint的变更
注意:本文基于commit,版本为v0.25.1。其中HighNodeUtilization、LowNodeUtilization中基于实际节点使用率的功能,尚未实现,相关issue及roadmap,目前相关开发集中在scheduler-plugins的trimaran模块
Descheduler的policy可配,包括可以启用或禁用相关策略。默认情况下,所有策略都是启用的。
policy支持所有策略的通用配置如下所示:
名称 | 默认值 | 描述 |
---|---|---|
nodeSelector | nil | 限定节点 |
evictLocalStoragePods | false | 运行驱逐配置本地存储的pod |
evictSystemCriticalPods | false | 会驱逐系统pod,如coredns等 |
ignorePvcPods | false | 配置是否驱逐配置PVC的pod |
maxNoOfPodsToEvictPerNode | nil | 每个节点驱逐的最大pod数 |
maxNoOfPodsToEvictPerNamespace | nil | 每个ns驱逐的最大pod数 |
evictFailedBarePods | false | 允许驱逐没有owner reference或者处于faild阶段的pod |
通过policy定义,每个策略的参数均可配。
apiVersion: "descheduler/v1alpha1"
kind: "DeschedulerPolicy"
nodeSelector: prod=dev
evictFailedBarePods: false
evictLocalStoragePods: true
evictSystemCriticalPods: true
maxNoOfPodsToEvictPerNode: 40
ignorePvcPods: false
strategies:
...
下图提供了大多数策略的可视化过程,以帮助区分如何配置策略。
当前支持以下几种策略:
RemoveDuplicates
LowNodeUtilization
HighNodeUtilization
RemovePodsViolatingInterPodAntiAffinity
RemovePodsViolatingNodeAffinity
RemovePodsViolatingNodeTaints
RemovePodsViolatingTopologySpreadConstraint
RemovePodsHavingTooManyRestarts
PodLifeTime
RemoveFailedPods
下文逐一介绍。
Descheduler策略
RemoveDuplicates
这个策略确保同一节点上运行一个与ReplicaSet(RS)、ReplicationController(RC)、StatefulSet或Job相关联的pod。如果存在多个,这些pod将会被驱逐,类似于kube-schduler的反亲和性、打散功能,其为了在集群中打散pod。主要为了确保一些节点发生故障时,保障业务的稳定性。
它提供了一个可选的参数,excludeOwnerKinds,它是一个OwnerRef Kinds的列表。如果一个pod的OwnerRefernce字段有这些类型,该pod将不被驱逐。需要注意的是,通过Deployment创建的pod会被考虑用这个策略驱逐。excludeOwnerKinds参数应包括ReplicaSet,以排除由Deployments创建的pod。
配置参数
Name | Type |
---|---|
excludeOwnerKinds | list(string) |
namespaces | 参看namespace filtering |
thresholdPriority | int (参看 优先级过滤) |
thresholdPriorityClassName | string (参看 优先级过滤) |
nodeFit | bool (参看 nodeFit过滤) |
示例:
apiVersion: "descheduler/v1alpha1"
kind: "DeschedulerPolicy"
strategies:
:
enabled: true
params:
removeDuplicates:
excludeOwnerKinds:
"ReplicaSet"
LowNodeUtilization
这个策略找到利用率低(requests不是实际的利用率)的节点,将其他节点驱逐的pod,尽量在该节点重建。这个策略的参数在nodeResourceUtilizationThresholds下配置。
注意:因为当前kube-scheduler也未支持实时资源方式的调度算法,因此可能调度到其他利用率高的节点。
节点的利用率低于多少是由配置的阈值决定的。阈值支持配置cpu、内存、pod数量和扩展资源的百分比(百分比的计算方法是节点上当前请求的资源与可分配的总资源)。
如果一个节点的使用率低于所有(cpu、内存、pod数量和扩展资源)的阈值,该节点就被认定为未充分利用的节点。当前,计算节点资源利用率时只考虑了pods的请求资源(request)。
还有一个可配置的阈值,是targetThresholds,其用于计算那些潜在的节点,从那里可以驱逐pod。如果一个节点的使用量在任何(cpu、内存、pod数量或扩展资源)方面都超过了targetThreshold,那么该节点就被认为是过度利用了。任何在阈值、阈值和targetThresholds之间的节点都被认为是适当的利用,不考虑驱逐。阈值,targetThresholds,也可以用百分比来配置cpu、内存和pod的数量。
这些阈值,thresholds和targetThresholds,可以根据你的集群要求进行调整。需要注意的是,该策略将pod从过度使用的节点(使用率高于targetThresholds的节点)驱逐到使用率不足的节点(使用率低于阈值的节点),如果任何使用率不足的节点或过度使用的节点的数量为零,它将中止驱逐。
此外,该策略还接受一个useDeviationThresholds参数。如果该参数设置为"true",阈值将从所有节点的平均值中扣除,而targetThresholds将被添加到平均值。高于(或低于)该窗口的资源消耗被认为是过度使用(或使用不足)。
注意:节点资源消耗是由pod的请求和限制决定的,而不是实际使用率。选择这种方法是为了与kube-scheduler保持一致,kube-scheduler遵循相同的设计来调度pod到节点上。这意味着Kubelet(或像kubectl top这样的命令)报告的资源使用量可能与计算的消耗量不同,这是因为这些组件报告了实际的使用指标。
配置参数
Name | Type |
---|---|
thresholds | map(string:int) |
targetThresholds | map(string:int) |
numberOfNodes | int |
useDeviationThresholds | bool |
thresholdPriority | int 参看priority filtering章节 |
thresholdPriorityClassName | string 参看priority filtering章节 |
nodeFit | bool 参看node fit filtering章节 |
示例:
apiVersion: "descheduler/v1alpha1"
kind: "DeschedulerPolicy"
strategies:
"LowNodeUtilization":
enabled: true
params:
nodeResourceUtilizationThresholds:
thresholds:
"cpu" : 20
"memory": 20
"pods": 20
targetThresholds:
"cpu" : 50
"memory": 50
"pods": 50
支持三种基本的资源类型:Cpu、内存和pod。如果没有指定这些资源类型中的任何一种,它的所有阈值默认为100%,以避免节点从利用不足到利用过度。支持扩展资源,例如,资源类型nvidia.com/gpu被指定GPU节点利用,如果没有配置阈值,将不被计算。thresholds或targetThresholds不能为零,它们必须配置完全相同类型的资源。资源的百分比值的有效范围是[0, 100]。阈值的百分比值不能大于同一资源的targetThresholds。还有一个与LowNodeUtilization策略相关的参数,叫做numberOfNodes。这个参数可以被配置为只有在利用率低的节点数量超过配置值时才激活该策略。这在大型集群中可能很有帮助,因为有几个节点可能经常或在短时间内利用不足。默认情况下,numberOfNodes被设置为0。
HighNodeUtilization
这个策略找到利用率低(requests不是实际的利用率)的节点,并从这些节点上驱逐pod。该策略与节点自动扩展结合使用,旨在帮助触发利用率低的节点的缩减。这个策略必须与调度器的评分策略MostAllocated一起使用。该策略的参数在nodeResourceUtilizationThresholds下配置。
节点的利用率不足由可配置的阈值决定。阈值阈值可以为cpu、内存、pod的数量和扩展资源的百分比进行配置。百分比的计算方法是节点上当前请求的资源与可分配的总资源。对于pods,这意味着节点上的pods数量占该节点设置的pod容量的一部分。
如果一个节点的使用率低于所有(cpu、内存、pod数量和扩展资源)的阈值,该节点就被认为是未充分利用的。目前,计算节点资源利用率时考虑了pods请求资源的要求。任何高于阈值的节点都被认为是适当的利用,不考虑驱逐。
阈值参数可以根据集群规模进行调整。需要注意的是,该策略从利用不足的节点(即使用率低于阈值的节点)驱逐pod,以便可以在适当利用的节点上重新创建。如果任何未充分利用的节点或适当利用的节点的数量为零,该策略将中止。
注意:节点资源消耗是由pod的请求和限制决定的,而不是实际使用。选择这种方法是为了与kube-scheduler保持一致,kube-scheduler遵循相同的设计来调度pod到节点上。这意味着Kubelet(或像kubectl top这样的命令)报告的资源使用量可能与计算的消耗量不同,这是因为这些组件报告了实际的使用指标。
配置参数
Name | Type |
---|---|
thresholds | map(string:int) |
numberOfNodes | int |
thresholdPriority | int (see priority filtering) |
thresholdPriorityClassName | string (see priority filtering) |
nodeFit | bool (see node fit filtering) |
示例:
apiVersion: "descheduler/v1alpha1"
kind: "DeschedulerPolicy"
strategies:
"HighNodeUtilization":
enabled: true
params:
nodeResourceUtilizationThresholds:
thresholds:
"cpu" : 20
"memory": 20
"pods": 20
支持三种基本的资源类型:cpu、内存和pod。如果这些资源类型都没有配置,那么阈值均为100%,同时还支持扩展资源,例如,资源类型nvidia.com/gpu被指定GPU节点利用,如果没有配置阈值,将不被计算。资源的百分比值的有效范围是[0, 100]。还有一个与HighNodeUtilization策略相关的参数,叫做numberOfNodes。这个参数可以被配置为只有在利用率低的节点数量超过配置值时才激活该策略。这在大型集群中很有帮助,因为在这些集群中,有几个节点可能经常或在很短的时间内利用不足。默认情况下,numberOfNodes被设置为零。
RemovePodsViolatingInterPodAntiAffinity
该策略可确保从节点中删除违反反亲和性的pod。例如,如果某个节点上有podA,并且podB和podC(在同一节点上运行)具有禁止它们在同一节点上运行的反亲和规则,则podA将被从该节点逐出,以便podB和podC正常运行。当 podB 和 podC 已经运行在节点上后,反亲和性规则就会发现这样的问题。目前,没有与该策略关联的参数。
配置参数
Name | Type |
---|---|
thresholdPriority | int (see priority filtering) |
thresholdPriorityClassName | string (see priority filtering) |
namespaces | (see namespace filtering) |
labelSelector | (see label filtering) |
nodeFit | bool (see node fit filtering) |
示例:
apiVersion: "descheduler/v1alpha1"
kind: "DeschedulerPolicy"
strategies:
"RemovePodsViolatingInterPodAntiAffinity":
enabled: true
RemovePodsViolatingNodeAffinity
这个策略确保了所有违反节点亲和性的pod最终都会从节点上被驱逐。节点亲和力规则允许pod指定requiredDuringSchedulingIgnoredDuringExecution类型,它告诉调度器在调度pod时要满足节点亲和力,但节点可能随时间变化而不再满足亲和力时,但kubelet会忽略这一变化,当启用该策略时,其作为requiredDuringSchedulingRequiredDuringExecution的临时实现,将不再满足节点亲和力的pod驱逐出该节点。
例如,在节点A上调度有podA,它在调度时满足节点亲和性规则requiredDuringSchedulingIgnoredDuringExecution。随着时间的推移,节点A不再满足该规则,当策略被触发并且有另一个满足节点亲和性规则的节点可用时,podA被从节点A驱逐。
配置参数
Name | Type |
---|---|
nodeAffinityType | list(string) |
thresholdPriority | int (see priority filtering) |
thresholdPriorityClassName | string (see priority filtering) |
namespaces | (see namespace filtering) |
labelSelector | (see label filtering) |
nodeFit | bool (see node fit filtering) |
示例:
apiVersion: "descheduler/v1alpha1"
kind: "DeschedulerPolicy"
strategies:
"RemovePodsViolatingNodeAffinity":
enabled: true
params:
nodeAffinityType:
- "requiredDuringSchedulingIgnoredDuringExecution"
RemovePodsViolatingNodeTaints
这个策略确保在节点上违反NoSchedule污点的pod被移除。例如,有一个pod "podA "具有容忍污点key=value:NoSchedule的容忍度,并在被污点的节点上运行。如果该节点的污点随后被更新/删除,污点就不再满足于其pod的容忍度,将被驱逐出去。
节点污点可以通过指定exceptedTaints的列表排除。如果一个节点污点的键或键=值与exceptedTaints条目相匹配,该污点将被忽略。
例如,excludedTaints条目 "dedicated "将匹配所有键为 "dedicated "的污点,而不考虑其值;excludedTaints条目 "dedicated=special-user "将匹配键为 "dedicated "而值为 "special-user "的污点。
配置参数
Name | Type |
---|---|
excludedTaints | list(string) |
thresholdPriority | int (see priority filtering) |
thresholdPriorityClassName | string (see priority filtering) |
namespaces | (see namespace filtering) |
labelSelector | (see label filtering) |
nodeFit | bool (see node fit filtering) |
示例:
apiVersion: "descheduler/v1alpha1"
kind: "DeschedulerPolicy"
strategies:
"RemovePodsViolatingNodeTaints":
enabled: true
params:
excludedTaints:
- dedicated=special-user # exclude taints with key "dedicated" and value "special-user"
- reserved # exclude all taints with key "reserved"
RemovePodsViolatingTopologySpreadConstraint
这个策略确保把违反拓扑传播约束的pod从节点上驱逐出去。具体来说,它试图驱逐最小数量的pod,以平衡拓扑域到每个约束的最大打散度。该策略需要k8s 1.18+版本。
默认情况下,该策略只处理硬约束,将参数includeSoftConstraints设置为true时,将包括软性约束。
策略参数labelSelector在平衡拓扑域时不被利用,只在驱逐过程中应用,以确定pod是否可以被驱逐。
配置参数
Name | Type |
---|---|
includeSoftConstraints | bool |
thresholdPriority | int (see priority filtering) |
thresholdPriorityClassName | string (see priority filtering) |
namespaces | (see namespace filtering) |
labelSelector | (see label filtering) |
nodeFit | bool (see node fit filtering) |
示例:
apiVersion: "descheduler/v1alpha1"
kind: "DeschedulerPolicy"
strategies:
"RemovePodsViolatingTopologySpreadConstraint":
enabled: true
params:
includeSoftConstraints: false
RemovePodsHavingTooManyRestarts
这个策略可以确保将重启次数过多的pod从节点上驱逐。例如,一个带有EBS/PD的pod不能把卷/磁盘挂到实例上,那么这个pod应该被重新安排到其他节点上。它的参数包括podRestartThreshold,这是一个pod应该被驱逐的重启次数(所有符合条件的容器的总和),以及initContainers,这决定了init容器的重启是否应该被计入该计算中。
配置参数
Name | Type |
---|---|
podRestartThreshold | int |
includingInitContainers | bool |
thresholdPriority | int (see priority filtering) |
thresholdPriorityClassName | string (see priority filtering) |
namespaces | (see namespace filtering) |
labelSelector | (see label filtering) |
nodeFit | bool (see node fit filtering) |
示例:
apiVersion: "descheduler/v1alpha1"
kind: "DeschedulerPolicy"
strategies:
:
enabled: true
params:
podsHavingTooManyRestarts:
podRestartThreshold: 100
includingInitContainers: true
PodLifeTime
这个策略会驱逐超过maxPodLifeTimeSeconds的pod。
还可以指定podStatusPhases,以便只驱逐具有特定StatusPhases的pod,目前这个参数仅限于运行和待定。
配置参数
Name | Type |
---|---|
maxPodLifeTimeSeconds | int |
podStatusPhases | list(string) |
thresholdPriority | int (see priority filtering) |
thresholdPriorityClassName | string (see priority filtering) |
namespaces | (see namespace filtering) |
labelSelector | (see label filtering) |
示例:
apiVersion: "descheduler/v1alpha1"
kind: "DeschedulerPolicy"
strategies:
:
enabled: true
params:
podLifeTime:
maxPodLifeTimeSeconds: 86400
podStatusPhases:
"Pending"
RemoveFailedPods
这个策略将驱逐处于失败状态阶段的pod。可以提供一个可选的参数来过滤失败的原因。通过设置可选的参数includingInitContainers为true,原因可以扩展到包括InitContainers的原因。可以指定一个可选的参数minPodLifetimeSeconds来驱逐超过指定秒数的pod。最后,可以指定可选的参数excludeOwnerKinds,如果一个pod有任何这些Kinds列为OwnerRef,该pod将不被考虑驱逐。
配置参数
Name | Type |
---|---|
minPodLifetimeSeconds | uint |
excludeOwnerKinds | list(string) |
reasons | list(string) |
includingInitContainers | bool |
thresholdPriority | int (see priority filtering) |
thresholdPriorityClassName | string (see priority filtering) |
namespaces | (see namespace filtering) |
labelSelector | (see label filtering) |
nodeFit | bool (see node fit filtering) |
示例:
apiVersion: "descheduler/v1alpha1"
kind: "DeschedulerPolicy"
strategies:
:
enabled: true
params:
failedPods:
reasons:
"NodeAffinity"
includingInitContainers: true
excludeOwnerKinds:
"Job"
minPodLifetimeSeconds: 3600
Pods过滤方式
在驱逐Pods的时候,有时并不需要所有Pods都被驱逐,Descheduler提供一些过滤方式。
a. Namespace过滤
PodLifeTime
RemovePodsHavingTooManyRestarts
RemovePodsViolatingNodeTaints
RemovePodsViolatingNodeAffinity
RemovePodsViolatingInterPodAntiAffinity
RemoveDuplicates
RemovePodsViolatingTopologySpreadConstraint
RemoveFailedPods
LowNodeUtilization和HighNodeUtilization
示例:
apiVersion: "descheduler/v1alpha1"
kind: "DeschedulerPolicy"
strategies:
:
enabled: true
params:
podLifeTime:
maxPodLifeTimeSeconds: 86400
namespaces:
include:
"namespace1"
"namespace2"
在例子中,PodLifeTime只在namespace1和namespace2上执行。对于排除字段也是如此。
apiVersion: "descheduler/v1alpha1"
kind: "DeschedulerPolicy"
strategies:
:
enabled: true
params:
podLifeTime:
maxPodLifeTimeSeconds: 86400
namespaces:
exclude:
"namespace1"
"namespace2"
该策略在除namespace1和namespace2之外的所有命名空间上执行。
b. 优先级过滤
所有的策略都能够配置一个优先级阈值,只有在阈值下的pod才能被驱逐。你可以通过设置thresholdPriorityClassName(将阈值设置为给定的优先级类别的值)或thresholdPriority(直接设置阈值)参数来指定这个阈值。默认情况下,该阈值被设置为系统-集群-关键优先级类的值。
注意:将evictSystemCriticalPods设置为 "true "可以完全禁用优先级过滤。
例如,设置 thresholdPriority
apiVersion: "descheduler/v1alpha1"
kind: "DeschedulerPolicy"
strategies:
:
enabled: true
params:
podLifeTime:
maxPodLifeTimeSeconds: 86400
thresholdPriority: 10000
设置thresholdPriorityClassName
apiVersion: "descheduler/v1alpha1"
kind: "DeschedulerPolicy"
strategies:
:
enabled: true
params:
podLifeTime:
maxPodLifeTimeSeconds: 86400
thresholdPriorityClassName: "priorityclass1"
注意: 你不能同时配置thresholdPriority和thresholdPriorityClassName,如果给定的优先级类不存在,descheduler将不会创建它,并会抛出一个错误。
c. 标签过滤
下面的策略可以配置一个标准的kubernetes labelSelector,通过标签过滤pod。如果设置为 "true",在驱逐它们之前,discheduler将考虑符合驱逐标准的pod是否适合在其他节点上。如果一个pod不能被重新安排到其他节点上,它将不会被驱逐。目前,在设置nodeFit为true时,会考虑以下标准。
示例:
apiVersion: "descheduler/v1alpha1"
kind: "DeschedulerPolicy"
strategies:
"LowNodeUtilization":
enabled: true
params:
nodeResourceUtilizationThresholds:
thresholds:
"cpu": 20
"memory": 20
"pods": 20
targetThresholds:
"cpu": 50
"memory": 50
"pods": 50
nodeFit: true
请注意,节点适应性过滤参考的是当前的pod规格,而不是它的所有者的规格,因为Descheduler是一个 "尽力而为 "的机制。
apiVersion: "descheduler/v1alpha1"
kind: "DeschedulerPolicy"
strategies:
:
enabled: true
params:
podLifeTime:
maxPodLifeTimeSeconds: 86400
labelSelector:
matchLabels:
component: redis
matchExpressions:
{key: tier, operator: In, values: [cache]}
{key: environment, operator: NotIn, values: [dev]}
d. nodeFit过滤
以下策略接受一个nodeFit参数,可以优化调度。
RemoveDuplicates
LowNodeUtilization
HighNodeUtilization
RemovePodsViolatingInterPodAntiAffinity
RemovePodsViolatingNodeAffinity
RemovePodsViolatingNodeTaints
RemovePodsViolatingTopologySpreadConstraint
RemovePodsHavingTooManyRestarts
RemoveFailedPods
小结
Pod 驱逐机制
当desceduler决定从一个节点上驱逐pod时,它采用了以下一般机制。
关键pod不会被驱逐,比如 priorityClassName 设置为 system-cluster-critical 或 system-node-critical 的 Pod。
不属于ReplicationController、ReplicaSet(Deployment)、StatefulSet或Job的pod(如静态pod等)永远不会被驱逐,因为这些pod不会被重新创建。
与DaemonSets相关的pods永远不会被驱逐。
具有本地存储的Pod永远不会被驱逐(除非设置evictLocalStoragePods为true)。
带有PVC的Pod不会被驱逐(除非设置了ignorePvcPods为true)
在LowNodeUtilization和RemovePodsViolatingInterPodAntiAffinity中,pod是按其优先级从低到高驱逐的,如果它们有相同的优先级,Besteffort 类型的 Pod 要先于 Burstable 和 Guaranteed 类型被驱逐
带有descheduler.alpha.kubernetes.io/evict annotation的pod都可以被驱逐。这个annotation用,可以让用户选择哪些pod被驱逐。
具有非零的DeletionTimestamp的pod默认不会被驱逐
Pod Disruption Budget (PDB)
PodDisruptionBudget能够针对自发的驱逐(即上面提到的通过API发起驱逐)提供保护,通过配置 PDB(PodDisruptionBudget) 对象来避免所有副本同时被删除。
局限性
以下为其他注意项与缺陷:
基于request计算节点负载并不能反映实际情况
驱逐Pod导致应用服务的不稳定,过策略计算出一系列符合要求的 Pod,进行驱逐。好的方面是,descheduler 不会驱逐没有副本控制器的 Pod,不会驱逐带本地存储的 Pod 等,保障在驱逐时,不会导致应用故障。但是使用 client.PolicyV1beta1().Evictions 驱逐 Pod 时,会先删掉 Pod 再重新启动,而不是滚动更新。
其功能在于驱逐而非调度,因资源相关视图与调度器存在差异,可能存在频繁调度驱逐节点
由于笔者时间、视野、认知有限,本文难免出现错误、疏漏等问题,期待各位读者朋友、业界专家指正交流。
参考文献
1.https://github.com/kubernetes-sigs/descheduler
2. https://cloud.tencent.com/developer/article/2050316
真诚推荐你关注