2個工具,助你排查Kubelet CPU 使用率過高問題

本文是跟安信證券容器雲技術團隊共同進行問題排查的最佳實踐。

問題背景

我們發現客戶的Kubernetes集羣環境中所有的worker節點的Kubelet進程的CPU使用率長時間佔用過高,通過pidstat可以看到CPU使用率高達100%。本文記錄下了本次問題排查的過程。

圖片

集羣環境

在這裏插入圖片描述

排查過程

使用strace工具對kubelet進程進行跟蹤

1、由於Kubelet進程CPU使用率異常,可以使用strace工具對kubelet進程動態跟蹤進程的調用情況,首先使用strace -cp <PID>命令統計kubelet進程在某段時間內的每個系統調用的時間、調用和錯誤情況。

圖片

從上圖可以看到,執行系統調用過程中,futex拋出了五千多個errors,這並不是一個正常的數量,而且該函數佔用的時間達到了99%,所以需要進一步查看kubelet進程相關的調用。

2、由於strace -cp命令只能查看進程的整體調用情況,所以我們可以通過strace -tt -p <PID>命令打印每個系統調用的時間戳,如下:

圖片

從strace輸出的結果來看,在執行futex相關的系統調用時,有大量的Connect timed out,並返回了-1和ETIMEDOUT的error,所以纔會在strace -cp中看到了那麼多的error。

futex是一種用戶態和內核態混合的同步機制,當futex變量告訴進程有競爭發生時,會執行系統調用去完成相應的處理,例如wait或者wake up,從官方的文檔瞭解到,futex有這麼幾個參數:

futex(uint32_t *uaddr, int futex_op, uint32_t val,
                 const struct timespec *timeout,   /* or: uint32_t val2 */
                 uint32_t *uaddr2, uint32_t val3);

官方文檔給出ETIMEDOUT的解釋:

ETIMEDOUT
       The operation in futex_op employed the timeout specified in
       timeout, and the timeout expired before the operation
       completed.

意思就是在指定的timeout時間中,未能完成相應的操作,其中futex_op對應上述輸出結果的FUTEX_WAIT_PRIVATEFUTEX_WAIT_PRIVATE,可以看到基本都是發生在FUTEX_WAIT_PRIVATE時發生的超時。

從目前的系統調用層面可以判斷,futex無法順利進入睡眠狀態,但是futex進行了哪些操作還是不清楚,因此仍無法判斷kubeletCPU飆高的原因,所以我們需要進一步從kubelet的函數調用中去看到底是執行卡在了哪個地方。

FUTEX_PRIVATE_FLAG:這個參數告訴內核futex是進程專用的,不與其他進程共享,這裏的FUTEX_WAIT_PRIVATE和FUTEX_WAKE_PRIVATE就是其中的兩種FLAG;

futex相關說明1: https://man7.org/linux/man-pages/man7/futex.7.html fuex相關說明2: https://man7.org/linux/man-pages/man2/futex.2.html

使用go pprof工具對kubelet函數調用進行分析

早期的Kubernetes版本,可以直接通過debug/pprof 接口獲取debug數據,後面考慮到相關安全性的問題,取消了這個接口,具體信息可以參考CVE-2019-11248(https://github.com/kubernetes/kubernetes/issues/81023)。因此我們將通過kubectl開啓proxy進行相關數據指標的獲取:

1、首先使用kubectl proxy命令啓動API server代理

kubectl proxy --address='0.0.0.0'  --accept-hosts='^*$'

這裏需要注意,如果使用的是Rancher UI上覆制的kubeconfig文件,則需要使用指定了master IP的context,如果是RKE或者其他工具安裝則可以忽略。

2、構建Golang環境。go pprof需要在golang環境下使用,本地如果沒有安裝golang,則可以通過Docker快速構建Golang環境

docker run -itd --name golang-env --net host golang bash

3、使用go pprof工具導出採集的指標,這裏替換127.0.0.1爲apiserver節點的IP,默認端口是8001,如果docker run的環境跑在apiserver所在的節點上,可以使用127.0.0.1。另外,還要替換NODENAME爲對應的節點名稱。

docker exec -it golang-env bash
go tool pprof -seconds=60 -raw -output=kubelet.pprof http://127.0.0.1:8001/api/v1/nodes/${NODENAME}/proxy/debug/pprof/profile

4、輸出好的pprof文件不方便查看,需要轉換成火焰圖,推薦使用FlameGraph工具生成svg圖

git clone https://github.com/brendangregg/FlameGraph.git
cd FlameGraph/
./stackcollapse-go.pl kubelet.pprof > kubelet.out
./flamegraph.pl kubelet.out > kubelet.svg

轉換成火焰圖後,就可以在瀏覽器直觀地看到函數相關調用和具體調用時間比了。

5、分析火焰圖

圖片

從kubelet的火焰圖可以看到,調用時間最長的函數是*k8s.io/kubernetes/vendor/github.com/google/cadvisor/manager.(containerData).housekeeping,其中cAdvisor是kubelet內置的指標採集工具,主要是負責對節點機器上的資源及容器進行實時監控和性能數據採集,包括CPU使用情況、內存使用情況、網絡吞吐量及文件系統使用情況。

深入函數調用可以發現k8s.io/kubernetes/vendor/github.com/opencontainers/runc/libcontainer/cgroups/fs.(*Manager).GetStats這個函數佔用k8s.io/kubernetes/vendor/github.com/google/cadvisor/manager.(*containerData).housekeeping這個函數的時間是最長的,說明在獲取容器CGroup相關狀態時佔用了較多的時間。

6、既然這個函數佔用時間長,那麼我們就分析一下這個函數具體幹了什麼。

查看源代碼: https://github.com/kubernetes/kubernetes/blob/ded8a1e2853aef374fc93300fe1b225f38f19d9d/vendor/github.com/opencontainers/runc/libcontainer/cgroups/fs/memory.go#L162

func (s *MemoryGroup) GetStats(path string, stats *cgroups.Stats) error {
  // Set stats from memory.stat.
  statsFile, err := os.Open(filepath.Join(path, "memory.stat"))
  if err != nil {
    if os.IsNotExist(err) {
      return nil
    }
    return err
  }
  defer statsFile.Close()

  sc := bufio.NewScanner(statsFile)
  for sc.Scan() {
    t, v, err := fscommon.GetCgroupParamKeyValue(sc.Text())
    if err != nil {
      return fmt.Errorf("failed to parse memory.stat (%q) - %v", sc.Text(), err)
    }
    stats.MemoryStats.Stats[t] = v
  }
  stats.MemoryStats.Cache = stats.MemoryStats.Stats["cache"]

  memoryUsage, err := getMemoryData(path, "")
  if err != nil {
    return err
  }
  stats.MemoryStats.Usage = memoryUsage
  swapUsage, err := getMemoryData(path, "memsw")
  if err != nil {
    return err
  }
  stats.MemoryStats.SwapUsage = swapUsage
  kernelUsage, err := getMemoryData(path, "kmem")
  if err != nil {
    return err
  }
  stats.MemoryStats.KernelUsage = kernelUsage
  kernelTCPUsage, err := getMemoryData(path, "kmem.tcp")
  if err != nil {
    return err
  }
  stats.MemoryStats.KernelTCPUsage = kernelTCPUsage

  useHierarchy := strings.Join([]string{"memory", "use_hierarchy"}, ".")
  value, err := fscommon.GetCgroupParamUint(path, useHierarchy)
  if err != nil {
    return err
  }
  if value == 1 {
    stats.MemoryStats.UseHierarchy = true
  }

  pagesByNUMA, err := getPageUsageByNUMA(path)
  if err != nil {
    return err
  }
  stats.MemoryStats.PageUsageByNUMA = pagesByNUMA

  return nil
}

從代碼中可以看到,進程會去讀取memory.stat這個文件,這個文件存放了cgroup內存使用情況。也就是說,在讀取這個文件花費了大量的時間。這時候,如果我們手動去查看這個文件,會是什麼效果?

# time cat /sys/fs/cgroup/memory/memory.stat >/dev/null
real 0m9.065s
user 0m0.000s
sys 0m9.064s

從這裏可以看出端倪了,讀取這個文件花費了9s,顯然是不正常的。

基於上述結果,我們在cAdvisor的GitHub上查找到一個issue(https://github.com/google/cadvisor/issues/1774),從該issue中可以得知,該問題跟slab memory 緩存有一定的關係。從該issue中得知,受影響的機器的內存會逐漸被使用,通過/proc/meminfo看到使用的內存是slab memory,該內存是內核緩存的內存頁,並且其中絕大部分都是dentry緩存。從這裏我們可以判斷出,當CGroup中的進程生命週期結束後,由於緩存的原因,還存留在slab memory中,導致其類似殭屍CGroup一樣無法被釋放。

也就是每當創建一個memory CGroup,在內核內存空間中,就會爲其創建分配一份內存空間,該內存包含當前CGroup相關的cache(dentry、inode),也就是目錄和文件索引的緩存,該緩存本質上是爲了提高讀取的效率。但是當CGroup中的所有進程都退出時,存在內核內存空間的緩存並沒有清理掉。

內核通過夥伴算法進行內存分配,每當有進程申請內存空間時,會爲其分配至少一個內存頁面,也就是最少會分配4k內存,每次釋放內存,也是按照最少一個頁面來進行釋放。當請求分配的內存大小爲幾十個字節或幾百個字節時,4k對其來說是一個巨大的內存空間,在Linux中,爲了解決這個問題,引入了slab內存分配管理機制,用來處理這種小量的內存請求,這就會導致,當CGroup中的所有進程都退出時,不會輕易回收這部分的內存,而這部分內存中的緩存數據,還會被讀取到stats中,從而導致影響讀取的性能。

解決方法

1、清理節點緩存,這是一個臨時的解決方法,暫時清空節點內存緩存,能夠緩解kubelet CPU使用率,但是後面緩存上來了,CPU使用率又會升上來。

echo 2 > /proc/sys/vm/drop_caches

2、升級內核版本

其實這個主要還是內核的問題,在GitHub上這個commit(https://github.com/torvalds/linux/commit/205b20cc5a99cdf197c32f4dbee2b09c699477f0)中有提到,在5.2+以上的內核版本中,優化了CGroup stats相關的查詢性能,如果想要更好的解決該問題,建議可以參考自己操作系統和環境,合理的升級內核版本。 另外Redhat在kernel-4.18.0-176(https://bugzilla.redhat.com/show_bug.cgi?id=1795049)版本中也優化了相關CGroup的性能問題,而CentOS 8/RHEL 8默認使用的內核版本就是4.18,如果目前您使用的操作系統是RHEL7/CentOS7,則可以嘗試逐漸替換新的操作系統,使用這個4.18.0-176版本以上的內核,畢竟新版本內核總歸是對容器相關的體驗會好很多。

kernel相關commit: https://github.com/torvalds/linux/commit/205b20cc5a99cdf197c32f4dbee2b09c699477f0 redhat kernel bug fix: https://bugzilla.redhat.com/show_bug.cgi?id=1795049

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章