Kubernetes集群实践(九)使用StatefulSet 搭建高可用的MySQL集群

本文主要介绍如何使用StatefulSet搭建高可用的MySQL集群。

关键词:k8s

前言

为什么要用K8s搭建MySQL集群?

  • 在业务全面上云的背景下,为了要使用现有的设备快速搭建高可用的MySQL集群,K8s无非是一种比较方便的方案。

StatefulSet

StatefulSet被用来管理有状态应用的工作负载API对象。MySQL服务就是一个典型的有状态应用。

和Deployment类似,StatefulSet也是用来管理基于相同容器Spec的一组Pod。但和Deployment不同的是,StatefulSet为他们的Pod维护了一个有粘性的ID。这些Pod是基于相同的Spec来创建的,但是不能替换:无论怎么调度,他们Pod都有一个永久不变的ID。

如果使用PV卷为Pod提供持久存储,可以使用StatefulSet作为解决方案的一部分,尽管StatefulSet中的单个Pod仍可能出现故障,但持久的 Pod 标识符使得将现有卷与替换已失败 Pod 的新 Pod 相匹配变得更加容易。

StatefulSet主要用于以下状态的应用更新:

  • 需要稳定的、唯一的网络标识符;
  • 需要稳定的、持久的存储;
  • 需要有序的、优雅的部署和扩缩;
  • 需要有序的、自动的滚动更新。

稳定意味着Pod的调度或者重新调度的整个过程是持久性的;

如果应用程序不需要任何稳定的标识符或者有序的部署、删除或扩缩,则应该使用无状态的副本控制提供的工作负载来部署应用程序,如Deployment或者ReplicaSet;

  • StatefulSet的应用都是一个一个依次创建的;

限制

  • Pod存储必须要由PV驱动基于StrongeClass来提供,或者由管理员预先提供;

  • 删除或者扩缩StatefulSet不会删除它关联的PVC

  • StatefulSet需要无头服务(headless service)来负责创建Pod的网络标识,管理员需要创建此服务;

  • 当删除一个StatefulSet时,StatefulSet不提供任何终止Pod的保证。为了实现StatefulSet有序终止,可以在删除之前将StatefulSet缩容至0;

  • 默认Pod管理策略是滚动更新,出现异常时可能需要人工干预才能恢复

组件

下面的示例演示了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
apiVersion: v1
kind: Service
metadata:
name: nginx
labels:
app: nginx
spec:
ports:
- port: 80
name: web
clusterIP: None
selector:
app: nginx
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: web
spec:
selector:
matchLabels:
app: nginx # 必须匹配 .spec.template.metadata.labels
serviceName: "nginx"
replicas: 3 # 默认值是 1
minReadySeconds: 10 # 默认值是 0
template:
metadata:
labels:
app: nginx # 必须匹配 .spec.selector.matchLabels
spec:
terminationGracePeriodSeconds: 10
containers:
- name: nginx
image: k8s.gcr.io/nginx-slim:0.8
ports:
- containerPort: 80
name: web
volumeMounts:
- name: www
mountPath: /usr/share/nginx/html
volumeClaimTemplates:
- metadata:
name: www
spec:
accessModes: [ "ReadWriteOnce" ]
storageClassName: "my-storage-class"
resources:
requests:
storage: 1Gi

MySQL HA搭建

普通MySQL集群的搭建可以参考:https://www.modb.pro/db/29214

主要就是以下几个步骤:

  1. 安装数据库
  2. 主库开启binlog
  3. 授权
  4. 登陆主库查看此时日子状态
  5. 导出主库当前数据
  6. 从库和指定serverid
  7. 从库写入主库数据
  8. 指定开始同步位置

K8s集群上的MySQL集群搭建略有不同,主要通过StatefulSet+ConfigMap+initContainer的模式和xtrabackup+ncat软件来实现主从复制;

  1. 首先创建主库和从库的my.cnf文件,存入ConfigMap。这样可以持久化配置文件;
  2. 然后创建InitContainer。InitContainer容器是首先创建的容器,且该容器成功推出是Pod Ready的比较条件;
    • InitContainer:init-mysql 主要任务是根据hostname,选择拷贝主库的配置文件还是从库的文件到Volume中,这样就区分了Master和Slave;
    • InitContainer:clone-mysql 任务是为级联复制做准备。如果存在数据,跳过克隆;跳过主库的克隆;从上一个Ready的Pod克隆数据来;准备备份为后面的节点服务;
  3. 创建Container。这里的Container就是正常业务的MySQL容器了;
  4. 创建Sicar Container:backup-sql,主要任务是:调整当前节点的主从设置;准备为下一个节点提供复制的文件

资源清单文件

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
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
apiVersion: v1
kind: ConfigMap
metadata:
name: mysql-ha
labels:
app: mysql
app.kubernetes.io/name: mysql
data:
init.sql: |
use mysql;
/* update user set host = '%' where user ='root';
flush privileges; */
grant all privileges on *.* to 'root'@'%';
flush privileges;
primary.cnf: |
# 仅在主服务器上应用此配置
[mysqld]
lower_case_table_names=1
relay-log=mysql-relay
log-bin=mysql-bin
gtid_mode=on
enforce_gtid_consistency

max_connections=1000
character_set_server=utf8mb4
collation_server=utf8mb4_general_ci
default_authentication_plugin=mysql_native_password
replica.cnf: |
# 仅在副本服务器上应用此配置
[mysqld]
lower_case_table_names=1
log_replica_updates=1
relay-log=mysql-relay
log-bin=mysql-bin
gtid_mode=on
enforce_gtid_consistency

max_connections=1000
character_set_server=utf8mb4
collation_server=utf8mb4_general_ci
default_authentication_plugin=mysql_native_password
---
# 为 StatefulSet 成员提供稳定的 DNS 表项的无头服务(Headless Service)
apiVersion: v1
kind: Service
metadata:
name: mysql
labels:
app: mysql
app.kubernetes.io/name: mysql
spec:
ports:
- name: mysql
port: 3306
clusterIP: None
selector:
app: mysql
---
# 用于连接到任一 MySQL 实例执行读操作的客户端服务
# 对于写操作,你必须连接到主服务器:mysql-0.mysql
apiVersion: v1
kind: Service
metadata:
name: mysql-read
labels:
app: mysql
app.kubernetes.io/name: mysql
readonly: "true"
spec:
ports:
- name: mysql
port: 3306
selector:
app: mysql
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: mysql
spec:
selector:
matchLabels:
app: mysql
app.kubernetes.io/name: mysql
serviceName: mysql
replicas: 3
template:
metadata:
labels:
app: mysql
app.kubernetes.io/name: mysql
spec:
securityContext:
runAsUser: 999
runAsGroup: 999
fsGroup: 999
initContainers:
- name: label-pod
image: hub.deepsoft-tech.com/wf09/curl
imagePullPolicy: IfNotPresent
env:
- name: PODNAME
valueFrom:
fieldRef:
fieldPath: metadata.name
command:
- bash
- "-c"
- |
set -ex
APISERVER=https://kubernetes.default.svc
# 服务账号令牌的路径
SERVICEACCOUNT=/var/run/secrets/kubernetes.io/serviceaccount
# 读取 Pod 的名字空间
NAMESPACE=$(cat ${SERVICEACCOUNT}/namespace)
# 读取服务账号的持有者令牌
TOKEN=$(cat ${SERVICEACCOUNT}/token)
# 引用内部证书机构(CA)
CACERT=${SERVICEACCOUNT}/ca.crt
# 使用令牌访问 API
# 基于 Pod 序号生成 MySQL 服务器的 ID。
[[ `hostname` =~ -([0-9]+)$ ]] || exit 1
ordinal=${BASH_REMATCH[1]}
if [[ $ordinal -eq 0 ]]; then
FLAG=false
else
FLAG=true
fi
curl -X PATCH \
--cacert ${CACERT} \
-H "Content-Type:application/json-patch+json" \
-H "Authorization: Bearer ${TOKEN}" ${APISERVER}/api \
-d \
'[
{
"op": "add",
"path": "/metadata/labels/readonly",
"value": "'"$FLAG"'"
}
]' \
${APISERVER}/api/v1/namespaces/${NAMESPACE}/pods/${PODNAME}
- name: init-mysql
image: ubuntu
imagePullPolicy: IfNotPresent
command:
- bash
- "-c"
- |
set -ex
# 基于 Pod 序号生成 MySQL 服务器的 ID。
[[ `hostname` =~ -([0-9]+)$ ]] || exit 1
ordinal=${BASH_REMATCH[1]}
echo [mysqld] > /mnt/conf.d/server-id.cnf
# 添加偏移量以避免使用 server-id=0 这一保留值。
echo server-id=$((100 + $ordinal)) >> /mnt/conf.d/server-id.cnf
# 将合适的 conf.d 文件从 config-map 复制到 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
echo "Find init.sql..."
cp /mnt/config-map/init.sql /docker-entrypoint-initdb.d/init.sql
[[ $? -eq 0 ]] || exit 1
cat /docker-entrypoint-initdb.d/init.sql
ls -l /var/lib/mysql
[[ $? -eq 0 ]] || exit 1
volumeMounts:
- name: data
mountPath: /var/lib/mysql
subPath: mysql
- name: init-sql
mountPath: /docker-entrypoint-initdb.d/
- name: conf
mountPath: /mnt/conf.d
- name: config-map
mountPath: /mnt/config-map
- name: clone-mysql
image: hub.deepsoft-tech.com/wf09/backupsql
command:
- bash
- "-c"
- |
set -ex
# 如果已有数据,则跳过克隆。
[[ -d /var/lib/mysql/mysql ]] && exit 0
# 跳过主实例(序号索引 0)的克隆。
[[ `hostname` =~ -([0-9]+)$ ]] || exit 1
ordinal=${BASH_REMATCH[1]}
[[ $ordinal -eq 0 ]] && exit 0
# 从原来的对等节点克隆数据。
ncat --recv-only mysql-$(($ordinal-1)).mysql 3307 | xbstream -x -C /var/lib/mysql
# 准备备份。
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:8.0.29-debian
imagePullPolicy: IfNotPresent
env:
- name: MYSQL_ROOT_PASSWORD
value: "root"
ports:
- name: mysql
containerPort: 3306
volumeMounts:
- name: socket
mountPath: /var/run/mysqld
- name: init-sql
mountPath: /docker-entrypoint-initdb.d/
- 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", "-uroot", "-proot"]
initialDelaySeconds: 150
failureThreshold: 10
readinessProbe:
exec:
# 检查我们是否可以通过 TCP 执行查询(skip-networking 是关闭的)。
command: ["mysql", "-uroot", "-proot", "-e", "SELECT 1"]
initialDelaySeconds: 60
failureThreshold: 5
- name: backup-sql
image: hub.deepsoft-tech.com/wf09/backupsql
imagePullPolicy: Always
ports:
- name: xtrabackup
containerPort: 3307
command:
- bash
- "-c"
- |
set -ex
cd /var/lib/mysql
[[ `hostname` =~ -([0-9]+)$ ]] || exit 1
ordinal=${BASH_REMATCH[1]}
# 如果是第0个说明是Master,跳过设置主从的部分
[[ $ordinal -eq 0 ]] && exec ncat --listen --keep-open --send-only --max-conns=1 3307 -c \
"xtrabackup --backup --slave-info --stream=xbstream --user=root --password=root"

echo "Waiting for mysqld to be ready (accepting connections)"
until mysql -uroot -proot -e "SELECT 1" ; do sleep 1; done

echo "Initializing replication from clone position"
mysql -uroot -proot \
-e "STOP SLAVE;" \
-e "RESET SLAVE;" \
-e "CHANGE MASTER TO \
MASTER_HOST='mysql-0.mysql', \
MASTER_USER='root', \
MASTER_PASSWORD='root', \
MASTER_AUTO_POSITION = 1;" \
-e "START SLAVE;" || exit 1
exec ncat --listen --keep-open --send-only --max-conns=1 3307 -c \
"xtrabackup --backup --slave-info --stream=xbstream --user=root --password=root"
volumeMounts:
- name: socket
mountPath: /var/run/mysqld
- name: data
mountPath: /var/lib/mysql
subPath: mysql
- name: conf
mountPath: /etc/mysql/conf.d
resources:
requests:
cpu: 100m
memory: 100Mi
volumes:
- name: socket
emptyDir: {}
- name: conf
emptyDir: {}
- name: init-sql
emptyDir: {}
- name: config-map
configMap:
name: mysql-ha
volumeClaimTemplates:
- metadata:
name: data
spec:
storageClassName: rook-ceph-block
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 1Gi

如何给Pod动态的增加Label

现在要求Service A可以直接连接主库,即可以直接连接到StatefulSet创建的第一个Pod;Service B可以直接连接所有的从库,即除去第一个Pod都可以通过Service负载均衡到后端Pod。

现在思路是通过 initContainerd 容器,在work Pod起来之前中执行以下逻辑:

  • 判断hostname,如果带0,说明是第一个主库,通过REST API请求K8s API Server,根据每一个Pod都会自动挂载默认Service Account(投射卷)的机制,可以获取到请求K8s API Server的密钥。

部分资源清单:

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
initContainers:
- name: label-pod
image: hub.deepsoft-tech.com/wf09/curl
imagePullPolicy: IfNotPresent
env:
- name: PODNAME
valueFrom:
fieldRef:
fieldPath: metadata.name
command:
- bash
- "-c"
- |
set -ex
APISERVER=https://kubernetes.default.svc
# 服务账号令牌的路径
SERVICEACCOUNT=/var/run/secrets/kubernetes.io/serviceaccount
# 读取 Pod 的名字空间
NAMESPACE=$(cat ${SERVICEACCOUNT}/namespace)
# 读取服务账号的持有者令牌
TOKEN=$(cat ${SERVICEACCOUNT}/token)
# 引用内部证书机构(CA)
CACERT=${SERVICEACCOUNT}/ca.crt
# 使用令牌访问 API
# 基于 Pod 序号生成 MySQL 服务器的 ID。
[[ `hostname` =~ -([0-9]+)$ ]] || exit 1
ordinal=${BASH_REMATCH[1]}
if [[ $ordinal -eq 0 ]]; then
FLAG=false
else
FLAG=true
fi
curl -X PATCH \
--cacert ${CACERT} \
-H "Content-Type:application/json-patch+json" \
-H "Authorization: Bearer ${TOKEN}" ${APISERVER}/api \
-d \
'[
{
"op": "add",
"path": "/metadata/labels/readonly",
"value": "'"$FLAG"'"
}
]' \
${APISERVER}/api/v1/namespaces/${NAMESPACE}/pods/${PODNAME}

上面

⚠️默认情况下默认服务账号是不能更改Pod Label的,需要执行一下资源清单开放权限

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
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: patch-my-pods
namespace: default
labels:
app: patch-my-pods
rules:
- apiGroups: [""] # "" indicates the core API group
resources: ["pods"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: patch-my-pods
namespace: default
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: patch-my-pods
subjects:
- kind: ServiceAccount
name: default
namespace: default

apply以后可以检查一下自己是否有权限

1
kubectl auth can-i patch pods --as=system:serviceaccount:default:default -n default

执行完以上操作就可以动态的Pod添加标签啦~

问题

[ERROR] [MY-010544] [Repl] Failed to open the relay log

当使用xtrabackup对从库进行冷备份时,并使用这个从库的备份创建一个新的从库实例时,会出现此问题。

通过relay log介绍,很容易知道由于mysql.slave_relay_log_info表中保留了以前的复制信息,导致新从库启动时无法找到对应文件。

此时需要登陆到从库的MySQL实例,重置一下slave即可

1
2
3
mysql> stop slave;
mysql> reset slave;
mysql> start slave;

The slave I/O thread stops because master and slave have equal MySQL server UUIDs

这个问题通常是因为备份数据库时,把数据库的UUID也复制了下来。而主从关系要求这个UUID必须唯一。这个uuid通常存在/var/log/mysql/auto.cnf文件夹下,通常删除该文件,重启一下数据库即可。

PS:在开启GTID复制时,貌似不会自动复制auto.cnf此文件。

引用