MySQL backup lab, part 3 – data, and proof the lab is worth testing
Published By Krzysztof Książek Lab
Lab write-up. A hands-on environment built and run by the author, not a customer engagement.
Environment described in the article: Percona Server for MySQL 8.4.10-10.1, Percona Operator for MySQL 1.2.0, local kind Kubernetes cluster.
This is part 3 of nine. The whole series builds one MySQL backup verification lab and explains every playbook, template and script in it.
Scroll horizontally to see all columns when needed.
A backup test against an empty database proves nothing. So does a backup test against a database you cannot describe precisely: “the restore worked” is only meaningful if you can state what “worked” means in numbers that were true before and are true again after.
This part builds both halves of that. First a dataset with exact, known dimensions. Then a verification playbook whose job is to decide — with assertions, not eyeballs — whether the lab is currently in the state the rest of the series assumes.
The dataset: 10 tables, 200,000 rows, about 500 MB
sysbench_tables: 10
sysbench_table_size: 200000
sysbench_database: sbtest
data_size_min_mb: 400
data_size_max_mb: 700
ansible/inventory/group_vars/all.yml
sysbench’s oltp_read_write workload in
prepare mode creates sbtest1 … sbtest10, each with an integer primary key,
a secondary index and padding columns. It is not chosen because it resembles any
particular application. It is chosen because it is:
- deterministic in shape — ten tables, 200,000 rows each, every time;
- big enough to be slow — around 500 MB means a restore takes minutes, not milliseconds, so restore duration is a real measurement;
- small enough to fit — on a laptop with 8 GB allocated to Docker;
- trivially checkable —
COUNT(*)andCHECKSUM TABLEper table give an exact fingerprint, which is precisely what part 8 compares between a live cluster and a restored one.
That last property is the reason this dataset exists at all. The entire argument of this lab ends in a table of checksums.
Why the 400–700 MB band rather than a fixed number: data_length + index_length from information_schema is an InnoDB estimate. It moves with
page fill factor and with whatever the scenarios have written. A band asserts
“this is the right dataset” without being brittle. The recorded validation run
measured SIZE_MB=504 before any scenario ran and RESTORED_SIZE_MB=511 after a
whole-cluster restore — both inside the band, and the difference between them is
exactly why the band is there.
Loading it
- name: Wait until a writable MySQL primary answers
kubernetes.core.k8s_exec:
namespace: "{{ mysql_namespace }}"
pod: "{{ mysql_cluster_name }}-mysql-0"
container: mysql
command: >
mysql -uroot -p{{ mysql_root_password }} -N -e "SELECT 1"
register: mysql_ping
until: mysql_ping.rc == 0
retries: 30
delay: 5
ansible/roles/load_sysbench/tasks/main.yml
The cluster reported ready at the end of part 2, but readiness and accepting
queries are not the same instant. SELECT 1 in a retry loop is the cheapest
possible statement that settles the question.
Note container: mysql. The pod has four containers (mysql, exporter,
pt-heartbeat, xtrabackup), so every exec in this lab names one. Omitting it
gets you whichever container Kubernetes considers default, which is not a
guarantee worth relying on.
- name: Recreate sysbench database on the source
kubernetes.core.k8s_exec:
pod: "{{ mysql_cluster_name }}-mysql-0"
container: mysql
command: >
mysql -uroot -p{{ mysql_root_password }} -e
"DROP DATABASE IF EXISTS {{ sysbench_database }};
CREATE DATABASE {{ sysbench_database }}"
DROP DATABASE before loading makes load-data idempotent in the only sense
that matters: running it twice produces the same dataset, not a doubled one. It
is also the reset button. After a suite run has left markers, canaries and probe
tables scattered through sbtest, ./scripts/lab.sh load-data returns the lab
to a known state without rebuilding the cluster.
It runs against lab-mysql-0 directly rather than through HAProxy, because
dropping a database is an operation you want aimed at a server you named.
The prepare Job
- name: Delete previous sysbench prepare job if present
kubernetes.core.k8s:
state: absent
api_version: batch/v1
kind: Job
name: sysbench-prepare
wait: true
wait_timeout: 60
- name: Run sysbench oltp prepare (10 tables, ~500MB)
kubernetes.core.k8s:
state: present
definition:
apiVersion: batch/v1
kind: Job
metadata: { name: sysbench-prepare, namespace: "{{ mysql_namespace }}" }
spec:
backoffLimit: 1
ttlSecondsAfterFinished: 86400
template:
spec:
restartPolicy: Never
containers:
- name: sysbench
image: "{{ sysbench_image }}" # ubuntu:24.04
env:
- name: MYSQL_PWD
valueFrom:
secretKeyRef: { name: "{{ mysql_secrets_name }}", key: root }
command:
- /bin/bash
- -c
- |
set -euo pipefail
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq
apt-get install -y -qq sysbench mysql-client
sysbench oltp_read_write \
--db-driver=mysql \
--mysql-host={{ mysql_cluster_name }}-haproxy \
--mysql-port=3306 \
--mysql-user=root \
--mysql-password="$MYSQL_PWD" \
--mysql-db={{ sysbench_database }} \
--tables={{ sysbench_tables }} \
--table-size={{ sysbench_table_size }} \
--mysql-storage-engine=innodb \
prepare
A Job inside the cluster, not sysbench on the laptop. The load generator runs next to the database, on the cluster network, so the measurement is not shaped by a NodePort hop and no host-side tooling is required. It is also the same shape you would use to load data into a real cluster you cannot reach directly.
--mysql-host=lab-haproxy — the load goes through the proxy, deliberately.
That exercises the path a real client uses and means the load does not break if
the source is not lab-mysql-0.
ubuntu:24.04 plus apt-get install sysbench is the honest trade. A purpose-built
image would start faster; a stock base image with a visible two-line install
means anyone can read exactly what is running and reproduce it without a registry.
The cost is one apt-get per load, which happens once per lab lifetime.
MYSQL_PWD from a secretKeyRef rather than --mysql-password=mysql on the
command line. A password in command: is visible in kubectl describe pod and
in the process list inside the container. The environment variable is the
conventional MySQL client mechanism and keeps it out of both.
backoffLimit: 1 — if sysbench fails, it is a real failure (wrong
credentials, no such host, out of disk). Retrying five times just delays the
error message.
ttlSecondsAfterFinished: 86400 — the finished Job and its logs stick around
for a day, so kubectl logs job/sysbench-prepare still works when you want to
know how long the load took, then it cleans itself up.
set -euo pipefail is in every inline script in this repo. Without -e, a
failed apt-get is followed by a sysbench: command not found whose exit code
becomes the Job’s — and the real error scrolls past.
Waiting, then asserting — again
- name: Wait for sysbench prepare job
kubernetes.core.k8s_info:
api_version: batch/v1
kind: Job
name: sysbench-prepare
register: sb_job
until:
- sb_job.resources | length == 1
- sb_job.resources[0].status.succeeded | default(0) | int == 1
or (sb_job.resources[0].status.failed | default(0) | int) > 0
retries: 120
delay: 15
- name: Fail if sysbench prepare did not succeed
ansible.builtin.assert:
that:
- sb_job.resources[0].status.succeeded | default(0) | int == 1
fail_msg: >-
sysbench-prepare job failed. Inspect logs with
kubectl logs -n {{ mysql_namespace }} job/sysbench-prepare
The same wait-for-terminal-then-assert split from part 2, with 30 minutes of
patience (120 × 15s) because a first run downloads packages before it writes a
single row. And note the fail_msg: it contains the exact command to run next.
An error message that tells you what to type is worth the two lines it costs.
The ANALYZE TABLE nobody expects
- name: Analyze sysbench tables so information_schema sizes are current
kubernetes.core.k8s_exec:
command: >
mysql -uroot -p{{ mysql_root_password }} -e
"ANALYZE TABLE sbtest.sbtest1, …, sbtest.sbtest10"
Without this, the size assertion that follows is a coin flip. InnoDB’s
information_schema.tables.data_length is served from cached statistics that are
refreshed on a sampling schedule, and immediately after a bulk load they can
still report near-zero. ANALYZE TABLE forces a refresh so the next query sees
the data that is actually on disk.
This is a small thing that cost real debugging time, and it is the kind of detail that only shows up when you make your setup assert its own success instead of printing a number for a human to glance at.
- name: Measure sysbench data size on the source
kubernetes.core.k8s_exec:
command: >
mysql -uroot -p{{ mysql_root_password }} -N -e "SELECT COUNT(*) FROM information_schema.tables
WHERE table_schema='{{ sysbench_database }}' AND table_name LIKE 'sbtest%';
SELECT ROUND(SUM(data_length+index_length)/1024/1024)
FROM information_schema.tables
WHERE table_schema='{{ sysbench_database }}'"
register: size_query
- name: Require 10 sysbench tables and size in the documented band
ansible.builtin.assert:
that:
- (size_query.stdout_lines[0] | trim | int) == (sysbench_tables | int)
- (size_query.stdout_lines[1] | trim | int) >= (data_size_min_mb | int)
- (size_query.stdout_lines[1] | trim | int) <= (data_size_max_mb | int)
-N suppresses column headers so stdout_lines[0] and [1] are the two values,
in order. Two statements in one -e produce two lines. It is terse, and it is
the pattern the whole lab uses for reading values out of MySQL.
load-data that ends in an assertion is the point. It does not report that
it ran; it reports that the dataset exists with the right dimensions. Everything
downstream is allowed to assume that.
The verify playbook
./scripts/lab.sh verify
load-data proves the data got in. verify answers a broader question: is
this lab currently in a state where testing backups means anything? It is the
command to run after a reboot, after an interrupted suite, or any time you are
not sure what state you left things in.
- name: Ensure kubeconfig exists
ansible.builtin.stat:
path: "{{ kubeconfig_path }}"
register: kubeconfig
- name: Fail if cluster was not created
ansible.builtin.assert:
that: kubeconfig.stat.exists
fail_msg: "Missing {{ kubeconfig_path }}. Run `make deploy` first."
ansible/roles/verify_cluster/tasks/main.yml
The cheapest possible check first, with an actionable message. Everything after it would fail with a connection error that says nothing useful.
Discovering the topology instead of assuming it
pods=$(kubectl -n "$NS" get pods -l app.kubernetes.io/instance=lab,app.kubernetes.io/component=database \
-o jsonpath='{range .items[*]}{.metadata.name}{"\n"}{end}')
replica_ok=0
primary=""
for p in $pods; do
ro=$(kubectl -n "$NS" exec "$p" -c mysql -- mysql -uroot -pmysql -N -e "SELECT @@read_only" | tr -d '\r')
host=$(kubectl -n "$NS" exec "$p" -c mysql -- mysql -uroot -pmysql -N -e "SELECT @@hostname" | tr -d '\r')
echo "POD $p hostname=$host read_only=$ro"
if [ "$ro" = "0" ]; then
primary="$p"
else
io=$(kubectl -n "$NS" exec "$p" -c mysql -- mysql -uroot -pmysql -N -e \
"SELECT SERVICE_STATE FROM performance_schema.replication_connection_status LIMIT 1" | tr -d '\r')
sql=$(kubectl -n "$NS" exec "$p" -c mysql -- mysql -uroot -pmysql -N -e \
"SELECT SERVICE_STATE FROM performance_schema.replication_applier_status LIMIT 1" | tr -d '\r')
echo "POD $p replica_io=$io replica_sql=$sql"
if [ "$io" = "ON" ] && [ "$sql" = "ON" ]; then
replica_ok=$((replica_ok+1))
fi
fi
done
echo "PRIMARY=$primary"
echo "REPLICA_OK=$replica_ok"
Three design decisions in that loop, all of which recur in every scenario.
The primary is discovered by SELECT @@read_only, never assumed. lab-mysql-0
is usually the source. It is not necessarily the source — Orchestrator can fail
over, and a restore rebuilds the topology. A test that hard-codes the primary
eventually writes its canary to a read-only replica and reports a confusing
failure. Asking the server which role it is playing costs one query.
Replication health comes from performance_schema, not SHOW REPLICA STATUS.
replication_connection_status.SERVICE_STATE is the receiver thread;
replication_applier_status.SERVICE_STATE is the applier. Both are single-value
queries returning ON or OFF. SHOW REPLICA STATUS\G returns fifty fields of
Name: value text that has to be grepped, which is fine for a human and poor for
an assertion. (Part 8 does grep SHOW REPLICA STATUS — because it needs
Seconds_Behind_Source and the error fields, which the performance_schema
tables do not present as conveniently.)
tr -d '\r' on every captured value. kubectl exec without a TTY still
yields carriage returns in some environments, and "ON\r" != "ON" is a
comparison failure that looks like a replication failure. It appears everywhere
in this lab for that reason.
Structured output, then parsed
- name: Parse topology lines
ansible.builtin.set_fact:
primary_name: "{{ topology.stdout_lines | select('match', '^PRIMARY=') | map('regex_replace', '^PRIMARY=', '') | first }}"
replica_ok: "{{ topology.stdout_lines | select('match', '^REPLICA_OK=') | map('regex_replace', '^REPLICA_OK=', '') | first }}"
sysbench_table_count: "{{ topology.stdout_lines | select('match', '^SYSBENCH_TABLES=') | ... | first }}"
size_mb: "{{ topology.stdout_lines | select('match', '^SIZE_MB=') | ... | first }}"
The shell script prints KEY=value lines; Ansible selects them by prefix. This
convention runs through the entire lab — every scenario emits KEY=value
evidence — and it buys three things at once:
- the script can be run by hand and read by a human;
- Ansible can parse it without JSON plumbing inside a shell heredoc;
- the same lines become the recorded evidence in the run report (part 9) verbatim, so what the documentation quotes is literally what the command printed.
The assertions
- name: Require a writable source and at least one applying replica
ansible.builtin.assert:
that:
- primary_name | length > 0
- (replica_ok | int) >= 1
fail_msg: "Replication is not healthy. Output:\n{{ topology.stdout }}"
- name: Require sysbench 10 tables and size around 500MB
ansible.builtin.assert:
that:
- (sysbench_table_count | int) == (sysbench_tables | int)
- (size_mb | int) >= (data_size_min_mb | int)
- (size_mb | int) <= (data_size_max_mb | int)
Note that fail_msg embeds the full captured output. When this fails you get the
per-pod detail immediately, without re-running anything.
The end-to-end check
- name: Check Prometheus scrape of mysqld
ansible.builtin.uri:
url: "http://127.0.0.1:{{ kind_host_prometheus_port }}/api/v1/query?query=up%7Bjob%3D%22mysql%22%7D"
return_content: true
register: prom_up
- name: Require at least one MySQL Prometheus target up
ansible.builtin.assert:
that:
- (prom_up.json.data.result | selectattr('value.1', 'equalto', '1') | list | length) >= 1
fail_msg: "Prometheus is not scraping job=mysql with up=1. Body={{ prom_up.content }}"
This single HTTP request validates a surprising amount at once: the kind port
mapping from part 1, the NodePort service, Prometheus itself, the ServiceMonitor,
the headless metrics service, the exporter sidecar, and the monitor account’s
password. It is the only check in the lab that runs from outside the cluster,
over the same path a human uses — and that is deliberate. Everything else talks
to the Kubernetes API; this one proves the lab is reachable the way the README
says it is.
The recorded validation run’s health log ends with exactly that:
replica_io=ON replica_sql=ON
Prometheus mysql up ok
Monitoring, and why it is only three files
- name: Install kube-prometheus-stack
kubernetes.core.helm:
name: kps
chart_ref: prometheus-community/kube-prometheus-stack
chart_version: "{{ kube_prometheus_chart_version }}" # 88.5.2
release_namespace: "{{ monitoring_namespace }}"
wait: true
wait_timeout: "15m0s"
values_files:
- "{{ repo_root }}/k8s/monitoring/kube-prometheus-values.yaml"
ansible/roles/monitoring/tasks/main.yml
Fifteen minutes of timeout because a first install pulls Prometheus, Grafana, node-exporter, kube-state-metrics and the operator.
grafana:
adminPassword: admin
service: { type: NodePort, nodePort: 30300 }
alertmanager:
enabled: false
prometheus:
service: { type: NodePort, nodePort: 30091 }
prometheusSpec:
retention: 2d
serviceMonitorSelectorNilUsesHelmValues: false
podMonitorSelectorNilUsesHelmValues: false
ruleSelectorNilUsesHelmValues: false
serviceMonitorNamespaceSelector: {}
ruleNamespaceSelector: {}
kubeEtcd: { enabled: false }
kubeControllerManager: { enabled: false }
kubeScheduler: { enabled: false }
ansible/roles/monitoring/templates/kube-prometheus-values.yaml.j2 (abridged)
alertmanager.enabled: false — nobody is on call for a laptop. This lab’s
sibling ClickHouse lab does route alerts to Slack; here it would be pure
overhead.
retention: 2d — long enough to look back over a full suite run (about 25
minutes of scenario time), short enough that Prometheus never becomes the
biggest thing on the disk.
The four …SelectorNilUsesHelmValues: false settings are the ones that
actually matter and the ones most people hit. By default the chart makes
Prometheus discover only ServiceMonitors carrying its own Helm release
labels. Ours lives in a different namespace and describes a database, not a chart
component. Setting these to false, plus empty namespace selectors, tells
Prometheus to consider every ServiceMonitor in the cluster. Without them the lab
deploys cleanly, Grafana loads, every panel is empty, and nothing anywhere says
why.
kubeEtcd, kubeControllerManager, kubeScheduler disabled — on kind these
control-plane components do not expose metrics endpoints the chart’s default
scrape config can reach, so leaving them on produces three permanently red
targets. Three false alarms teach you to ignore red targets, which is the exact
habit a monitoring setup should not teach.
Connecting Prometheus to MySQL
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: mysql
namespace: mysql
labels: { release: kps, app: mysql-lab }
spec:
namespaceSelector: { matchNames: [mysql] }
selector:
matchLabels: { app: mysql-metrics }
endpoints:
- port: metrics
path: /metrics
interval: 15s
honorLabels: true
relabelings:
- action: replace
targetLabel: job
replacement: mysql
ansible/roles/monitoring/templates/servicemonitors.yaml.j2
It selects the headless mysql-metrics service from part 2, so Prometheus
scrapes each MySQL pod individually rather than a load-balanced endpoint.
The relabeling rewrites the job label to the literal mysql. Without it the
job name is derived from the service name and would change if the service were
ever renamed — and the verify playbook’s up{job="mysql"} query, the Grafana
dashboard’s panels, and every future alert would all quietly stop matching. One
relabel rule buys a stable contract.
The dashboard ships as a ConfigMap with grafana_dashboard: "1", which the
chart’s sidecar watches for:
apiVersion: v1
kind: ConfigMap
metadata:
name: grafana-mysql-lab
labels:
grafana_dashboard: "1"
data:
mysql-lab.json: |
{ … "panels": [ … ] }
Five panels: up{job="mysql"}, mysql_up,
mysql_slave_status_slave_io_running, rate(mysql_global_status_questions[1m])
and mysql_slave_status_seconds_behind_master. Dashboard-as-a-ConfigMap means it
is version-controlled and redeployed with everything else, rather than being
something someone clicked together and will lose on the next rebuild.
One Jinja detail, since this file is a template that contains Grafana templating:
"legendFormat": "{{ '{{' }} instance {{ '}}' }}"
Grafana’s legend syntax and Jinja’s expression syntax both use double braces, so
the braces are emitted as literals. The same escaping trick appeared in part 1
for docker inspect --format.
Doing it by hand
export KUBECONFIG="$PWD/.kube/config"
# 1. reset and load
kubectl -n mysql exec lab-mysql-0 -c mysql -- \
mysql -uroot -pmysql -e "DROP DATABASE IF EXISTS sbtest; CREATE DATABASE sbtest"
kubectl -n mysql delete job sysbench-prepare --ignore-not-found
kubectl -n mysql apply -f - <<'YAML'
apiVersion: batch/v1
kind: Job
metadata: { name: sysbench-prepare, namespace: mysql }
spec:
backoffLimit: 1
ttlSecondsAfterFinished: 86400
template:
spec:
restartPolicy: Never
containers:
- name: sysbench
image: ubuntu:24.04
env:
- name: MYSQL_PWD
valueFrom: { secretKeyRef: { name: lab-secrets, key: root } }
command: ["/bin/bash","-c"]
args:
- |
set -euo pipefail
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq && apt-get install -y -qq sysbench mysql-client
sysbench oltp_read_write --db-driver=mysql \
--mysql-host=lab-haproxy --mysql-port=3306 \
--mysql-user=root --mysql-password="$MYSQL_PWD" \
--mysql-db=sbtest --tables=10 --table-size=200000 \
--mysql-storage-engine=innodb prepare
YAML
kubectl -n mysql wait --for=condition=complete job/sysbench-prepare --timeout=30m
# 2. refresh statistics, then measure
kubectl -n mysql exec lab-mysql-0 -c mysql -- mysql -uroot -pmysql -e \
"ANALYZE TABLE sbtest.sbtest1,sbtest.sbtest2,sbtest.sbtest3,sbtest.sbtest4,sbtest.sbtest5,
sbtest.sbtest6,sbtest.sbtest7,sbtest.sbtest8,sbtest.sbtest9,sbtest.sbtest10"
kubectl -n mysql exec lab-mysql-0 -c mysql -- mysql -uroot -pmysql -N -e \
"SELECT COUNT(*) FROM information_schema.tables
WHERE table_schema='sbtest' AND table_name LIKE 'sbtest%';
SELECT ROUND(SUM(data_length+index_length)/1024/1024)
FROM information_schema.tables WHERE table_schema='sbtest';"
# 3. verify by hand
for p in lab-mysql-0 lab-mysql-1; do
echo -n "$p read_only="
kubectl -n mysql exec $p -c mysql -- mysql -uroot -pmysql -N -e "SELECT @@read_only"
done
kubectl -n mysql exec lab-mysql-1 -c mysql -- mysql -uroot -pmysql -N -e \
"SELECT SERVICE_STATE FROM performance_schema.replication_connection_status LIMIT 1;
SELECT SERVICE_STATE FROM performance_schema.replication_applier_status LIMIT 1"
curl -s 'http://127.0.0.1:9091/api/v1/query?query=up%7Bjob%3D%22mysql%22%7D' | head -c 400
Expected from step 2:
10
504
What can go wrong here
- The size assertion fails with a small number.
ANALYZE TABLEdid not run, or ran before the load finished. The data is there; the statistics are stale. - The size assertion fails with a large number. A previous suite run left
probe tables behind — t03’s
t03_delta, t04’st04_stream_probe, t12’spost_backup_writes_*.load-dataclears them. - sysbench cannot resolve
lab-haproxy. HAProxy is disabled, or the Job is in the wrong namespace. Service DNS is namespace-scoped. - Grafana loads but every panel is empty. The ServiceMonitor selector
settings. Check
up{job="mysql"}in Prometheus first — if that is empty, the problem is discovery, not Grafana. verifysaysREPLICA_OK=0. The replica’s receiver or applier thread is stopped.kubectl -n mysql exec lab-mysql-1 -c mysql -- mysql -uroot -pmysql -e "SHOW REPLICA STATUS\G"has the error text.
Next
There is a database with a known shape, a replica applying its changes, and a
check that says so. Part 4 starts taking backups —
what the operator actually does when you create a PerconaServerMySQLBackup,
how to push that work onto the replica instead of the source, what an incremental
really contains, and how to confirm the bytes reached object storage rather than
trusting a green status.