Этой статьёй мы открываем череду публикаций с практическими инструкциями о том, как облегчить жизнь себе (эксплуатации) и разработчикам в различных ситуациях, случающихся буквально ежедневно. Все они собраны из реального опыта решения задач от клиентов и со временем улучшались, но по-прежнему не претендуют на идеал — рассматривайте их скорее как идеи и заготовки.
Я начну с «трюка» по подготовке больших дампов баз данных вроде MySQL и PostgreSQL для их быстрого развёртывания для различных нужд — в первую очередь, на площадках для разработчиков. Контекст описанных ниже операций — наше типовое окружение, включающее в себя работающий кластер Kubernetes и применение GitLab (и dapp) для CI/CD. Поехали!
Основная боль в Kubernetes при использовании feature branch — это большие базы данных, когда разработчики хотят протестировать/продемонстрировать свои изменения на полной (или почти полной) базе из production. Например:
Примечание: Обычно базы мы бэкапим MySQL с помощью innobackupex от Percona, что позволяет сохранить и все базы, и пользователей… — словом, всё, что может потребоваться. Именно такой пример и рассмотрен дальше в статье, хотя в общем случае абсолютно всё равно, как именно вы делаете бэкапы.
Итак, допустим, у нас есть бэкап базы данных. Что делать дальше?
Первым делом мы создадим в Kubernetes Deployment, который будет состоять из двух init-контейнеров (т.е. таких специальных контейнеров, что запускаются до подов с приложением и позволяют выполнять предварительную настройку) и одного пода.
Но где его размещать? У нас большая база (1 Тб) и мы хотим поднять десять её экземпляров — потребуется сервер с большим диском (10+ Тб). Закажем его отдельно для этой задачи и пометим узел с этим сервером специальным лейблом
С помощью
… и займёмся описанием содержимого этого узла.
Первый init-контейнер мы назовём
Описание этого контейнера в Deployment выглядит следующим образом:
Используемый в контейнере скрипт
После скачивания бэкапа запускается второй init-контейнер —
Именно в этом init-контейнере мы делаем все необходимые преобразования в БД, готовя её к выбранному применению: очищаем таблицы, для которых это допустимо, меняем доступы внутри базы и т.п. Затем выключаем сервер MySQL и просто архивируем весь
Описание второго init-контейнера в Deployment:
Используемый в нём скрипт
Финальный аккорд — запуск основного пода, что происходит после выполнения init-контейнеров. В поде у нас стоит простой nginx, а через
Конфигурация пода:
Дополнительные примечания:
При выкате базы данных MySQL в тестовом окружении разработчика у него есть кнопка в GitLab, которая запускает редеплой Deployment'а с MySQL со стратегией
Выбранный подход позволяет нам дождаться, пока будет скачан, разархивирован и запущен новый дамп, а только после этого удалится старый под с MySQL. Таким образом, пока у нас готовится новый дамп, мы спокойно работаем со старой базой.
В init-контейнере данного Deployment используется команда следующего вида:
Т.е. мы скачиваем сжатый дамп базы, который был подготовлен на шаге 1, разархивируем его в
Получается, что у нас всегда есть Deployment, который выкатывается каждую ночь и делает следующее:
Для рассмотренного нами примера создание дампа с реальной реплики занимает около 6 часов, подготовка «образа базы» — 7 часов, а обновление базы в окружении разработчика — 2 часа. Поскольку первые два действия выполняются «в фоне» и незримы для разработчиков, то по факту они могут разворачивать себе продовую версию базы (с размером в 1 Тб) за те же 2 часа.
Вопросы, критика и исправления к предложенной схеме и её компонентам — с радостью принимаются в комментариях!
P.S. Конечно, мы понимаем, что в случае VMware и некоторых других инструментов можно было бы обойтись созданием снапшота виртуалки и запуском новой вируталки из снапшота (что ещё быстрее), но этот вариант не включает в себя подготовку базы, с учётом которой получится примерно то же время… Не говоря уже о том, что не у всех есть возможность или желание использовать коммерческие продукты.
Читайте также в нашем блоге:
Я начну с «трюка» по подготовке больших дампов баз данных вроде 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('password') 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 -ppassword somedb
/usr/bin/mysqladmin shutdown -uroot -ppassword
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: {}
Вот как выглядит весь Deployment с его initContainers…
---
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
:Как это реализуется?
В GitLab при нажатии на reload db деплоится Deployment с такой спецификацией:
Т.е. мы говорим Kubernetes, чтобы он обновлял Deployment (создавал новый под) и при этом следил за тем, чтобы как минимум один под был живой. Так как при создании нового пода у него есть init-контейнеры, пока они работают, новый под не переходит в статус Running, а значит — старый под продолжает работать. И только в момент, как сам под с MySQL запустился (и отработала readiness probe), трафик переключается на него, а старый под (со старой базой) удаляется.
Подробности об этой схеме можно почерпнуть из следующих материалов:
spec:
strategy:
rollingUpdate:
maxUnavailable: 0
Т.е. мы говорим Kubernetes, чтобы он обновлял Deployment (создавал новый под) и при этом следил за тем, чтобы как минимум один под был живой. Так как при создании нового пода у него есть init-контейнеры, пока они работают, новый под не переходит в статус Running, а значит — старый под продолжает работать. И только в момент, как сам под с MySQL запустился (и отработала readiness probe), трафик переключается на него, а старый под (со старой базой) удаляется.
Подробности об этой схеме можно почерпнуть из следующих материалов:
- Performing a Rolling Update (документация Kubernetes);
- Rolling Updates with Kubernetes Deployments (Ta-Ching Chen);
- Kubernetes deployment strategies (Container Solutions).
Выбранный подход позволяет нам дождаться, пока будет скачан, разархивирован и запущен новый дамп, а только после этого удалится старый под с MySQL. Таким образом, пока у нас готовится новый дамп, мы спокойно работаем со старой базой.
В init-контейнере данного Deployment используется команда следующего вида:
curl "$DUMP_URL" | tar -C /var/lib/mysql/ -xvz
Т.е. мы скачиваем сжатый дамп базы, который был подготовлен на шаге 1, разархивируем его в
/var/lib/mysql
, после чего стартует под Deployment'а, в котором запускается MySQL с уже подготовленными данными. Всё это занимает примерно 2 часа.А Deployment выглядит следующим образом…
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: "password"
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.
Читайте также в нашем блоге:
- «Сборка и дeплой приложений в Kubernetes с помощью dapp и GitLab CI»;
- «Практика с dapp. Часть 1: Сборка простых приложений»;
- «Практика с dapp. Часть 2. Деплой Docker-образов в Kubernetes с помощью Helm»;
- «Оркестровка СУБД CockroachDB в Kubernetes»;
- «Наш опыт с Kubernetes в небольших проектах» (видео доклада, включающего в себя знакомство с техническим устройством Kubernetes);
- «Полезные утилиты при работе с Kubernetes».
Комментарии (5)
qwertyRu
20.07.2018 09:51Добрый день, можете под спойлер разместить весть файл .gitlab-ci.yml?
Wimbo Автор
20.07.2018 10:03+1Все очень простоvariables: DAPP_VERSION: "0.31" .base_deploy: &base_deploy stage: deploy script: - source dapp_use ${DAPP_VERSION} - dapp --version; set -x; pwd - dapp kube deploy --tag-ci --namespace ${CI_ENVIRONMENT_SLUG} --set "global.env=${CI_ENVIRONMENT_SLUG}" --set "global.reload_db=${DB:-false}" ${CI_REGISTRY_IMAGE} stages: - build - deploy Build: stage: build script: - source dapp_use ${DAPP_VERSION} - dapp --version; set -x; pwd - dapp dimg bp ${CI_REGISTRY_IMAGE} --tag-ci --use-system-tar tags: - build except: - schedules To test: <<: *base_deploy except: - schedules tags: - deploy when: manual To test (Reload DB): <<: *base_deploy except: - schedules variables: DB_RELOAD: "true" tags: - deploy when: manual
qwertyRu
20.07.2018 12:40Спасибо, действительно несложно. Еще вопрос. У Вас есть два init контейнера, один скачивает базу, другой ее подготавливает. Насколько я понял, они выполняются всегда по очереди, почему их не объединить в запуск одного скрипта (закачка и подготовка)?
Wimbo Автор
21.07.2018 14:04+1На самом деле ничего не мешает :) Как ничего не мешает, и обойтись без инит контейнеров.
Просто тут каждый контейнер выполняет свою отдельную функцию, мы можем смотреть логи отдельных контейнеров, выделять ресурсы на отдельные стадии (CPU, RAM).
Negash
Использую свой велосипед на ZFS, также каждый день приходит новый порезанный дамп базы (10-15гб) с прода, собрал сервис для выдачи баз разработчикам и для каждого review в пайплайнах, правда без документации тут