Kubernetes Deployment 最佳實踐

於清樂發表於2021-11-27

零、示例

首先給出一個 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
    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:
        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:
            cpu: 1000m
            memory: 1Gi
          limits:
            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"

參考

二、節點維護與Pod干擾預算

在我們通過 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 數
  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.

最佳實踐 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 是完全的線性關係)

三、節點親和性與節點組

我們一個叢集,通常會使用不同的標籤為節點組進行分類,比如 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 用於為節點評分,會優先選擇評分最高的節點
         # 如果沒 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 的就緒探針、存活探針與啟動探針

在 Kubernetes 1.18 之前,通用的手段是在「就緒探針」和「存活探針」中新增較長的 initialDelaySeconds 來實現類似「啟動探針」的功能——探測前先等待容器慢啟動。

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

  • startupProbe 啟動探針(Kubernetes v1.18 [beta]): 此探針通過後,「就緒探針」與「存活探針」才會進行存活性與就緒檢查
    • 用於對慢啟動容器進行存活性檢測,避免它們在啟動執行之前就被殺掉
    • 程式將最多有 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

五、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 沒有為容器提供特別的支援。

視訊:

相關文章