Run a Replicated Stateful Application

This page shows how to run a replicated stateful application using a StatefulSet controller. This application is a replicated MySQL database. The example topology has a single primary server and multiple replicas, using asynchronous row-based replication.

Note: This is not a production configuration. MySQL settings remain on insecure defaults to keep the focus on general patterns for running stateful applications in Kubernetes.

Before you begin

Objectives

  • Deploy a replicated MySQL topology with a StatefulSet controller.
  • Send MySQL client traffic.
  • Observe resistance to downtime.
  • Scale the StatefulSet up and down.

Deploy MySQL

The example MySQL deployment consists of a ConfigMap, two Services, and a StatefulSet.

ConfigMap

Create the ConfigMap from the following YAML configuration file:

application/mysql/mysql-configmap.yaml Run a Replicated Stateful Application - 图1

  1. apiVersion: v1
  2. kind: ConfigMap
  3. metadata:
  4. name: mysql
  5. labels:
  6. app: mysql
  7. data:
  8. primary.cnf: |
  9. # Apply this config only on the primary.
  10. [mysqld]
  11. log-bin
  12. replica.cnf: |
  13. # Apply this config only on replicas.
  14. [mysqld]
  15. super-read-only
  1. kubectl apply -f https://k8s.io/examples/application/mysql/mysql-configmap.yaml

This ConfigMap provides my.cnf overrides that let you independently control configuration on the primary MySQL server and replicas. In this case, you want the primary server to be able to serve replication logs to replicas and you want replicas to reject any writes that don’t come via replication.

There’s nothing special about the ConfigMap itself that causes different portions to apply to different Pods. Each Pod decides which portion to look at as it’s initializing, based on information provided by the StatefulSet controller.

Services

Create the Services from the following YAML configuration file:

application/mysql/mysql-services.yaml Run a Replicated Stateful Application - 图2

  1. # Headless service for stable DNS entries of StatefulSet members.
  2. apiVersion: v1
  3. kind: Service
  4. metadata:
  5. name: mysql
  6. labels:
  7. app: mysql
  8. spec:
  9. ports:
  10. - name: mysql
  11. port: 3306
  12. clusterIP: None
  13. selector:
  14. app: mysql
  15. ---
  16. # Client service for connecting to any MySQL instance for reads.
  17. # For writes, you must instead connect to the primary: mysql-0.mysql.
  18. apiVersion: v1
  19. kind: Service
  20. metadata:
  21. name: mysql-read
  22. labels:
  23. app: mysql
  24. spec:
  25. ports:
  26. - name: mysql
  27. port: 3306
  28. selector:
  29. app: mysql
  1. kubectl apply -f https://k8s.io/examples/application/mysql/mysql-services.yaml

The Headless Service provides a home for the DNS entries that the StatefulSet controller creates for each Pod that’s part of the set. Because the Headless Service is named mysql, the Pods are accessible by resolving <pod-name>.mysql from within any other Pod in the same Kubernetes cluster and namespace.

The Client Service, called mysql-read, is a normal Service with its own cluster IP that distributes connections across all MySQL Pods that report being Ready. The set of potential endpoints includes the primary MySQL server and all replicas.

Note that only read queries can use the load-balanced Client Service. Because there is only one primary MySQL server, clients should connect directly to the primary MySQL Pod (through its DNS entry within the Headless Service) to execute writes.

StatefulSet

Finally, create the StatefulSet from the following YAML configuration file:

application/mysql/mysql-statefulset.yaml Run a Replicated Stateful Application - 图3

  1. apiVersion: apps/v1
  2. kind: StatefulSet
  3. metadata:
  4. name: mysql
  5. spec:
  6. selector:
  7. matchLabels:
  8. app: mysql
  9. serviceName: mysql
  10. replicas: 3
  11. template:
  12. metadata:
  13. labels:
  14. app: mysql
  15. spec:
  16. initContainers:
  17. - name: init-mysql
  18. image: mysql:5.7
  19. command:
  20. - bash
  21. - "-c"
  22. - |
  23. set -ex
  24. # Generate mysql server-id from pod ordinal index.
  25. [[ `hostname` =~ -([0-9]+)$ ]] || exit 1
  26. ordinal=${BASH_REMATCH[1]}
  27. echo [mysqld] > /mnt/conf.d/server-id.cnf
  28. # Add an offset to avoid reserved server-id=0 value.
  29. echo server-id=$((100 + $ordinal)) >> /mnt/conf.d/server-id.cnf
  30. # Copy appropriate conf.d files from config-map to emptyDir.
  31. if [[ $ordinal -eq 0 ]]; then
  32. cp /mnt/config-map/primary.cnf /mnt/conf.d/
  33. else
  34. cp /mnt/config-map/replica.cnf /mnt/conf.d/
  35. fi
  36. volumeMounts:
  37. - name: conf
  38. mountPath: /mnt/conf.d
  39. - name: config-map
  40. mountPath: /mnt/config-map
  41. - name: clone-mysql
  42. image: gcr.io/google-samples/xtrabackup:1.0
  43. command:
  44. - bash
  45. - "-c"
  46. - |
  47. set -ex
  48. # Skip the clone if data already exists.
  49. [[ -d /var/lib/mysql/mysql ]] && exit 0
  50. # Skip the clone on primary (ordinal index 0).
  51. [[ `hostname` =~ -([0-9]+)$ ]] || exit 1
  52. ordinal=${BASH_REMATCH[1]}
  53. [[ $ordinal -eq 0 ]] && exit 0
  54. # Clone data from previous peer.
  55. ncat --recv-only mysql-$(($ordinal-1)).mysql 3307 | xbstream -x -C /var/lib/mysql
  56. # Prepare the backup.
  57. xtrabackup --prepare --target-dir=/var/lib/mysql
  58. volumeMounts:
  59. - name: data
  60. mountPath: /var/lib/mysql
  61. subPath: mysql
  62. - name: conf
  63. mountPath: /etc/mysql/conf.d
  64. containers:
  65. - name: mysql
  66. image: mysql:5.7
  67. env:
  68. - name: MYSQL_ALLOW_EMPTY_PASSWORD
  69. value: "1"
  70. ports:
  71. - name: mysql
  72. containerPort: 3306
  73. volumeMounts:
  74. - name: data
  75. mountPath: /var/lib/mysql
  76. subPath: mysql
  77. - name: conf
  78. mountPath: /etc/mysql/conf.d
  79. resources:
  80. requests:
  81. cpu: 500m
  82. memory: 1Gi
  83. livenessProbe:
  84. exec:
  85. command: ["mysqladmin", "ping"]
  86. initialDelaySeconds: 30
  87. periodSeconds: 10
  88. timeoutSeconds: 5
  89. readinessProbe:
  90. exec:
  91. # Check we can execute queries over TCP (skip-networking is off).
  92. command: ["mysql", "-h", "127.0.0.1", "-e", "SELECT 1"]
  93. initialDelaySeconds: 5
  94. periodSeconds: 2
  95. timeoutSeconds: 1
  96. - name: xtrabackup
  97. image: gcr.io/google-samples/xtrabackup:1.0
  98. ports:
  99. - name: xtrabackup
  100. containerPort: 3307
  101. command:
  102. - bash
  103. - "-c"
  104. - |
  105. set -ex
  106. cd /var/lib/mysql
  107. # Determine binlog position of cloned data, if any.
  108. if [[ -f xtrabackup_slave_info && "x$(<xtrabackup_slave_info)" != "x" ]]; then
  109. # XtraBackup already generated a partial "CHANGE MASTER TO" query
  110. # because we're cloning from an existing replica. (Need to remove the tailing semicolon!)
  111. cat xtrabackup_slave_info | sed -E 's/;$//g' > change_master_to.sql.in
  112. # Ignore xtrabackup_binlog_info in this case (it's useless).
  113. rm -f xtrabackup_slave_info xtrabackup_binlog_info
  114. elif [[ -f xtrabackup_binlog_info ]]; then
  115. # We're cloning directly from primary. Parse binlog position.
  116. [[ `cat xtrabackup_binlog_info` =~ ^(.*?)[[:space:]]+(.*?)$ ]] || exit 1
  117. rm -f xtrabackup_binlog_info xtrabackup_slave_info
  118. echo "CHANGE MASTER TO MASTER_LOG_FILE='${BASH_REMATCH[1]}',\
  119. MASTER_LOG_POS=${BASH_REMATCH[2]}" > change_master_to.sql.in
  120. fi
  121. # Check if we need to complete a clone by starting replication.
  122. if [[ -f change_master_to.sql.in ]]; then
  123. echo "Waiting for mysqld to be ready (accepting connections)"
  124. until mysql -h 127.0.0.1 -e "SELECT 1"; do sleep 1; done
  125. echo "Initializing replication from clone position"
  126. mysql -h 127.0.0.1 \
  127. -e "$(<change_master_to.sql.in), \
  128. MASTER_HOST='mysql-0.mysql', \
  129. MASTER_USER='root', \
  130. MASTER_PASSWORD='', \
  131. MASTER_CONNECT_RETRY=10; \
  132. START SLAVE;" || exit 1
  133. # In case of container restart, attempt this at-most-once.
  134. mv change_master_to.sql.in change_master_to.sql.orig
  135. fi
  136. # Start a server to send backups when requested by peers.
  137. exec ncat --listen --keep-open --send-only --max-conns=1 3307 -c \
  138. "xtrabackup --backup --slave-info --stream=xbstream --host=127.0.0.1 --user=root"
  139. volumeMounts:
  140. - name: data
  141. mountPath: /var/lib/mysql
  142. subPath: mysql
  143. - name: conf
  144. mountPath: /etc/mysql/conf.d
  145. resources:
  146. requests:
  147. cpu: 100m
  148. memory: 100Mi
  149. volumes:
  150. - name: conf
  151. emptyDir: {}
  152. - name: config-map
  153. configMap:
  154. name: mysql
  155. volumeClaimTemplates:
  156. - metadata:
  157. name: data
  158. spec:
  159. accessModes: ["ReadWriteOnce"]
  160. resources:
  161. requests:
  162. storage: 10Gi
  1. kubectl apply -f https://k8s.io/examples/application/mysql/mysql-statefulset.yaml

You can watch the startup progress by running:

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

After a while, you should see all 3 Pods become Running:

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

Press Ctrl+C to cancel the watch. If you don’t see any progress, make sure you have a dynamic PersistentVolume provisioner enabled as mentioned in the prerequisites.

This manifest uses a variety of techniques for managing stateful Pods as part of a StatefulSet. The next section highlights some of these techniques to explain what happens as the StatefulSet creates Pods.

Understanding stateful Pod initialization

The StatefulSet controller starts Pods one at a time, in order by their ordinal index. It waits until each Pod reports being Ready before starting the next one.

In addition, the controller assigns each Pod a unique, stable name of the form <statefulset-name>-<ordinal-index>, which results in Pods named mysql-0, mysql-1, and mysql-2.

The Pod template in the above StatefulSet manifest takes advantage of these properties to perform orderly startup of MySQL replication.

Generating configuration

Before starting any of the containers in the Pod spec, the Pod first runs any Init Containers in the order defined.

The first Init Container, named init-mysql, generates special MySQL config files based on the ordinal index.

The script determines its own ordinal index by extracting it from the end of the Pod name, which is returned by the hostname command. Then it saves the ordinal (with a numeric offset to avoid reserved values) into a file called server-id.cnf in the MySQL conf.d directory. This translates the unique, stable identity provided by the StatefulSet controller into the domain of MySQL server IDs, which require the same properties.

The script in the init-mysql container also applies either primary.cnf or replica.cnf from the ConfigMap by copying the contents into conf.d. Because the example topology consists of a single primary MySQL server and any number of replicas, the script simply assigns ordinal 0 to be the primary server, and everyone else to be replicas. Combined with the StatefulSet controller’s deployment order guarantee, this ensures the primary MySQL server is Ready before creating replicas, so they can begin replicating.

Cloning existing data

In general, when a new Pod joins the set as a replica, it must assume the primary MySQL server might already have data on it. It also must assume that the replication logs might not go all the way back to the beginning of time. These conservative assumptions are the key to allow a running StatefulSet to scale up and down over time, rather than being fixed at its initial size.

The second Init Container, named clone-mysql, performs a clone operation on a replica Pod the first time it starts up on an empty PersistentVolume. That means it copies all existing data from another running Pod, so its local state is consistent enough to begin replicating from the primary server.

MySQL itself does not provide a mechanism to do this, so the example uses a popular open-source tool called Percona XtraBackup. During the clone, the source MySQL server might suffer reduced performance. To minimize impact on the primary MySQL server, the script instructs each Pod to clone from the Pod whose ordinal index is one lower. This works because the StatefulSet controller always ensures Pod N is Ready before starting Pod N+1.

Starting replication

After the Init Containers complete successfully, the regular containers run. The MySQL Pods consist of a mysql container that runs the actual mysqld server, and an xtrabackup container that acts as a sidecar.

The xtrabackup sidecar looks at the cloned data files and determines if it’s necessary to initialize MySQL replication on the replica. If so, it waits for mysqld to be ready and then executes the CHANGE MASTER TO and START SLAVE commands with replication parameters extracted from the XtraBackup clone files.

Once a replica begins replication, it remembers its primary MySQL server and reconnects automatically if the server restarts or the connection dies. Also, because replicas look for the primary server at its stable DNS name (mysql-0.mysql), they automatically find the primary server even if it gets a new Pod IP due to being rescheduled.

Lastly, after starting replication, the xtrabackup container listens for connections from other Pods requesting a data clone. This server remains up indefinitely in case the StatefulSet scales up, or in case the next Pod loses its PersistentVolumeClaim and needs to redo the clone.

Sending client traffic

You can send test queries to the primary MySQL server (hostname mysql-0.mysql) by running a temporary container with the mysql:5.7 image and running the mysql client binary.

  1. kubectl run mysql-client --image=mysql:5.7 -i --rm --restart=Never --\
  2. mysql -h mysql-0.mysql <<EOF
  3. CREATE DATABASE test;
  4. CREATE TABLE test.messages (message VARCHAR(250));
  5. INSERT INTO test.messages VALUES ('hello');
  6. EOF

Use the hostname mysql-read to send test queries to any server that reports being Ready:

  1. kubectl run mysql-client --image=mysql:5.7 -i -t --rm --restart=Never --\
  2. mysql -h mysql-read -e "SELECT * FROM test.messages"

You should get output like this:

  1. Waiting for pod default/mysql-client to be running, status is Pending, pod ready: false
  2. +---------+
  3. | message |
  4. +---------+
  5. | hello |
  6. +---------+
  7. pod "mysql-client" deleted

To demonstrate that the mysql-read Service distributes connections across servers, you can run SELECT @@server_id in a loop:

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

You should see the reported @@server_id change randomly, because a different endpoint might be selected upon each connection attempt:

  1. +-------------+---------------------+
  2. | @@server_id | NOW() |
  3. +-------------+---------------------+
  4. | 100 | 2006-01-02 15:04:05 |
  5. +-------------+---------------------+
  6. +-------------+---------------------+
  7. | @@server_id | NOW() |
  8. +-------------+---------------------+
  9. | 102 | 2006-01-02 15:04:06 |
  10. +-------------+---------------------+
  11. +-------------+---------------------+
  12. | @@server_id | NOW() |
  13. +-------------+---------------------+
  14. | 101 | 2006-01-02 15:04:07 |
  15. +-------------+---------------------+

You can press Ctrl+C when you want to stop the loop, but it’s useful to keep it running in another window so you can see the effects of the following steps.

Simulating Pod and Node downtime

To demonstrate the increased availability of reading from the pool of replicas instead of a single server, keep the SELECT @@server_id loop from above running while you force a Pod out of the Ready state.

Break the Readiness Probe

The readiness probe for the mysql container runs the command mysql -h 127.0.0.1 -e 'SELECT 1' to make sure the server is up and able to execute queries.

One way to force this readiness probe to fail is to break that command:

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

This reaches into the actual container’s filesystem for Pod mysql-2 and renames the mysql command so the readiness probe can’t find it. After a few seconds, the Pod should report one of its containers as not Ready, which you can check by running:

  1. kubectl get pod mysql-2

Look for 1/2 in the READY column:

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

At this point, you should see your SELECT @@server_id loop continue to run, although it never reports 102 anymore. Recall that the init-mysql script defined server-id as 100 + $ordinal, so server ID 102 corresponds to Pod mysql-2.

Now repair the Pod and it should reappear in the loop output after a few seconds:

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

Delete Pods

The StatefulSet also recreates Pods if they’re deleted, similar to what a ReplicaSet does for stateless Pods.

  1. kubectl delete pod mysql-2

The StatefulSet controller notices that no mysql-2 Pod exists anymore, and creates a new one with the same name and linked to the same PersistentVolumeClaim. You should see server ID 102 disappear from the loop output for a while and then return on its own.

Drain a Node

If your Kubernetes cluster has multiple Nodes, you can simulate Node downtime (such as when Nodes are upgraded) by issuing a drain.

First determine which Node one of the MySQL Pods is on:

  1. kubectl get pod mysql-2 -o wide

The Node name should show up in the last column:

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

Then drain the Node by running the following command, which cordons it so no new Pods may schedule there, and then evicts any existing Pods. Replace <node-name> with the name of the Node you found in the last step.

This might impact other applications on the Node, so it’s best to only do this in a test cluster.

  1. kubectl drain <node-name> --force --delete-local-data --ignore-daemonsets

Now you can watch as the Pod reschedules on a different Node:

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

It should look something like this:

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

And again, you should see server ID 102 disappear from the SELECT @@server_id loop output for a while and then return.

Now uncordon the Node to return it to a normal state:

  1. kubectl uncordon <node-name>

Scaling the number of replicas

With MySQL replication, you can scale your read query capacity by adding replicas. With StatefulSet, you can do this with a single command:

  1. kubectl scale statefulset mysql --replicas=5

Watch the new Pods come up by running:

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

Once they’re up, you should see server IDs 103 and 104 start appearing in the SELECT @@server_id loop output.

You can also verify that these new servers have the data you added before they existed:

  1. kubectl run mysql-client --image=mysql:5.7 -i -t --rm --restart=Never --\
  2. mysql -h mysql-3.mysql -e "SELECT * FROM test.messages"
  1. Waiting for pod default/mysql-client to be running, status is Pending, pod ready: false
  2. +---------+
  3. | message |
  4. +---------+
  5. | hello |
  6. +---------+
  7. pod "mysql-client" deleted

Scaling back down is also seamless:

  1. kubectl scale statefulset mysql --replicas=3

Note, however, that while scaling up creates new PersistentVolumeClaims automatically, scaling down does not automatically delete these PVCs. This gives you the choice to keep those initialized PVCs around to make scaling back up quicker, or to extract data before deleting them.

You can see this by running:

  1. kubectl get pvc -l app=mysql

Which shows that all 5 PVCs still exist, despite having scaled the StatefulSet down to 3:

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

If you don’t intend to reuse the extra PVCs, you can delete them:

  1. kubectl delete pvc data-mysql-3
  2. kubectl delete pvc data-mysql-4

Cleaning up

  1. Cancel the SELECT @@server_id loop by pressing Ctrl+C in its terminal, or running the following from another terminal:

    1. kubectl delete pod mysql-client-loop --now
  2. Delete the StatefulSet. This also begins terminating the Pods.

    1. kubectl delete statefulset mysql
  3. Verify that the Pods disappear. They might take some time to finish terminating.

    1. kubectl get pods -l app=mysql

    You’ll know the Pods have terminated when the above returns:

    1. No resources found.
  4. Delete the ConfigMap, Services, and PersistentVolumeClaims.

    1. kubectl delete configmap,service,pvc -l app=mysql
  5. If you manually provisioned PersistentVolumes, you also need to manually delete them, as well as release the underlying resources. If you used a dynamic provisioner, it automatically deletes the PersistentVolumes when it sees that you deleted the PersistentVolumeClaims. Some dynamic provisioners (such as those for EBS and PD) also release the underlying resources upon deleting the PersistentVolumes.

What’s next