Ускоряем bootstrap больших баз данных с помощью Kubernetes

Этой статьёй мы открываем череду публикаций с практическими инструкциями о том, как облегчить жизнь себе (эксплуатации) и разработчикам в различных ситуациях, случающихся буквально ежедневно. Все они собраны из реального опыта решения задач от клиентов и со временем улучшались, но по-прежнему не претендуют на идеал — рассматривайте их скорее как идеи и заготовки.

Я начну с «трюка» по подготовке больших дампов баз данных вроде MySQL и PostgreSQL для их быстрого развёртывания для различных нужд — в первую очередь, на площадках для разработчиков. Контекст описанных ниже операций — наше типовое окружение, включающее в себя работающий кластер Kubernetes и применение GitLab (и dapp) для CI/CD. Поехали!

6j2l4z7lqghreoy3nykvws5x2u0.jpeg

Основная боль в 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: {}


Вот как выглядит весь 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


Дополнительные примечания:

  1. В нашем случае мы каждую ночь подготавливаем новый дамп с помощью scheduled job в GitLab. Т.е. каждую ночь у нас автоматически раскатывается этот Deployment, который подтягивает свежий дамп и подготавливает его для раздачи всем тестовым окружениям разработчиков.
  2. Для чего мы в init-контейнеры прокидываем ещё и volume /dump (и в скрипте есть проверка на существование /dump/version.txt)? Это сделано на тот случай, если будет перезапущен сервер, на котором работает под. Контейнеры будут стартовать заново и без этой проверки начнёт повторно скачиваться дамп. Если же мы один раз уже подготовили дамп, то при следующем старте (в случае ребута сервера) файл-флаг /dump/version.txt сообщит об этом.
  3. Что за образ 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:

upbvj8ouflxbxyemaok3llra03a.png

Как это реализуется?
В GitLab при нажатии на reload db деплоится Deployment с такой спецификацией:
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 часа.

А 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: "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.


Читайте также в нашем блоге:

© Habrahabr.ru