有狀態軟體如何在 k8s 上快速擴容甚至自動擴容

東風微鳴發表於2022-12-07

概述

在傳統的虛機/物理機環境裡, 如果我們想要對一個有狀態應用擴容, 我們需要做哪些步驟?

  1. 申請虛機/物理機
  2. 安裝依賴
  3. 下載安裝包
  4. 按規範配置主機名, hosts
  5. 配置網路: 包括域名, DNS, 虛 ip, 防火牆...
  6. 配置監控

今天虛機環境上出現了問題, 是因為 RabbitMQ 資源不足. 手動擴容的過程中花費了較長的時間.

但是在 K8S 上, 有狀態應用的擴容就很簡單, YAML 裡改一下replicas副本數, 等不到 1min 就擴容完畢.

當然, 最基本的: 下映象, 啟動 pod(相當於上邊的前 3 步), 就不必多提. 那麼, 還有哪些因素, 讓有狀態應用可以在 k8s 上快速擴容甚至自動擴容呢?

原因就是這兩點:

  1. peer discovery +peer discovery 的 相關實現(透過 hostname, dns, k8s api 或其他)
  2. 可觀察性 + 自動伸縮

我們今天選擇幾個典型的有狀態應用, 一一梳理下:

  1. Eureka
  2. Nacos
  3. Redis
  4. RabbitMQ
  5. Kafka
  6. TiDB

K8S 上有狀態應用擴容

在 Kubernetes 上, 有狀態應用快速擴容甚至自動擴容很容易. 這得益於 Kubernetes 優秀的設計以及良好的生態. Kubernetes 就像是一個雲原生時代的作業系統. 它自身就具有:

  1. 自動化工具;
  2. 內部服務發現 + 負載均衡
  3. 內部 DNS
  4. 和 Prometheus 整合
  5. 統一的宣告式 API
  6. 標準, 開源的生態環境.

所以, 需要擴容, 一個 yaml 搞定全部. 包括上邊提到的: 下載, 安裝, 儲存配置, 節點發現, 加入叢集, 監控配置...

Eureka 擴容

eureka

? 備註:

有狀態擴容第一層:

StatefulSet + Headless Service

eureka 的擴容在 K8S 有狀態應用中是最簡單的, 就是:

headless service + statefulset

Eureka 要擴容, 只要 eureka 例項彼此能相互發現就可以. headless service 在這種情況下就派上用場了, 就是讓彼此發現.

Eureka 的一個完整叢集 yaml, 如下:詳細說明如下:

apiVersion: v1
kind: Service
metadata:
  name: eureka
  namespace: ms
spec:
  clusterIP: None
  ports:
    - name: eureka
      port: 8888
  selector:
    project: ms
    app: eureka
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: eureka
  namespace: ms
spec:
  serviceName: eureka
  replicas: 3
  selector:
    matchLabels:
      project: ms
      app: eureka
  template:
    metadata:
      labels:
        project: ms
        app: eureka
    spec:
      terminationGracePeriodSeconds: 10   
      imagePullSecrets:
      - name: registry-pull-secret
      containers:
        - name: eureka
          image: registry.example.com/kubernetes/eureka:latest
          ports:
            - protocol: TCP
              containerPort: 8888
          env:
            - name: APP_NAME
              value: "eureka"
            - name: POD_NAME
              valueFrom:
                fieldRef:
                  fieldPath: metadata.name
            - name: APP_OPTS
              value: "
                     --eureka.instance.hostname=${POD_NAME}.${APP_NAME}
                     --registerWithEureka=true
                     --fetchRegistry=true
                     --eureka.instance.preferIpAddress=false
                     --eureka.client.serviceUrl.defaultZone=http://eureka-0.${APP_NAME}:8888/eureka/,http://eureka-1.${APP_NAME}:8888/eureka/,http://eureka-2.${APP_NAME}:8888/eureka/
  1. 配置名為eureka的 Service
  2. 在名為eureka的 statefulset 配置下,共 3 個 eureka 副本, 每個 eureka 的 HOSTNAME 為: ${POD_NAME}.${SERVICE_NAME}. 如: eureka-0.eureka
  3. 彼此透過--registerWithEureka=true --fetchRegistry=true --eureka.instance.preferIpAddress=false --eureka.client.serviceUrl.defaultZone=http://eureka-0.${APP_NAME}:8888/eureka/,http://eureka-1.${APP_NAME}:8888/eureka/,http://eureka-2.${APP_NAME}:8888/eureka/透過 HOSTNAME 相互註冊, 完成了叢集的建立.

那麼, 如果要快速擴容到 5 個:

  1. 調整 StatefulSet: replicas: 5
  2. 在環境變數APP_OPTS中加入新增的 2 個副本 hostname: http://eureka-3.${APP_NAME}:8888/eureka/,http://eureka-4.${APP_NAME}:8888/eureka/

即可完成.

Headless Service

有時不需要或不想要負載均衡,以及單獨的 Service IP。 遇到這種情況,可以透過指定 Cluster IP(spec.clusterIP)的值為 None 來建立 Headless Service。

您可以使用無頭 Service 與其他服務發現機制進行介面,而不必與 Kubernetes 的實現捆綁在一起。

對這無頭 Service 並不會分配 Cluster IP,kube-proxy 不會處理它們, 而且平臺也不會為它們進行負載均衡和路由。 DNS 如何實現自動配置,依賴於 Service 是否定義了選擇算符。

Nacos

nacos

? 備註:

有狀態擴容第二層:

StatefulSet + Headless Service + Init Container(自動化發現) + PVC

相比 Eureka, nacos 透過一個init container,(這個 init container, 就是一個自動化的 peer discovery 指令碼.) , 實現了一行命令快速擴容:

kubectl scale sts nacos --replicas=3

指令碼連結為: https://github.com/nacos-group/nacos-k8s/tree/master/plugin/peer

擴容的相關自動化操作為:

  1. 從 Headless Service 自動發現所有的 replicas 的 HOSTNAME;
  2. 並將 HOSTNAME 寫入到: ${CLUSTER_CONF} 這個檔案下.
  3. ${CLUSTER_CONF}這個檔案就是 nacos 叢集的所有 member 資訊. 將新寫入 HOSTNAME 的例項加入到 nacos 叢集中.

在這裡, 透過 Headless Service 和 PV/PVC(儲存 nacos 外掛或其他資料),實現了對 Pod 的拓撲狀態和儲存狀態的維護,從而讓使用者可以在 Kubernetes 上執行有狀態的應用。

然而 Statefullset 只能提供受限的管理,透過 StatefulSet 我們還是需要編寫複雜的指令碼(如 nacos 的peer-finder相關指令碼), 透過判斷節點編號來區別節點的關係和拓撲,需要關心具體的部署工作。

RabbitMQ

rabbitmq

? 備註:

有狀態擴容第三層:

StatefulSet + Headless Service + 外掛(自動化發現和監控) + PVC

RabbitMQ 的叢集可以參考這邊官方文件: Cluster Formation and Peer Discovery

這裡提到的, 動態的發現機制需要依賴外部的服務, 如: DNS, API(AWS 或 K8S).

對於 Kubernetes, 使用的動態發現機制是基於rabbitmq-peer-discovery-k8s外掛 實現的.

透過這種機制,節點可以使用一組配置的值從 Kubernetes API 端點獲取其對等方的列表:URI 模式,主機,埠以及令牌和證書路徑。

另外, rabbitmq 映象也預設整合了監控的外掛 - rabbitmq_prometheus.

當然, 透過Helm Chart也能一鍵部署和擴容.

Helm Chart

一句話概括, Helm 之於 Kubernetes, 相當於 yum 之於 centos. 解決了依賴的問題. 將部署 rabbitmq 這麼複雜的軟體所需要的一大堆 yaml, 透過引數化抽象出必要的引數(並且提供預設引數)來快速部署.

Redis

redis

? 備註:

有狀態擴容第四層:

透過 Operator 統一編排和管理:

Deployment(哨兵) + StatefulSet + Headless Service + Sidecar Container(監控) + PVC

這裡以 UCloud 開源的: redis-operator 為例. 它是基於 哨兵模式 的 redis 叢集.

於之前的 StatefulSet + Headless 不同, 這裡用到了一項新的 K8S 技術: operator.

Operator 原理

? 說明:

解釋 Operator 不得不提 Kubernetes 中兩個最具價值的理念:“宣告式 API” 和 “控制器模式”。“宣告式 API”的核心原理就是當使用者向 Kubernetes 提交了一個 API 物件的描述之後,Kubernetes 會負責為你保證整個叢集裡各項資源的狀態,都與你的 API 物件描述的需求相一致。Kubernetes 透過啟動一種叫做“控制器模式”的無限迴圈,WATCH 這些 API 物件的變化,不斷檢查,然後調諧,最後確保整個叢集的狀態與這個 API 物件的描述一致。

比如 Kubernetes 自帶的控制器:Deployment,如果我們想在 Kubernetes 中部署雙副本的 Nginx 服務,那麼我們就定義一個 repicas 為 2 的 Deployment 物件,Deployment 控制器 WATCH 到我們的物件後,透過控制迴圈,最終會幫我們在 Kubernetes 啟動兩個 Pod。

Operator 是同樣的道理,以我們的 Redis Operator 為例,為了實現 Operator,我們首先需要將自定義物件的說明註冊到 Kubernetes 中,這個物件的說明就叫 CustomResourceDefinition(CRD),它用於描述我們 Operator 控制的應用:redis 叢集,這一步是為了讓 Kubernetes 能夠認識我們應用。然後需要實現自定義控制器去 WATCH 使用者提交的 redis 叢集例項,這樣當使用者告訴 Kubernetes 我想要一個 redis 叢集例項後,Redis Operator 就能夠透過控制迴圈執行調諧邏輯達到使用者定義狀態。

簡單說, operator 可以翻譯為: 運維人(操作員) . 就是將高階原廠運維專家多年的經驗, 濃縮為一個: operator. 那麼, 我們所有的 運維打工人 就不需要再苦哈哈的"從零開始搭建 xxx 叢集", 而是透過這個可擴充套件、可重複、標準化、甚至全生命週期運維管理的operator。 來完成複雜軟體的安裝,擴容,監控, 備份甚至故障恢復。

Redis Operator

使用 Redis Operator 我們可以很方便的起一個哨兵模式的叢集,叢集只有一個 Master 節點,多個 Slave 節點,假如指定 Redis 叢集的 size 為 3,那麼 Redis Operator 就會幫我們啟動一個 Master 節點,兩個 Salve 節點,同時啟動三個 Sentinel 節點來管理 Redis 叢集:

redis operator 哨兵架構

Redis Operator 透過 Statefulset 管理 Redis 節點,透過 Deployment 來管理 Sentinel 節點,這比管理裸 Pod 要容易,節省實現成本。同時建立一個 Service 指向所有的哨兵節點,透過 Service 對客戶端提供查詢 Master、Slave 節點的服務。最終,Redis Operator 控制迴圈會調諧叢集的狀態,設定叢集的拓撲,讓所有的 Sentinel 監控同一個 Master 節點,監控相同的 Salve 節點,Redis Operator 除了會 WATCH 例項的建立、更新、刪除事件,還會定時檢測已有的叢集的健康狀態,實時把叢集的狀態記錄到 spec.status.conditions 中.

同時, 還提供了快速持久化, 監控, 自動化 redis 叢集配置的能力. 只需一個 yaml 即可實現:

apiVersion: redis.kun/v1beta1
kind: RedisCluster
metadata:
  name: redis
spec:
  config:  # redis叢集配置
    maxmemory: 1gb
    maxmemory-policy: allkeys-lru
  password: sfdfghc56s  # redis密碼配置
  resources:  # redis資源配置
    limits:
      cpu: '1'
      memory: 1536Mi
    requests:
      cpu: 250m
      memory: 1Gi
  size: 3  # redis副本數配置
  storage:  # 持久化儲存配置
    keepAfterDeletion: true
    persistentVolumeClaim:
      metadata:
        name: redis
      spec:
        accessModes:
          - ReadWriteOnce
        resources:
          requests:
            storage: 5Gi
        storageClassName: nfs
        volumeMode: Filesystem
  sentinel:   # 哨兵配置
    image: 'redis:5.0.4-alpine'     
  exporter:  # 啟用監控
    enabled: true 

要擴容也很簡單, 將上邊的size: 3按需調整即可. 調整後, 自動申請資源, 擴容, 加儲存, 改 redis 配置, 加入 redis 叢集, 並且自動新增監控.

Kafka

strimzi

? 備註:

有狀態擴容第五層:

透過 Operator 統一編排和管理多個有狀態元件的:

StatefulSet + Headless Service + ... + 監控

這裡以 Strimzi 為例 - Strimzi Overview guide (0.20.0). 這是一個 Kafka 的 Operator.

提供了 Apache Kafka 元件以透過 Strimzi 發行版部署到 Kubernetes。 Kafka 元件通常以叢集的形式執行以提高可用性。

包含 Kafka 元件的典型部署可能包括:

  • Kafka 代理節點叢集叢集
  • ZooKeeper - ZooKeeper 例項的叢集
  • Kafka Connect 叢集用於外部資料連線
  • Kafka MirrorMaker 叢集可在第二個叢集中映象 Kafka 叢集
  • Kafka Exporter 提取其他 Kafka 指標資料以進行監控
  • Kafka Bridge 向 Kafka 叢集發出基於 HTTP 的請求

Kafka 的元件架構比較複雜, 具體如下:

img

透過 Operator, 一個 YAML 即可完成一套複雜的部署:

  • 資源請求(CPU /記憶體)
  • 用於最大和最小記憶體分配的 JVM 選項
  • Listeners (和身份驗證)
  • 認證
  • 儲存
  • Rack awareness
  • 監控指標
apiVersion: kafka.strimzi.io/v1beta1
kind: Kafka
metadata:
  name: my-cluster
spec:
  kafka:
    replicas: 3
    version: 0.20.0
    resources:
      requests:
        memory: 64Gi
        cpu: "8"
      limits:
        memory: 64Gi
        cpu: "12"
    jvmOptions:
      -Xms: 8192m
      -Xmx: 8192m
    listeners:
      - name: plain
        port: 9092
        type: internal
        tls: false
        useServiceDnsDomain: true
      - name: tls
        port: 9093
        type: internal
        tls: true
        authentication:
          type: tls
      - name: external
        port: 9094
        type: route
        tls: true
        configuration:
          brokerCertChainAndKey:
            secretName: my-secret
            certificate: my-certificate.crt
            key: my-key.key
    authorization:
      type: simple
    config:
      auto.create.topics.enable: "false"
      offsets.topic.replication.factor: 3
      transaction.state.log.replication.factor: 3
      transaction.state.log.min.isr: 2
      ssl.cipher.suites: "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384" (17)
      ssl.enabled.protocols: "TLSv1.2"
      ssl.protocol: "TLSv1.2"
    storage: 
      type: persistent-claim
      size: 10000Gi
    rack:
      topologyKey: topology.kubernetes.io/zone
    metrics:
      lowercaseOutputName: true
      rules:
      # Special cases and very specific rules
      - pattern : kafka.server<type=(.+), name=(.+), clientId=(.+), topic=(.+), partition=(.*)><>Value
        name: kafka_server_$1_$2
        type: GAUGE
        labels:
          clientId: "$3"
          topic: "$4"
          partition: "$5"
        # ...
  zookeeper:
    replicas: 3
    resources:
      requests:
        memory: 8Gi
        cpu: "2"
      limits:
        memory: 8Gi
        cpu: "2"
    jvmOptions:
      -Xms: 4096m
      -Xmx: 4096m
    storage:
      type: persistent-claim
      size: 1000Gi
    metrics:
      # ...
  entityOperator:
    topicOperator:
      resources:
        requests:
          memory: 512Mi
          cpu: "1"
        limits:
          memory: 512Mi
          cpu: "1"
    userOperator:
      resources:
        requests:
          memory: 512Mi
          cpu: "1"
        limits:
          memory: 512Mi
          cpu: "1"
  kafkaExporter:
    # ...
  cruiseControl:
    # ...

當然, 由於 Kafka 的特殊性, 如果要將新增的 brokers 新增到現有叢集, 還需要重新分割槽, 這裡邊涉及的更多操作詳見: Scaling Clusters - Using Strimzi

TiDB

tidb

? 備註:

有狀態擴容第六層:

透過 Operator 統一編排和管理多個有狀態元件的:

StatefulSet + Headless Service + ... + 監控 + TidbClusterAutoScaler(類似 HPA 的實現)

甚至能做到備份和災難恢復.

TiDB 更進一步, 可以實現 有狀態應用自動擴容.

具體見這裡: Enable TidbCluster Auto-scaling | PingCAP Docs

Kubernetes 提供了Horizontal Pod Autoscaler ,這是一種基於 CPU 利用率的原生 API。 TiDB 4.0 基於 Kubernetes,實現了彈性排程機制。

只需要啟用此功能即可使用:

features:
  - AutoScaling=true

TiDB 實現了一個TidbClusterAutoScaler CR 物件用於控制 TiDB 叢集中自動縮放的行為。 如果您使用過Horizontal Pod Autoscaler ,大概是您熟悉 TidbClusterAutoScaler 概念。 以下是 TiKV 中的自動縮放示例。

apiVersion: pingcap.com/v1alpha1
kind: TidbClusterAutoScaler
metadata:
  name: auto-scaling-demo
spec:
  cluster:
    name: auto-scaling-demo
    namespace: default
  monitor:
    name: auto-scaling-demo
    namespace: default
  tikv:
    minReplicas: 3
    maxReplicas: 4
    metrics:
      - type: "Resource"
        resource:
          name: "cpu"
          target:
            type: "Utilization"
            averageUtilization: 80

需要指出的是: 需要向TidbClusterAutoScaler 提供指標收集和查詢(監控)服務,因為它透過指標收集元件捕獲資源使用情況。 monitor 屬性引用TidbMonitor 物件(其實就是自動化地配置 TiDB 的 prometheus 監控和展示等)。 有關更多資訊,請參見使用TidbMonitor監視TiDB群集

總結

透過 6 個有狀態軟體, 我們見識到了層層遞進的 K8S 上有狀態應用的快速擴容甚至是自動擴容:

  1. 最簡單實現: StatefulSet + Headless Service -- Eureka
  2. 指令碼/Init Container 自動化實現: StatefulSet + Headless Service + Init Container(自動化發現) + PVC -- Nacos
  3. 透過外掛實現擴容和監控:StatefulSet + Headless Service + 外掛(自動化發現和監控) + PVC -- RabbitMQ
  4. 透過 Operator 統一編排和管理: -- Redis
  5. 對於複雜有狀態, 是需要透過 Operator 統一編排和管理多個有狀態元件的: -- Kafka
  6. 透過 Operator 統一編排和管理多個有狀態元件的: -- TiDB

??? 解放開發和運維打工人, 是時候在 K8S 上部署有狀態軟體了! ???

三人行, 必有我師; 知識共享, 天下為公. 本文由東風微鳴技術部落格 EWhisper.cn 編寫.

相關文章