运行复制的有状态应用程序

本页面演示如何使用 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 控制器提供的信息来决定查看哪一部分。

创建 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

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

客户端 Service 称为 mysql-read,是一个正常的 Service,具有自己的集群 IP,它将连接分发到报告为 Ready 的所有 MySQL Pod。潜在端点集包括主 MySQL 服务器和所有副本。

请注意,只有读取查询可以使用负载均衡的客户端 Service。由于只有一个主 MySQL 服务器,因此客户端应直接连接到主 MySQL Pod(通过无头 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。

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

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

生成配置

在启动 Pod 规范中的任何容器之前,Pod 首先按定义的顺序运行任何 初始化容器

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

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

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

克隆现有数据

通常,当一个新 Pod 加入集作为副本时,它必须假设主 MySQL 服务器可能已经拥有数据。它还必须假设复制日志可能没有一直追溯到时间的开始。这些保守的假设是允许运行的 StatefulSet 随着时间的推移进行扩展和缩减的关键,而不是固定在初始大小。

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

MySQL 本身没有提供执行此操作的机制,因此示例使用了一个名为 Percona XtraBackup 的流行开源工具。在克隆期间,源 MySQL 服务器的性能可能会降低。为了最大程度地减少对主 MySQL 服务器的影响,该脚本指示每个 Pod 从序号索引低一个的 Pod 克隆。这是可行的,因为 StatefulSet 控制器始终确保 Pod N 在启动 Pod N+1 之前处于 Ready 状态。

启动复制

在初始化容器成功完成之后,常规容器将运行。MySQL Pod 包含一个运行实际 mysqld 服务器的 mysql 容器,以及一个充当 边车xtrabackup 容器。

xtrabackup 边车会查看克隆的数据文件,并确定是否需要在副本上初始化 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)。

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 和节点故障

为了演示从副本池中读取而不是从单个服务器读取的提高的可用性,请让上面的 `SELECT @@server_id` 循环继续运行,同时您将 Pod 从 Ready 状态中强制移除。

破坏就绪探测

针对 `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 被删除时重新创建 Pod,类似于 ReplicaSet 对无状态 Pod 的操作。

kubectl delete pod mysql-2

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

清空节点

如果您的 Kubernetes 集群有多个节点,您可以通过发出 drain 命令来模拟节点停机(例如,当节点升级时)。

首先确定 MySQL Pod 所在的节点

kubectl get pod mysql-2 -o wide

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

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

然后,通过运行以下命令清空节点,该命令将节点隔离,以便不再有新 Pod 在该节点上调度,然后驱逐所有现有 Pod。用您在上一步中找到的节点名称替换 ``。

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

现在您可以观察 Pod 如何在不同的节点上重新调度

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

现在解除节点隔离以将其恢复到正常状态

kubectl uncordon <node-name>

扩展副本数量

当您使用 MySQL 复制时,您可以通过添加副本来扩展读取查询容量。对于 StatefulSet,您可以使用单个命令实现此目的

kubectl scale statefulset mysql  --replicas=5

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

kubectl get pods -l app=mysql --watch

它们启动后,您应该看到服务器 ID `103` 和 `104` 开始出现在 `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、服务和 PersistentVolumeClaims。

    kubectl delete configmap,service,pvc -l app=mysql
    
  5. 如果您手动配置了 PersistentVolumes,您还需要手动删除它们,并释放底层资源。如果您使用了动态配置器,它会在发现您删除了 PersistentVolumeClaims 时自动删除 PersistentVolumes。一些动态配置器(例如用于 EBS 和 PD 的配置器)还会在删除 PersistentVolumes 时释放底层资源。

下一步

最后修改时间:2023 年 8 月 24 日下午 6:38 PST:使用 code_sample 短代码代替 code 短代码 (e8b136c3b3)