1. 程式人生 > >在Kubernetes上執行有狀態應用:從StatefulSet到Operator

在Kubernetes上執行有狀態應用:從StatefulSet到Operator

    一開始Kubernetes只是被設計用來執行無狀態應用,直到在1.5版本中才添加了StatefulSet控制器用於支援有狀態應用,但它直到1.9版本才正式可用。本文將介紹有狀態和無狀態應用,一個通過K8S StatefulSet來編排有狀態應用的示例,以及當前有狀態應用容器化現狀及將來的發展趨勢。

1. 有狀態應用和無狀態應用

無狀態應用(Stateless Application)是指應用不會在會話中儲存下次會話所需要的客戶端資料。每一個會話都像首次執行一樣,不會依賴之前的資料進行響應。有狀態的應用(Stateful Application)是指應用會在會話中儲存客戶端的資料,並在客戶端下一次的請求中來使用那些資料。

以伺服器端元件為例,判斷它是有狀態的還是無狀態的,其依據是兩個來自相同發起者的請求在伺服器端是否具備上下文關係。如果是有狀態的,那麼伺服器端一般都要儲存請求的相關資訊,每個請求可以使用以前的請求資訊。而如果是無狀態的,其處理的過程必須全部來自於請求所攜帶的資訊,以及其他伺服器端自身所儲存的、並且可以被所有請求所使用的公共資訊。最著名的無狀態的伺服器應用是WEB伺服器。每次HTTP請求和以前都沒有啥關係,只是獲取目標URI。得到目標內容之後,這次連線就被殺死,沒有任何痕跡。有狀態的伺服器應用有更廣闊的應用範圍,比如網路遊戲等伺服器。它在服務端維護每個連線的狀態資訊,服務端在接收到每個連線的傳送的請求時,可以從本地儲存的資訊來重現上下文關係。這樣,客戶端可以很容易使用預設的資訊,服務端也可以很容易地進行狀態管理。比如說,當一個使用者登入後,服務端可以根據使用者名稱獲取他的生日等先前的註冊資訊;而且在後續的處理中,服務端也很容易找到這個使用者的歷史資訊。

一個大型應用往往具有許多功能模組,很難簡單地將其整體性地設計為有狀態或無狀態的,而往往將其整個架構分成兩個部分,即無狀態部分和有狀態部分。業務邏輯部分往往作為無狀態的部分,而將狀態儲存在有狀態的中介軟體中,如快取、資料庫、物件儲存、大資料平臺、訊息佇列等。這樣無狀態的部分可以很容易的橫向擴充套件,而狀態儲存到後端。而後端的中介軟體是有狀態的,這些中介軟體設計之初,就考慮了擴容的時候狀態的遷移、複製、同步等機制,不用業務層關心。

 (來源:劉超博文)

通常應用會有如下幾種狀態資料:

  • 永續性狀態資料:這種狀態資料在應用重啟或宕機時需要能被儲存下來。典型地,這種狀態會被儲存到一個冗餘的資料庫層,而且資料會被週期性地備份。建議將應用元件和資料庫分開,以便能使得應用元件變成無狀態的。

  • 配置狀態資料:應用總是會用各種配置資料,比如資料庫連線字串等,過去往往儲存在配置檔案中。進行容器化時,配置檔案應該外部化,或環境變數,或配置中心管理。

  • 會話狀態資料:每當使用者登入進應用後,應用都會為它產生會話資料。在現代應用中,會話資料都會儲存在分散式快取中,因此可以被所有服務例項訪問到。但是在傳統web應用中,會話資料會被儲存在伺服器本地,因此,登入後的該使用者的所有請求都必須在這臺伺服器上才能被處理,這就是所謂的粘滯會話(sticky session)。

  • 連線狀態:一些應用使用有狀態通訊協議,比如Websocket。另外一些協議比如HTTP被認為是無狀態的。對於使用有狀態協議的應用,客戶端的訪問必須被路由到指定的容器內。

  • 叢集狀態:某些應用以叢集形式執行多個例項,以滿足可用性和規模性。在這種應用中,叢集內每個成員需要了解其他成員的狀態和角色,比如MySQL叢集。現在,Kubernetes提供了StatefulSet控制器來支援這種應用。

  • 日誌資料:傳統應用的日誌通過儲存在日誌檔案中。進行容器化時,要對日誌輸出格式進行改造,適配集中式日誌系統規範,和容器執行時的日誌元件對接,使得日誌能通過標準輸出被收集到再儲存到統一容器儲存中。

 (來源:劉超博文) 

2. Kubernetes StatefulSet控制器

常見的Kubernetes控制器不合適處理有狀態應用:

   

2.1 Kubernetes StatefulSet概述

Kubernetes在1.9版本中正式釋出的StatefulSet控制器能支援:

  • Pod會被順序部署和順序終結:StatefulSet中的各個 Pod會被順序地創建出來,每個Pod都有一個唯一的ID,在建立後續 Pod 之前,首先要等前面的 Pod 執行成功並進入到就緒狀態。刪除會銷燬StatefulSet 中的每個 Pod,並且按照建立順序的反序來執行,只有在成功終結後面一個之後,才會繼續下一個刪除操作。

  • Pod具有唯一網路名稱:Pod具有唯一的名稱,而且在重啟後會保持不變。通過Headless服務,基於主機名,每個 Pod 都有獨立的網路地址,這個網域由一個Headless 服務所控制。這樣每個Pod會保持穩定的唯一的域名,使得叢集就不會將重新創建出的Pod作為新成員。

  • Pod能有穩定的持久儲存:StatefulSet中的每個Pod可以有其自己獨立的PersistentVolumeClaim物件。即使Pod被重新排程到其它節點上以後,原有的持久磁碟也會被掛載到該Pod。

  • Pod能被通過Headless服務訪問到:客戶端可以通過服務的域名連線到任意Pod。

以在K8S中部署高可用的PostgreSQL叢集為例,下面是其架構示意圖:

 

該架構中包含一個主節點和兩個副本節點共3個Pod,這三個Pod在一個StatefulSet中。Master Service是一個Headless服務,指向主Pod,用於資料寫入;Replica Service也是一個Headless服務,指向兩個副本Pod,用於資料讀取。這三個Pod都有唯一名稱,這樣StatefulSet讓使用者可以用穩定、可重複的方式來部署PostgreSQL叢集。StatefulSet不會建立具有重複ID的Pod,Pod之間可以通過穩定的網路地址互相通訊。

2.2 使用Kubernetes StatefulSet部署高可用MySQL

當前名稱空間為testmysql。

(1)建立ConfigMap,用於向mysql傳遞配置檔案。

apiVersion: v1
kind: ConfigMap
metadata:
 name: mysql
 labels:
   app: mysql
data:
 master.cnf: |
    #Apply this config only on the master.
   [mysqld]
   log-bin
 slave.cnf: |
    #Apply this config only on slaves.
   [mysqld]
super-read-only
(2)建立StatefulSet物件,它會負責建立Pod。 
apiVersion: apps/v1
kind: StatefulSet
metadata:
 name: mysql
spec:
 selector:
   matchLabels:
     app: mysql
 serviceName: mysql
 replicas: 3
 template:
   metadata:
     labels:
       app: mysql
   spec:
     initContainers:
     - name: init-mysql
       image: mysql:5.7
       command:
       - bash
       - "-c"
       - |
         set -ex
         # Generate mysql server-id from pod ordinal index.
         [[ `hostname` =~ -([0-9]+)$ ]] || exit 1
         ordinal=${BASH_REMATCH[1]}
         echo [mysqld] > /mnt/conf.d/server-id.cnf
         # Add an offset to avoid reserved server-id=0 value.
         echo server-id=$((100 + $ordinal)) >> /mnt/conf.d/server-id.cnf
         # Copy appropriate conf.d files from config-map to emptyDir.
         if [[ $ordinal -eq 0 ]]; then
           cp /mnt/config-map/master.cnf /mnt/conf.d/
         else
           cp /mnt/config-map/slave.cnf /mnt/conf.d/
         fi
       volumeMounts:
       - name: conf
         mountPath: /mnt/conf.d
       - name: config-map
         mountPath: /mnt/config-map
     - name: clone-mysql
       image: gcr.io/google-samples/xtrabackup:1.0
       command:
       - bash
       - "-c"
       - |
         set -ex
         # Skip the clone if data already exists.
         [[ -d /var/lib/mysql/mysql ]] && exit 0
         # Skip the clone on master (ordinal index 0).
         [[ `hostname` =~ -([0-9]+)$ ]] || exit 1
         ordinal=${BASH_REMATCH[1]}
         [[ $ordinal -eq 0 ]] && exit 0
         # Clone data from previous peer.
         ncat --recv-only mysql-$(($ordinal-1)).mysql 3307 | xbstream -x -C/var/lib/mysql
         # Prepare the backup.
         xtrabackup --prepare --target-dir=/var/lib/mysql
       volumeMounts:
       - name: data
         mountPath: /var/lib/mysql
         subPath: mysql
       - name: conf
         mountPath: /etc/mysql/conf.d
     containers:
     - name: mysql
       image: mysql:5.7
       env:
       - name: MYSQL_ALLOW_EMPTY_PASSWORD
         value: "1"
       ports:
       - name: mysql
         containerPort: 3306
       volumeMounts:
       - name: data
         mountPath: /var/lib/mysql
         subPath: mysql
       - name: conf
         mountPath: /etc/mysql/conf.d
       resources:
         requests:
           cpu: 500m
           memory: 1Gi
       livenessProbe:
         exec:
           command: ["mysqladmin", "ping"]
         initialDelaySeconds: 30
         periodSeconds: 10
         timeoutSeconds: 5
       readinessProbe:
         exec:
           # Check we can execute queries over TCP (skip-networking is off).
           command: ["mysql", "-h", "127.0.0.1","-u", "root", "-e", "SELECT 1"]
         initialDelaySeconds: 5
         periodSeconds: 2
         timeoutSeconds: 1
     - name: xtrabackup
       image: gcr.io/google-samples/xtrabackup:1.0
       ports:
       - name: xtrabackup
         containerPort: 3307
       command:
       - bash
       - "-c"
       - |
         set -ex
         cd /var/lib/mysql
 
         # Determine binlog position of cloned data, if any.
         if [[ -f xtrabackup_slave_info &&"x$(<xtrabackup_slave_info)" != "x" ]]; then
           # XtraBackup already generated a partial "CHANGE MASTER TO"query
           # because we're cloning from an existing slave. (Need to remove thetailing semicolon!)
           cat xtrabackup_slave_info | sed -E 's/;$//g' >change_master_to.sql.in
           # Ignore xtrabackup_binlog_info in this case (it's useless).
           rm -f xtrabackup_slave_info xtrabackup_binlog_info
         elif [[ -f xtrabackup_binlog_info ]]; then
           # We're cloning directly from master. Parse binlog position.
           [[ `cat xtrabackup_binlog_info` =~ ^(.*?)[[:space:]]+(.*?)$ ]] || exit 1
           rm -f xtrabackup_binlog_info xtrabackup_slave_info
           echo "CHANGE MASTER TO MASTER_LOG_FILE='${BASH_REMATCH[1]}',\
                  MASTER_LOG_POS=${BASH_REMATCH[2]}"> change_master_to.sql.in
         fi
 
         # Check if we need to complete a clone by starting replication.
         if [[ -f change_master_to.sql.in ]]; then
           echo "Waiting for mysqld to be ready (accepting connections)"
           until mysql -h 127.0.0.1 -u root-e "SELECT 1"; do sleep 1; done
 
           echo "Initializing replication from clone position"
           mysql -h 127.0.0.1 -u root \
                  -e"$(<change_master_to.sql.in), \
                          MASTER_HOST='mysql-0.mysql',\
                          MASTER_USER='root', \
                          MASTER_PASSWORD='', \
                         MASTER_CONNECT_RETRY=10; \
                        START SLAVE;" ||exit 1
           # In case of container restart, attempt this at-most-once.
           mv change_master_to.sql.in change_master_to.sql.orig
         fi
 
         # Start a server to send backups when requested by peers.
         exec ncat --listen --keep-open --send-only --max-conns=1 3307 -c \
           "xtrabackup --backup --slave-info --stream=xbstream--host=127.0.0.1 --user=root"
       volumeMounts:
       - name: data
         mountPath: /var/lib/mysql
         subPath: mysql
       - name: conf
         mountPath: /etc/mysql/conf.d
       resources:
         requests:
           cpu: 100m
           memory: 100Mi
     volumes:
     - name: conf
       emptyDir: {}
     - name: config-map
       configMap:
         name: mysql
 volumeClaimTemplates:
  -metadata:
     name: data
   spec:
     accessModes: ["ReadWriteOnce"]
     storageClassName: "nfs"
     resources:
       requests:
         storage: 2Gi

(3)建立服務,用於訪問mysql叢集。

# Headless service for stable DNS entriesof StatefulSet members.
apiVersion: v1
kind: Service
metadata:
 name: mysql
 labels:
   app: mysql
spec:
 ports:
  -name: mysql
   port: 3306
 clusterIP: None
 selector:
   app: mysql
---
# Client service for connecting to anyMySQL instance for reads.
# For writes, you must instead connect tothe master: mysql-0.mysql.
apiVersion: v1
kind: Service
metadata:
 name: mysql-read
 labels:
   app: mysql
spec:
 ports:
  -name: mysql
   port: 3306
 selector:
app: mysql

2.3 MySQL StatefulSet例項

(1)一個StatefulSet物件

NAME                     DESIRED   CURRENT   AGE
statefulset.apps/mysql   2         2         2d
(2)三個Pod
[root@master1 ~]# oc get pod
NAME                                      READY     STATUS    RESTARTS   AGE
mysql-0                                   2/2       Running   0          2d
mysql-1                                   2/2       Running   0          2d
mysql-2                                   2/2       Running   0          2d
StatefulSet 控制器創建出三個Pod,每個Pod使用數字字尾來區分順序。建立時,首先mysql-0 Pod被創建出來,然後建立mysql-1 Pod,再建立mysql-2 Pod。

(3)兩個服務

[root@master1 ~]# oc get svc
NAME              TYPE        CLUSTER-IP      EXTERNAL-IP   PORT(S)                              AGE
mysql             ClusterIP   None            <none>        3306/TCP                             2d
mysql-read        ClusterIP   172.30.169.48   <none>        3306/TCP                             2d
mysql服務是一個Headless服務,它沒有ClusterIP,只是為每個Pod提供一個域名,三個Pod的域名分別是:
  • mysql-0.mysql.testmysql.svc.cluster.local

  • mysql-1.mysql.testmysql.svc.cluster.local

  • mysql-2.mysql.testmysql.svc.cluster.local

mysql-read 服務則是一個ClusterIP服務,作為叢集內部的負載均衡,將資料庫讀請求分發到後端的兩個Pod。

(4)三個PVC

[root@master1 ~]# oc get pvc
NAME           STATUS    VOLUME                                     CAPACITY   ACCESS MODES   STORAGECLASS   AGE
data-mysql-0   Bound     pvc-98a6f5c9-11a9-11ea-b651-fa163e71648a   2Gi        RWO            nfs            2d
data-mysql-1   Bound     pvc-845c0eae-11bb-11ea-b651-fa163e71648a   2Gi        RWO            nfs            2d
data-mysql-2   Bound     pvc-018762f6-11bc-11ea-b651-fa163e71648a   2Gi        RWO            nfs            2d
每個pvc和一個pod相對應,從名字上也能看出來其對應關係。mysql Pod的 /var/lib/mysql 資料夾儲存在PVC卷中。

2.4 MySQL 叢集操作

(1)叢集訪問

客戶端通過 mysql-0.mysql.testmysql.svc.cluster.local 域名來向資料庫寫入資料:

[root@master1 ~]# mysql -h mysql-0.mysql.testmysql.svc.cluster.local -P 3306 -u root          
Welcome to the MariaDB monitor.  Commands end with ; or \g.
Your MySQL connection id is 142230
Server version: 5.7.28-log MySQL Community Server (GPL)

Copyright (c) 2000, 2018, Oracle, MariaDB Corporation Ab and others.

Type 'help;' or '\h' for help. Type '\c' to clear the current input statement.

MySQL [(none)]> show databases;
客戶端通過 mysql-read.testmysql.svc.cluster.local 域名來從資料庫讀取資料:
[root@master1 ~]# mysql -h mysql-read.testmysql.svc.cluster.local -P 3306 -u root         
Welcome to the MariaDB monitor.  Commands end with ; or \g.
Your MySQL connection id is 142318
Server version: 5.7.28-log MySQL Community Server (GPL)

Copyright (c) 2000, 2018, Oracle, MariaDB Corporation Ab and others.

Type 'help;' or '\h' for help. Type '\c' to clear the current input statement.

MySQL [(none)]> show databases;
(2)叢集擴容

當前的MySQL叢集,具有一個寫節點(mysql-0)和兩個讀節點(mysql-1和mysql-2)。如果要提升讀能力,可以對StatefulSet物件擴容,以增加讀節點。比如以下命令將總Pod數目擴大到4,讀Pod數目擴大到3.

oc scale statefulset mysql --replicas=4

(3)叢集縮容

執行以下命令,將叢集節點數目縮容到3:

oc scale statefulset mysql --replicas=3

然後mysql-3 Pod會被刪除:

[root@master1 ~]# oc get pod
NAME                                      READY     STATUS        RESTARTS   AGE
mysql-0                                   2/2       Running       0          2d
mysql-1                                   2/2       Running       0          2d
mysql-2                                   2/2       Running       0          2d
mysql-3                                   2/2       Terminating   0          2m

3. Kubernetes Operator

StatefulSet 無法解決有狀態應用的所有問題,它只是一個抽象層,負責給每個Pod打上不同的ID,並支援每個Pod使用自己的PVC卷。但有狀態應用的維護非常複雜,否則每個公司也不用有一個獨立的DBA團隊來負責管理資料庫。從上文也能看出,通過StatefulSet例項的操作,也只能做到建立叢集、刪除叢集、擴縮容等基礎操作,但比如備份、恢復等資料庫常用操作,則無法實現。

3.1 Kubernetes Operator概述

基於此,CoreOS團隊提出了K8SOperator概念。Operator是一個自動化的軟體管理程式,負責處理部署在K8S和OpenShift上的軟體的安裝和生命週期管理。它包含一個Controller和CRD(Custom Resource Definition),CRD擴充套件了K8S API。其基本模式如下圖所示:

 

 OpenShift 在V4中釋出了全新的OperatorHub,集成了原廠商的或第三方的或RedHat開發的各種Operator,用來部署和維護相應的服務。

  

Operator可以很簡單,比如只負責軟體安裝,也可以很複雜,比如軟體更新、完整生命週期管理、監控告警甚至自動伸縮等等。

 

3.2 MySQL Operator

一年以前,Oracle在github上開源了K8S MySQL Operator,它能在K8S上建立、配置和管理MySQL InnoDB 叢集,其地址是https://github.com/oracle/mysql-operator。其主要功能包括:

  • 在K8S上建立和刪除高可用的MySQL InnoDB叢集

  • 自動化資料庫的備份、故障檢測和恢復操作

  • 自動化定時備份和按需備份

  • 通過備份恢復資料庫

其基本架構如下圖所示:

 

定義一個1主2備MySQL叢集:

apiVersion: mysql.oracle.com/v1alpha1
kind: Cluster
metadata:
  name: mysql-test-cluster
spec:
  members: 3
定義一個3主叢集:
apiVersion: mysql.oracle.com/v1alpha1
kind: Cluster
metadata:
  name: mysql-multimaster-cluster
spec:
  multiMaster: true
  members: 3
 建立一個到S3的備份:
apiVersion: "mysql.oracle.com/v1"
kind: MySQLBackup
metadata:
  name: mysql-backup
spec:
  executor:
    provider: mysqldump
    databases:
      - test
  storage:
    provider: s3  
    secretRef:
      name: s3-credentials
    config:          
      endpoint: x.compat.objectstorage.y.oraclecloud.com
      region: ociregion
      bucket: mybucket
  clusterRef:
    name: mysql-cluster
 詳細資訊,請閱讀 github專案文件以及https://blogs.oracle.com/developers/introducing-the-oracle-mysql-operator-for-kubernetes博文。可惜的是,已經快有一年該專案沒什麼更新了。

4. 展望未來

通過K8S Operator實現常見運維操作是容易的,但對於複雜問題,Operator要麼會做得非常複雜,但也可能無法面面俱到,對某些複雜場景甚至會無能為力。以etcd Operator為例,其開源專案地址是 https://github.com/coreos/etcd-operator。etcd本身應該不算特別複雜的有狀態應用,etcd Operator的功能看起來也很基礎,主要包括建立和刪除叢集、擴縮容、切換、滾動升級、備份和回覆等基礎功能,但其程式碼超過了9000行。

因此,Operator要解決“有“的問題還相對容易,但要解決”好“的問題,確實非常困難。這是因為管理有狀態應用本來就是非常困難的,更何況在容器雲平臺上進行管理。從技術上講,維護有狀態資料非常困難。大量研究和方式都被提了出來,比如冗餘、高可用等等,但問題並沒徹底解決。從商務上講,所有云供應商都提供了託管資料庫服務。因此,他們沒有太大興趣去提供另一個會跟他們直接競爭的方案,也許Oracle沒繼續更新K8S MySQL Operator專案也有這方面的考慮。從實際情況來看,在傳統企業中,資料庫的架構變遷一直就很緩慢,很多企業的資料庫還部署在小機上,部分資料庫部署在x86物理機上,部分資料庫部署在虛擬機器上。

因此,短期內,對於生產環境,需要有穩定性,因此如果你用公有云,那就使用公有云的各種託管服務,將你的精力更多用到業務應用自身上吧;如果你用私有云,對生產環境來說,短期內有狀態應用還是放在虛擬化環境甚至物理機環境上,然後安排專業運維團隊來維護吧。對於開發測試環境,可以自己通過K8S StatefulSet來做編排或者使用Operator,來利用其便捷性。

但是,有狀態應用要想在K8S上生產就緒地執行,目前來看,Operator也許是最可行的路徑,這也是為什麼RedHat在上面大量投入的原因。可以想象,在將來所有要釋出在K8S上的應用,廠商在釋出軟體時都會發布對應的Operator。其實現在已經有廠商這麼做了,比如PingCAP公司已經發布了TiDB K8S Operator,其開源專案地址在https://github.com/pingcap/tidb-operator。在某種意義上,Operator也符合DevOps理念,因為開發人員通過編寫程式碼做了本該是運維團隊乾的事情。

讓我們一起期待Operator時代的到來吧!

 

參考連結:

  • Run a Replicated Stateful Application,https://kubernetes.io/docs/tasks/run-application/run-replicated-stateful-application/

  • Containerizing Stateful Applications,https://dzone.com/articles/containerizing-stateful-applications

  • The sad state of stateful Pods in Kubernetes, https://elastisys.com/2018/09/18/sad-state-stateful-pods-kubernetes/

  • 劉超,微服務化之無狀態化與容器化,https://myopsblog.wordpress.com/2017/02/06/why-databases-is-not-for-containers/

感謝您的閱讀,歡迎關注我的微信公眾號:

&n