Statefulset MySQL

此例是多副本的 MySQL 数据库。
示例应用的拓扑结构有一个主服务器和多个副本,使用异步的基于行(Row-Based)的数据复制。

说明: 这不是生产环境下配置。 尤其注意,MySQL 设置都使用的是不安全的默认值,这是因为我们想把重点放在 Kubernetes 中运行有状态应用程序的一般模式上。

创建存储卷

集群需要用到存储,准备持久卷(PersistentVolume,简称PV),我这里以yaml文件创建3个PV。如后续伸缩需要更新PersistentVolume 配置

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
kind: PersistentVolume
apiVersion: v1
metadata:
name: k8s-pv-my1
labels:
type: mysql
spec:
capacity:
storage: 20Gi
storageClassName: mysql
accessModes:
- ReadWriteOnce
hostPath:
path: "/var/lib/mysql"
persistentVolumeReclaimPolicy: Retain
---
kind: PersistentVolume
apiVersion: v1
metadata:
name: k8s-pv-my2
labels:
type: mysql
spec:
capacity:
storage: 20Gi
storageClassName: mysql
accessModes:
- ReadWriteOnce
hostPath:
path: "/var/lib/mysql"
persistentVolumeReclaimPolicy: Retain
---
kind: PersistentVolume
apiVersion: v1
metadata:
name: k8s-pv-my3
labels:
type: mysql
spec:
capacity:
storage: 20Gi
storageClassName: mysql
accessModes:
- ReadWriteOnce
hostPath:
path: "/var/lib/mysql"
persistentVolumeReclaimPolicy: Retain

部署及存储卷状态查询

注意:如果是使用云服务提供的云盘,注意购买云盘要与node节点使用区一致, 还要注意 node 类型支持那些云盘类型

这里发现pv和pvc还没有绑定状态是Available

1
kubectl apply -f persistent-volume.yaml
1
kubectl get pv  

部署 MySQL

MySQL 示例部署包含一个 ConfigMap、两个 Service 与一个 StatefulSet。

ConfigMap

使用以下的 YAML 配置文件创建 ConfigMap :

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: |
# Apply this config only on the master.
[mysqld]
log-bin
slave.cnf: |
# Apply this config only on slaves.
[mysqld]
super-read-only
1
kubectl apply -f mysql-configmap.yaml 

这个 ConfigMap 提供 my.cnf 覆盖设置,使你可以独立控制 MySQL 主服务器和从服务器的配置。在这里,你希望主服务器能够将复制日志提供给副本服务器,并且希望副本服务器拒绝任何不是通过复制进行的写操作。

ConfigMap 本身没有什么特别之处,因而也不会出现不同部分应用于不同的 Pod 的情况。每个 Pod 都会在初始化时基于 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
# Headless service for stable DNS entries of 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 any MySQL instance for reads.
# For writes, you must instead connect to the master: mysql-0.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

这个无头服务给 StatefulSet 控制器为集合中每个 Pod 创建的 DNS 条目提供了一个宿主。因为服务名为 mysql,所以可以通过在同一 Kubernetes 集群和名字中的任何其他 Pod 内解析 <Pod 名称>.mysql 来访问 Pod。

客户端服务称为 mysql-read,是一种常规服务,具有其自己的集群 IP。该集群 IP 在报告就绪的所有MySQL Pod 之间分配连接。可能的端点集合包括 MySQL 主节点和所有副本节点。

请注意,只有读查询才能使用负载平衡的客户端服务。因为只有一个 MySQL 主服务器,所以客户端应直接连接到 MySQL 主服务器 Pod(通过其在无头服务中的 DNS 条目)以执行写入操作。

StatefulSet

最后,使用以下 YAML 配置文件创建 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
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
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: ist0ne/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", "-e", "SELECT 1"]
initialDelaySeconds: 5
periodSeconds: 2
timeoutSeconds: 1
- name: xtrabackup
image: ist0ne/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 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 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 -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', \
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:
storageClassName: mysql
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 20Gi
1
kubectl apply -f mysql-statefulset.yaml

你可以通过运行以下命令查看启动进度:

1
kubectl get pods -l app=mysql --watch

一段时间后,你应该看到所有 3 个 Pod 进入 Running 状态:

1
2
3
4
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

输入 Ctrl+C 结束 watch 操作。如果你看不到任何进度,确保已启用 动态 PersistentVolume 预配器。

了解有状态的 Pod 初始化

StatefulSet 控制器按序数索引顺序地每次启动一个 Pod。它一直等到每个 Pod 报告就绪才再启动下一个 Pod。

此外,控制器为每个 Pod 分配一个唯一、稳定的名称,形如 <statefulset 名称>-<序数索引>其结果是 Pods 名为 mysql-0mysql-1mysql-2

上述 StatefulSet 清单中的 Pod 模板利用这些属性来执行 MySQL 副本的有序启动。

生成配置

在启动 Pod 规约中的任何容器之前,Pod 首先按顺序运行所有的 Init 容器

第一个名为 init-mysql 的 Init 容器根据序号索引生成特殊的 MySQL 配置文件。

该脚本通过从 Pod 名称的末尾提取索引来确定自己的序号索引,而 Pod 名称由 hostname 命令返回。然后将序数(带有数字偏移量以避免保留值)保存到 MySQL conf.d 目录中的文件 server-id.cnf。这一操作将 StatefulSet 所提供的唯一、稳定的标识转换为 MySQL 服务器的 ID,
而这些 ID 也是需要唯一性、稳定性保证的。

通过将内容复制到 conf.d 中,init-mysql 容器中的脚本也可以应用 ConfigMap 中的 primary.cnfreplica.cnf。由于示例部署结构由单个 MySQL 主节点和任意数量的副本节点组成,因此脚本仅将序数 0 指定为主节点,而将其他所有节点指定为副本节点。

与 StatefulSet 控制器的 部署顺序保证相结合,可以确保 MySQL 主服务器在创建副本服务器之前已准备就绪,以便它们可以开始复制。

克隆现有数据

通常,当新 Pod 作为副本节点加入集合时,必须假定 MySQL 主节点可能已经有数据。还必须假设复制日志可能不会一直追溯到时间的开始。

这些保守的假设是允许正在运行的 StatefulSet 随时间扩大和缩小而不是固定在其初始大小的关键。

第二个名为 clone-mysql 的 Init 容器,第一次在带有空 PersistentVolume 的副本 Pod上启动时,会在从属 Pod 上执行克隆操作。
这意味着它将从另一个运行中的 Pod 复制所有现有数据,使此其本地状态足够一致,从而可以开始从主服务器复制。

MySQL 本身不提供执行此操作的机制,因此本示例使用了一种流行的开源工具 Percona XtraBackup。在克隆期间,源 MySQL 服务器性能可能会受到影响。为了最大程度地减少对 MySQL 主服务器的影响,该脚本指示每个 Pod 从序号较低的 Pod 中克隆。可以这样做的原因是 StatefulSet 控制器始终确保在启动 Pod N + 1 之前 Pod N 已准备就绪。

开始复制

Init 容器成功完成后,应用容器将运行。MySQL Pod 由运行实际 mysqld 服务的 mysql 容器和充当的 xtrabackup 容器组成。

xtrabackup sidecar 容器查看克隆的数据文件,并确定是否有必要在副本服务器上初始化 MySQL 复制。如果是这样,它将等待 mysqld 准备就绪,然后使用从 XtraBackup 克隆文件中提取的复制参数执行 CHANGE MASTER TOSTART SLAVE 命令。

一旦副本服务器开始复制后,它会记住其 MySQL 主服务器,并且如果服务器重新启动或连接中断也会自动重新连接。另外,因为副本服务器会以其稳定的 DNS 名称查找主服务器(mysql-0.mysql),即使由于重新调度而获得新的 Pod IP,它们也会自动找到主服务器。

最后,开始复制后,xtrabackup 容器监听来自其他 Pod 的连接,处理其数据克隆请求。如果 StatefulSet 扩大规模,或者下一个 Pod 失去其 PersistentVolumeClaim 并需要重新克隆,则此服务器将无限期保持运行。

发送客户端请求

你可以通过运行带有 mysql:5.7 镜像的临时容器并运行 mysql 客户端二进制文件,将测试查询发送到 MySQL 主服务器(主机名 mysql-0.mysql)。

1
2
3
4
5
6
7
8
9
#进入主内部
kubectl exec -it mysql-0 -n <namespace> -- /bin/sh

#执行或者单独另启动一个客户端执行
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 将测试查询发送到任何报告为就绪的服务器:

1
2
3
4
5
#进入主内部
kubectl exec -it mysql-0 -n <namespace> -- /bin/sh

#执行或者单独另启动一个客户端执行
mysql -h mysql-read -e "SELECT * FROM test.messages"

你应该获得如下输出:

1
2
3
4
5
+---------+
| message |
+---------+
| hello |
+---------+

为了演示 mysql-read 服务在服务器之间分配连接,你可以在循环中运行 SELECT @@server_id

1
2
3
4
5
#进入主内部
kubectl exec -it mysql-0 -n <namespace> -- /bin/sh

#执行或者单独另启动一个客户端执行
bash -ic "while sleep 1; do mysql -h mysql-read -e 'SELECT @@server_id,NOW()'; done"

你应该看到报告的 @@server_id 发生随机变化,因为每次尝试连接时都可能选择了不同的端点:

1
2
3
4
5
6
7
8
9
10
11
#如果进入的主执行则结果显示ID`102`与`101`|另客户端执行 则多显示ID`100`,因为主默认ID`100`
+-------------+---------------------+
| @@server_id | NOW() |
+-------------+---------------------+
| 102 | 2006-01-02 15:04:06 |
+-------------+---------------------+
+-------------+---------------------+
| @@server_id | NOW() |
+-------------+---------------------+
| 101 | 2006-01-02 15:04:07 |
+-------------+---------------------+

要停止循环时可以按 Ctrl+C ,但是让它在另一个窗口中运行非常有用,这样你就可以看到以下步骤的效果。

模拟 Pod 和 Node 的宕机时间

为了证明从副本节点缓存而不是单个服务器读取数据的可用性提高,请在使 Pod 退出 Ready状态时,保持上述 SELECT @@server_id 循环一直运行。

破坏就绪态探测

mysql 容器的运行命令 mysql -h 127.0.0.1 -e 'SELECT 1',以确保服务器已启动并能够执行查询。

迫使就绪态探测失败的一种方法就是中止该命令:

1
kubectl exec mysql-2 -c mysql -- mv /usr/bin/mysql /usr/bin/mysql.off

此命令会进入 Pod mysql-2 的实际容器文件系统,重命名 mysql 命令,导致就绪态探测无法找到它。几秒钟后, Pod 会报告其中一个容器未就绪。你可以通过运行以下命令进行检查:

1
kubectl get pod mysql-2

READY 列中查找 1/2

1
2
NAME      READY     STATUS    RESTARTS   AGE
mysql-2 1/2 Running 0 3m

此时,你应该会看到 SELECT @@server_id 循环继续运行,尽管它不再报告 102。回想一下,init-mysql 脚本将 server-id 定义为 100 + $ordinal,因此服务器 ID 102 对应于 Pod mysql-2

现在修复 Pod,几秒钟后它应该重新出现在循环输出中:

1
kubectl exec mysql-2 -c mysql -- mv /usr/bin/mysql.off /usr/bin/mysql

删除 Pods

如果删除了 Pod,则 StatefulSet 还会重新创建 Pod,类似于 ReplicaSet 对无状态 Pod 所做的操作。

1
kubectl delete pod mysql-2

StatefulSet 控制器注意到不再存在 mysql-2 Pod,于是创建一个具有相同名称并链接到相同PersistentVolumeClaim 的新 Pod。你应该看到服务器 ID 102 从循环输出中消失了一段时间,然后又自行出现。

腾空节点

如果你的 Kubernetes 其中一个节点 设置不可调度,则可以通过发出以下命令来模拟节点停机(就好像节点在被升级)。

首先确定 MySQL Pod 之一在哪个节点上:

1
kubectl get pod mysql-2 -o wide

节点名称应显示在最后一列中:

1
2
NAME      READY     STATUS    RESTARTS   AGE       IP            NODE
mysql-2 2/2 Running 0 15m 10.244.5.27 kubernetes-node-9l2t

然后通过运行以下命令腾空节点,该命令将其保护起来,以使新的 Pod 不能调度到该节点,然后逐出所有现有的 Pod。将 <节点名称> 替换为在上一步中找到的节点名称。

这可能会影响节点上的其他应用程序,因此最好 仅在测试集群中执行此操作

1
kubectl drain <节点名称> --force --delete-local-data --ignore-daemonsets

现在,你可以看到 Pod 被重新调度到其他节点上:

1
kubectl get pod mysql-2 -o wide --watch

它看起来应该像这样:

1
2
3
4
5
6
7
8
9
NAME      READY   STATUS          RESTARTS   AGE       IP            NODE
mysql-2 2/2 Terminating 0 15m 10.244.1.56 kubernetes-node-9l2t
[...]
mysql-2 0/2 Pending 0 0s <none> kubernetes-node-fjlm
mysql-2 0/2 Init:0/2 0 0s <none> kubernetes-node-fjlm
mysql-2 0/2 Init:1/2 0 20s 10.244.5.32 kubernetes-node-fjlm
mysql-2 0/2 PodInitializing 0 21s 10.244.5.32 kubernetes-node-fjlm
mysql-2 1/2 Running 0 22s 10.244.5.32 kubernetes-node-fjlm
mysql-2 2/2 Running 0 30s 10.244.5.32 kubernetes-node-fjlm

再次,你应该看到服务器 ID 102SELECT @@server_id 循环输出中消失一段时间,然后自行出现。

现在去掉节点保护(Uncordon),使其恢复为正常模式:

1
kubectl uncordon <节点名称>

扩展副本节点数量

使用 MySQL 复制,你可以通过添加副本节点来扩展读取查询的能力。使用 StatefulSet,你可以使用单个命令执行此操作:

注意:要有满足伸缩的 PersistentVolume 配置

1
kubectl scale statefulset mysql --replicas=5

查看新的 Pod 的运行情况:

1
kubectl get pods -l app=mysql --watch

一旦 Pod 启动,你应该看到服务器 IDs 103104 开始出现在 SELECT @@server_id 循环输出中。

你还可以验证这些新服务器在存在之前已添加了数据:

1
2
3
4
5
#进入主内部
kubectl exec -it mysql-0 -n <namespace> -- /bin/sh

#执行或者单独另启动一个客户端执行
mysql -h mysql-3.mysql -e "SELECT * FROM test.messages"
1
2
3
4
5
+---------+
| message |
+---------+
| hello |
+---------+

向下缩容操作也是很平滑的:

1
kubectl scale statefulset mysql --replicas=3

但是请注意,按比例扩大会自动创建新的 PersistentVolumeClaims,而按比例缩小不会自动删除这些 PVC。这使你可以选择保留那些初始化的 PVC,以更快地进行缩放,或者在删除它们之前提取数据。

你可以通过运行以下命令查看此信息:

1
kubectl get pvc -l app=mysql

这表明,尽管将 StatefulSet 缩小为3,所有5个 PVC 仍然存在:

1
2
3
4
5
6
NAME           STATUS    VOLUME                                     CAPACITY   ACCESSMODES   AGE
data-mysql-0 Bound pvc-8acbf5dc-b103-11e6-93fa-42010a800002 10Gi RWO 20m
data-mysql-1 Bound pvc-8ad39820-b103-11e6-93fa-42010a800002 10Gi RWO 20m
data-mysql-2 Bound pvc-8ad69a6d-b103-11e6-93fa-42010a800002 10Gi RWO 20m
data-mysql-3 Bound pvc-50043c45-b1c5-11e6-93fa-42010a800002 10Gi RWO 2m
data-mysql-4 Bound pvc-500a9957-b1c5-11e6-93fa-42010a800002 10Gi RWO 2m

如果你不打算重复使用多余的 PVC,则可以删除它们:

1
2
kubectl delete pvc data-mysql-3
kubectl delete pvc data-mysql-4
  1. 通过在终端上按 Ctrl+C 取消 SELECT @@server_id 循环,或从另一个终端运行以下命令:

    1
    kubectl delete pod mysql-client-loop --now
  2. 删除 StatefulSet。这也会开始终止 Pod。

    1
    kubectl delete statefulset mysql
  3. 验证 Pod 消失。他们可能需要一些时间才能完成终止。

    1
    kubectl get pods -l app=mysql

    当上述命令返回如下内容时,你就知道 Pod 已终止:

    1
    No resources found.
  4. 删除 ConfigMap、Services 和 PersistentVolumeClaims。

    1
    kubectl delete configmap,service,pvc -l app=mysql
  5. 如果你手动供应 PersistentVolume,则还需要手动删除它们,并释放下层资源。如果你使用了动态预配器,当得知你删除 PersistentVolumeClaims 时,它将自动删除 PersistentVolumes。一些动态预配器(例如用于 EBS 和 PD 的预配器)也会在删除 PersistentVolumes 时释放下层资源。

详细参考:https://kubernetes.io/zh/docs/tasks/run-application/run-replicated-stateful-application/
https://kubernetes.io/zh/docs/concepts/storage/persistent-volumes/