Ускоряем 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('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

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

    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:



    Как это реализуется?
    В 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: "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.


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

    Флант

    276,42

    Специалисты по DevOps и высоким нагрузкам в вебе

    Поделиться публикацией
    Комментарии 5
      +2
      Использую свой велосипед на ZFS, также каждый день приходит новый порезанный дамп базы (10-15гб) с прода, собрал сервис для выдачи баз разработчикам и для каждого review в пайплайнах, правда без документации тут
        0
        Добрый день, можете под спойлер разместить весть файл .gitlab-ci.yml?
          +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
          

            0
            Спасибо, действительно несложно. Еще вопрос. У Вас есть два init контейнера, один скачивает базу, другой ее подготавливает. Насколько я понял, они выполняются всегда по очереди, почему их не объединить в запуск одного скрипта (закачка и подготовка)?
              +1
              На самом деле ничего не мешает :) Как ничего не мешает, и обойтись без инит контейнеров.
              Просто тут каждый контейнер выполняет свою отдельную функцию, мы можем смотреть логи отдельных контейнеров, выделять ресурсы на отдельные стадии (CPU, RAM).

        Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

        Самое читаемое