运行复制有状态应用程序

本页展示了如何使用 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,因此 Pod 可以通过在同一 Kubernetes 集群和命名空间内的任何其他 Pod 中解析 <pod-name>.mysql 来访问。

客户端 Service,名为 mysql-read,是一个具有自己的集群 IP 的普通 Service,它将连接分发到所有报告为 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>,从而产生名为 mysql-0mysql-1mysql-2 的 Pod。

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

生成配置

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

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

该脚本通过从 Pod 名称的末尾提取序号来确定其自身的序号,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

开始复制

在 init 容器成功完成后,常规容器运行。 MySQL Pod 由运行实际 mysqld 服务器的 mysql 容器和充当 sidecarxtrabackup 容器组成。

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)。

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 故障

为了演示从副本池而不是单个服务器读取的可用性提高,请保持上述 SELECT @@server_id 循环运行,同时强制 Pod 退出 Ready 状态。

破坏 Readiness 探针

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

强制此 readiness 探针失败的一种方法是破坏该命令

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

这将进入 Pod mysql-2 的实际容器文件系统,并重命名 mysql 命令,以便 readiness 探针无法找到它。 几秒钟后,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 在循环输出中消失一段时间,然后自行返回。

Drain 一个 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

然后,通过运行以下命令来 drain 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 循环输出中消失一段时间,然后返回。

现在取消 cordon 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 时释放底层的资源。

接下来