Запуск СУБД в Kubernetes — вопрос неоднозначный и дискуссионный. Но если плюсы такого решения для вас перевешивают, запуск одноистансового экземпляра СУБД в Kubernetes с маппингом данных на persistent volume выполнить довольно просто. В этой статье рассказываем, как организовать резервное копирование такого решения, которое станет простым в организации и удобным в поддержке.

СУБД в Kubernetes

У запуска СУБД в Kubernetes есть свои за и против.

Плюсы. Развертывание баз данных в оркестраторе позволяет проще организовать хранение данных микросервисных приложений за счет размещения всех частей приложения в одном логическом пространстве.

Кроме того, расположение базы данных в одном кластере кубернетеса вместе с другими контейнерами приложения экономит человеческие и материальные ресурсы. Например, мы избегаем издержек на сетевое взаимодействие между компонентами приложения. А манифест деплоймента для поднятия экземпляра СУБД в оркестраторе написать проще, чем развернуть СУБД на сервере и организовать её связь с приложением.

Минусы. Контейнеризированное приложение в Kubernetes за счет своей способности к масштабированию при повышении нагрузок требует функциональной репликации всех своих компонент, в том числе и систем управления данными.

При этом философия Kubernetes строится на разворачивании stateless-приложений, не предназначенных для длительного хранения данных. Да, есть механизмы сохранения чувствительных данных, например, persistent volume. Но для серьезных решений с большими нагрузками возникает нетривиальная задача по правильной организации кластера самой СУБД, требующая специального опыта архитектора баз данных.

К тому же облачные провайдеры предлагают базы данных как сервис (DBaaS), которые можно интегрировать с облачным Kubernetes, также предлагаемым как сервис.

Всё же, несмотря на наличие минусов, запуск СУБД в Kubernetes бывает актуален для небольших стартап-команд и RND-подразделений, тестирующих новые решения из-за своей гибкости.

Выбор решения организации бэкапа

Предположим, что при развертывании базы мы предусмотрели отказоустойчивость и сохранность данных выбором в качестве persistent volume диска ceph с фактором репликации 3. Но мы хотим перестраховаться от потери данных за счёт дополнительной копии нашей production-базы данных где-то еще. Оптимальным для этого было бы S3-хранилище. Желательно от того же провайдера, который предоставляет облачный кластер Kubernetes.

Сформулируем алгоритм, который мы хотим получить в качестве рабочего решения:

  • Поднимаем под в Kubernetes.
  • Под делает бэкап production-базы и отправляет полученную копию в S3-бакет.
  • Под должен выполнять дамп раз в сутки. Эту задачу решаем с помощью абстракции CronJob.

С учётом того, что CronJob накладывает ограничения на передаваемые ему в качестве аргументов команды, необходимые для запуска задачи по расписанию, есть 2 способа бэкапа и отправки дампа в S3:

  • Если брать за основу образ postgres и передавать ему в качестве параметра команду pgdump, то возникает проблема с передачей файла в S3.
  • Если брать в качестве основы awscli-образ, то потребуется дополнительные манипуляции для интеграции Postgres утилит.

Мы придумали, как сделать компромиссное решение с помощью python-скрипта.

Python скрипт

В качестве компромиссного решения напишем python скрипт, содержащий все необходимые инструменты: утилиты Postgres становим на этапе сборки докер-образа, а за взаимодействие с S3 будет отвечать библиотека boto.

Первым делом организуем бэкап с удаленного хоста:

import os
os.system('pg_dump -h $PG_HOST -U $PG_USER $PG_DATABASE > pgsql')

Этой командой мы используем утилиту pg_dump для создания бэкапа в файл psql с удаленного хоста. В качестве удаленного хоста выступает под с инстансом postgresql.

Далее нам нужен механизм отправки файла в хранилище S3. Воспользуемся функцией upload_to_s3 из библиотеки boto:

import boto
from boto.s3.key import Key
def upload_to_s3(source_path, destination_filename):
    conn = boto.connect_s3(
        aws_access_key_id = os.getenv('AWS_ACCESS_KEY_ID'),
        aws_secret_access_key = os.getenv('AWS_SECRET_ACCESS_KEY'),
        host = os.getenv('AWS_HOST'),
        port = int(os.getenv('AWS_PORT'))
    )
    bucket = conn.get_bucket(os.getenv('AWS_BUCKET_NAME'))
    k = Key(bucket)
    k.key = destination_filename
    k.set_contents_from_filename(source_path)

Переменные мы передадим из манифеста CronJob, а внутри контейнера при выполнении скрипта будем считывать из окружения. Важно привести переменную порта к числовому типу — из манифеста значение порта придет как строка, а библиотека boto требует integer.

Аргументы функции source_path и destination_filename — названия файла, который мы хотим разместить в бакете S3.

Так как утилита pg_dump на выходе отдает файл pqsql, необходимо добавить к нему данные о времени создании файла. Проверяем, что файл pqsql существует, соответственно, предыдущий шаг с бэкапом был выполнен. Следующим шагом добавим данные о времени и расширении файла и передадим получившийся результат в функцию загрузки в бакет S3:

from datetime import datetime
# pgdump command
if os.path.exists('pgsql'):
    source_path = 'pgsql'
    destination_filename = source_path + '_' + datetime.strftime(datetime.now(), "%Y.%m.%d.%H:%M") + 'UTC' + '.sql'
    upload_to_s3(source_path, destination_filename)

В общем виде наш скрипт backup.py примет такой вид:

import os
import boto
-
from boto.s3.key import Key
from datetime import datetime
def main():
    os.system('pg_dump -h $PG_HOST -U $PG_USER $PG_DATABASE > pgsql')
    if os.path.exists('pgsql'):
        source_path = 'pgsql'
        destination_filename = source_path + '_' + datetime.strftime(datetime.now(), "%Y.%m.%d.%H:%M") + 'UTC' + '.sql'
        upload_to_s3(source_path, destination_filename)
def upload_to_s3(source_path, destination_filename):
    conn = boto.connect_s3(
        aws_access_key_id = os.getenv('AWS_ACCESS_KEY_ID'),
        aws_secret_access_key = os.getenv('AWS_SECRET_ACCESS_KEY'),
        host = os.getenv('AWS_HOST'),
        port = int(os.getenv('AWS_PORT'))
    )
    bucket = conn.get_bucket(os.getenv('AWS_BUCKET_NAME'))
    k = Key(bucket)
    k.key = destination_filename
    k.set_contents_from_filename(source_path)
if __name__ == '__main__':
    main()

Упаковка в docker

Перейдем к следующему этапу — упаковке скрипта в докер образ. Тут механизм прост — на базе легковесного alpine создадим образ с python, pip и postgres на борту. А с помощью pip затянем библиотеку boto.

Dockerfile:

FROM alpine:3.10
RUN apk update && apk add postgresql && apk add gzip && \\
    apk add --no-cache python && \\
    if [ ! -e /usr/bin/python ]; then ln -sf python /usr/bin/python ; fi && \\
    python -m ensurepip && \\
    rm -r /usr/lib/python*/ensurepip && \\
    pip install --no-cache --upgrade pip setuptools wheel && \\
    if [ ! -e /usr/bin/pip ]; then ln -s pip /usr/bin/pip ; fi
RUN pip install boto
COPY backup.py .
ENTRYPOINT ["python"]
CMD ["backup.py"]

CronJob манифест

И, наконец, приступим к описанию манифеста абстракции Kubernetes — CronJob.

CronJob Kubernetes запускается по заявленному расписанию, механизм такой же, как у обычного cron. Аналогично crontab задается расписание задач. Мы хотим делать бэкап раз в сутки в 3:30 по московскому времени. Делаем поправку на UTC и заполняем блок schedule:

apiVersion: batch/v1beta1
kind: CronJob
metadata:
  name: pgdumper
spec:
  schedule: "30 00 * * *"

Важно строго ограничить количество успешных отработанных заданий (successfulJobsHistoryLimit: 2 ), без этого Kubernetes не будет удалять отработанные поды, и вскоре ваш кластер сам превратится в хранилище бэкапов. Переменные, используемые в скрипте бэкапа, в данном примере мы зададим в теле манифеста:

apiVersion: batch/v1beta1
kind: CronJob
metadata:
  name: pgdumper
spec:
  schedule: "30 00 * * *"
  successfulJobsHistoryLimit: 2
  concurrencyPolicy: Replace
  jobTemplate:
    spec:
      template:
        spec:
          restartPolicy: OnFailure
          containers:
          - name: pgdumper
            image: devops/pgdumper:k8s
            env:
              - name: PG_HOST
                value: database-host
       - name: PGPASSWORD
                value: pgpass
       - name: PG_USER
                value: pguser
       - name: PG_DATABASE
                value: maindb
       - name: AWS_ACCESS_KEY_ID
                value: xxxxxxxxxxxxxxxx
       - name: AWS_SECRET_ACCESS_KEY
                value: yyyyyyyyyyyyyyyyyyyyyyyyyy
       - name: AWS_BUCKET_NAME
                value: maindbbackup
       - name: AWS_HOST
                value: hb.bizmrg.com
       - name: AWS_PORT
                value: '443'
            imagePullPolicy: Always

В решении для продуктового кластера эти переменные необходимо зашифровать средствами Kubernetes secret.

Создадим манифест для секрета:

apiVersion: v1
kind: Secret
metadata:
  name: pgdumpersecret
type: Opaque
stringData:
    PG_HOST: "database-host"
    PG_PASSWORD: "pgpass"
    PG_USER: "pguser"
    PG_DATABASE: "maindb"
    AWS_ACCESS_KEY_ID: "xxxxxxxxxxxxxxxx"
    AWS_SECRET_ACCESS_KEY: "yyyyyyyyyyyyyyyyyyyyyyyyyy"
    AWS_BUCKET_NAME: "maindbbackup"
    AWS_HOST: "hb.bizmrg.com"
    AWS_PORT: "443"

А в теле CronJob манифеста для продуктового кластера вызывать переменные будем через обращение к полям секрета pgdumpersecret:

...
          containers:
          - name: pgdumper
            image: devops/pgdumper:k8s
            env:
              - name: PG_HOST
                valueFrom:
                   secretKeyRef:
                      name: pgdumpersecret
                      key: PG_HOST
              - name: PGPASSWORD
                valueFrom:
                   secretKeyRef:
                      name: pgdumpersecret
                      key: PGPASSWORD
              - name: PG_USER
                valueFrom:
                   secretKeyRef:
                      name: pgdumpersecret
                      key: PG_USER
              - name: PG_DATABASE
                valueFrom:
                   secretKeyRef:
                      name: pgdumpersecret
                      key: PG_DATABASE
              - name: AWS_ACCESS_KEY_ID
                valueFrom:
                   secretKeyRef:
                      name: pgdumpersecret
                      key: AWS_ACCESS_KEY_ID
              - name: AWS_SECRET_ACCESS_KEY
                valueFrom:
                   secretKeyRef:
                      name: pgdumpersecret
                      key: AWS_SECRET_ACCESS_KEY
              - name: AWS_BUCKET_NAME
                valueFrom:
                   secretKeyRef:
                      name: pgdumpersecret
                      key: AWS_BUCKET_NAME
              - name: AWS_HOST
                valueFrom:
                   secretKeyRef:
                      name: pgdumpersecret
                      key: AWS_HOST
              - name: AWS_PORT
                valueFrom:
                   secretKeyRef:
                      name: pgdumpersecret
                      key: AWS_PORT
            ...

После всех приготовлений запустим наш CronJob в кластер:

kubectl apply -f backuper-cj.yaml

И запросим этот ресурс:

В нашем примере видно, что CronJob pgdumper последний раз выполнялся 19 часов назад, создан был 9 дней назад. В процессе работы были созданы поды этого ресурса и их можно увидеть в том же нэймспейсе, что и целевая база данных:

По прошествии времени, настроенного в расписании, мы будем наблюдать бэкапы в S3-хранилище VK:

Заделы на будущее

Реализованный механизм создания резервной копии базы данных прост и надежен. Его легко модифицировать, например:

  • добавить сжатие данных при вызове утилиты pgdump;
  • настроить схему хранения бэкапов S3 и удаления более ранних копий дампов.

Платформы, использованные в статье, легко получить в виде облачных сервисов VK Cloud (бывш. MCS)