容器编排5:深入理解StatefulSet之部署一个有状态应用
在前面的文章中,我们主要讨论了StatefulSet是如何处理拓扑状态和存储状态的,以及StatefulSet的工作原理。接下来,我们通过一个实际例子,熟悉一下部署一个StatefulSet的完整流程。
一、部署MySQL主从集群的三座大山
相比于 Etcd、Cassandra 等“原生”就考虑了分布式需求的项目,MySQL 以及很多其他的数据库项目,在分布式集群的搭建上并不友好,甚至有点“原始”。首先,用自然语言来描述一下我们想要部署的“有状态应用”:
- 一个“主从复制”(Maser-Slave Replication)的 MySQL 集群;
- 有 1 个主节点(Master);
- 有多个从节点(Slave);
- 从节点需要能水平扩展;
- 所有的写操作,只能在主节点上执行;
- 读操作可以在所有节点上执行。
这就是一个非常典型的主从模式的 MySQL 集群了。我们可以把上面描述的“有状态应用”的需求,通过一张图来表示。
在常规环境里,部署这样一个主从模式的 MySQL 集群的主要难点在于:如何让从节点能够拥有主节点的数据,即:如何配置主从节点的复制与同步。
所以,在安装好 MySQL 的 Master 节点之后,我们需要做的第一步工作,就是通过 XtraBackup 将 Master 节点的数据备份到指定目录。这一步会自动在目标目录里生成一个备份信息文件,名叫:xtrabackup_binlog_info。这个文件一般会包含如下两个信息:
1 |
|
这两个信息会在接下来配置 Slave 节点的时候用到。
第二步:配置 Slave 节点。Slave 节点在第一次启动前,需要先把 Master 节点的备份数据,连同备份信息文件,一起拷贝到自己的数据目录(/var/lib/mysql)下。然后,我们执行这样一句 SQL:
1 |
|
其中,MASTER_LOG_FILE 和 MASTER_LOG_POS,就是备份数据对应的二进制日志(Binary Log)文件名称和开始位置(偏移量),这也正是 xtrabackup_binlog_info 文件里的两部分内容(即:TheMaster-bin.000001 和 481)。
第三步,启动 Slave 节点。在这一步,我们需要执行这样一句 SQL:
1 |
|
这样,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 本身同时拥有拓扑状态(主从节点的区别)和存储状态(MySQL 保存在本地的数据),我们自然要通过 StatefulSet 来解决这“三座大山”的问题。
二、解决第一座大山
“第一座大山:Master 节点和 Slave 节点需要有不同的配置文件”,这很容易处理:我们只需要给主从节点分别准备两份不同的 MySQL 配置文件,然后根据 Pod 的序号(Index)挂载进去即可。
而这样的配置文件信息,应该保存在 ConfigMap 里供 Pod 使用。它的定义如下所示:
1 |
|
在这里,我们定义了 master.cnf 和 slave.cnf 两个 MySQL 的配置文件。master.cnf 开启了 log-bin,即:使用二进制日志文件的方式进行主从复制,这是一个标准的设置。slave.cnf 开启了 super-read-only,代表的是从节点会拒绝除了主节点的数据同步操作之外的所有写操作,即:它对用户是只读的。
而上述 ConfigMap 定义里的 data 部分,是 Key-Value 格式的。比如,master.cnf 就是这份配置数据的 Key,而“|”后面的内容,就是这份配置数据的 Value。这份数据将来挂载进 Master 节点对应的 Pod 后,就会在 Volume 目录里生成一个叫作 master.cnf 的文件。
在开始定义实际的容器之前,我们还需要创建两个 Service 来供 StatefulSet 以及用户使用。这两个 Service 的定义如下所示:
1 |
|
可以看到,这两个 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 的主节点或者从节点上。而写请求,则必须直接以 DNS 记录的方式访问到 MySQL 的主节点,也就是:“mysql-0.mysql“这条 DNS 记录。
三、解决第二座大山
接下来,我们再一起解决“第二座大山:Master 节点和 Slave 节点需要能够传输备份文件”的问题。而翻越这座大山的一个推荐思路是:先搭建框架,再完善细节。其中,Pod 部分如何定义,是完善细节时的重点。
3.1、搭建框架
首先,我们先为 StatefulSet 对象规划一个大致的框架,内容如下:
1 |
|
在这一步,我们可以先为 StatefulSet 定义一些通用的字段。比如:
-
selector 表示,这个 StatefulSet 要管理的 Pod 必须携带 app=mysql 标签;
-
serviceName表示,这个 StatefulSet 要使用的 Headless Service 的名字是:mysql。
-
replicas表示,这个 StatefulSet 定义了一个由三个节点组成的MySQL 集群:一个 Master 节点,两个 Slave 节点。
此外,还可以看到,StatefulSet 管理的“有状态应用”的多个实例,也都是通过同一份 Pod 模板创建出来的,使用的是同一个 Docker 镜像。这也就意味着:如果我们的应用要求不同节点的镜像不一样,那就不能再使用 StatefulSet 了。对于这种情况,应该考虑使用 Operator。
除了这些基本的字段外,作为一个有存储状态的 MySQL 集群,StatefulSet 还需要管理存储状态。所以,我们需要通过 volumeClaimTemplate(PVC 模板)来为每个 Pod 定义 PVC。比如,在这里的 PVC 模板中:
- resources.requests.strorage 指定了存储的大小为 10 GiB;
- ReadWriteOnce 指定了该存储的属性为可读写,并且一个 PV 只允许挂载在一个宿主机上。将来,这个 PV 对应的的 Volume 就会充当 MySQL Pod 的数据存储目录。
接下来,我们来重点设计一下这个 StatefulSet 的 Pod 模板,也就是 template 字段。由于 StatefulSet 所管理的 Pod 都来自于同一个镜像,这就要求我们在编写 Pod 时,一定要保持清醒,用“人格分裂”的方式进行思考:
- 如果这个 Pod 是 Master 节点,我们要怎么做;
- 如果这个 Pod 是 Slave 节点,我们又要怎么做。
想清楚了这两个问题,我们就可以按照 Pod 的启动过程来一步步定义它们了。
3.2、获取配置文件
第一步:从 ConfigMap 中,获取 MySQL Pod 对应的配置文件。
为此,我们需要进行一个初始化操作,根据节点的角色是 Master 还是 Slave,为 Pod 分配对应的配置文件。此外,MySQL 还要求集群里的每个节点都有一个唯一的 ID 文件,名叫 server-id.cnf。而根据我们已经掌握的 Pod 知识,这些初始化操作显然适合通过 InitContainer 来完成。所以,我们首先定义了一个 InitContainer,如下所示:
1 |
|
在这个名叫 init-mysql 的 InitContainer 的配置中,它从 Pod 的 hostname 里,读取到了 Pod 的序号,以此作为 MySQL 节点的 server-id。
然后,init-mysql 通过这个序号,判断当前 Pod 到底是 Master 节点(即:序号为 0)还是 Slave 节点(即:序号不为 0),从而把对应的配置文件从 /mnt/config-map 目录拷贝到 /mnt/conf.d/ 目录下。
其中,文件拷贝的源目录 /mnt/config-map,正是 ConfigMap 在这个 Pod 的 Volume,如下所示:
1 |
|
通过这个定义,init-mysql 在挂载了 config-map 这个 Volume 之后,ConfigMap 里保存的内容,就会以文件的方式出现在它的 /mnt/config-map 目录当中。而文件拷贝的目标目录,即容器里的 /mnt/conf.d/ 目录,对应的则是一个名叫 conf 的、emptyDir 类型的 Volume。基于 Pod Volume 共享的原理,当 InitContainer 复制完配置文件退出后,后面启动的 MySQL 容器只需要直接声明挂载这个名叫 conf 的 Volume,它所需要的.cnf 配置文件已经出现在里面了。这跟我们之前介绍的 Tomcat 和 WAR 包的处理方法是完全一样的。
3.3、拷贝数据
第二步:在 Slave Pod 启动前,从 Master 或者其他 Slave Pod 里拷贝数据库数据到自己的目录下。为了实现这个操作,我们就需要再定义第二个 InitContainer,如下所示:
1 |
|
在这个名叫 clone-mysql 的 InitContainer 里,我们使用的是 xtrabackup 镜像(它里面安装了 xtrabackup 工具)。而在它的启动命令里,我们首先做了一个判断。即:当初始化所需的数据(/var/lib/mysql/mysql 目录)已经存在,或者当前 Pod 是 Master 节点的时候,不需要做拷贝操作。
接下来,clone-mysql 会使用 Linux 自带的 ncat 指令,向 DNS 记录为“mysql-< 当前序号减一 >.mysql”的 Pod,也就是当前 Pod 的前一个 Pod,发起数据传输请求,并且直接用 xbstream 指令将收到的备份数据保存在 /var/lib/mysql 目录下。
备注:3307 是一个特殊端口,运行着一个专门负责备份 MySQL 数据的辅助进程。
当然,这一步我们可以随意选择用自己喜欢的方法来传输数据。比如,用 scp 或者 rsync,都没问题。
我们可能已经注意到,这个容器里的 /var/lib/mysql 目录,实际上正是一个名为 data 的 PVC,即:我们在前面声明的持久化存储。
这就可以保证,哪怕宿主机宕机了,数据库中的数据也不会丢失。更重要的是,由于 Pod Volume 是被 Pod 里的容器共享的,所以当最后 MySQL 容器启动时,就可以把这个 Volume 挂载到自己的 /var/lib/mysql 目录下,直接使用里面的备份数据。
不过,clone-mysql 容器还要对 /var/lib/mysql 目录,执行一句 xtrabackup --prepare 操作,目的是让拷贝来的数据进入一致性状态,这样,这些数据才能被MySQL容器直接使用。
至此,我们就通过 InitContainer 完成了对“主、从节点间备份文件传输”操作的处理过程,也就是翻越了“第二座大山”。
四、解决第三座大山
接下来,我们可以开始定义 MySQL 容器, 启动 MySQL 服务了。由于 StatefulSet 里的所有 Pod 都来自于同一个 Pod 模板,所以我们还要“人格分裂”地去思考:这个 MySQL 容器的启动命令,在 Master 和 Slave 两种情况下有什么不同。
有了 Docker 镜像,在 Pod 里声明一个 Master 角色的 MySQL 容器并不是什么困难的事情:直接执行 MySQL 启动命令即可。但是,如果这个 Pod 是一个第一次启动的 Slave 节点,在执行 MySQL 启动命令之前,它就需要使用前面 InitContainer 拷贝来的备份数据进行初始化。可是,别忘了,容器是一个单进程模型。所以,一个 Slave 角色的 MySQL 容器启动之前,谁能负责给它执行初始化的 SQL 语句呢?
这就是我们需要解决的“第三座大山”问题,即:如何在 Slave 节点的 MySQL 容器第一次启动之前,执行初始化 SQL。其实,我们可以为这个 MySQL 容器额外定义一个 sidecar 容器,来完成这个操作,它的定义如下所示:
1 |
|
可以看到,在这个名叫 xtrabackup 的 sidecar 容器的启动命令里,其实实现了两部分工作。
第一部分工作,当然是 MySQL 节点的初始化工作。这个初始化需要使用的 SQL,是 sidecar 容器拼装出来、保存在一个名为 change_master_to.sql.in 的文件里的,具体过程是这样的:
-
sidecar 容器首先会判断当前 Pod 的 /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 文件中。
-
接下来,sidecar 容器就可以执行初始化了。
从上面的叙述中可以看到,只要这个 change_master_to.sql.in 文件存在,那就说明接下来需要进行集群初始化操作。所以,这时候,sidecar 容器只需要读取并执行 change_master_to.sql.in 里面的“CHANGE MASTER TO”指令,再执行一句 START SLAVE 命令,一个 Slave 节点就被成功启动了。
但需要注意的是:Pod 里的容器并没有先后顺序,所以在执行初始化 SQL 之前,必须先执行一句 SQL(select 1)来检查一下 MySQL 服务是否已经可用。
当然,上述这些初始化操作完成后,我们还要删除掉前面用到的这些备份信息文件。否则,下次这个容器重启时,就会发现这些文件存在,所以又会重新执行一次集群初始化的操作,这是不对的。因此,change_master_to.sql.in 在使用后要被重命名,以免容器重启时因为发现这个文件存在又执行一遍初始化。
第二部分工作,是在完成 MySQL 节点的初始化后,启动一个数据传输服务。具体做法是:sidecar 容器会使用 ncat 命令启动一个工作在 3307 端口上的网络发送服务。一旦收到数据传输请求时,sidecar 容器就会调用 xtrabackup --backup 指令备份当前 MySQL 的数据,然后把这些备份数据返回给请求者。这就是为什么我们在 InitContainer 里定义数据拷贝的时候,访问的是“上一个 MySQL 节点”的 3307 端口。
值得一提的是,由于 sidecar 容器和 MySQL 容器同处于一个 Pod 里,所以它是直接通过 Localhost 来访问和备份 MySQL 容器里的数据的,非常方便。同样地,在这里举例用的只是一种备份方法而已,我们完全可以选择其他的方案。比如,可以使用 innobackupex 命令做数据备份和准备,它的使用方法几乎与本文的备份方法一样。
至此,我们也就翻越了“第三座大山”,完成了 Slave 节点第一次启动前的初始化工作。
五、定义MySQL容器
扳倒了这“三座大山”后,我们终于可以定义 Pod 里的主角,MySQL 容器了。有了前面这些定义和初始化工作,MySQL 容器本身的定义就非常简单了,如下所示:
1 |
|
在这个容器的定义里,我们使用了一个标准的 MySQL 5.7 的官方镜像。它的数据目录是 /var/lib/mysql,配置文件目录是 /etc/mysql/conf.d。这时候,我们应该能够明白,如果 MySQL 容器是 Slave 节点的话,它的数据目录里的数据,是 InitContainer 从其他节点拷贝而来的。它的配置文件目录 /etc/mysql/conf.d 里的内容,则来自于 ConfigMap 对应的 Volume。而它的初始化工作,则是由同一个 Pod 里的 sidecar 容器完成的。
另外,我们为它定义了一个 livenessProbe,通过 mysqladmin ping 命令来检查它是否健康;还定义了一个 readinessProbe,通过查询 SQL(select 1)来检查 MySQL 服务是否可用。当然,凡是 readinessProbe 检查失败的 MySQL Pod,都会从 Service 里被摘除掉。
至此,一个完整的主从复制模式的 MySQL 集群就定义完了。
六、创建MySQL集群
现在,我们就可以使用 kubectl 命令,尝试运行一下这个 StatefulSet 了。
首先,我们需要在 Kubernetes 集群里创建满足条件的 PV。假设我们的存储集群为Ceph,因此可以按照如下方式使用存储插件 Rook:
1 |
|
在这里,我们用到了 StorageClass 来完成这个操作。它的作用是,自动地为集群里存在的每一个 PVC,调用存储插件(Rook)创建对应的 PV,从而省去了我们手动创建 PV 的机械劳动。
备注:在使用 Rook 的情况下,mysql-statefulset.yaml 里的 volumeClaimTemplates 字段需要加上声明 storageClassName=rook-ceph-block,才能使用到这个 Rook 提供的持久化存储。
然后,我们就可以创建这个 StatefulSet 了,如下所示:
1 |
|
可以看到,StatefulSet 启动成功后,会有三个 Pod 运行。接下来,我们可以尝试向这个 MySQL 集群发起请求,执行一些 SQL 操作来验证它是否正常:
1 |
|
如上所示,我们通过启动一个容器,使用 MySQL client 执行了创建数据库和表、以及插入数据的操作。需要注意的是,我们连接的 MySQL 的地址必须是 mysql-0.mysql(即:Master 节点的 DNS 记录)。因为,只有 Master 节点才能处理写操作。
而通过连接 mysql-read 这个 Service,我们就可以用 SQL 进行读操作,如下所示:
1 |
|
在有了 StatefulSet 以后,我们就可以像 Deployment 那样,非常方便地扩展这个 MySQL 集群,比如:
1 |
|
这时候,我们就会发现新的 Slave Pod mysql-3 和 mysql-4 被自动创建了出来。而如果我们像如下所示的这样,直接连接 mysql-3.mysql,即 mysql-3 这个 Pod 的 DNS 名字来进行查询操作:
1 |
|
就会看到,从 StatefulSet 为我们新创建的 mysql-3 上,同样可以读取到之前插入的记录。也就是说,我们的数据备份和恢复,都是有效的。
七、小结
在这篇文章中,我们以MySQL主从集群为例,讨论了一个实际的StatefulSet的编写过程。在这个过程中,有以下几个关键点需要认真体会:
- “人格分裂”:在解决需求的过程中,一定要记得思考,该Pod在扮演不同角色时的不同操作。
- “阅后即焚”:很多“有状态应用”的节点,只是在第一次启动时才需要做额外处理。所以,在编写YAML文件时,一定要考虑“容器重启”的情况,不要让这一次的操作干扰到下一次的容器启动。
- “人人平等”:除了InitContainer之外,一个Pod里的多个容器之间是完全平等的。所以,我们设计的sidecar,绝对不能对容器的顺序做出假设,否则就需要进行前置检查。