运行副本式有状态应用

此页面展示如何使用 StatefulSet 运行有状态的、可复制的应用。本例的应用是一个可复制的 MySQL 数据库。示例拓扑包含一个主服务器和多个副本,它们使用异步基于行的复制。

开始之前

目标

  • 使用 StatefulSet 部署可复制的 MySQL 拓扑。
  • 发送 MySQL 客户端流量。
  • 观察对宕机的抵抗能力。
  • 扩缩 StatefulSet。

部署 MySQL

本示例的 MySQL 部署包含一个 ConfigMap、两个 Service 和一个 StatefulSet。

创建 ConfigMap

从以下 YAML 配置文件创建 ConfigMap:

apiVersion: v1
kind: ConfigMap
metadata:
  name: mysql
  labels:
    app: mysql
    app.kubernetes.io/name: mysql
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    

kubectl apply -f https://k8s.io/examples/application/mysql/mysql-configmap.yaml

此 ConfigMap 提供 my.cnf 覆盖配置,使你能够独立控制主 MySQL 服务器及其副本的配置。在本例中,你希望主服务器能够向副本提供复制日志,并且希望副本拒绝任何非通过复制进行的写入。

ConfigMap 本身并没有什么特别之处,不会导致不同部分适用于不同的 Pod。每个 Pod 在初始化时,会根据 StatefulSet 控制器提供的信息来决定查看 ConfigMap 的哪一部分。

创建 Service

从以下 YAML 配置文件创建 Service:

# Headless service for stable DNS entries of StatefulSet members.
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
---
# Client service for connecting to any MySQL instance for reads.
# For writes, you must instead connect to the primary: 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
kubectl apply -f https://k8s.io/examples/application/mysql/mysql-services.yaml

Headless Service 为 StatefulSet 控制器 为集合中的每个 Pod 创建的 DNS 条目提供了归宿。由于 Headless Service 被命名为 mysql,因此可以在同一 Kubernetes 集群和命名空间中的任何其他 Pod 内通过解析 <pod-name>.mysql 来访问 Pod。

客户端 Service,名为 mysql-read,是一个拥有自己 ClusterIP 的普通 Service,它将连接分发到所有报告 Ready 的 MySQL Pod。潜在的端点集合包括主 MySQL 服务器和所有副本。

注意,只有读查询可以使用负载均衡的客户端 Service。由于只有一个主 MySQL 服务器,客户端应直接连接到主 MySQL Pod(通过其在 Headless Service 中的 DNS 条目)来执行写入操作。

创建 StatefulSet

最后,从以下 YAML 配置文件创建 StatefulSet:

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:
      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
      - 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 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: 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', \
                          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:
      accessModes: ["ReadWriteOnce"]
      resources:
        requests:
          storage: 10Gi
kubectl apply -f https://k8s.io/examples/application/mysql/mysql-statefulset.yaml

你可以通过运行以下命令来观察启动进度:

kubectl get pods -l app=mysql --watch

一段时间后,你应该看到所有 3 个 Pod 都变成 Running 状态。

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 取消观察。

此清单使用多种技术来管理作为 StatefulSet 一部分的状态化 Pod。下一节将重点介绍其中一些技术,以解释 StatefulSet 创建 Pod 时发生的情况。

理解状态化 Pod 初始化

StatefulSet 控制器按照其序数索引的顺序,一次启动一个 Pod。它会等到每个 Pod 报告 Ready 状态后才启动下一个。

此外,控制器为每个 Pod 分配一个唯一、稳定的名称,形式为 <statefulset-name>-<ordinal-index>,这导致 Pod 被命名为 mysql-0mysql-1mysql-2

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

生成配置

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

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

该脚本通过从 Pod 名称末尾(由 hostname 命令返回)提取序数索引来确定自身的序数索引。然后,它将该序数(加上一个数字偏移量以避免保留值)保存到 MySQL conf.d 目录中名为 server-id.cnf 的文件中。这会将 StatefulSet 提供的唯一、稳定身份转换为需要相同属性的 MySQL 服务器 ID 领域。

init-mysql 容器中的脚本还通过将内容复制到 conf.d 中来应用 ConfigMap 中的 primary.cnfreplica.cnf。由于示例拓扑由一个主 MySQL 服务器和任意数量的副本组成,脚本将序数 0 分配给主服务器,其他所有都分配给副本。结合 StatefulSet 控制器的部署顺序保证,这确保了主 MySQL 服务器在创建副本之前处于 Ready 状态,以便它们可以开始复制。

克隆现有数据

一般来说,当一个新的 Pod 作为副本加入集合时,它必须假定主 MySQL 服务器可能已经有数据。它还必须假定复制日志可能不会追溯到最开始的时间。这些保守的假定是 StatefulSet 能够随着时间推移进行扩缩容的关键,而不是固定在其初始大小。

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

MySQL 本身没有提供这种机制,因此本示例使用一个流行的开源工具 Percona XtraBackup。在克隆过程中,源 MySQL 服务器的性能可能会有所降低。为了尽量减少对主 MySQL 服务器的影响,脚本指示每个 Pod 从其序数索引小一的 Pod 进行克隆。这之所以有效,是因为 StatefulSet 控制器总是确保 Pod N 在启动 Pod N+1 之前处于 Ready 状态。

启动复制

Init 容器成功完成后,常规容器开始运行。MySQL Pod 包含一个运行实际 mysqld 服务器的 mysql 容器,以及一个充当Sidecarxtrabackup 容器。

xtrabackup Sidecar 查看克隆的数据文件,并确定是否需要在副本上初始化 MySQL 复制。如果需要,它会等待 mysqld 准备就绪,然后执行 CHANGE MASTER TOSTART SLAVE 命令,这些命令的复制参数是从 XtraBackup 克隆文件中提取的。

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

最后,在启动复制后,xtrabackup 容器会监听来自其他请求数据克隆的 Pod 的连接。这个服务器会一直运行,以防 StatefulSet 扩容,或者下一个 Pod 丢失其 PersistentVolumeClaim 并且需要重新进行克隆。

发送客户端流量

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

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-read 向任何报告 Ready 的服务器发送测试查询。

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-read Service 会将连接分布到不同的服务器上,你可以循环运行 SELECT @@server_id

kubectl run mysql-client-loop --image=mysql:5.7 -i -t --rm --restart=Never --\
  bash -ic "while sleep 1; do mysql -h mysql-read -e 'SELECT @@server_id,NOW()'; done"

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

+-------------+---------------------+
| @@server_id | NOW()               |
+-------------+---------------------+
|         100 | 2006-01-02 15:04:05 |
+-------------+---------------------+
+-------------+---------------------+
| @@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 循环运行。

破坏 Readiness 探针

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

迫使此就绪探针失败的一种方法是破坏该命令:

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

这会进入 Pod mysql-2 的实际容器文件系统,并重命名 mysql 命令,以便就绪探针无法找到它。几秒钟后,Pod 应该报告其一个容器处于非 Ready 状态,你可以通过运行以下命令来检查:

kubectl get pod mysql-2

READY 列中查找 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,几秒钟后它应该会再次出现在循环输出中:

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

删除 Pod

StatefulSet 也会在 Pod 被删除时重新创建它们,这类似于 ReplicaSet 对无状态 Pod 的处理方式。

kubectl delete pod mysql-2

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

排空 Node

如果你的 Kubernetes 集群有多个 Node,你可以通过执行排空(drain)操作来模拟 Node 宕机(例如在升级 Node 时)。

首先确定其中一个 MySQL Pod 位于哪个 Node 上:

kubectl get pod mysql-2 -o wide

Node 名称应该显示在最后一列。

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

然后,通过运行以下命令排空 Node,该命令会封锁(cordon)它,使其不再调度新的 Pod,然后驱逐任何现有的 Pod。将 <node-name> 替换为你上一步找到的 Node 名称。

# See above advice about impact on other workloads
kubectl drain <node-name> --force --delete-emptydir-data --ignore-daemonsets

现在你可以观察 Pod 如何重新调度到不同的 Node 上:

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

输出应该类似于这样:

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 循环输出中消失一段时间,然后返回。

现在取消封锁 Node,使其恢复正常状态:

kubectl uncordon <node-name>

扩缩副本数量

使用 MySQL 复制时,可以通过添加副本来扩缩读查询容量。对于 StatefulSet,可以通过一个命令实现这一点:

kubectl scale statefulset mysql  --replicas=5

通过运行以下命令观察新 Pod 的启动:

kubectl get pods -l app=mysql --watch

它们启动后,你应该会看到服务器 ID 103104 开始出现在 SELECT @@server_id 循环输出中。

你还可以验证这些新服务器是否包含你在它们创建之前添加的数据:

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

缩减也很顺利:

kubectl scale statefulset mysql --replicas=3

你可以通过运行以下命令看到这一点:

kubectl get pvc -l app=mysql

这表明所有 5 个 PVC 仍然存在,尽管 StatefulSet 已缩减到 3 个。

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,可以删除它们:

kubectl delete pvc data-mysql-3
kubectl delete pvc data-mysql-4

清理

  1. 通过在其终端中按 Ctrl+C,或者在另一个终端中运行以下命令来取消 SELECT @@server_id 循环:

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

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

    kubectl get pods -l app=mysql
    

    当上述命令返回以下内容时,表示 Pod 已终止:

    No resources found.
    
  4. 删除 ConfigMap、Service 和 PersistentVolumeClaim。

    kubectl delete configmap,service,pvc -l app=mysql
    
  5. 如果你手动配置了 PersistentVolume,还需要手动删除它们并释放底层资源。如果你使用了动态供应程序,它在看到你删除了 PersistentVolumeClaim 后会自动删除 PersistentVolume。一些动态供应程序(例如 EBS 和 PD 的供应程序)在删除 PersistentVolume 时也会释放底层资源。

接下来

最后修改于太平洋标准时间 2024 年 10 月 08 日晚上 10:10:更新 content/en/docs/tasks/run-application/run-replicated-stateful-application.md (7b8fd10630)