💡 NOTE: Interested in mysql-operator instead? See this blogpost.

Official kubernetes docs have a page about running replicated statefulset using mysql, unfortunately:

I think in small to medium organisations/teams/projects what you usually want it some finite amount of replicas - maybe one that you could use for some read-only operations and for doing backups from. So let’s try to do that.

Let’s create a two separate statefulsets under same namespace - one will be leader and another one follower - we will implemented GTID-based replication.

First we’re gonna need to prepare xtrabackup docker image:

FROM bitnami/percona-xtrabackup:8.2.0-1

USER root

RUN apt update && \
    apt install -y ncat

The extra bit is ncat which is technically a network cat command. Build it and push it into the wild so your k8s cluster can pull it from somewhere.

docker build . -t my-image/percona-xtrabackup:8.2.0-1

Then let’s prepare a leader statefulset.

Note: I’m going to simplify the yamls below a little bit and omit irrelevant bits

apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: mysql
spec:
  serviceName: mysql
  selector:
    matchLabels:
      app: mysql
  replicas: 1
  updateStrategy:
    type: RollingUpdate
  template:
    metadata:
      labels:
        app: mysql
    spec:
      containers:
        - name: mysql
          image: mysql:8.2
          args:
            # leader options
            - "--server-id=1"
            - "--gtid-mode=ON"
          env:
            - name: MYSQL_USER
              valueFrom:
                secretKeyRef:
                  name: mysql
                  key: MYSQL_USER
            - name: MYSQL_DATABASE
              valueFrom:
                secretKeyRef:
                  name: mysql
                  key: MYSQL_DATABASE
            - name: MYSQL_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: mysql
                  key: MYSQL_PASSWORD
            - name: MYSQL_ROOT_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: mysql
                  key: MYSQL_ROOT_PASSWORD
          ports:
            - containerPort: 3306
              name: mysql
          volumeMounts:
            - mountPath: /var/lib/mysql
              name: mysql
        - name: xtrabackup
          image: my-image/percona-xtrabackup:8.2.0-1
          ports:
            - name: xtrabackup
              containerPort: 3307
          command:
            - bash
            - "-c"
            - |
              set -ex
              cd /var/lib/mysql

              # 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 --password=$MYSQL_ROOT_PASSWORD"              
          env:
            - name: MYSQL_ROOT_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: mysql
                  key: MYSQL_ROOT_PASSWORD
          volumeMounts:
            - name: mysql-cloud
              mountPath: /var/lib/mysql
      volumes:
        - name: mysql
          persistentVolumeClaim:
            claimName: mysql
  1. We’re gonna explicitly set the server_id and enable GTID based logging in our mysql container.
  2. We’re gonna start xtrabackup so follower can request fresh copy of the database

That alone should work - you should have a pod with two containers running - mysql and xtrabackup waiting for the requests.

Now let’s create a follower mysql user using mysql command line:

CREATE USER 'follower'@'%' IDENTIFIED BY '<your password>';
GRANT REPLICATION SLAVE ON *.* TO 'follower'@'%';
FLUSH PRIVILEGES;

and store that REPLICATION_USER and REPLICATION_PASSWORD in mysql-follower secret - we’re going to need that in the follower.

Speaking of - the follower bit:

apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: mysql-follower
spec:
  serviceName: mysql-follower
  selector:
    matchLabels:
      app: mysql-follower
  replicas: 1
  updateStrategy:
    type: RollingUpdate
  template:
    metadata:
      labels:
        app: mysql-follower
    spec:
      initContainers:
        - name: clone-mysql
          image: my-image/percona-xtrabackup:8.2.0-1
          command:
            - bash
            - "-c"
            - |
              set -ex

              # Skip the clone if data already exists.
              [[ -d /var/lib/mysql/mysql ]] && exit 0

              # Clone data from leader
              ncat --recv-only mysql 3307 | xbstream -x -C /var/lib/mysql
              # Prepare the backup.
              xtrabackup --prepare --target-dir=/var/lib/mysql              
          volumeMounts:
            - name: mysql-follower
              mountPath: /var/lib/mysql
      containers:
        - name: mysql
          image: mysql:8.2
          args:
            # follower options
            - "--server-id=2"
            - "--gtid-mode=ON"
            - "--log-bin=OFF"
            - "--enforce-gtid-consistency=ON"
            - "--super-read-only"
            - "--skip-replica-start=ON" # IMPORTANT: You will want to switch it to OFF after you set everything up
          env:
            - name: MYSQL_ROOT_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: mysql
                  key: MYSQL_ROOT_PASSWORD
          ports:
            - containerPort: 3306
              name: mysql
          volumeMounts:
            - mountPath: /var/lib/mysql
              name: mysql-follower
        - name: replication
          image: mysql:8.2
          command:
            - bash
            - "-c"
            - |
              set -e
              cd /var/lib/mysql

              # This file should be created by init container:
              # https://docs.percona.com/percona-xtrabackup/2.4/xtrabackup_bin/working_with_binary_logs.html
              if [[ -f xtrabackup_binlog_info ]]; then
                echo "xtrabackup_binlog_info exists"
                cat xtrabackup_binlog_info

                [[ $(cat xtrabackup_binlog_info) =~ ^([^.]*.[0-9]+)[[:space:]]+([0-9]+)[[:space:]]+([0-9a-zA-Z:-]+) ]] || exit 1
                rm -f xtrabackup_binlog_info xtrabackup_slave_info

                GTID_MATCHED=${BASH_REMATCH[3]}

                echo "Found GTID match: ${GTID_MATCHED}"
                echo "SET GLOBAL gtid_purged='${GTID_MATCHED}';" > gtid_purged.sql.in
              fi

              # Check if we need to complete a clone by starting replication.
              if [[ -f gtid_purged.sql.in ]]; then
                echo "Waiting for mysqld to be ready (accepting connections)"
                until mysql -p"$MYSQL_ROOT_PASSWORD" -h 127.0.0.1 -e "SELECT 1"; do sleep 2; done

                echo "Initializing replication"

                mysql -h 127.0.0.1 \
                      -p"$MYSQL_ROOT_PASSWORD" \
                      -e "RESET MASTER; \
                      $(<gtid_purged.sql.in) \
                      CHANGE REPLICATION SOURCE TO \
                      SOURCE_HOST='mysql', \
                      SOURCE_USER='$REPLICATION_USER', \
                      SOURCE_PASSWORD='$REPLICATION_PASSWORD', \
                      SOURCE_AUTO_POSITION = 1, \
                      SOURCE_CONNECT_RETRY = 30; \
                      START REPLICA;" || exit 1
                # In case of container restart, attempt this at-most-once.
                mv gtid_purged.sql.in gtid_purged.sql.orig
              fi

              echo "All done"
              trap : TERM INT; sleep infinity & wait              
          env:
            - name: REPLICATION_USER
              valueFrom:
                secretKeyRef:
                  name: mysql-follower
                  key: REPLICATION_USER
            - name: REPLICATION_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: mysql-follower
                  key: REPLICATION_PASSWORD
            - name: MYSQL_ROOT_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: mysql
                  key: MYSQL_ROOT_PASSWORD
          volumeMounts:
            - name: mysql-follower
              mountPath: /var/lib/mysql
      volumes:
        - name: mysql-follower
          persistentVolumeClaim:
            claimName: mysql-follower

This can be hard to grasp at first glance, what is happening here is:

If everything went right you should see in follower logs something like:

Replica receiver thread for channel ‘’: connected to source ‘follower@mysql:3306’ with server_uuid=7ee6b0b5-3370-11ee-bd46-be │ │ 0882b78b76, server_id=1. Starting GTID-based replication.