这是对StatefulSet有状态应用的一个应用实践。以部署一个MySQL集群为例来了解一下部署一个StatefulSet的完整流程。

这个有状态应用如下:

  • 主从复制的MYSQL集群。
  • 一主多从
  • 从节点能够水平扩展。
  • 主节点能写能读,从节点只能读。

MYSQL集群迁移到k8s项目上,需要容器化解决三个问题:

  1. Master节点和Slave节点需要有不同的配置文件,即不同的my.cnf
  2. Master节点和Slave节点需要能够传输备份信息文件
  3. 在Slave节点第一次启动之前,需要执行一些初始化SQL操作

MYSQL集群具有拓扑关系(主节点先启动)和存储状态(MySQL保存在本地的数据),所以通过StatefulSet来解决这个问题。

创建ConfigMap

第一个问题,Master节点和Slave节点需要的不同的配置文件,可以提前准备好,然后根据Pod的序号挂载进去即可。这一点可以利用ConfigMap来实现。

编写如下配置文件,将两个配置文件的数据保存在ConfigMap中供Pod使用:

apiVersion: v1
kind: ConfigMap
metadata:
  name: mysql
  labels:
    app: mysql
data:
  master.cnf: |
    # 主节点MySQL的配置文件
    [mysqld]
    log-bin
  slave.cnf: |
    # 从节点MySQL的配置文件
    [mysqld]
    super-read-only
  • mater.cnf开启了log-bin,标识以二进制日志文件的方式进行主从复制,这是标准的设置。
  • slave.cnf开启了super-read-only,表示从节点拒绝除了主节点的数据同步操作之外的写操作,即:对用户是只读的。

ConfigMap的data部分是Key-Value格式的,上面的例子中,master.cnf就是key,”|“后面的内容就是Value。这份数据挂载到对应的Pod后,就会在Volume目录里生成对应的文件。

创建Service

需要创建两个Service供StatefulSet以及用户使用。

一个是Headless Service,用来为Pod分配DNS记录来固定拓扑状态,比如mysql-0.mysql为主节点,其它为从节点。 另一个是常规的Service,所有的读请求都通过这个Service来转发到任意一个MYSQL的主节点和从节点上。

Kubernetes 中的所有 Service、Pod 对象,都会被自动分配同名的 DNS 记录。具体细节,我会在后面 Service 部分做重点讲解。

之所以这样创建是,因为主从集群存在拓扑结构,并且写请求必须发送到主节点上,因为需要一个Headless Service来固定拓扑结构并为每一个应用实例提供DNS记录。 而读请求可以发送给任意一个应用。通过普通的Service可以将读请求转发给任意一个节点。

两个Service的YAML文件如下:

apiVersion: v1
kind: Service
metadata:
  name: mysql
  labels:
    app: mysql
spec:
  ports:
  - name: mysql
    port: 3306
  clusterIP: None
  selector:
    app: mysql
---
apiVersion: v1
kind: Service
metadata:
  name: mysql-read
  labels:
    app: mysql
spec:
  ports:
  - name: mysql
    port: 3306
  selector:
    app: mysql

创建StatefulSet

这个StatefulSet管理的Pod必须携带app=mysql标签,使用名为mysql的Headless Service。需要PVC模板来为每个Pod定义PVC来存储状态。

大致的框架如下:

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
      - name: clone-mysql
      containers:
      - name: mysql
      - name: xtrabackup
      volumes:
      - name: conf
        emptyDir: {}
      - name: config-map
        configMap:
          name: mysql
  volumeClaimTemplates:
  - metadata:
      name: data
    spec:
      accessModes: ["ReadWriteOnce"]  
      resources:
        requests:
          storage: 10Gi

接下来的重点内容就是设计这个StatefulSet的Pod模板了,即template字段。

1. 从ConfigMap中获取MySQL的Pod的对应的配置文件

根据节点的角色是Master还是Slave,来分配对应的配置文件。MySQL还要求集群中的每个节点都有一个唯一的ID文件,名叫server-id.cnf。

这些初始化工作利用initContainer来完成比较合适。首先是init-mysql,负责初始化配置。

  • 根据当前Pod的序号来创建server-id.cnf文件。
  • 根据序号来选择使用master.cnf还是slave.cnf文件。
      ...
      # template.spec
      initContainers:
      - name: init-mysql
        image: mysql:5.7
        command:
        - bash
        - "-c"
        - |
          set -ex
          # 从Pod的序号,生成server-id
          [[ `hostname` =~ -([0-9]+)$ ]] || exit 1
          ordinal=${BASH_REMATCH[1]}
          echo [mysqld] > /mnt/conf.d/server-id.cnf
          # 由于server-id=0有特殊含义,我们给ID加一个100来避开它
          echo server-id=$((100 + $ordinal)) >> /mnt/conf.d/server-id.cnf
          # 如果Pod序号是0,说明它是Master节点,从ConfigMap里把Master的配置文件拷贝到/mnt/conf.d/目录;
          # 否则,拷贝Slave的配置文件
          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

这个initContainer容器的流程如下:

  1. 从Pod的hostname中读取到Pod的序号,将此序号作为MYSQL节点的server-id(这里都加上了100,因为0有特殊含义)。
  2. ConfigMap中保存的配置文件挂载到了/mnt/config-map路径下,根据当前的序号是否为0来判断是否是主节点,据此来选择合适的配置文件,并拷贝到/mnt/conf.d目录下。
  3. initContainer退出后,只要后面的容器挂载conf 的volume,既可以看到配置文件了。与WAR包与Web服务的例子相同。

2. Slave Pod拷贝数据库

对于mysql集群的slave节点,启动之前需要从Master节点拷贝数据库到自己的目录下。对应到容器,就是Slave Pod启动前,需要从Master或者其它Slave Pod里拷贝数据库到自己的目录下。这通过名为clone-mysql的initContainer来完成。

      ...
      # template.spec.initContainers
      - name: clone-mysql
        image: gcr.io/google-samples/xtrabackup:1.0
        command:
        - bash
        - "-c"
        - |
          set -ex
          # 拷贝操作只需要在第一次启动时进行,所以如果数据已经存在,跳过
          [[ -d /var/lib/mysql/mysql ]] && exit 0
          # Master节点(序号为0)不需要做这个操作
          [[ `hostname` =~ -([0-9]+)$ ]] || exit 1
          ordinal=${BASH_REMATCH[1]}
          [[ $ordinal -eq 0 ]] && exit 0
          # 使用ncat指令,远程地从前一个节点拷贝数据到本地
          ncat --recv-only mysql-$(($ordinal-1)).mysql 3307 | xbstream -x -C /var/lib/mysql
          # 执行--prepare,这样拷贝来的数据就可以用作恢复了
          xtrabackup --prepare --target-dir=/var/lib/mysql
        volumeMounts:
        - name: data
          mountPath: /var/lib/mysql
          subPath: mysql
        - name: conf
          mountPath: /etc/mysql/conf.d

处理流程如下:

  1. 初始化所需的数据目录/var/lib/mysql/mysql已经存在,或者当前节点是Master节点的时候,不需要拷贝。
  2. 利用Linux的ncat指令,向前一个Pod的DNS记录发起数据传输请求,并直接使用xbstream指令将收到的数据保存在/var/lib/mysql目录下。这个/var/lib/mysql正是名为data的PVC,哪怕宿主宕机了,数据库中的数据也不会丢失。并且重启之后,就自动挂载这个PVC并使用。
  3. 对/var/lib/mysql目录执行xtrabackup --prepare操作,让拷贝的数据进入一致性状态,用作数据恢复。

3. Slave Pod执行初始化SQL

如果这个 Pod 是一个第一次启动的 Slave 节点,在执行 MySQL 启动命令之前,它就需要使用前面 InitContainer 拷贝来的备份数据进行初始化。

利用容器的日志收集例子中的sidecar来解决这个问题。

      ...
      # template.spec.containers
      - name: xtrabackup
        image: gcr.io/google-samples/xtrabackup:1.0
        ports:
        - name: xtrabackup
          containerPort: 3307
        command:
        - bash
        - "-c"
        - |
          set -ex
          cd /var/lib/mysql
          
          # 从备份信息文件里读取MASTER_LOG_FILEM和MASTER_LOG_POS这两个字段的值,用来拼装集群初始化SQL
          if [[ -f xtrabackup_slave_info ]]; then
            # 如果xtrabackup_slave_info文件存在,说明这个备份数据来自于另一个Slave节点。这种情况下,XtraBackup工具在备份的时候,就已经在这个文件里自动生成了"CHANGE MASTER TO" SQL语句。所以,我们只需要把这个文件重命名为change_master_to.sql.in,后面直接使用即可
            mv xtrabackup_slave_info change_master_to.sql.in
            # 所以,也就用不着xtrabackup_binlog_info了
            rm -f xtrabackup_binlog_info
          elif [[ -f xtrabackup_binlog_info ]]; then
            # 如果只存在xtrabackup_binlog_inf文件,那说明备份来自于Master节点,我们就需要解析这个备份信息文件,读取所需的两个字段的值
            [[ `cat xtrabackup_binlog_info` =~ ^(.*?)[[:space:]]+(.*?)$ ]] || exit 1
            rm xtrabackup_binlog_info
            # 把两个字段的值拼装成SQL,写入change_master_to.sql.in文件
            echo "CHANGE MASTER TO MASTER_LOG_FILE='${BASH_REMATCH[1]}',\
                  MASTER_LOG_POS=${BASH_REMATCH[2]}" > change_master_to.sql.in
          fi
          
          # 如果change_master_to.sql.in,就意味着需要做集群初始化工作
          if [[ -f change_master_to.sql.in ]]; then
            # 但一定要先等MySQL容器启动之后才能进行下一步连接MySQL的操作
            echo "Waiting for mysqld to be ready (accepting connections)"
            until mysql -h 127.0.0.1 -e "SELECT 1"; do sleep 1; done
            
            echo "Initializing replication from clone position"
            # 将文件change_master_to.sql.in改个名字,防止这个Container重启的时候,因为又找到了change_master_to.sql.in,从而重复执行一遍这个初始化流程
            mv change_master_to.sql.in change_master_to.sql.orig
            # 使用change_master_to.sql.orig的内容,也是就是前面拼装的SQL,组成一个完整的初始化和启动Slave的SQL语句
            mysql -h 127.0.0.1 <<EOF
          $(<change_master_to.sql.orig),
            MASTER_HOST='mysql-0.mysql',
            MASTER_USER='root',
            MASTER_PASSWORD='',
            MASTER_CONNECT_RETRY=10;
          START SLAVE;
          EOF
          fi
          
          # 使用ncat监听3307端口。它的作用是,在收到传输请求的时候,直接执行"xtrabackup --backup"命令,备份MySQL的数据并发送给请求者
          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

这个名为xtrabackup的sidecar的启动命令中完成了两个工作:

  1. 初始化MYSQL节点。首先判断/var/lib/mysql目录下有无xtrabackup_slave_info这个备份信息文件,
    • 如果有,则说明这个目录下的备份数据是由一个 Slave 节点生成的。这种情况下,XtraBackup 工具在备份的时候,就已经在这个文件里自动生成了”CHANGE MASTER TO” SQL 语句。所以,我们只需要把这个文件重命名为 change_master_to.sql.in,后面直接使用即可。
    • 如果没有 xtrabackup_slave_info 文件、但是存在 xtrabackup_binlog_info 文件,那就说明备份数据来自于 Master 节点。这种情况下,sidecar 容器就需要解析这个备份信息文件,读取 MASTER_LOG_FILE 和 MASTER_LOG_POS 这两个字段的值,用它们拼装出初始化 SQL 语句,然后把这句 SQL 写入到 change_master_to.sql.in 文件中。
    • 只要看到change_master_to_sql.in文件存在,就进行集群初始化操作,读取此文件中的指令再执行START SLAVE就启动了一个Slave节点。然后删除这些备份信息文件,避免这个容器重启后又重新执行一次数据恢复和集群初始化的操作。
  2. 启动一个数据传输服务。sidecar容器使用ncat命令启动一个工作再3307端口上的网络发送服务。一旦收到数据传输请求时,sidecar 容器就会调用 xtrabackup —backup 指令备份当前 MySQL 的数据,然后把这些备份数据返回给请求者。这就是上一节中Slave Pod拷贝数据库功能的基础

4. 定义MYSQL容器本身

其它的工作全部完成,最后定义MYSQL容器即可。

      ...
      # template.spec
      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:
            # 通过TCP连接的方式进行健康检查
            command: ["mysql", "-h", "127.0.0.1", "-e", "SELECT 1"]
          initialDelaySeconds: 5
          periodSeconds: 2
          timeoutSeconds: 1

将上面的Pod模板整合后创建StatefulSet。‘

测试

StatefulSet启动后,会有三个Pod运行:

$ kubectl create -f mysql-statefulset.yaml
$ kubectl get pod -l app=mysql
NAME      READY     STATUS    RESTARTS   AGE
mysql-0   2/2       Running   0          2m
mysql-1   2/2       Running   0          1m
mysql-2   2/2       Running   0          1m

向MYSQL集群的主节点进行写请求:

$ kubectl run mysql-client --image=mysql:5.7 -i --rm --restart=Never --\
  mysql -h mysql-0.mysql <<EOF
CREATE DATABASE test;
CREATE TABLE test.messages (message VARCHAR(250));
INSERT INTO test.messages VALUES ('hello');
EOF

使用mysql-read这个service进行读操作:

$ kubectl run mysql-client --image=mysql:5.7 -i -t --rm --restart=Never --\
 mysql -h mysql-read -e "SELECT * FROM test.messages"
Waiting for pod default/mysql-client to be running, status is Pending, pod ready: false
+---------+
| message |
+---------+
| hello   |
+---------+
pod "mysql-client" deleted

利用StatefulSet这个控制器来水平扩展集群:

$ kubectl scale statefulset mysql  --replicas=5

k8s的强大在于应用部署之后的升级、版本管理等更工程化的能力。比如,对StatefulSet进行滚动更新:

$ kubectl patch statefulset mysql --type='json' -p='[{"op": "replace", "path": "/spec/template/spec/containers/0/image", "value":"mysql:5.7.23"}]'
statefulset.apps/mysql patched

这里使用kubectl patch命令,以打补丁的方式修改一个API对象字段,Pod模板被修改之后,就会自动进行滚动更新

StatefulSet Controller按照与Pod编号相反的顺序,从最后一个Pod开始,逐一更新这个StatefulSet管理的每个Pod

StatefulSet 的“滚动更新”还允许我们进行更精细的控制,比如金丝雀发布(Canary Deploy)或者灰度发布,这意味着应用的多个实例中被指定的一部分不会被更新到最新的版本。使用 StatefulSet 的 spec.updateStrategy.rollingUpdate 的 partition 字段。 这个字段的意义是小于它的序号的Pod不会进行更新,也就是实例中的一部分实例不会被更新,保持旧版本。

$ kubectl patch statefulset mysql -p '{"spec":{"updateStrategy":{"type":"RollingUpdate","rollingUpdate":{"partition":2}}}}'
statefulset.apps/mysql patched

tags: 应用部署 k8s