你真的理解 Kubernetes 中的 requests 和 limits 嗎?
在 K8s 集羣中部署資源的時候,你是否經常遇到以下情形:
-
經常在 Kubernetes 集羣種部署負載的時候不設置 CPU
requests
或將 CPUrequests
設置得過低(這樣 “看上去” 就可以在每個節點上容納更多 Pod )。在業務比較繁忙的時候,節點的 CPU 全負荷運行。業務延遲明顯增加,有時甚至機器會莫名其妙地進入 CPU 軟死鎖等 “假死” 狀態。
-
類似地,部署負載的時候,不設置內存
requests
或者內存requests
設置得過低,這時會發現有些 Pod 會不斷地失敗重啓。而不斷重啓的這些 Pod 通常跑的是 Java 業務應用。但是這些 Java 應用本地調試運行地時候明明都是正常的。
-
在 Kubernetes 集羣中,集羣負載並不是完全均勻地在節點間分配的,通常內存不均勻分配的情況較爲突出,集羣中某些節點的內存使用率明顯高於其他節點。
Kubernetes 作爲一個衆所周知的雲原生分佈式容器編排系統,一個所謂的事實上標準,其調度器不是應該保證資源的均勻分配嗎?
如果在業務高峯時間遇到上述問題,並且機器已經 hang 住甚至無法遠程 ssh 登陸,那麼通常留給集羣管理員的只剩下重啓集羣這一個選項。
如果你遇到過上面類似的情形,想了解如何規避相關問題或者你是 Kubernetes 運維開發人員,想對這類問題的本質一探究竟,那麼請耐心閱讀下面的章節。
我們會先對這類問題做一個定性分析,並給出避免此類問題的最佳實踐,最後如果你對 Kubernetes requests
和 limits
的底層機制感興趣,我們可以從源碼角度做進一步地分析,做到 “知其然也知其所以然”。
問題分析
-
對於情形 1
首先我們需要知道對於 CPU 和內存這 2 類資源,他們是有一定區別的。CPU 屬於可壓縮資源,其中 CPU 資源的分配和管理是 Linux 內核藉助於完全公平調度算法( CFS )和 Cgroup 機制共同完成的。
簡單地講,如果 Pod 中服務使用 CPU 超過設置的 CPU
limits
, Pod 的 CPU 資源會被限流( throttled )。對於沒有設置limit
的 Pod ,一旦節點的空閒 CPU 資源耗盡,之前分配的 CPU 資源會逐漸減少。不管是上面的哪種情況,最終的結果都是 Pod 已經越來越無法承載外部更多的請求,表現爲應用延時增加,響應變慢。
-
對於情形 2
內存屬於不可壓縮資源, Pod 之間是無法共享的,完全獨佔的,這也就意味着資源一旦耗盡或者不足,分配新的資源一定是會失敗的。有的 Pod 內部進程在初始化啓動時會提前開闢出一段內存空間。
比如 JVM 虛擬機在啓動的時候會申請一段內存空間。如果內存
requests
指定的數值小於 JVM 虛擬機向系統申請的內存,導致內存申請失敗( oom-kill ),從而 Pod 出現不斷地失敗重啓。 -
對於情形 3
實際上在創建 Pod 的過程中,一方面, Kubernetes 需要撥備包含 CPU 和內存在內的多種資源,這裏的資源均衡是包含 CPU 和內存在內的所有資源的綜合考量。
另一方面, Kubernetes 內置的調度算法不僅僅涉及到 “最小資源分配節點”,還會把其他諸如 Pod 親和性等因素考慮在內。並且 Kubernetes 調度基於的是資源的
requests
數值,而之所以往往觀察到的是內存分佈不夠均衡,是因爲對於應用來說,相比於其他資源,內存一般是更緊缺的一類資源。Kubernetes 的調度機制是基於當前的狀態。比如當出現新的 Pod 進行調度時,調度程序會根據其當時對 Kubernetes 集羣的資源描述做出最佳調度決定。
但是 Kubernetes 集羣是非常動態的。比如一個節點爲了維護,我們先執行了驅逐操作,這個節點上的所有 Pod 會被驅逐到其他節點去,當我們維護完成後,之前的 Pod 並不會自動回到該節點上來,因爲 Pod 一旦被綁定了節點是不會觸發重新調度的。
最佳實踐
由上面的分析我們可以看到,集羣的穩定性直接決定了其上運行的業務應用的穩定性。而臨時性的資源短缺往往是導致集羣不穩定的主要因素。集羣一旦不穩定,輕則業務應用的性能下降,重則出現相關結點不可用。
那麼如何提高集羣的穩定性呢?
一方面,可以通過編輯 Kubelet 配置文件 [1] 來預留一部分系統資源,從而保證當可用計算資源較少時 kubelet 所在節點的穩定性。這在處理如內存和硬盤之類的不可壓縮資源時尤爲重要。
另一方面,通過合理地設置 Pod 的 QoS 可以進一步提高集羣穩定性:不同 QoS 的 Pod 具有不同的 OOM 分數,當出現資源不足時,集羣會優先 Kill 掉 Best-Effort
類型的 Pod ,其次是 Burstable
類型的 Pod ,最後是Guaranteed
類型的 Pod 。
因此,如果資源充足,可將 QoS pods 類型均設置爲 Guaranteed
。用計算資源換業務性能和穩定性,減少排查問題時間和成本。同時如果想更好的提高資源利用率,業務服務也可以設置爲 Guaranteed
,而其他服務根據重要程度可分別設置爲 Burstable
或 Best-Effort
。
下面我們會以 Kubesphere 平臺爲例,演示如何方便優雅地配置 Pod 相關的資源。
KubeSphere 資源配置實踐
前面我們已經瞭解到 Kubernetes 中requests
、limits
這 2 個參數的合理設置對整個集羣的穩定性至關重要。而作爲 Kubernetes 的發行版,KubeSphere 極大地降低了 Kubernetes 的學習門檻,配合簡潔美觀的 UI 界面,你會發現有效運維原來是一件如此輕鬆的事情。下面我們將演示如何在 KubeSphere 平臺中配置容器的相關資源配額與限制。
相關概念
在進行演示之前,讓我們再回顧一下 Kubernetes 相關概念。
requests 與 limits 簡介
爲了實現 Kubernetes 集羣中資源的有效調度和充分利用, Kubernetes 採用requests
和limits
兩種限制類型來對資源進行容器粒度的分配。每一個容器都可以獨立地設定相應的requests
和limits
。這 2 個參數是通過每個容器 containerSpec 的 resources 字段進行設置的。一般來說,在調度的時候requests
比較重要,在運行時limits
比較重要。
resources:
requests:
cpu: 50m
memory: 50Mi
limits:
cpu: 100m
memory: 100Mi
requests
定義了對應容器需要的最小資源量。這句話的含義是,舉例來講,比如對於一個 Spring Boot 業務容器,這裏的requests
必須是容器鏡像中 JVM 虛擬機需要佔用的最少資源。如果這裏把 Pod 的內存requests
指定爲 10Mi ,顯然是不合理的,JVM 實際佔用的內存 Xms 超出了 Kubernetes 分配給 Pod 的內存,導致 Pod 內存溢出,從而 Kubernetes 不斷重啓 Pod 。
limits
定義了這個容器最大可以消耗的資源上限,防止過量消耗資源導致資源短缺甚至宕機。特別的,設置爲 0 表示對使用的資源不做限制。值得一提的是,當設置limits
而沒有設置requests
時,Kubernetes 默認令requests
等於limits
。
進一步可以把requests
和limits
描述的資源分爲 2 類:可壓縮資源(例如 CPU )和不可壓縮資源(例如內存)。合理地設置limits
參數對於不可壓縮資源來講尤爲重要。
前面我們已經知道requests
參數會對最終的 Kubernetes 調度結果起到直接的顯而易見的影響。藉助於 Linux 內核 Cgroup 機制,limits
參數實際上是被 Kubernetes 用來約束分配給進程的資源。對於內存參數而言,實際上就是告訴 Linux 內核什麼時候相關容器進程可以爲了清理空間而被殺死( oom-kill )。
總結一下:
-
對於 CPU,如果 Pod 中服務使用 CPU 超過設置的
limits
,Pod 不會被 kill 掉但會被限制。如果沒有設置 limits ,pod 可以使用全部空閒的 CPU 資源。 -
對於內存,當一個 Pod 使用內存超過了設置的
limits
,Pod 中 container 的進程會被 kernel 因 OOM kill 掉。當 container 因爲 OOM 被 kill 掉時,系統傾向於在其原所在的機器上重啓該 container 或本機或其他重新創建一個 Pod。 -
0 <= requests <=Node Allocatable, requests <= limits <= Infinity
Pod 的服務質量( QoS )
Kubernetes 創建 Pod 時就給它指定了下列一種 QoS 類:Guaranteed,Burstable,BestEffort。
-
Guaranteed:Pod 中的每個容器,包含初始化容器,必須指定內存和 CPU 的
requests
和limits
,並且兩者要相等。 -
Burstable:Pod 不符合 Guaranteed QoS 類的標準;Pod 中至少一個容器具有內存或 CPU
requests
。 -
BestEffort:Pod 中的容器必須沒有設置內存和 CPU
requests
或limits
。
結合結點上 Kubelet 的 CPU 管理策略,可以對指定 Pod 進行綁核操作,參見官方文檔 [2]。
準備工作
您需要創建一個企業空間、一個項目和一個帳戶 (ws-admin),務必邀請該帳戶到項目中並賦予 admin 角色。有關更多信息,請參見創建企業空間、項目、帳戶和角色 [3]。
設置項目配額( Resource Quotas )
- 進入項目基本信息界面,依次直接點擊 “項目管理 -> 編輯配額” 進入項目的配額設置頁面。
- 進入項目配額頁面,爲該項目分別指定
requests
和limits
配額。
設置項目配額的有 2 方面的作用:
-
限定了該項目下所有 pod 指定的
requests
和limits
之和分別要小於等與這裏指定的項目的總requests
和limits
。 -
如果在項目中創建任何一個容器沒有指定
requests
或者limits
,那麼相應的資源會創建報錯,並會以事件的形式給出報錯提示。
可以看到,設定項目配額以後,在該項目中創建任何容器都需要指定requests
和limits
,隱含實現了所謂的 “code is law”,即人人都需要遵守的規則。
Kubesphere 中的項目配額等價於 Kubernetes 中的 resource quotas ,項目配額除了能夠以項目爲單位管理 CPU 和內存的使用使用分配情況,還能夠管理其他類型的資源數目等,詳細信息參見資源配額 [4]。
設置容器資源的默認請求
上面我們已經討論過項目中開啓了配額以後,那麼之後創建的 Pod 必須明確指定相應的 requests
和 limits
。事實上,在實際的測試或者生產環境當中,大部分 Pod 的 requests
和 limits
是高度相近甚至完全相同的。
有沒有辦法在項目中,事先設定好默認的缺省 requests
和 limits
,當用戶沒有指定容器的 requests
和 limits
時,直接應用默認值,若 Pod 已經指定 requests
和 limits
是否直接跳過呢?答案是肯定的。
- 進入項目基本信息界面,依次直接點擊 “項目管理 -> 編輯資源默認請求” 進入項目的默認請求設置頁面。
- 進入項目配額頁面,爲該項目分別指定 CPU 和內存的默認值。
KubeSphere 中的項目容器資源默認請求是藉助於 Kubernetes 中的 Limit Ranges ,目前 KubeSphere 支持 CPU 和內存的
requests
和limits
的默認值設定。
前面我們已經瞭解到,對於一些關鍵的業務容器,通常其流量和負載相比於其他 Pod 都是比較高的,對於這類容器的requests
和limits
需要具體問題具體分析。
分析的維度是多個方面的,例如該業務容器是 CPU 密集型的,還是 IO 密集型的。是單點的還是高可用的,這個服務的上游和下游是誰等等。
另一方面,在生產環境中這類業務容器的負載從時間維度看的話,往往是具有周期性的。因此,業務容器的歷史監控數據可以在參數設置方面提供重要的參考價值。
而 KubeSphere 在最初的設計中,就已經在架構層面考慮到了這點,將 Prometheus 組件無縫集成到 KubeSphere 平臺中,並提供縱向上至集羣層級,下至 Pod 層級的完整的監控體系。橫向涵蓋 CPU ,內存,網絡,存儲等。
一般,requests
值可以設定爲歷史數據的均值,而limits
要大於歷史數據的均值,最終數值還需要結合具體情況做一些小的調整。
源碼分析
前面我們從日常 Kubernetes 運維出發,描述了由於 requests
和 limits
參數配置不當而引起的一系列問題,闡述了問題產生的原因並給出的最佳實踐。
下面我們將深入到 Kubernetes 內部,從代碼裏表徵的邏輯關係來進一步分析和驗證上面給出的結論。
requests 是如何影響 Kubernetes 調度決策的?
我們知道在 Kubernetes 中 Pod 是最小的調度單位,Pod 的requests
與 Pod 內容器的requests
關係如下:
func computePodResourceRequest(pod *v1.Pod) *preFilterState {
result := &preFilterState{}
for _, container := range pod.Spec.Containers {
result.Add(container.Resources.Requests)
}
// take max_resource(sum_pod, any_init_container)
for _, container := range pod.Spec.InitContainers {
result.SetMaxResource(container.Resources.Requests)
}
// If Overhead is being utilized, add to the total requests for the pod
if pod.Spec.Overhead != nil && utilfeature.DefaultFeatureGate.Enabled(features.PodOverhead) {
result.Add(pod.Spec.Overhead)
}
return result
}
...
func (f *Fit) PreFilter(ctx context.Context, cycleState *framework.CycleState, pod *v1.Pod) *framework.Status {
cycleState.Write(preFilterStateKey, computePodResourceRequest(pod))
return nil
}
...
func getPreFilterState(cycleState *framework.CycleState) (*preFilterState, error) {
c, err := cycleState.Read(preFilterStateKey)
if err != nil {
// preFilterState doesn't exist, likely PreFilter wasn't invoked.
return nil, fmt.Errorf("error reading %q from cycleState: %v", preFilterStateKey, err)
}
s, ok := c.(*preFilterState)
if !ok {
return nil, fmt.Errorf("%+v convert to NodeResourcesFit.preFilterState error", c)
}
return s, nil
}
...
func (f *Fit) Filter(ctx context.Context, cycleState *framework.CycleState, pod *v1.Pod, nodeInfo *framework.NodeInfo) *framework.Status {
s, err := getPreFilterState(cycleState)
if err != nil {
return framework.NewStatus(framework.Error, err.Error())
}
insufficientResources := fitsRequest(s, nodeInfo, f.ignoredResources, f.ignoredResourceGroups)
if len(insufficientResources) != 0 {
// We will keep all failure reasons.
failureReasons := make([]string, 0, len(insufficientResources))
for _, r := range insufficientResources {
failureReasons = append(failureReasons, r.Reason)
}
return framework.NewStatus(framework.Unschedulable, failureReasons...)
}
return nil
}
從上面的源碼中不難看出,調度器(實際上是 Schedule thread )首先會在 Pre filter 階段計算出待調度 Pod 所需要的資源,具體講就是從 Pod Spec 中分別計算初始容器和工作容器requests
之和,並取其較大者,特別地,對於像 Kata-container 這樣的微虛機,其自身的虛擬化開銷相比於容器來說是不能忽略不計的,所以還需要加上虛擬化本身的資源開銷,計算出的結果存入到緩存中,在緊接着的 Filter 階段,會遍歷所有節點過濾出符合條件的節點。
在過濾出所有符合條件的節點以後,如果當前滿足的條件的節點只有一個,那麼該 Pod 隨後將被調度到該節點。但是更多的情況下,此時過濾之後符合條件的節點往往有多個,這時候就需要進入 Score 階段,依次對這些節點進行打分( Score )。而打分本身也是包括多個維度,通過內置 plugin 的形式綜合評判的。值得注意的是,前面我們定義的 Pod 的requests
和limits
參數也會直接影響到NodeResourcesLeastAllocated
算法最終的計算結果。源碼如下:
func leastResourceScorer(resToWeightMap resourceToWeightMap) func(resourceToValueMap, resourceToValueMap, bool, int, int) int64 {
return func(requested, allocable resourceToValueMap, includeVolumes bool, requestedVolumes int, allocatableVolumes int) int64 {
var nodeScore, weightSum int64
for resource, weight := range resToWeightMap {
resourceScore := leastRequestedScore(requested[resource], allocable[resource])
nodeScore += resourceScore * weight
weightSum += weight
}
return nodeScore / weightSum
}
}
...
func leastRequestedScore(requested, capacity int64) int64 {
if capacity == 0 {
return 0
}
if requested > capacity {
return 0
}
return ((capacity - requested) * int64(framework.MaxNodeScore)) / capacity
}
可以看到在NodeResourcesLeastAllocated
算法中,對於同一個 Pod ,目標節點的資源越充裕,那麼該節點的得分也就越高。換句話說,同一個 Pod 更傾向於調度到資源充足的節點。
需要注意的是,實際上在創建 Pod 的過程中,一方面, Kubernetes 需要撥備包含 CPU 和內存在內的多種資源。每種資源都會對應一個權重(對應源碼中的 resToWeightMap 數據結構),所以這裏的資源均衡是包含 CPU 和內存在內的所有資源的綜合考量。另一方面,在 Score 階段,除了NodeResourcesLeastAllocated
算法以外,調用器還會使用到其他算法(例如InterPodAffinity
)進行分數的評定。
注:在 Kubernetes 調度器中,會把調度過程分爲若干個階段,即 Pre filter, Filter, Post filter, Score 等。在 Pre filter 階段,用於選擇符合 Pod Spec 描述的 Nodes 。
QoS 是如何影響 Kubernetes 調度決策的?
QOS 作爲 Kubernetes 中一種資源保護機制,主要是針對不可壓縮資源的一種控制技術。比如在內存中其通過爲不同的 Pod 和容器構造 OOM 評分,並且通過內核的策略的輔助,從而實現當節點內存資源不足的時候,內核可以按照策略的優先級,優先 kill 掉優先級比較低(分值越高優先級越低)的 Pod。相關源碼如下:
func GetContainerOOMScoreAdjust(pod *v1.Pod, container *v1.Container, memoryCapacity int64) int {
if types.IsCriticalPod(pod) {
// Critical pods should be the last to get killed.
return guaranteedOOMScoreAdj
}
switch v1qos.GetPodQOS(pod) {
case v1.PodQOSGuaranteed:
// Guaranteed containers should be the last to get killed.
return guaranteedOOMScoreAdj
case v1.PodQOSBestEffort:
return besteffortOOMScoreAdj
}
// Burstable containers are a middle tier, between Guaranteed and Best-Effort. Ideally,
// we want to protect Burstable containers that consume less memory than requested.
// The formula below is a heuristic. A container requesting for 10% of a system's
// memory will have an OOM score adjust of 900. If a process in container Y
// uses over 10% of memory, its OOM score will be 1000. The idea is that containers
// which use more than their request will have an OOM score of 1000 and will be prime
// targets for OOM kills.
// Note that this is a heuristic, it won't work if a container has many small processes.
memoryRequest := container.Resources.Requests.Memory().Value()
oomScoreAdjust := 1000 - (1000*memoryRequest)/memoryCapacity
// A guaranteed pod using 100% of memory can have an OOM score of 10. Ensure
// that burstable pods have a higher OOM score adjustment.
if int(oomScoreAdjust) < (1000 + guaranteedOOMScoreAdj) {
return (1000 + guaranteedOOMScoreAdj)
}
// Give burstable pods a higher chance of survival over besteffort pods.
if int(oomScoreAdjust) == besteffortOOMScoreAdj {
return int(oomScoreAdjust - 1)
}
return int(oomScoreAdjust)
}
總結
Kubernetes 是一個具有良好移植和擴展性的開源平臺,用於管理容器化的工作負載和服務。Kubernetes 擁有一個龐大且快速增長的生態系統,已成爲容器編排領域的事實標準。但是也不可避免地引入許多複雜性。
而 KubeSphere 作爲國內唯一一個開源的 Kubernetes 發行版,極大地降低了使用 Kubernetes 的門檻。藉助於 KubeSphere 平臺,原先需要通過後臺命令行和 yaml 文件管理的系統配置,現在只需要在簡潔美觀的 UI 界面上輕鬆完成。
本文從雲原生應用部署階段requests
和limits
的設置問題切入,分析了相關 Kubernetes 底層的工作原理以及如何通過 KubeSphere 平臺簡化相關的運維工作。
參考文獻
-
https://learnk8s.io/setting-cpu-memory-limits-requests[5]
-
https://kubernetes.io/docs/tasks/configure-pod-container/quality-service-pod/[6]
-
https://docs.oracle.com/cd/E13150_01/jrockit_jvm/jrockit/jrdocs/refman/optionX.html[7]
-
https://kubesphere.com.cn/forum/d/1155-k8s[8]
-
https://kubernetes.io/docs/tasks/administer-cluster/out-of-resource/[9]
-
https://kubernetes.io/docs/concepts/policy/limit-range/[10]
-
https://www.kernel.org/doc/Documentation/scheduler/sched-bwc.txt[11]
-
https://www.kernel.org/doc/Documentation/scheduler/sched-design-CFS.txt[12]
-
https://access.redhat.com/documentation/en-us/red_hat_enterprise_linux/6/html/resource_management_guide/sec-cpu[13]
-
https://medium.com/omio-engineering/cpu-limits-and-aggressive-throttling-in-kubernetes-c5b20bd8a718[14]
腳註
[1]
Kubelet 配置文件: https://kubernetes.io/docs/tasks/administer-cluster/out-of-resource/
[2]
官方文檔: https://kubernetes.io/docs/tasks/administer-cluster/cpu-management-policies/
[3]
創建企業空間、項目、帳戶和角色: https://kubesphere.io/zh/docs/quick-start/create-workspace-and-project/
[4]
資源配額: https://kubernetes.io/docs/concepts/policy/resource-quotas/
[5]
https://learnk8s.io/setting-cpu-memory-limits-requests: https://learnk8s.io/setting-cpu-memory-limits-requests
[6]
https://kubernetes.io/docs/tasks/configure-pod-container/quality-service-pod/: https://kubernetes.io/docs/tasks/configure-pod-container/quality-service-pod/
[7]
https://docs.oracle.com/cd/E13150_01/jrockit_jvm/jrockit/jrdocs/refman/optionX.html: https://docs.oracle.com/cd/E13150_01/jrockit_jvm/jrockit/jrdocs/refman/optionX.html
[8]
https://kubesphere.com.cn/forum/d/1155-k8s: https://kubesphere.com.cn/forum/d/1155-k8s
[9]
https://kubernetes.io/docs/tasks/administer-cluster/out-of-resource/: https://kubernetes.io/docs/tasks/administer-cluster/out-of-resource/
[10]
https://kubernetes.io/docs/concepts/policy/limit-range/: https://kubernetes.io/docs/concepts/policy/limit-range/
[11]
https://www.kernel.org/doc/Documentation/scheduler/sched-bwc.txt: https://www.kernel.org/doc/Documentation/scheduler/sched-bwc.txt
[12]
https://www.kernel.org/doc/Documentation/scheduler/sched-design-CFS.txt: https://www.kernel.org/doc/Documentation/scheduler/sched-design-CFS.txt
[13]
https://access.redhat.com/documentation/en-us/red_hat_enterprise_linux/6/html/resource_management_guide/sec-cpu: https://access.redhat.com/documentation/en-us/red_hat_enterprise_linux/6/html/resource_management_guide/sec-cpu
[14]
https://medium.com/omio-engineering/cpu-limits-and-aggressive-throttling-in-kubernetes-c5b20bd8a718: https://medium.com/omio-engineering/cpu-limits-and-aggressive-throttling-in-kubernetes-c5b20bd8a718
KubeSphere (https://kubesphere.io)是在 Kubernetes 之上構建的開源容器混合雲,提供全棧的 IT 自動化運維的能力,簡化企業的 DevOps 工作流。
KubeSphere 已被 Aqara 智能家居、本來生活、新浪、華夏銀行、四川航空、國藥集團、微衆銀行、紫金保險、中通、****中國人保壽險、中國太平保險、**中移金科、Radore、ZaloPay 等海內外數千家企業採用。KubeSphere 提供了開發者友好的嚮導式操作界面和豐富的企業級功能,包括多雲與多集羣管理、Kubernetes 資源管理、DevOps (CI/CD)、應用生命週期管理、微服務治理 (Service Mesh)、多租戶管理、監控日誌、告警通知、審計事件、存儲與網絡管理、GPU support** 等功能,幫助企業快速構建一個強大和功能豐富的容器雲平臺。
** ✨ GitHub**:https://github.com/kubesphere
** 💻 官網(中國站)**:https://kubesphere.com.cn
👨💻 **微信羣:**請搜索添加羣助手微信號 kubesphere
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/H6zs0w84GchEsa8L_qtXRA