Ускоряем bootstrap больших баз данных с помощью Kubernetes
Этой статьёй мы открываем череду публикаций с практическими инструкциями о том, как облегчить жизнь себе (эксплуатации) и разработчикам в различных ситуациях, случающихся буквально ежедневно. Все они собраны из реального опыта решения задач от клиентов и со временем улучшались, но по-прежнему не претендуют на идеал — рассматривайте их скорее как идеи и заготовки.
Я начну с «трюка» по подготовке больших дампов баз данных вроде MySQL и PostgreSQL для их быстрого развёртывания для различных нужд — в первую очередь, на площадках для разработчиков. Контекст описанных ниже операций — наше типовое окружение, включающее в себя работающий кластер Kubernetes и применение GitLab (и dapp) для CI/CD. Поехали!
Основная боль в Kubernetes при использовании feature branch — это большие базы данных, когда разработчики хотят протестировать/продемонстрировать свои изменения на полной (или почти полной) базе из production. Например:
- Есть приложение с БД в MySQL на 1 Тб и 10 разработчиков, которые разрабатывают свои фичи.
- Разработчики хотят индивидуальные тестовые контуры и ещё пару специфичных контуров для тестов и/или демонстраций.
- Вдобавок, есть необходимость восстанавливать ночной дамп production-базы в своём тестовом контуре за вменяемое время — для воспроизведения проблемы у клиента или бага.
- Наконец, имеется возможность облегчить размер базы хотя бы на 150 Гб — не так много, но всё равно экономия места. Т.е. нам нужно ещё как-то подготавливать дамп.
Примечание: Обычно базы мы бэкапим MySQL с помощью innobackupex от Percona, что позволяет сохранить и все базы, и пользователей… — словом, всё, что может потребоваться. Именно такой пример и рассмотрен дальше в статье, хотя в общем случае абсолютно всё равно, как именно вы делаете бэкапы.
Итак, допустим, у нас есть бэкап базы данных. Что делать дальше?
Шаг 1: Подготовка новой базы из дампа
Первым делом мы создадим в Kubernetes Deployment, который будет состоять из двух init-контейнеров (т.е. таких специальных контейнеров, что запускаются до подов с приложением и позволяют выполнять предварительную настройку) и одного пода.
Но где его размещать? У нас большая база (1 Тб) и мы хотим поднять десять её экземпляров — потребуется сервер с большим диском (10+ Тб). Закажем его отдельно для этой задачи и пометим узел с этим сервером специальным лейблом dedicated: non-prod-db
. Заодно воспользуемся одноимённым taint, который будет говорить Kubernetes, что на этот узел могут катиться только приложения, которые устойчивы (имеют tolerations) к нему, т.е., переводя на язык Kubernetes, dedicated Equal non-prod-db
.
С помощью nodeSelector
и tolerations
выберём нужный узел (размещённый на сервере с большим диском):
nodeSelector:
dedicated: non-prod-db
tolerations:
- key: "dedicated"
operator: "Equal"
value: "non-prod-db"
effect: "NoExecute"
… и займёмся описанием содержимого этого узла.
Init-контейнеры: get-bindump
Первый init-контейнер мы назовём get-bindump
. В него монтируется emptyDir
(в /var/lib/mysql
), куда будет складываться полученный с бэкап-сервера дамп базы данных. Для этого в контейнере есть все необходимое: SSH-ключи, адреса бэкап-серверов. Данная стадия в нашем случае занимает около 2 часов.
Описание этого контейнера в Deployment выглядит следующим образом:
- name: get-bindump
image: db-dumps
imagePullPolicy: Always
command: [ "/bin/sh", "-c", "/get_bindump.sh" ]
resources:
limits:
memory: "5000Mi"
cpu: "1"
requests:
memory: "5000Mi"
cpu: "1"
volumeMounts:
- name: dump
mountPath: /dump
- name: mysqlbindir
mountPath: /var/lib/mysql
- name: id-rsa
mountPath: /root/.ssh
Используемый в контейнере скрипт get_bindump.sh
:
#!/bin/bash
date
if [ -f /dump/version.txt ]; then
echo "Dump file already exists."
exit 0
fi
rm -rf /var/lib/mysql/*
borg extract --stdout user@your.server.net:somedb-mysql::${lastdump} stdin | xbstream -x -C /var/lib/mysql/
echo $lastdump > /dump/version.txt
Init-контейнеры: prepare-bindump
После скачивания бэкапа запускается второй init-контейнер — prepare-bindump
. Он выполняет innobackupex --apply-log
(так как файлы уже доступны в /var/lib/mysql
— благодаря emptyDir
из get-bindump
) и стартует сервер MySQL.
Именно в этом init-контейнере мы делаем все необходимые преобразования в БД, готовя её к выбранному применению: очищаем таблицы, для которых это допустимо, меняем доступы внутри базы и т.п. Затем выключаем сервер MySQL и просто архивируем весь /var/lib/mysql
в tar.gz-файл. В итоге, дамп умещается в файл размером 100 Гб, что уже на порядок меньше, чем исходный 1 Тб. Данная стадия занимает около 5 часов.
Описание второго init-контейнера в Deployment:
- name: prepare-bindump
image: db-dumps
imagePullPolicy: Always
command: [ "/bin/sh", "-c", "/prepare_bindump.sh" ]
resources:
limits:
memory: "5000Mi"
cpu: "1"
requests:
memory: "5000Mi"
cpu: "1"
volumeMounts:
- name: dump
mountPath: /dump
- name: mysqlbindir
mountPath: /var/lib/mysql
- name: debian-cnf
mountPath: /etc/mysql/debian.cnf
subPath: debian.cnf
Используемый в нём скрипт prepare_bindump.sh
выглядит примерно так:
#!/bin/bash
date
if [ -f /dump/healthz ]; then
echo "Dump file already exists."
exit 0
fi
innobackupex --apply-log /var/lib/mysql/
chown -R mysql:mysql /var/lib/mysql
chown -R mysql:mysql /var/log/mysql
echo "`date`: Starting mysql"
/usr/sbin/mysqld --character-set-server=utf8 --collation-server=utf8_general_ci --innodb-data-file-path=ibdata1:200M:autoextend --user=root --skip-grant-tables &
sleep 200
echo "`date`: Creating mysql root user"
echo "update mysql.user set Password=PASSWORD('toor') WHERE user='root';" | mysql -uroot -h 127.0.0.1
echo "delete from mysql.user where USER like '';" | mysql -uroot -h 127.0.0.1
echo "delete from mysql.user where user = 'root' and host NOT IN ('127.0.0.1', 'localhost');" | mysql -uroot -h 127.0.0.1
echo "FLUSH PRIVILEGES;" | mysql -uroot -h 127.0.0.1
echo "truncate somedb.somedb_table_one;" | mysql -uroot -h 127.0.0.1 -ptoor somedb
/usr/bin/mysqladmin shutdown -uroot -ptoor
cd /var/lib/mysql/
tar -czf /dump/mysql_bindump.tar.gz ./*
touch /dump/healthz
rm -rf /var/lib/mysql/*
Под
Финальный аккорд — запуск основного пода, что происходит после выполнения init-контейнеров. В поде у нас стоит простой nginx, а через emtpyDir
подложен сжатый и обрезанный дамп в 100 Гб. Функция данного nginx — отдавать этот дамп.
Конфигурация пода:
- name: nginx
image: nginx:alpine
resources:
requests:
memory: "1500Mi"
cpu: "400m"
lifecycle:
preStop:
exec:
command: ["/usr/sbin/nginx", "-s", "quit"]
livenessProbe:
httpGet:
path: /healthz
port: 80
scheme: HTTP
timeoutSeconds: 7
failureThreshold: 5
volumeMounts:
- name: dump
mountPath: /usr/share/nginx/html
- name: nginx-config
mountPath: /etc/nginx/nginx.conf
subPath: nginx.conf
readOnly: false
volumes:
- name: dump
emptyDir: {}
- name: mysqlbindir
emptyDir: {}
---
apiVersion: apps/v1beta1
kind: Deployment
metadata:
name: db-dumps
spec:
strategy:
rollingUpdate:
maxUnavailable: 0
revisionHistoryLimit: 2
template:
metadata:
labels:
app: db-dumps
spec:
imagePullSecrets:
- name: regsecret
nodeSelector:
dedicated: non-prod-db
tolerations:
- key: "dedicated"
operator: "Equal"
value: "non-prod-db"
effect: "NoExecute"
initContainers:
- name: get-bindump
image: db-dumps
imagePullPolicy: Always
command: [ "/bin/sh", "-c", "/get_bindump.sh" ]
resources:
limits:
memory: "5000Mi"
cpu: "1"
requests:
memory: "5000Mi"
cpu: "1"
volumeMounts:
- name: dump
mountPath: /dump
- name: mysqlbindir
mountPath: /var/lib/mysql
- name: id-rsa
mountPath: /root/.ssh
- name: prepare-bindump
image: db-dumps
imagePullPolicy: Always
command: [ "/bin/sh", "-c", "/prepare_bindump.sh" ]
resources:
limits:
memory: "5000Mi"
cpu: "1"
requests:
memory: "5000Mi"
cpu: "1"
volumeMounts:
- name: dump
mountPath: /dump
- name: mysqlbindir
mountPath: /var/lib/mysql
- name: log
mountPath: /var/log/mysql
- name: debian-cnf
mountPath: /etc/mysql/debian.cnf
subPath: debian.cnf
containers:
- name: nginx
image: nginx:alpine
resources:
requests:
memory: "1500Mi"
cpu: "400m"
lifecycle:
preStop:
exec:
command: ["/usr/sbin/nginx", "-s", "quit"]
livenessProbe:
httpGet:
path: /healthz
port: 80
scheme: HTTP
timeoutSeconds: 7
failureThreshold: 5
volumeMounts:
- name: dump
mountPath: /usr/share/nginx/html
- name: nginx-config
mountPath: /etc/nginx/nginx.conf
subPath: nginx.conf
readOnly: false
volumes:
- name: dump
emptyDir: {}
- name: mysqlbindir
emptyDir: {}
- name: log
emptyDir: {}
- name: id-rsa
secret:
defaultMode: 0600
secretName: somedb-id-rsa
- name: nginx-config
configMap:
name: somedb-nginx-config
- name: debian-cnf
configMap:
name: somedb-debian-cnf
---
apiVersion: v1
kind: Service
metadata:
name: somedb-db-dump
spec:
clusterIP: None
selector:
app: db-dumps
ports:
- name: http
port: 80
Дополнительные примечания:
- В нашем случае мы каждую ночь подготавливаем новый дамп с помощью scheduled job в GitLab. Т.е. каждую ночь у нас автоматически раскатывается этот Deployment, который подтягивает свежий дамп и подготавливает его для раздачи всем тестовым окружениям разработчиков.
- Для чего мы в init-контейнеры прокидываем ещё и volume
/dump
(и в скрипте есть проверка на существование/dump/version.txt
)? Это сделано на тот случай, если будет перезапущен сервер, на котором работает под. Контейнеры будут стартовать заново и без этой проверки начнёт повторно скачиваться дамп. Если же мы один раз уже подготовили дамп, то при следующем старте (в случае ребута сервера) файл-флаг/dump/version.txt
сообщит об этом. - Что за образ
db-dumps
? Мы его собираем dapp’ом и егоDappfile
выглядит так:dimg: "db-dumps" from: "ubuntu:16.04" docker: ENV: TERM: xterm ansible: beforeInstall: - name: "Install percona repositories" apt: deb: https://repo.percona.com/apt/percona-release_0.1-4.xenial_all.deb - name: "Add repository for borgbackup" apt_repository: repo="ppa:costamagnagianfranco/borgbackup" codename="xenial" update_cache=yes - name: "Add repository for mysql 5.6" apt_repository: repo: deb http://archive.ubuntu.com/ubuntu trusty universe state: present update_cache: yes - name: "Install packages" apt: name: "{{`{{ item }}`}}" state: present with_items: - openssh-client - mysql-server-5.6 - mysql-client-5.6 - borgbackup - percona-xtrabackup-24 setup: - name: "Add get_bindump.sh" copy: content: | {{ .Files.Get ".dappfiles/get_bindump.sh" | indent 8 }} dest: /get_bindump.sh mode: 0755 - name: "Add prepare_bindump.sh" copy: content: | {{ .Files.Get ".dappfiles/prepare_bindump.sh" | indent 8 }} dest: /prepare_bindump.sh mode: 0755
Шаг 2: Запуск базы в окружении разработчика
При выкате базы данных MySQL в тестовом окружении разработчика у него есть кнопка в GitLab, которая запускает редеплой Deployment'а с MySQL со стратегией RollingUpdate.maxUnavailable: 0
:
spec:
strategy:
rollingUpdate:
maxUnavailable: 0
Т.е. мы говорим Kubernetes, чтобы он обновлял Deployment (создавал новый под) и при этом следил за тем, чтобы как минимум один под был живой. Так как при создании нового пода у него есть init-контейнеры, пока они работают, новый под не переходит в статус Running, а значит — старый под продолжает работать. И только в момент, как сам под с MySQL запустился (и отработала readiness probe), трафик переключается на него, а старый под (со старой базой) удаляется.
Подробности об этой схеме можно почерпнуть из следующих материалов:
Выбранный подход позволяет нам дождаться, пока будет скачан, разархивирован и запущен новый дамп, а только после этого удалится старый под с MySQL. Таким образом, пока у нас готовится новый дамп, мы спокойно работаем со старой базой.
В init-контейнере данного Deployment используется команда следующего вида:
curl "$DUMP_URL" | tar -C /var/lib/mysql/ -xvz
Т.е. мы скачиваем сжатый дамп базы, который был подготовлен на шаге 1, разархивируем его в /var/lib/mysql
, после чего стартует под Deployment'а, в котором запускается MySQL с уже подготовленными данными. Всё это занимает примерно 2 часа.
apiVersion: apps/v1beta1
kind: Deployment
metadata:
name: mysql
spec:
strategy:
rollingUpdate:
maxUnavailable: 0
template:
metadata:
labels:
service: mysql
spec:
imagePullSecrets:
- name: regsecret
nodeSelector:
dedicated: non-prod-db
tolerations:
- key: "dedicated"
operator: "Equal"
value: "non-prod-db"
effect: "NoExecute"
initContainers:
- name: getdump
image: mysql-with-getdump
command: ["/usr/local/bin/getdump.sh"]
resources:
limits:
memory: "6000Mi"
cpu: "1.5"
requests:
memory: "6000Mi"
cpu: "1.5"
volumeMounts:
- mountPath: /var/lib/mysql
name: datadir
- mountPath: /etc/mysql/debian.cnf
name: debian-cnf
subPath: debian.cnf
env:
- name: DUMP_URL
value: "http://somedb-db-dump.infra-db.svc.cluster.local/mysql_bindump.tar.gz"
containers:
- name: mysql
image: mysql:5.6
resources:
limits:
memory: "1024Mi"
cpu: "1"
requests:
memory: "1024Mi"
cpu: "1"
lifecycle:
preStop:
exec:
command: ["/etc/init.d/mysql", "stop"]
ports:
- containerPort: 3306
name: mysql
protocol: TCP
volumeMounts:
- mountPath: /var/lib/mysql
name: datadir
- mountPath: /etc/mysql/debian.cnf
name: debian-cnf
subPath: debian.cnf
env:
- name: MYSQL_ROOT_PASSWORD
value: "toor"
volumes:
- name: datadir
emptyDir: {}
- name: debian-cnf
configMap:
name: somedb-debian-cnf
---
apiVersion: v1
kind: Service
metadata:
name: mysql
spec:
clusterIP: None
selector:
service: mysql
ports:
- name: mysql
port: 3306
protocol: TCP
---
apiVersion: v1
kind: ConfigMap
metadata:
name: somedb-debian-cnf
data:
debian.cnf: |
[client]
host = localhost
user = debian-sys-maint
password = password
socket = /var/run/mysqld/mysqld.sock
[mysql_upgrade]
host = localhost
user = debian-sys-maint
password = password
socket = /var/run/mysqld/mysqld.sock
Итоги
Получается, что у нас всегда есть Deployment, который выкатывается каждую ночь и делает следующее:
- получает свежий дамп базы данных;
- как-то его подготавливает для корректной работы в тестовом окружении (например, транкейтит какие-то таблицы, заменяет реальные пользовательские данные, заводит нужных юзеров и т.п.);
- предоставляет каждому разработчику возможность по нажатию на кнопку в CI выкатывать такую подготовленную базу в свой namespace в Deployment — благодаря имеющемуся в нём Service база будет доступна по адресу
mysql
(например, это может быть имя сервиса в namespace).
Для рассмотренного нами примера создание дампа с реальной реплики занимает около 6 часов, подготовка «образа базы» — 7 часов, а обновление базы в окружении разработчика — 2 часа. Поскольку первые два действия выполняются «в фоне» и незримы для разработчиков, то по факту они могут разворачивать себе продовую версию базы (с размером в 1 Тб) за те же 2 часа.
Вопросы, критика и исправления к предложенной схеме и её компонентам — с радостью принимаются в комментариях!
P.S. Конечно, мы понимаем, что в случае VMware и некоторых других инструментов можно было бы обойтись созданием снапшота виртуалки и запуском новой вируталки из снапшота (что ещё быстрее), но этот вариант не включает в себя подготовку базы, с учётом которой получится примерно то же время… Не говоря уже о том, что не у всех есть возможность или желание использовать коммерческие продукты.
P.P. S.
Читайте также в нашем блоге: