例子来源:kubernetes.io/docs/tasks/...
目标
通过StatefulSet实现一主多从的Mysql集群,并实现读写分离。
当前没有使用Dynamic Provisioning
,所以要扩展的话需要手动加PV
环境
只有一个虚拟机,作为Master节点。
虚拟机操作系统:Ubuntu 20.04
内存:6GB
Kubernetes:V1.23
Docker:24.0.5
准备工作
- k8s官网文中需要一个
[dynamic PersistentVolume provisioner](https://kubernetes.io/docs/concepts/storage/dynamic-provisioning/)
,此处只使用了基于本机filesystem的PV
搭建集群
准备mysql配置文件材料
我们需要搭建一个一主多从的MySQL集群,首先需要对MySQL进行配置,可以用到ConfigMap。
创建一个ConfigMap,yaml如下:
yaml
...
data:
primary.cnf: |
# Apply this config only on the primary.
[mysqld]
log-bin
replica.cnf: |
# Apply this config only on replicas.
[mysqld]
super-read-only
主节点用到的primary.cnf
指定了mysql需要使用二进制日志,用于数据的复制。
从节点则配置super-read-only
,代表从节点上只能执行读操作 。
创建Service
为了让外部可以访问到集群的Pod,就需要创建相应的Service对象
由于只有主节点能够进行写操作,因此执行写操作时就需要能够单独访问StatefulSet中的主节点Pod,因此需要用到Headless Service
,无头服务。 无头服务为直接访问单个Pod的手段,可以避开负载均衡、集群IP访问。通过使用无头服务,客户端就可以直接访问Mysql的主节点进行写操作。
另外,也需要一个普通的Service作为集群共有的入口,用户通过该Service访问集群进行读操作,Service就可以将请求分散到不同的节点上。Service的yaml文件如下:
yaml
# 无头服务
apiVersion: v1
kind: Service
metadata:
name: mysql-svc-rw
labels:
app: mysql
app.kubernetes.io/name: mysql
spec:
ports:
- name: mysql
port: 3306
clusterIP: None
selector:
app: mysql
---
# 用于读操作的服务
# 写操作时需要访问主节点Pod的地址: mysql-0.mysql-svc-rw
apiVersion: v1
kind: Service
metadata:
name: mysql-svc-read
labels:
app: mysql
app.kubernetes.io/name: mysql
readonly: "true"
spec:
ports:
- name: mysql
port: 3306
selector:
app: mysql
无头服务与下面读操作服务的差别就是,ClusterIP: None
。 通过该字段,kubernetes ApiServer就可以不为无头服务分配集群IP,而是为StatefulSet中的各个Pod分配特有的可以被DNS解析的固定地址,命名方式为:<pod-name>.<service-name>
.
创建StatefulSet
Deployment可用于无状态应用的部署,比如各个web服务,无所谓启动顺序,无所谓部署到哪个宿主机上。而StatefulSet用于有状态应用的部署,比如"主从关系","有状态"具体来说就是拓扑状态与存储状态。
拓扑状态 :多个实例之间不是对等关系,A实例必须先于B实例创建,比如主节点先于从节点创建,而且删除掉再部署的启动顺序也一样。
存储状态 :某个实例在删除前后访问到的数据应该是同一份。
准备配置文件
StatefulSet创建的多个pod的命名方式为:<statefulset name>-<ordinal index>
,比如web-0
,web-1
,web-2
。
我们在一个StatefulSet上创建Mysql主从集群,StatefulSet的名称为mysql
。在主从的划分上,可以将mysql-0
作为主节点,mysql-1/2/3...
作为从节点。
首先主节点和从节点需要的配置文件不同,配置文件内容在准备ConfigMap的时候就准备好了,我们需要将其挂载到pod上,并根据身份是主还是从将文件拷贝到Volume中。 这样,后续容器就能通过挂载访问到配置文件了。为了完成该任务,需要一个initcontainer:init-mysql
yaml
...
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/primary.cnf /mnt/conf.d/
else
cp /mnt/config-map/replica.cnf /mnt/conf.d/
fi
volumeMounts:
- name: conf
mountPath: /mnt/conf.d
- name: config-map
mountPath: /mnt/config-map
...
volumes:
- name: conf
emptyDir: {}
- name: config-map
configMap:
name: mysql
在编写容器时,由于主从节点都使用的一个template
,因此,在yaml中需要时刻注意分辨当前的pod代表主节点还是从节点,从而进行不同的行为。
回到yaml,首先获取了当前的pod编号。BASH_REMATCH[1]代表获取上面正则匹配得到的第一个匹配值,与[[
,=~
配合使用。比如hostname为mysql-1
时,ordinal等于1.
将100+1
写入mnt/conf.d/server-id.cnf
中,代表该节点的标识,主从复制会用到。至于加100的原因是,server-id.cnf
中值为0时具有特殊含义,被mysql保留,含义为:代表复制事件的原始服务器是未知的。
然后通过[[ $ordinal -eq 0 ]]
条件,根据节点身份将对应的配置文件拷入conf
中。拷入完毕后,该initcontainer的任务就完成了。
注意点
我使用的mysql:5.7
镜像无法执行hostname
命令,$HOSTNAME
倒是不影响。
拷贝数据
拷贝数据是初始化的时候将主节点上或者其他具有数据的从节点上的数据拷贝到当前节点中,拷贝策略是mysql-(n)
拷贝mysql-(n-1)
的数据。也就是,mysql-1
拷贝主节点mysql-0
的数据,mysql-2
拷贝从节点mysql-1
的数据。
例子中使用xtrabackup1.0实现数据拷贝,yaml文件如下:
yaml
...
initContainers:
- name: mysql-init
...
- 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 primary (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-svc-rw 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
...
如果目录/var/lib/mysql/mysql
存在,则代表数据已经存在,则直接退出。volumeMounts
中的data volume为pod挂载的存储卷,用于存储mysql数据。
下一步获取到该pod编号ordinal
,如果为主节点,则不需要从其他节点复制数据,也直接退出。
如果为从节点,则使用ncat监听3307端口,并设置参数--recv-only
,代表准备接收数据。若接收到数据,就通过xbstream -x -C /var/lib/mysql
将数据以流的形式发送到/var/lib/mysql
.mysql-$(($ordinal-1)).mysql-svc-rw
代表前一个节点的DNS记录。
xtrabackup --prepare --target-dir=/var/lib/mysql
则将/var/lib/mysql
进行预处理,使其可以被恢复到Mysql服务器。
通过PVC声明存储
PVC是Persistent Volume Claim的缩写,Pod具有,是声明要使用PV的资源量等属性。比如该yaml声明了1G大小的存储空间:
yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: nfs
spec:
accessModes:
- ReadWriteMany
storageClassName: manual
resources:
requests:
storage: 1Gi
在例子中则使用了volumeClaimTemplate
,VCT定义了一个PVC模板,可以根据模板为每个Pod都创建PVC,从而自动申请PV。其中accessModes代表访问方式,ReadWriteOnce
代表,可以读写,并且一个PV只能绑定到一块宿主机:
yaml
volumeClaimeTemplates:
- metadata:
name: data
spec:
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 10Gi
注意点
- 该镜像国内环境没法拉,因此我使用的镜像是:
registry.cn-hangzhou.aliyuncs.com/hxpdocker/xtrabackup:1.0
- ncat命令中,DNS记录名后缀需要和Headless Service名相同,否则CoreDNS会报错。
Slave容器的初始化
Slave容器在正式工作前,需要执行一系列命令为MySQL指明主节点的信息,以及数据同步的初始位置以及一些主从同步的设置属性:
yaml
TheSlave|mysql> CHANGE MASTER TO
MASTER_HOST='$masterip',
MASTER_USER='xxx',
MASTER_PASSWORD='xxx',
MASTER_LOG_FILE='TheMaster-bin.000001',
MASTER_LOG_POS=481;
> START SLAVE;
我们可以用一个sidecar容器帮助进行Slave的初始化,要点是:
- 拿到
MASTER_LOG_FILE
以及MASTER_LOG_POS
的值 - 在Mysql启动后,执行初始化SQL
- 监听一个端口,向后续启动的Slave节点发送数据
yaml文件如下:
yaml
- 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 replica. (Need to remove the tailing 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 primary. 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 -e "SELECT 1"; do sleep 1; done
echo "Initializing replication from clone position"
mysql -h 127.0.0.1 \
-e "$(<change_master_to.sql.in), \
MASTER_HOST='mysql-0.mysql-svc-rw', \
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
为了获取MASTER_LOG_FILE
以及MASTER_LOG_POS
的值,首先判断是否存在xtrabackup_slave_info
文件且不为空,如果有,则将其行后的分号去掉,从而方便完成初始化SQL的拼接。
sed -E 's/;$//g'
中,参数E代表正则匹配,s代表替换操作,;$代表以分号结尾的部分,g代表全局替换。
然后判断是否有文件xtrabackup_slave_info
或者xtrabackup_binlog_info
。如果有xtrabackup_binlog_info
则可以判断该节点的数据来自Master节点,就需要解析出这两个字段值。并拼装为初始化SQL命令的前半段,写入change_master_to.sql.in
中
获取到MASTER_LOG_FILE
以及MASTER_LOG_POS
的值并完成初始化SQL的前半部分后,就等待Mysql容器启动起来。启动后则可以执行初始化脚本。
在每一个步骤后面都将文件进行了删除,目的是在后续启动容器的过程中,避免发现到这些文件后再次进行数据恢复。
yaml
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"
则是监听端口3307,当收到下一个Pod的传输请求时,就会将-c参数后面的数据发送过去。
注意点
在初始化SQL脚本中,MASTER_HOST
需要和你的Headless Service Name对应。
创建MySQL容器
上述sidecar容器和mysql容器的启动顺序不定,因此需要在sidecar中用until
语句等待mysql容器启动后,再执行初始化SQL。
yaml如下:
yaml
- 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", "-e", "SELECT 1"]
initialDelaySeconds: 5
periodSeconds: 2
timeoutSeconds: 1
readinessProbe用于进行服务可用性的检测,如果指定的命令执行失败,该pod就会被从Headless Service中摘去。
验证
- 向该集群发起写请求,使用主节点pod的dns记录
mysql-0.mysql-svc-rw
:
yaml
$ kubectl run mysql-client --image=mysql:5.7 -i --rm --restart=Never --\
mysql -h mysql-0.mysql-svc-rw <<EOF
CREATE DATABASE test;
CREATE TABLE test.messages (message VARCHAR(250));
INSERT INTO test.messages VALUES ('hello');
EOF
然后查看test db是否建好:
- 通过
mysql-svc-read
这个service来进行读操作
yaml
$ kubectl run mysql-client --image=mysql:5.7 -i -t --rm --restart=Never --\
mysql -h mysql-read -e "SELECT * FROM test.messages"
- 也可以使用独立pod的dns记录进行读操作
遇到的坑
由于对K8S以及容器技术还是初学,出现了使用上、理解上的错误,做个记录
InnoDB: Unable to lock ./ibdata1 error: 11
没有使用Dynamic Persistent Volume,因此PV都是手动创建。
第一次使用时只创建了一块PV,结果用在了三个Pod上。
因此需要创建三块PV,分别挂载不同系统路径,最后创建出来的效果: