集群介绍 首先,用自然语言来描述一下我们想要部署的“有状态应用”。
是一个“主从复制”(Maser-Slave Replication)的 MySQL 集群;
有 1 个主节点(Master);
有多个从节点(Slave);
从节点需要能水平扩展;
所有的写操作,只能在主节点上执行;
读操作可以在所有节点上执行。
在常规环境里,部署这样一个主从模式的 MySQL 集群的主要难点在于:如何让从节点能够拥有主节点的数据,即:如何配置主(Master)从(Slave)节点的复制与同步。
所以,在安装好 MySQL 的 Master 节点之后,你需要做的第一步工作,就是通过 XtraBackup 将 Master 节点的数据备份到指定目录。
备注:XtraBackup 是业界主要使用的开源 MySQL 备份和恢复工具。
这一步会自动在目标目录里生成一个备份信息文件,名叫:xtrabackup_binlog_info。这个文件一般会包含如下两个信息:
1 2 $ cat xtrabackup_binlog_info TheMaster-bin.000001 481
这两个信息会在接下来配置 Slave 节点的时候用到。
第二步:配置 Slave 节点 。Slave 节点在第一次启动前,需要先把 Master 节点的备份数据,连同备份信息文件,一起拷贝到自己的数据目录(/var/lib/mysql)下。然后,我们执行这样一句 SQL:
1 2 3 4 5 6 TheSlave|mysql> CHANGE MASTER TO MASTER_HOST='$masterip' , MASTER_USER='xxx' , MASTER_PASSWORD='xxx' , MASTER_LOG_FILE='TheMaster-bin.000001' , MASTER_LOG_POS=481;
其中,MASTER_LOG_FILE 和 MASTER_LOG_POS,就是该备份对应的二进制日志(Binary Log)文件的名称和开始的位置(偏移量),也正是 xtrabackup_binlog_info 文件里的那两部分内容(即:TheMaster-bin.000001 和 481)。
第三步,启动 Slave 节点 。在这一步,我们需要执行这样一句 SQL:
1 TheSlave|mysql> START SLAVE;
这样,Slave 节点就启动了。它会使用备份信息文件中的二进制日志文件和偏移量,与主节点进行数据同步。
第四步,在这个集群中添加更多的 Slave 节点 。
需要注意的是,新添加的 Slave 节点的备份数据,来自于已经存在的 Slave 节点。
所以,在这一步,我们需要将 Slave 节点的数据备份在指定目录。而这个备份操作会自动生成另一种备份信息文件,名叫:xtrabackup_slave_info。同样地,这个文件也包含了 MASTER_LOG_FILE 和 MASTER_LOG_POS 两个字段。
然后,我们就可以执行跟前面一样的“CHANGE MASTER TO”和“START SLAVE” 指令,来初始化并启动这个新的 Slave 节点了。
集群容器化 将部署 MySQL 集群的流程迁移到 Kubernetes 项目上,需要能够“容器化”地解决下面的三个问题:
Master 节点和 Slave 节点需要有不同的配置文件(即:不同的 my.cnf);
Master 节点和 Slave 节点需要能够传输备份信息文件;
在 Slave 节点第一次启动之前,需要执行一些初始化 SQL 操作;
处理不同的配置文件
需要给主从节点分别准备两份不同的 MySQL 配置文件,然后根据 Pod 的序号(Index)挂载进去,这样的配置文件信息,应该保存在 ConfigMap 里供 Pod 使用。它的定义如下所示:
application/mysql/mysql-configmap.yaml
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 apiVersion: v1 kind: ConfigMap metadata: name: mysql labels: app: mysql data: master.cnf: | # 主节点MySQL的配置文件 [mysqld] log-bin slave.cnf: | # 从节点MySQL的配置文件 [mysqld] super-read-only
1 $ kubectl apply -f mysql-configmap.yaml
在这里,定义了 master.cnf 和 slave.cnf 两个 MySQL 的配置文件。
接下来,需要创建两个 Service 来供 StatefulSet 以及用户使用。这两个 Service 的定义如下所示:
application/mysql/mysql-services.yaml
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 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
1 $ kubectl apply -f mysql-services.yaml
可以看到,这两个 Service 都代理了所有携带 app=mysql 标签的 Pod,也就是所有的 MySQL Pod。端口映射都是用 Service 的 3306 端口对应 Pod 的 3306 端口。
不同的是,第一个名叫“mysql”的 Service 是一个 Headless Service(即:clusterIP= None)。所以它的作用,是通过为 Pod 分配 DNS 记录来固定它的拓扑状态,比如“mysql-0.mysql”和“mysql-1.mysql”这样的 DNS 名字。其中,编号为 0 的节点就是我们的主节点。
而第二个名叫“mysql-read”的 Service,则是一个常规的 Service。
并且我们规定,所有用户的读请求,都必须访问第二个 Service 被自动分配的 DNS 记录,即:“mysql-read”(当然,也可以访问这个 Service 的 VIP)。这样,读请求就可以被转发到任意一个 MySQL 的主节点或者从节点上。
备注:Kubernetes 中的所有 Service、Pod 对象,都会被自动分配同名的 DNS 记录。
而所有用户的写请求,则必须直接以 DNS 记录的方式访问到 MySQL 的主节点,也就是:“mysql-0.mysql“这条 DNS 记录。
节点之间传输备份文件 推荐的做法是:先搭建框架,再完善细节。其中,Pod 部分如何定义,是完善细节时的重点。
搭建StatefulSet框架 所以首先,我们先为 StatefulSet 对象规划一个大致的框架,如下图所示:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 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" ] storageClassName: rook-ceph-block resources: requests: storage: 10Gi
然后,我们来重点设计一下这个 StatefulSet 的 Pod 模板,也就是 template 字段。
设计 StatefulSet 的 Pod 模板 由于 StatefulSet 管理的 Pod 都来自于同一个镜像,这就要求我们在编写 Pod 时,一定要保持清醒,用“人格分裂”的方式进行思考:
如果这个 Pod 是 Master 节点,我们要怎么做;
如果这个 Pod 是 Slave 节点,我们又要怎么做。
想清楚这两个问题,我们就可以按照 Pod 的启动过程来一步步定义它们了。
第一步:从 ConfigMap 中,获取 MySQL 的 Pod 对应的配置文件。
获取 MySQL 的 Pod 对应的配置文件 为此,我们需要进行一个初始化操作,根据节点的角色是 Master 还是 Slave 节点,为 Pod 分配对应的配置文件。此外,MySQL 还要求集群里的每个节点都有一个唯一的 ID 文件,名叫 server-id.cnf。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 ... initContainers: - name: init-mysql image: mysql:5.7 command: - bash - "-c" - | set -ex # 从 Pod 的 hostname 里,读取到了 Pod 的序号,以此作为 MySQL 节点的 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
第二步:在 Slave Pod 启动前,从 Master 或者其他 Slave Pod 里拷贝数据库数据到自己的目录下。
从 Master 或者其他 Slave Pod 里拷贝数据库数据 为了实现这个操作,我们就需要再定义第二个 InitContainer,如下所示:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 ... - 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指令,远程地向 DNS 记录为“mysql-< 当前序号减一 >.mysql”的 Pod,也就是当前 Pod 的前一个 Pod,发起数据传输请求,并且直接用 xbstream 指令将收到的备份数据保存在 /var/lib/mysql 目录下; # 3307 是一个特殊端口,运行着一个专门负责备份 MySQL 数据的辅助进程 # 这一步还可以用其他方法来传输数据。比如,用 scp 或者 rsync,都没问题 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
你可能已经注意到,这个容器里的 /var/lib/mysql 目录,实际上正是一个名为 data 的 PVC ,即:我们在前面声明的持久化存储。
这就可以保证,哪怕宿主机宕机了,我们数据库的数据也不会丢失。更重要的是,由于 Pod Volume 是被 Pod 里的容器共享的,所以后面启动的 MySQL 容器,就可以把这个 Volume 挂载到自己的 /var/lib/mysql 目录下,直接使用里面的备份数据进行恢复操作。
不过,clone-mysql 容器还要对 /var/lib/mysql 目录,执行一句 xtrabackup –prepare 操作,目的是让拷贝来的数据进入一致性状态,这样,这些数据才能被用作数据恢复。
至此,我们就通过 InitContainer 完成了对“主、从节点间备份文件传输”操作的处理过程。
从节点首次启动之前执行初始化 SQL 操作 可以为这个 MySQL 容器额外定义一个 sidecar 容器,来完成这个操作,它的定义如下所示:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 ... - name: xtrabackup image: gcr.io/google-samples/xtrabackup:1.0 ports: - name: xtrabackup containerPort: 3307 command: - bash - "-c" - | set -ex cd /var/lib/mysql if [[ -f xtrabackup_slave_info ]]; then mv xtrabackup_slave_info change_master_to.sql.in rm -f xtrabackup_binlog_info elif [[ -f xtrabackup_binlog_info ]]; then [[ `cat xtrabackup_binlog_info` =~ ^(.*?) [[:space: ]]+(.*?)$ ]] || exit 1 rm xtrabackup_binlog_info echo "CHANGE MASTER TO MASTER_LOG_FILE='${BASH_REMATCH[1]}',\ MASTER_LOG_POS=${BASH_REMATCH[2]}" > change_master_to.sql.in fi if [[ -f change_master_to.sql.in ]]; then 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" mv change_master_to.sql.in change_master_to.sql.orig 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 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
定义 Pod -MySQL 容器 有了前面这些定义和初始化工作,MySQL 容器本身的定义就非常简单了,如下所示:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 ... 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: command: ["mysql" , "-h" , "127.0.0.1" , "-e" , "SELECT 1" ] initialDelaySeconds: 5 periodSeconds: 2 timeoutSeconds: 1
这时候,应该能够明白,如果 MySQL 容器是 Slave 节点的话,它的数据目录里的数据,就来自于 InitContainer 从其他节点里拷贝而来的备份。它的配置文件目录 /etc/mysql/conf.d 里的内容,则来自于 ConfigMap 对应的 Volume。而它的初始化工作,则是由同一个 Pod 里的 sidecar 容器完成的。这些操作,正是刚刚前三部讲述的大部分内容。
至此,一个完整的主从复制模式的 MySQL 集群就定义完了。
完整的StatefulSet如下:
application/mysql/mysql-statefulset.yaml
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 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 # 从 Pod 的 hostname 里,读取到了 Pod 的序号,以此作为 MySQL 节点的 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 - 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指令,远程地向 DNS 记录为“mysql-< 当前序号减一 >.mysql”的 Pod,也就是当前 Pod 的前一个 Pod,发起数据传输请求,并且直接用 xbstream 指令将收到的备份数据保存在 /var/lib/mysql 目录下; # 3307 是一个特殊端口,运行着一个专门负责备份 MySQL 数据的辅助进程 # 这一步还可以用其他方法来传输数据。比如,用 scp 或者 rsync,都没问题 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 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: command: ["mysql" , "-h" , "127.0.0.1" , "-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 if [[ -f xtrabackup_slave_info ]]; then mv xtrabackup_slave_info change_master_to.sql.in rm -f xtrabackup_binlog_info elif [[ -f xtrabackup_binlog_info ]]; then [[ `cat xtrabackup_binlog_info` =~ ^(.*?) [[:space: ]]+(.*?)$ ]] || exit 1 rm xtrabackup_binlog_info echo "CHANGE MASTER TO MASTER_LOG_FILE='${BASH_REMATCH[1]}',\ MASTER_LOG_POS=${BASH_REMATCH[2]}" > change_master_to.sql.in fi if [[ -f change_master_to.sql.in ]]; then 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" mv change_master_to.sql.in change_master_to.sql.orig 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 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 volumes: - name: conf emptyDir: {} - name: config-map configMap: name: mysql volumeClaimTemplates: - metadata: name: data spec: accessModes: ["ReadWriteOnce" ] storageClassName: rook-ceph-block resources: requests: storage: 10Gi
开始搭建集群 创建configmap 1 $ kubectl apply -f mysql-configmap.yaml
创建service 1 $ kubectl apply -f mysql-services.yaml
创建PV 首先,我们需要在 Kubernetes 集群里创建满足条件的 PV 。
application/mysql/rook-storage.yaml
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 apiVersion: ceph.rook.io/v1 kind: CephBlockPool metadata: name: replicapool namespace: rook-ceph spec: replicated: size: 3 --- apiVersion: storage.k8s.io/v1 kind: StorageClass metadata: name: rook-ceph-block provisioner: ceph.rook.io/block parameters: pool: replicapool clusterNamespace: rook-ceph
1 $ kubectl create -f rook-storage.yaml
在这里,用到了 StorageClass 来完成这个操作。它的作用,是自动地为集群里存在的每一个 PVC,调用存储插件(Rook)创建对应的 PV,从而省去了我们手动创建 PV 的机械劳动。
备注:在使用 Rook 的情况下,mysql-statefulset.yaml 里的 volumeClaimTemplates 字段需要加上声明 storageClassName=rook-ceph-block,才能使用到这个 Rook 提供的持久化存储。
创建StatefulSet 1 $ kubectl create -f mysql-statefulset.yaml
你可以通过运行以下命令查看启动进度:
1 kubectl get pods -l app=mysql --watch
一段时间后,你应该看到所有 3 个 Pod 进入 Running 状态:
1 2 3 4 5 $ 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 集群发起请求,执行一些 SQL 操作来验证它是否正常:
1 2 3 4 5 6 $ 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 client 执行了创建数据库和表、以及插入数据的操作。需要注意的是,我们连接的 MySQL 的地址必须是 mysql-0.mysql(即:Master 节点的 DNS 记录)。因为,只有 Master 节点才能处理写操作。
通过连接 mysql-read 这个 Service,我们就可以用 SQL 进行读操作,如下所示:
1 2 3 4 5 6 7 8 9 $ 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
扩展 MySQL 集群,比如:
1 $ kubectl scale statefulset mysql --replicas=5
这时候,就会发现新的 Slave Pod mysql-3 和 mysql-4 被自动创建了出来
如果你像如下所示的这样,直接连接 mysql-3.mysql,即 mysql-3 这个 Pod 的 DNS 名字来进行查询操作:
1 2 3 4 5 6 7 8 9 $ kubectl run mysql-client --image=mysql:5.7 -i -t --rm --restart=Never --\ mysql -h mysql-3.mysql -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 为新创建的 mysql-3 上,同样可以读取到之前插入的记录。也就是说,数据备份和恢复,都是有效的。