Kubernetes 微服務最佳實踐

於清樂發表於2022-01-25

本文的獨立部落格閱讀地址:https://ryan4yin.space/posts/kubernetes-best-practices/

本文由個人筆記 ryan4yin/knowledge 整理而來

本文主要介紹我個人在使用 Kubernetes 的過程中,總結出的一套「Kubernetes 配置」,是我個人的「最佳實踐」。
其中大部分內容都經歷過線上環境的考驗,但是也有少部分還只在我腦子裡模擬過,請謹慎參考。

閱讀前的幾個注意事項:

  • 這份文件比較長,囊括了很多內容,建議當成參考手冊使用,先參照目錄簡單讀一讀,有需要再細讀相關內容。
  • 這份文件需要一定的 Kubernetes 基礎才能理解,而且如果沒有過實踐經驗的話,看上去可能會比較枯燥。
    • 而有過實踐經驗的大佬,可能會跟我有不同的見解,歡迎各路大佬評論~

我會視情況不定期更新這份文件。

零、示例

首先,這裡給出一些本文遵守的前提,這些前提只是契合我遇到的場景,可靈活變通:

  • 這裡只討論無狀態服務,有狀態服務不在討論範圍內
  • 我們不使用 Deployment 的滾動更新能力,而是為每個服務的每個版本,都建立不同的 Deployment + HPA + PodDisruptionBudget,這是為了方便做金絲雀/灰度釋出
  • 我們的服務可能會使用 IngressController / Service Mesh 來進行服務的負載均衡、流量切分

下面先給出一個 Deployment + HPA + PodDisruptionBudget 的 demo,後面再拆開詳細說下:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: my-app-v3
  namespace: prod
  labels:
    app: my-app
spec:
  replicas: 3
  strategy:
    type: RollingUpdate
    # 因為服務的每個版本都使用各自的 Deployment,服務更新時其實是用不上這裡的滾動更新策略的
    # 這個配置應該只在 SRE 手動修改 Deployment 配置時才會生效(通常不應該發生這種事)
    rollingUpdate:
      maxSurge: 10%  # 滾動更新時,每次最多更新 10% 的 Pods
      maxUnavailable: 0  # 滾動更新時,不允許出現不可用的 Pods,也就是說始終要維持 3 個可用副本
  selector:
    matchLabels:
      app: my-app
      version: v3
  template:
    metadata:
      labels:
        app: my-app
        version: v3
    spec:
      affinity:
        podAffinity:
          preferredDuringSchedulingIgnoredDuringExecution: # 非強制性條件
          - weight: 100  # weight 用於為節點評分,會優先選擇評分最高的節點(只有一條規則的情況下,這個值沒啥意義)
            podAffinityTerm:
              labelSelector:
                matchExpressions:
                - key: app
                  operator: In
                  values:
                  - my-app
                - key: version
                  operator: In
                  values:
                  - v3
              # pod 儘量使用同一種節點型別,也就是儘量保證節點的效能一致
              topologyKey: node.kubernetes.io/instance-type
        podAntiAffinity:
          preferredDuringSchedulingIgnoredDuringExecution: # 非強制性條件
          - weight: 100  # weight 用於為節點評分,會優先選擇評分最高的節點(只有一條規則的情況下,這個值沒啥意義)
            podAffinityTerm:
              labelSelector:
                matchExpressions:
                - key: app
                  operator: In
                  values:
                  - my-app
                - key: version
                  operator: In
                  values:
                  - v3
              # 將 pod 儘量打散在多個可用區
              topologyKey: topology.kubernetes.io/zone
          requiredDuringSchedulingIgnoredDuringExecution:  # 強制性要求(這個建議按需新增)
          # 注意這個沒有 weights,必須滿足列表中的所有條件
          - labelSelector:
              matchExpressions:
              - key: app
                operator: In
                values:
                - my-app
              - key: version
                operator: In
                values:
                - v3
            # Pod 必須執行在不同的節點上
            topologyKey: kubernetes.io/hostname
      securityContext:
        # runAsUser: 1000  # 設定使用者
        # runAsGroup: 1000  # 設定使用者組
        runAsNonRoot: true  # Pod 必須以非 root 使用者執行
        seccompProfile:  # security compute mode
          type: RuntimeDefault
      nodeSelector:
        eks.amazonaws.com/nodegroup: common  # 使用專用節點組,如果希望使用多個節點組,可改用節點親和性
      volumes:
      - name: tmp-dir
        emptyDir: {}
      containers:
      - name: my-app-v3
        image: my-app:v3  # 建議使用私有映象倉庫,規避 docker.io 的映象拉取限制
        imagePullPolicy: IfNotPresent
        volumeMounts:
        - mountPath: /tmp
          name: tmp-dir
        lifecycle:
          preStop:
            exec:
              command:
              - /bin/sh
              - -c
              - "while [ $(netstat -plunt | grep tcp | wc -l | xargs) -ne 0 ]; do sleep 1; done"
        resources:  # 資源請求與限制
          # 對於核心服務,建議設定 requests = limits,避免資源競爭
          requests:
            # HPA 會使用 requests 計算資源利用率
            # 建議將 requests 設為服務正常狀態下的 CPU 使用率,HPA 的目前指標設為 80%
            # 所有容器的 requests 總量不建議為 2c/4G 4c/8G 等常見值,因為節點通常也是這個配置,這會導致 Pod 只能排程到更大的節點上,適當調小 requests 等擴充可用的節點型別,從而擴充節點池。 
            cpu: 1000m
            memory: 1Gi
          limits:
            # limits - requests 為允許超賣的資源量,建議為 requests 的 1 到 2 倍,酌情配置。
            cpu: 1000m
            memory: 1Gi
        securityContext:
          # 將容器層設為只讀,防止容器檔案被篡改
          ## 如果需要寫入臨時檔案,建議額外掛載 emptyDir 來提供可讀寫的資料卷
          readOnlyRootFilesystem: true
          # 禁止 Pod 做任何許可權提升
          allowPrivilegeEscalation: false
          capabilities:
            # drop ALL 的許可權比較嚴格,可按需修改
            drop:
            - ALL
        startupProbe:  # 要求 kubernetes 1.18+
          httpGet:
            path: /actuator/health  # 直接使用健康檢查介面即可
            port: 8080
          periodSeconds: 5
          timeoutSeconds: 1
          failureThreshold: 20  # 最多提供給服務 5s * 20 的啟動時間
          successThreshold: 1
        livenessProbe:
          httpGet:
            path: /actuator/health  # spring 的通用健康檢查路徑
            port: 8080
          periodSeconds: 5
          timeoutSeconds: 1
          failureThreshold: 5
          successThreshold: 1
        # Readiness probes are very important for a RollingUpdate to work properly,
        readinessProbe:
          httpGet:
            path: /actuator/health  # 簡單起見可直接使用 livenessProbe 相同的介面,當然也可額外定義
            port: 8080
          periodSeconds: 5
          timeoutSeconds: 1
          failureThreshold: 5
          successThreshold: 1
---
apiVersion: autoscaling/v2beta2
kind: HorizontalPodAutoscaler
metadata:
  labels:
    app: my-app
  name: my-app-v3
  namespace: prod
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: my-app-v3
  maxReplicas: 50
  minReplicas: 3
  metrics:
  - type: Resource
    resource:
      name: cpu
      target:
        type: Utilization
        averageUtilization: 70
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: my-app-v3
  namespace: prod
  labels:
    app: my-app
spec:
  minAvailable: 75%
  selector:
    matchLabels:
      app: my-app
      version: v3

一、優雅停止(Gracful Shutdown)與 502/504 報錯

如果 Pod 正在處理大量請求(比如 1000 QPS+)時,因為節點故障或「競價節點」被回收等原因被重新排程,
你可能會觀察到在容器被 terminate 的一段時間內出現少量 502/504。

為了搞清楚這個問題,需要先理解清楚 terminate 一個 Pod 的流程:

  1. Pod 的狀態被設為「Terminating」,(幾乎)同時該 Pod 被從所有關聯的 Service Endpoints 中移除
  2. preStop 鉤子被執行,它可以是一個命令,或者一個對 Pod 中容器的 http 呼叫
    1. 如果你的程式在收到 SIGTERM 訊號時,無法優雅退出,就可以考慮使用 preStop
    2. 如果讓程式本身支援優雅退出比較麻煩的話,用 preStop 實現優雅退出是一個非常好的方式
  3. 將 SIGTERM 傳送給 Pod 中的所有容器
  4. 繼續等待,直到超過 spec.terminationGracePeriodSeconds 設定好的時間,這個值預設為 30s
    1. 需要注意的是,這個優雅退出的等待計時是與 preStop 同步開始的!而且它也不會等待 preStop 結束!
  5. 如果超過了 spec.terminationGracePeriodSeconds 容器仍然沒有停止,k8s 將會傳送 SIGKILL 訊號給容器
  6. 程式全部終止後,整個 Pod 完全被清理掉

注意:1 和 2 兩個工作是非同步發生的,所以可能會出現「Pod 還在 Service Endpoints 中,但是 preStop 已經執行了」的情況,我們需要考慮到這種狀況的發生。

瞭解了上面的流程後,我們就能分析出兩種錯誤碼出現的原因:

  • 502:應用程式在收到 SIGTERM 訊號後直接終止了執行,導致部分還沒有被處理完的請求直接中斷,代理層返回 502 表示這種情況
  • 504:Service Endpoints 移除不夠及時,在 Pod 已經被終止後,仍然有個別請求被路由到了該 Pod,得不到響應導致 504

通常的解決方案是,在 Pod 的 preStop 步驟加一個 15s 的等待時間。
其原理是:在 Pod 處理 terminating 狀態的時候,就會被從 Service Endpoints 中移除,也就不會再有新的請求過來了。
preStop 等待 15s,基本就能保證所有的請求都在容器死掉之前被處理完成(一般來說,絕大部分請求的處理時間都在 300ms 以內吧)。

一個簡單的示例如下,它使 Pod 被終止時,總是先等待 15s,再傳送 SIGTERM 訊號給容器:

    containers:
    - name: my-app
      # 新增下面這部分
      lifecycle:
        preStop:
          exec:
            command:
            - /bin/sleep
            - "15"

更好的解決辦法,是直接等待所有 tcp 連線都關閉(需要映象中有 netstat):

    containers:
    - name: my-app
      # 新增下面這部分
      lifecycle:
      preStop:
          exec:
            command:
            - /bin/sh
            - -c
            - "while [ $(netstat -plunt | grep tcp | wc -l | xargs) -ne 0 ]; do sleep 1; done"

如果我的服務還使用了 Sidecar 代理網路請求,該怎麼處理? {#k8s-istio-pod-prestop}

以服務網格 Istio 為例,在 Envoy 代理了 Pod 流量的情況下,502/504 的問題會變得更復雜一點——還需要考慮 Sidecar 與主容器的關閉順序:

  • 如果在 Envoy 已關閉後,有新的請求再進來,將會導致 504(沒人響應這個請求了)
    • 所以 Envoy 最好在 Terminating 至少 3s 後才能關,確保 Istio 網格配置已完全更新
  • 如果在 Envoy 還沒停止時,主容器先關閉,然後又有新的請求再進來,Envoy 將因為無法連線到 upstream 導致 503
    • 所以主容器也最好在 Terminating 至少 3s 後,才能關閉。
  • 如果主容器處理還未處理完遺留請求時,Envoy 或者主容器的其中一個停止了,會因為 tcp 連線直接斷開連線導致 502
    • 因此 Envoy 必須在主容器處理完遺留請求後(即沒有 tcp 連線時),才能關閉

所以總結下:Envoy 及主容器的 preStop 都至少得設成 3s,並且在「沒有 tcp 連線」時,才能關閉,避免出現 502/503/504.

主容器的修改方法在前文中已經寫過了,下面介紹下 Envoy 的修改方法。

和主容器一樣,Envoy 也能直接加 preStop,修改 istio-sidecar-injector 這個 configmap,在 sidecar 裡新增 preStop sleep 命令:

    containers:
    - name: istio-proxy
      # 新增下面這部分
      lifecycle:
      preStop:
          exec:
            command:
            - /bin/sh
            - -c
            - "while [ $(netstat -plunt | grep tcp | grep -v envoy | wc -l | xargs) -ne 0 ]; do sleep 1; done"

參考

二、服務的伸縮配置 - HPA {#k8s-hpa}

Kubernetes 官方主要支援基於 Pod CPU 的伸縮,這是應用最為廣泛的伸縮指標,需要部署 metrics-server 才可使用。

先回顧下前面給出的,基於 Pod CPU 使用率進行伸縮的示例:

apiVersion: autoscaling/v2beta2  # k8s 1.23+ 此 API 已經 GA
kind: HorizontalPodAutoscaler
metadata:
  labels:
    app: my-app
  name: my-app-v3
  namespace: prod
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: my-app-v3
  maxReplicas: 50
  minReplicas: 3
  metrics:
  - type: Resource
    resource:
      name: cpu
      target:
        type: Utilization
        averageUtilization: 70

1. 當前指標值的計算方式

提前總結:每個 Pod 的指標是其中所有容器指標之和,如果計算百分比,就再除以 Pod 的 requests.

HPA 預設使用 Pod 的當前指標進行計算,以 CPU 使用率為例,其計算公式為:

「Pod 的 CPU 使用率」= 100% * 「所有 Container 的 CPU 用量之和」/「所有 Container 的 CPU requests 之和」

注意分母是總的 requests 量,而不是 limits.

1.1 存在的問題與解決方法

在 Pod 只有一個容器時這沒啥問題,但是當 Pod 注入了 envoy 等 sidecar 時,這就會有問題了。

因為 Istio 的 Sidecar requests 預設為 100m 也就是 0.1 核。
在未 tuning 的情況下,服務負載一高,sidecar 的實際用量很容易就能漲到 0.2-0.4 核。
把這兩個值代入前面的公式,會發現 對於 QPS 較高的服務,新增 Sidecar 後,「Pod 的 CPU 利用率」可能會高於「應用容器的 CPU 利用率」,造成不必要的擴容。

即使改用「Pod 的 CPU 用量」而非百分比來進行擴縮容,也解決不了這個問題。

解決方法:

  • 方法一:針對每個服務的 CPU 使用情況,為每個服務的 sidecar 設定不同的 requests/limits.
    • 感覺這個方案太麻煩了
  • 方法二:使用 KEDA 等第三方元件,獲取到應用程式的 CPU 利用率(排除掉 Sidecar),使用它進行擴縮容
  • 方法三:使用 k8s 1.20 提供的 alpha 特性:Container Resourse Metrics.

2. HPA 的擴縮容演算法

HPA 什麼時候會擴容,這一點是很好理解的。但是 HPA 的縮容策略,會有些迷惑,下面簡單分析下。

  1. HPA 的「目標指標」可以使用兩種形式:絕對度量指標和資源利用率。
    • 絕對度量指標:比如 CPU,就是指 CPU 的使用量
    • 資源利用率(資源使用量/資源請求 * 100%):在 Pod 設定了資源請求時,可以使用資源利用率進行 Pod 伸縮
  2. HPA 的「當前指標」是一段時間內所有 Pods 的平均值,不是峰值。

HPA 的擴縮容演算法為:

期望副本數 = ceil[當前副本數 * ( 當前指標 / 目標指標 )]

從上面的引數可以看到:

  1. 只要「當前指標」超過了目標指標,就一定會發生擴容。
  2. 當前指標 / 目標指標要小到一定的程度,才會觸發縮容。
    1. 比如雙副本的情況下,上述比值要小於等於 1/2,才會縮容到單副本。
    2. 三副本的情況下,上述比值的臨界點是 2/3。
    3. 五副本時臨界值是 4/5,100副本時臨界值是 99/100,依此類推。
    4. 如果 當前指標 / 目標指標 從 1 降到 0.5,副本的數量將會減半。(雖然說副本數越多,發生這麼大變化的可能性就越小。)
  3. 當前副本數 / 目標指標的值越大,「當前指標」的波動對「期望副本數」的影響就越大。

為了防止擴縮容過於敏感,它還有幾個延時相關的引數:

  1. HPA Loop 延時:預設 15 秒,每 15 秒鐘進行一次 HPA 掃描。
  2. --horizontal-pod-autoscaler-cpu-initialization-period:
  3. 縮容冷卻時間:預設 5 分鐘。

3. HPA 的期望值設成多少合適

這個需要針對每個服務的具體情況,具體分析。

以最常用的按 CPU 值伸縮為例,

  • 核心服務
    • requests/limits 值: 建議設成相等的,保證服務質量等級為 Guaranteed
      • 需要注意 CPU 跟 Memory 的 limits 限制策略是不同的,CPU 是真正地限制了上限,而 Memory 是用超了就幹掉容器(OOMKilled)
      • k8s 一直使用 cgroups v1 (cpu_shares/memory.limit_in_bytes)來限制 cpu/memory,但是對於 Guaranteed 的 Pods 而言,記憶體並不能完全預留,資源競爭總是有可能發生的。1.22 有 alpha 特性改用 cgroups v2,可以關注下。
    • HPA: 一般來說,期望值設為 60% 到 70% 可能是比較合適的,最小副本數建議設為 2 - 5. (僅供參考)
    • PodDisruptionBudget: 建議按服務的健壯性與 HPA 期望值,來設定 PDB,後面會詳細介紹,這裡就先略過了
  • 非核心服務
    • requests/limits 值: 建議 requests 設為 limits 的 0.6 - 0.9 倍(僅供參考),對應的服務質量等級為 Burstable
      • 也就是超賣了資源,這樣做主要的考量點是,很多非核心服務負載都很低,根本跑不到 limits 這麼高,降低 requests 可以提高叢集資源利用率,也不會損害服務穩定性。
    • HPA: 因為 requests 降低了,而 HPA 是以 requests 為 100% 計算使用率的,我們可以提高 HPA 的期望值(如果使用百分比為期望值的話),比如 80% ~ 90%,最小副本數建議設為 1 - 3. (僅供參考)
    • PodDisruptionBudget: 非核心服務嘛,保證最少副本數為 1 就行了。

4. HPA 的常見問題

4.1. Pod 擴容 - 預熱陷阱

預熱:Java/C# 這類執行在虛擬機器上的語言,第一次使用到某些功能時,往往需要初始化一些資源,例如「JIT 即時編譯」。
如果程式碼裡還應用了動態類載入之類的功能,就很可能導致微服務某些 API 第一次被呼叫時,響應特別慢(要動態編譯 class)。
因此 Pod 在提供服務前,需要提前「預熱(slow_start)」一次這些介面,將需要用到的資源提前初始化好。

在負載很高的情況下,HPA 會自動擴容。
但是如果擴容的 Pod 需要預熱,就可能會遇到「預熱陷阱」。

在有大量使用者訪問的時候,不論使用何種負載均衡策略,只要請求被轉發到新建的 Pod 上,這個請求就會「卡住」。
如果請求速度太快,Pod 啟動的瞬間「卡住」的請求就越多,這將會導致新建 Pod 因為壓力過大而垮掉。
然後 Pod 一重啟就被壓垮,進入 CrashLoopBackoff 迴圈。

如果是在使用多執行緒做負載測試時,效果更明顯:50 個執行緒在不間斷地請求,
別的 Pod 響應時間是「毫秒級」,而新建的 Pod 的首次響應是「秒級」。幾乎是一瞬間,50 個執行緒就會全部陷在新建的 Pod 這裡。
而新建的 Pod 在啟動的瞬間可能特別脆弱,瞬間的 50 個併發請求就可以將它壓垮。
然後 Pod 一重啟就被壓垮,進入 CrashLoopBackoff 迴圈。

解決方法

可以在「應用層面」解決:

  1. 在啟動探針 API 的後端控制器裡面,依次呼叫所有需要預熱的介面或者其他方式,提前初始化好所有資源。
    1. 啟動探針的控制器中,可以通過 localhost 迴環地址呼叫它自身的介面。
  2. 使用「AOT 預編譯」技術:預熱,通常都是因為「JIT 即時編譯」導致的問題,在需要用到時它才編譯。而 AOT 是預先編譯,在使用前完成編譯,因此 AOT 能解決預熱的問題。

也可以在「基礎設施層面」解決:

  1. 像 AWS ALB TargetGroup 以及其他雲服務商的 ALB 服務,通常都可以設定 slow_start 時長,即對新加入的例項,使用一定時間慢慢地把流量切過去,最終達到預期的負載均衡狀態。這個可以解決服務預熱問題。
  2. Envoy 也已經支援 slow_start 模式,支援在一個設定好的時間視窗內,把流量慢慢負載到新加入的例項上,達成預熱效果。

4.2. HPA 擴縮容過於敏感,導致 Pod 數量震盪

通常來講,EKS 上絕大部分負載都應該選擇使用 CPU 進行擴縮容。因為 CPU 通常能很好的反映服務的負載情況

但是有些服務會存在其他影響 CPU 使用率的因素,導致使用 CPU 擴縮容變得不那麼可靠,比如:

  • 有些 Java 服務堆記憶體設得很大,GC pause 也設得比較長,因此記憶體 GC 會造成 CPU 間歇性飆升,CPU 監控會有大量的尖峰。
  • 有些服務有定時任務,定時任務一執行 CPU 就漲,但是這跟服務的 QPS 是無關的
  • 有些服務可能一執行 CPU 就會立即處於一個高位狀態,它可能希望使用別的業務側指標來進行擴容,而不是 CPU.

因為上述問題存在,使用 CPU 擴縮容,就可能會造成服務頻繁的擴容然後縮容,或者無限擴容。
而有些服務(如我們的「推薦服務」),對「擴容」和「縮容」都是比較敏感的,每次擴縮都會造成服務可用率抖動。

對這類服務而言,HPA 有這幾種調整策略:

  • 選擇使用 QPS 等相對比較平滑,沒有 GC 這類干擾的指標來進行擴縮容,這需要藉助 KEDA 等社群元件。
  • 對 kubernetes 1.18+,可以直接使用 HPA 的 behavior.scaleDownbehavior.scaleUp 兩個引數,控制每次擴縮容的最多 pod 數量或者比例。 示例如下:
---
apiVersion: autoscaling/v2beta2
kind: HorizontalPodAutoscaler
metadata:
  name: podinfo
  namespace: default
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: podinfo
  minReplicas: 3
  maxReplicas: 50
  metrics:
  - type: Resource
    resource:
      name: cpu
      target:
        type: Utilization
        averageUtilization: 50  # 期望的 CPU 平均值
  behavior:
    scaleUp:
      stabilizationWindowSeconds: 0  # 預設為 0,只使用當前值進行擴縮容
      policies:
      - periodSeconds: 180  # 每 3 分鐘最多擴容 5% 的 Pods
        type: Percent
        value: 5
      - periodSeconds: 60  # 每分鐘最多擴容 1 個 Pod,擴的慢一點主要是為了一個個地預熱,避免一次擴容太多未預熱的 Pods 導致服務可用率劇烈抖動
        type: Pods
        value: 1
      selectPolicy: Min  # 選擇最小的策略
    # 以下的一切配置,都是為了更平滑地縮容
    scaleDown:
      stabilizationWindowSeconds: 600  # 使用過去 10 mins 的最大 cpu 值進行縮容計算,避免過快縮容
      policies:
      - type: Percent  # 每 3 mins 最多縮容 `ceil[當前副本數 * 5%]` 個 pod(20 個 pod 以內,一次只縮容 1 個 pod)
        value: 5
        periodSeconds: 180
      - type: Pods  # 每 1 mins 最多縮容 1 個 pod
        value: 1
        periodSeconds: 60
      selectPolicy: Min  # 上面的 policies 列表,只生效其中最小的值作為縮容限制(保證平滑縮容)

而對於擴容不夠平滑這個問題,可以考慮提供類似 AWS ALB TargetGroup slow_start 的功能,在擴容時緩慢將流量切到新 Pod 上,以實現預熱服務(JVM 預熱以及本地快取預熱),這樣就能達到比較好的平滑擴容效果。

5. HPA 注意事項

注意 kubectl 1.23 以下的版本,預設使用 hpa.v1.autoscaling 來查詢 HPA 配置,v2beta2 相關的引數會被編碼到 metadata.annotations 中。

比如 behavior 就會被編碼到 autoscaling.alpha.kubernetes.io/behavior 這個 key 所對應的值中。

因此如果使用了 v2beta2 的 HPA,一定要明確指定使用 v2beta2 版本的 HPA:

kubectl get hpa.v2beta2.autoscaling

否則不小心動到 annotations 中編碼的某些引數,可能會產生意料之外的效果,甚至直接把控制面搞崩...
比如這個 issue: Nil pointer dereference in KCM after v1 HPA patch request

6. 參考

三、節點維護與Pod干擾預算 {#k8s-PodDistruptionBuget}

在我們通過 kubectl drain 將某個節點上的容器驅逐走的時候,
kubernetes 會依據 Pod 的「PodDistruptionBuget」來進行 Pod 的驅逐。

如果不設定任何明確的 PodDistruptionBuget,Pod 將會被直接殺死,然後在別的節點重新排程,這可能導致服務中斷!

PDB 是一個單獨的 CR 自定義資源,示例如下:

apiVersion: policy/v1beta1
kind: PodDisruptionBudget
metadata:
  name: podinfo-pdb
spec:
  # 如果不滿足 PDB,Pod 驅逐將會失敗!
  minAvailable: 1      # 最少也要維持一個 Pod 可用
#   maxUnavailable: 1  # 最大不可用的 Pod 數,與 minAvailable 不能同時配置!二選一
  selector:
    matchLabels:
      app: podinfo

如果在進行節點維護時(kubectl drain),Pod 不滿足 PDB,drain 將會失敗,示例:

> kubectl drain node-205 --ignore-daemonsets --delete-local-data
node/node-205 cordoned
WARNING: ignoring DaemonSet-managed Pods: kube-system/calico-node-nfhj7, kube-system/kube-proxy-94dz5
evicting pod default/podinfo-7c84d8c94d-h9brq
evicting pod default/podinfo-7c84d8c94d-gw6qf
error when evicting pod "podinfo-7c84d8c94d-h9brq" (will retry after 5s): Cannot evict pod as it would violate the pod's disruption budget.
evicting pod default/podinfo-7c84d8c94d-h9brq
error when evicting pod "podinfo-7c84d8c94d-h9brq" (will retry after 5s): Cannot evict pod as it would violate the pod's disruption budget.
evicting pod default/podinfo-7c84d8c94d-h9brq
error when evicting pod "podinfo-7c84d8c94d-h9brq" (will retry after 5s): Cannot evict pod as it would violate the pod's disruption budget.
evicting pod default/podinfo-7c84d8c94d-h9brq
pod/podinfo-7c84d8c94d-gw6qf evicted
pod/podinfo-7c84d8c94d-h9brq evicted
node/node-205 evicted

上面的示例中,podinfo 一共有兩個副本,都執行在 node-205 上面。我給它設定了干擾預算 PDB minAvailable: 1

然後使用 kubectl drain 驅逐 Pod 時,其中一個 Pod 被立即驅逐走了,而另一個 Pod 大概在 15 秒內一直驅逐失敗。
因為第一個 Pod 還沒有在新的節點上啟動完成,它不滿足干擾預算 PDB minAvailable: 1 這個條件。

大約 15 秒後,最先被驅逐走的 Pod 在新節點上啟動完成了,另一個 Pod 滿足了 PDB 所以終於也被驅逐了。這才完成了一個節點的 drain 操作。

ClusterAutoscaler 等叢集節點伸縮元件,在縮容節點時也會考慮 PodDisruptionBudget. 如果你的叢集使用了 ClusterAutoscaler 等動態擴縮容節點的元件,強烈建議設定為所有服務設定 PodDisruptionBudget.

在 PDB 中使用百分比的注意事項

在使用百分比時,計算出的例項數都會被向上取整,這會造成兩個現象:

  • 如果使用 minAvailable,例項數較少的情況下,可能會導致 ALLOWED DISRUPTIONS 為 0
  • 如果使用 maxUnavailable,因為是向上取整,ALLOWED DISRUPTIONS 的值一定不會低於 1

因此從便於驅逐的角度看,如果你的服務至少有 2-3 個例項,建議在 PDB 中使用百分比配置 maxUnavailable,而不是 minAvailable.

最佳實踐 Deployment + HPA + PodDisruptionBudget

一般而言,一個服務的每個版本,都應該包含如下三個資源:

  • Deployment: 管理服務自身的 Pods 嘛
  • HPA: 負責 Pods 的擴縮容,通常使用 CPU 指標進行擴縮容
  • PodDisruptionBudget(PDB): 建議按照 HPA 的目標值,來設定 PDB.
    • 比如 HPA CPU 目標值為 60%,就可以考慮設定 PDB minAvailable=65%,保證至少有 65% 的 Pod 可用。這樣理論上極限情況下 QPS 均攤到剩下 65% 的 Pods 上也不會造成雪崩(這裡假設 QPS 和 CPU 是完全的線性關係)

四、節點親和性與節點組 {#k8s-affinity}

我們一個叢集,通常會使用不同的標籤為節點組進行分類,比如 kubernetes 自動生成的一些節點標籤:

  • kubernetes.io/os: 通常都用 linux
  • kubernetes.io/arch: amd64, arm64
  • topology.kubernetes.io/regiontopology.kubernetes.io/zone: 雲服務的區域及可用區

我們使用得比較多的,是「節點親和性」以及「Pod 反親和性」,另外兩個策略視情況使用。

1. 節點親和性

如果你使用的是 aws,那 aws 有一些自定義的節點標籤:

  • eks.amazonaws.com/nodegroup: aws eks 節點組的名稱,同一個節點組使用同樣的 aws ec2 例項模板
    • 比如 arm64 節點組、amd64/x64 節點組
    • 記憶體比例高的節點組如 m 系例項,計算效能高的節點組如 c 系列
    • 競價例項節點組:這個省錢啊,但是動態性很高,隨時可能被回收
    • 按量付費節點組:這類例項貴,但是穩定。

假設你希望優先選擇競價例項跑你的 Pod,如果競價例項暫時跑滿了,就選擇按量付費例項。
nodeSelector 就滿足不了你的需求了,你需要使用 nodeAffinity,示例如下:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: xxx
  namespace: xxx
spec:
  # ...
  template:
    # ...
    spec:
      affinity:
        nodeAffinity:
          # 優先選擇 spot-group-c 的節點
          preferredDuringSchedulingIgnoredDuringExecution:
          - preference:
              matchExpressions:
              - key: eks.amazonaws.com/nodegroup
                operator: In
                values:
                - spot-group-c
            weight: 80  # weight 用於為節點評分,會優先選擇評分最高的節點
          - preference:
              matchExpressions:
              # 優先選擇 aws c6i 的機器
              - key: node.kubernetes.io/instance-type
                operator: In
                values:
                - "c6i.xlarge"
                - "c6i.2xlarge"
                - "c6i.4xlarge"
                - "c6i.8xlarge"
            weight: 70
          - preference:
              matchExpressions:
              # 其次選擇 aws c5 的機器
              - key: node.kubernetes.io/instance-type
                operator: In
                values:
                - "c5.xlarge"
                - "c5.2xlarge"
                - "c5.4xlarge"
                - "c5.9xlarge"
            weight: 60
         # 如果沒 spot-group-c 可用,也可選擇 ondemand-group-c 的節點跑
          requiredDuringSchedulingIgnoredDuringExecution:
            nodeSelectorTerms:
            - matchExpressions:
              - key: eks.amazonaws.com/nodegroup
                operator: In
                values:
                - spot-group-c
                - ondemand-group-c
      containers:
        # ...

2. Pod 反親和性

通常建議為每個 Deployment 的 template 配置 Pod 反親和性,把 Pods 打散在所有節點上:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: xxx
  namespace: xxx
spec:
  # ...
  template:
    # ...
    spec:
      replicas: 3
      affinity:
        podAntiAffinity:
          preferredDuringSchedulingIgnoredDuringExecution: # 非強制性條件
          - weight: 100  # weight 用於為節點評分,會優先選擇評分最高的節點
            podAffinityTerm:
              labelSelector:
                matchExpressions:
                - key: app
                  operator: In
                  values:
                  - xxx
                - key: version
                  operator: In
                  values:
                  - v12
              # 將 pod 儘量打散在多個可用區
              topologyKey: topology.kubernetes.io/zone
          requiredDuringSchedulingIgnoredDuringExecution:  # 強制性要求
          # 注意這個沒有 weights,必須滿足列表中的所有條件
          - labelSelector:
              matchExpressions:
              - key: app
                operator: In
                values:
                - xxx
              - key: version
                operator: In
                values:
                - v12
            # Pod 必須執行在不同的節點上
            topologyKey: kubernetes.io/hostname

五、Pod 的就緒探針、存活探針與啟動探針 {#k8s-container-probe}

Pod 提供如下三種探針,均支援使用 Command、HTTP API、TCP Socket 這三種手段來進行服務可用性探測。

  • startupProbe 啟動探針(Kubernetes v1.18 [beta]): 此探針通過後,「就緒探針」與「存活探針」才會進行存活性與就緒檢查
    • 用於對慢啟動容器進行存活性檢測,避免它們在啟動執行之前就被殺掉
      • startupProbe 顯然比 livenessProbe 的 initialDelaySeconds 引數更靈活。
      • 同時它也能延遲 readinessProbe 的生效時間,這主要是為了避免無意義的探測。容器都還沒 startUp,顯然是不可能就緒的。
    • 程式將最多有 failureThreshold * periodSeconds 的時間用於啟動,比如設定 failureThreshold=20periodSeconds=5,程式啟動時間最長就為 100s,如果超過 100s 仍然未通過「啟動探測」,容器會被殺死。
  • readinessProbe 就緒探針:
    • 就緒探針失敗次數超過 failureThreshold 限制(預設三次),服務將被暫時從 Service 的 Endpoints 中踢出,直到服務再次滿足 successThreshold.
  • livenessProbe 存活探針: 檢測服務是否存活,它可以捕捉到死鎖等情況,及時殺死這種容器。
    • 存活探針失敗可能的原因:
      • 服務發生死鎖,對所有請求均無響應
      • 服務執行緒全部卡在對外部 redis/mysql 等外部依賴的等待中,導致請求無響應
    • 存活探針失敗次數超過 failureThreshold 限制(預設三次),容器將被殺死,隨後根據重啟策略執行重啟。
      • kubectl describe pod 會顯示重啟原因為 State.Last State.Reason = Error, Exit Code=137,同時 Events 中會有 Liveness probe failed: ... 這樣的描述。

上述三類探測器的引數都是通用的,五個時間相關的引數列舉如下:

# 下面的值就是 k8s 的預設值
initialDelaySeconds: 0  # 預設沒有 delay 時間
periodSeconds: 10
timeoutSeconds: 1
failureThreshold: 3
successThreshold: 1

示例:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: my-app-v3
spec:
  # ...
  template:
    #  ...
    spec:
      containers:
      - name: my-app-v3
        image: xxx.com/app/my-app:v3
        imagePullPolicy: IfNotPresent 
        # ... 省略若干配置
        startupProbe:
          httpGet:
            path: /actuator/health  # 直接使用健康檢查介面即可
            port: 8080
          periodSeconds: 5
          timeoutSeconds: 1
          failureThreshold: 20  # 最多提供給服務 5s * 20 的啟動時間
          successThreshold: 1
        livenessProbe:
          httpGet:
            path: /actuator/health  # spring 的通用健康檢查路徑
            port: 8080
          periodSeconds: 5
          timeoutSeconds: 1
          failureThreshold: 5
          successThreshold: 1
        # Readiness probes are very important for a RollingUpdate to work properly,
        readinessProbe:
          httpGet:
            path: /actuator/health  # 簡單起見可直接使用 livenessProbe 相同的介面,當然也可額外定義
            port: 8080
          periodSeconds: 5
          timeoutSeconds: 1
          failureThreshold: 5
          successThreshold: 1

在 Kubernetes 1.18 之前,通用的手段是為「就緒探針」新增較長的 initialDelaySeconds 來實現類似「啟動探針」的功能動,避免容器因為啟動太慢,存活探針失敗導致容器被重啟。示例如下:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: my-app-v3
spec:
  # ...
  template:
    #  ...
    spec:
      containers:
      - name: my-app-v3
        image: xxx.com/app/my-app:v3
        imagePullPolicy: IfNotPresent 
        # ... 省略若干配置
        livenessProbe:
          httpGet:
            path: /actuator/health  # spring 的通用健康檢查路徑
            port: 8080
          initialDelaySeconds: 120  # 前兩分鐘,都假設服務健康,避免 livenessProbe 失敗導致服務重啟
          periodSeconds: 5
          timeoutSeconds: 1
          failureThreshold: 5
          successThreshold: 1
        # 容器一啟動,Readiness probes 就會不斷進行檢測
        readinessProbe:
          httpGet:
            path: /actuator/health
            port: 8080
          initialDelaySeconds: 3  # readiness probe 不需要設太長時間,使 Pod 儘快加入到 Endpoints.
          periodSeconds: 5
          timeoutSeconds: 1
          failureThreshold: 5
          successThreshold: 1

六、Pod 安全 {#k8s-pod-security}

這裡只介紹 Pod 中安全相關的引數,其他諸如叢集全域性的安全策略,不在這裡討論。

1. Pod SecurityContext

通過設定 Pod 的 SecurityContext,可以為每個 Pod 設定特定的安全策略。

SecurityContext 有兩種型別:

  1. spec.securityContext: 這是一個 PodSecurityContext 物件
    • 顧名思義,它對 Pod 中的所有 contaienrs 都有效。
  2. spec.containers[*].securityContext: 這是一個 SecurityContext 物件
    • container 私有的 SecurityContext

這兩個 SecurityContext 的引數只有部分重疊,重疊的部分 spec.containers[*].securityContext 優先順序更高。

我們比較常遇到的一些提升許可權的安全策略:

  1. 特權容器:spec.containers[*].securityContext.privileged
  2. 新增(Capabilities)可選的系統級能力: spec.containers[*].securityContext.capabilities.add
    1. 只有 ntp 同步服務等少數容器,可以開啟這項功能。請注意這非常危險。
  3. Sysctls: 系統引數: spec.securityContext.sysctls

許可權限制相關的安全策略有(強烈建議在所有 Pod 上按需配置如下安全策略!):

  1. spec.volumes: 所有的資料卷都可以設定讀寫許可權
  2. spec.securityContext.runAsNonRoot: true Pod 必須以非 root 使用者執行
  3. spec.containers[*].securityContext.readOnlyRootFileSystem:true 將容器層設為只讀,防止容器檔案被篡改。
    1. 如果微服務需要讀寫檔案,建議額外掛載 emptydir 型別的資料卷。
  4. spec.containers[*].securityContext.allowPrivilegeEscalation: false 不允許 Pod 做任何許可權提升!
  5. spec.containers[*].securityContext.capabilities.drop: 移除(Capabilities)可選的系統級能力

還有其他諸如指定容器的執行使用者(user)/使用者組(group)等功能未列出,請自行查閱 Kubernetes 相關文件。

一個無狀態的微服務 Pod 配置舉例:

apiVersion: v1
kind: Pod
metadata:
  name: <Pod name>
spec:
  containers:
  - name: <container name>
    image: <image>
    imagePullPolicy: IfNotPresent 
    # ......此處省略 500 字
    securityContext:
      readOnlyRootFilesystem: true  # 將容器層設為只讀,防止容器檔案被篡改。
      allowPrivilegeEscalation: false  # 禁止 Pod 做任何許可權提升
      capabilities:
        drop:
        # 禁止容器使用 raw 套接字,通常只有 hacker 才會用到 raw 套接字。
        # raw_socket 可自定義網路層資料,避開 tcp/udp 協議棧,直接操作底層的 ip/icmp 資料包。可實現 ip 偽裝、自定義協議等功能。
        # 去掉 net_raw 會導致 tcpdump 無法使用,無法進行容器內抓包。需要抓包時可臨時去除這項配置
        - NET_RAW
        # 更好的選擇:直接禁用所有 capabilities
        # - ALL
  securityContext:
    # runAsUser: 1000  # 設定使用者
    # runAsGroup: 1000  # 設定使用者組
    runAsNonRoot: true  # Pod 必須以非 root 使用者執行
    seccompProfile:  # security compute mode
      type: RuntimeDefault

2. seccomp: security compute mode

seccomp 和 seccomp-bpf 允許對系統呼叫進行過濾,可以防止使用者的二進位制文對主機作業系統件執行通常情況下並不需要的危險操作。它和 Falco 有些類似,不過 Seccomp 沒有為容器提供特別的支援。

視訊:

其他問題

  • 不同節點型別的效能有差距,導致 QPS 均衡的情況下,CPU 負載不均衡
    • 解決辦法(未驗證):
      • 儘量使用效能相同的例項型別:通過 podAffinitynodeAffinity 新增節點型別的親和性

相關文章