Из жизни с Kubernetes: Как мы выносили СУБД (и не только) из review-окружений в статическое

aeyuzw9pz0k6d0pbc0g86pxkfd0.png

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

Использование review-окружений в CI/CD может быть весьма полезным, причём как для разработчиков, так и для системных инженеров. Давайте для начала синхронизируем общие представления о них:

  1. Review-окружения могут создаваться из отдельных веток в Git-репозитории, определяемых разработчиками (так называемые feature-ветки).
  2. Они могут иметь отдельные экземпляры СУБД, обработчиков очередей, кэширующих сервисов и т.п. — в общем, всё для полноценного воспроизведения production-окружения.
  3. Они позволяют вести параллельную разработку, значительно ускоряя выпуск новых функций в приложении. При этом каждый день могут потребоваться десятки подобных окружений, из-за чего скорость их создания критична.


На пересечении второго и третьего пунктов зачастую и возникают сложности: поскольку инфраструктура бывает очень разной, её компоненты могут деплоиться долгое время. В это затрачиваемое время, например, входит восстановление базы данных из уже подготовленного бэкапа*. Статья — о том, каким увлекательным путем мы однажды отправились для решения такой проблемы.

* Кстати, конкретно о больших дампах БД в этом контексте мы уже писали в материале про ускорение bootstrap«а БД.)

Проблема и путь её решения


В одном из проектов нам поставили задачу «создать единую точку входа для разработчиков и QA-инженеров». За этой формулировкой скрывалось технически следующее:

  1. Для упрощения работы QA-инженеров и некоторых других сотрудников — вынести все базы данных (и соответствующие vhost’ы), используемые при review, в отдельное — статическое — окружение. По сложившимся в проекте причинам, такой способ взаимодействия с ними был оптимальным.
  2. Уменьшить время создания review-окружения. Подразумевается весь процесс их создания с нуля, т.е. включая клонирование БД, выполнение миграций и т.д.


С точки зрения реализации основная проблема сводится к обеспечению идемпотентности при создании и удалении review-окружений. Чтобы добиться этого, мы изменили механизм создания review-окружений, предварительно перенеся сервисы PostgreSQL, MongoDB и RabbitMQ в статическое окружение. Под статическим понимается такое «постоянное» окружение, которое не будет создаваться по запросу пользователя (как это происходит в случае review-окружений).

Важно! Сам подход со статическим окружением далек от идеального — о его конкретных недостатках см. в завершении статьи. Однако мы в подробностях делимся этим опытом, поскольку он может быть в той или иной степени применим в иных задачах, а заодно послужить аргументом при обсуждении вопросов проектирования инфраструктуры.

Итак, последовательность действий в реализации:

  • При создании review-окружения единожды должно произойти: создание баз данных в двух СУБД (MongoDB и PostgreSQL), восстановление баз данных из бэкапа/шаблона, а также создание vhost«а в RabbitMQ. При этом потребуется удобный способ загружать актуальные дампы. (Если у вас и раньше были review-окружения, то, вероятнее всего, готовое решение для этого уже есть.)
  • После завершения работы review-окружения необходимо удалить БД и виртуальный хост в RabbitMQ.


В нашем случае инфраструктура функционирует в рамках Kubernetes (с использованием Helm). Поэтому для реализации вышеописанных задач отлично подошли Helm-хуки. Они могут выполняться как перед созданием всех остальных компонентов в Helm-релизе, так и/или после их удаления. Поэтому:

  • для задачи инициализации воспользуемся хуком pre-install, чтобы запускать его перед созданием всех ресурсов в релизе;
  • для задачи удаления — хуком post-delete.


Перейдем к деталям реализации.

Практическая реализация


В изначальном варианте в этом проекте использовался только один Job, состоящий из трех контейнеров. Конечно, это не совсем удобно, поскольку в итоге получается большой манифест, который банально сложно прочитать. Поэтому мы разделили его на три небольших Job«а.

Ниже представлен листинг для PostgreSQL, а два остальных (MongoDB и RabbitMQ) идентичны ему по структуре манифеста:

{{- if .Values.global.review }}
---
apiVersion: batch/v1
kind: Job
metadata:
  name: db-create-postgres-database
  annotations:
    "helm.sh/hook": "pre-install"
    "helm.sh/hook-weight": "5"
spec:
  template:
    metadata:
      name: init-db-postgres
    spec:
      volumes:
      - name: postgres-scripts
        configMap:
          defaultMode: 0755
          name: postgresql-configmap
      containers:
      - name: init-postgres-database
        image: private-registry/postgres 
        command: ["/docker-entrypoint-initdb.d/01-review-load-dump.sh"]
        volumeMounts:
        - name: postgres-scripts
          mountPath: /docker-entrypoint-initdb.d/01-review-load-dump.sh
          subPath: review-load-dump.sh
        env:
{{- include "postgres_env" . | indent 8 }}
      restartPolicy: Never
{{- end }}


Комментарии по содержимому манифеста:

  1. Job предназначен только для review-окружений. Статус review устанавливается в CI/CD и дальше передается в виде одноименной Helm-переменной (см. if с .Values.global.review в первой строке листинга).
  2. Помимо Job мы создаем и другие объекты — например, ConfigMap. Мы их импортируем к себе в контейнер, а следовательно, они уже должны существовать на тот момент. Чтобы их создание происходило в первую очередь, задействован hook-weight.
  3. В самом контейнере будут использоваться cURL и другие утилиты, которые могут не входить в базовый образ PostgreSQL, поэтому используется его аналог с предустановленными пакетами.
  4. Для работы с внешней инсталляцией PostgreSQL требуются данные для подключения: они перенесены в переменные окружения, которые будут использоваться в shell-скриптах ниже.


PostgreSQL


Самое интересное находится в уже упомянутом в листинге shell-скрипте (review-load-dump.sh). Какие вообще есть варианты восстановления БД в PostgreSQL?

  1. «Стандартное» восстановление из бэкапа;
  2. Восстановление с помощью шаблонов.


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

С помощью второго варианта (восстановление с шаблонами) можно клонировать базу данных на физическом уровне, не отправляя в нее данные удаленно из контейнера в другом окружении — это уменьшает время восстановления. Однако есть ограничение: нельзя клонировать БД, к которой остаются активные соединения. Так как в качестве статического окружения у нас используется именно stage (а не отдельное окружение для review), требуется сделать вторую базу данных и конвертировать ее в шаблон, ежедневно обновляя (например, по утрам). Для этого был подготовлен небольшой CronJob:

---
apiVersion: batch/v1beta1
kind: CronJob
metadata:
  name: update-postgres-template
spec:
  schedule: "50 4 * * *"
  concurrencyPolicy: Forbid
  successfulJobsHistoryLimit: 3
  failedJobsHistoryLimit: 3
  startingDeadlineSeconds: 600
  jobTemplate:
    spec:
      template:
        spec:
          restartPolicy: Never
          imagePullSecrets:
          - name: registrysecret
          volumes:
          - name: postgres-scripts
            configMap:
              defaultMode: 0755
              name: postgresql-configmap-update-cron
          containers:
          - name: cron
            command: ["/docker-entrypoint-initdb.d/update-postgres-template.sh"]
          image: private-registry/postgres 
            volumeMounts:
            - name: postgres-scripts
              mountPath: /docker-entrypoint-initdb.d/update-postgres-template.sh
              subPath: update-postgres-template.sh
            env:
{{- include "postgres_env" . | indent 8 }}


Полный манифест с ConfigMap, содержащий скрипт, скорее всего не имеет большого смысла (сообщайте в комментариях, если это не так). Вместо него приведу самое главное — bash-скрипт:

#!/bin/bash -x

CREDENTIALS="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}/postgres"

psql -d "${CREDENTIALS}" -w -c "REVOKE CONNECT ON DATABASE ${POSTGRES_DB_TEMPLATE} FROM public"
psql -d "${CREDENTIALS}" -w -c "SELECT pg_terminate_backend(pg_stat_activity.pid) FROM pg_stat_activity WHERE pg_stat_activity.datname = '${POSTGRES_DB_TEMPLATE}'"

curl --fail -vsL ${HOST_FORDEV}/latest_${POSTGRES_DB_STAGE}.psql -o /tmp/${POSTGRES_DB}.psql

psql -d "${CREDENTIALS}" -w -c "ALTER DATABASE ${POSTGRES_DB_TEMPLATE} WITH is_template false allow_connections true;"
psql -d "${CREDENTIALS}" -w -c "DROP DATABASE ${POSTGRES_DB_TEMPLATE};" || true
psql -d "${CREDENTIALS}" -w -c "CREATE DATABASE ${POSTGRES_DB_TEMPLATE};" || true
pg_restore -U ${POSTGRES_USER} -h ${POSTGRES_HOST} -w -j 4 -d ${POSTGRES_DB_TEMPLATE} /tmp/${POSTGRES_DB}.psql

psql -d "${CREDENTIALS}" -w -c "ALTER DATABASE ${POSTGRES_DB_TEMPLATE} WITH is_template true allow_connections false;"

rm -v /tmp/${POSTGRES_DB}.psql


Восстанавливать можно сразу несколько БД из одного шаблона без каких-либо конфликтов. Главное — чтобы подключения к БД были запрещены, а сама база данных — была шаблоном. Это делается в предпоследнем шаге.

Манифест, содержащий shell-скрипт для восстановления БД, получился таким:

---
apiVersion: v1
kind: ConfigMap
metadata:
  name: postgresql-configmap
  annotations:
    "helm.sh/hook": "pre-install"
    "helm.sh/hook-weight": "1"
    "helm.sh/hook-delete-policy": before-hook-creation,hook-succeeded
data:
  review-load-dump.sh: |
    #!/bin/bash -x
    
 
 
    CREDENTIALS="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}/postgres"

    if [ "$( psql -d "${CREDENTIALS}" -tAc "SELECT CASE WHEN EXISTS (SELECT * FROM pg_stat_activity WHERE datname = '${POSTGRES_DB}' LIMIT 1) THEN 1 ELSE 0 END;" )" = '1' ]
      then
          echo "Open connections has been found in ${POSTGRES_DB} database, will drop them"
          psql -d "${CREDENTIALS}" -c "SELECT pg_terminate_backend(pg_stat_activity.pid) FROM pg_stat_activity WHERE pg_stat_activity.datname = '${POSTGRES_DB}' -- AND pid <> pg_backend_pid();"
      else
          echo "No open connections has been found ${POSTGRES_DB} database, skipping this stage"
    fi

    psql -d "${CREDENTIALS}" -c "DROP DATABASE ${POSTGRES_DB}"

    if [ "$( psql -d "${CREDENTIALS}" -tAc "SELECT 1 FROM pg_database WHERE datname='${POSTGRES_DB}'" )" = '1' ]
      then
          echo "Database ${POSTGRES_DB} still exists, delete review job failed"
          exit 1
      else
          echo "Database ${POSTGRES_DB} does not exist, skipping"
    fi


    psql ${CREDENTIALS} -d postgres -c 'CREATE DATABASE ${POSTGRES_DB} TEMPLATE "loot-stage-copy"'


Как видно, здесь задействованы hook-delete-policy. Подробно о применении этих политик написано здесь. В приведенном манифесте мы используем before-hook-creation,hook-succeeded, которые позволяют выполнить следующие требования: удалять предыдущий объект перед созданием нового хука и удалять только тогда, когда хук был выполнен успешно.

Удаление базы данных опишем в таком ConfigMap’е:

---
apiVersion: v1
kind: ConfigMap
metadata:
  name: postgresql-configmap-on-delete
  annotations:
    "helm.sh/hook": "post-delete, pre-delete"
    "helm.sh/hook-weight": "1"
    "helm.sh/hook-delete-policy": before-hook-creation
data:
  review-delete-db.sh: |
    #!/bin/bash -e

    CREDENTIALS="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}/postgres"

    psql -d "${CREDENTIALS}" -w postgres -c "DROP DATABASE ${POSTGRES_DB}"


Хотя мы и вынесли в отдельный ConfigMap, его можно поместить в обычный command в Job. Ведь из него можно сделать однострочник, не усложнив вид самого манифеста.

Если вариант с шаблонами PostgreSQL по какой-то причине не устраивает или не подходит, можно вернуться к упомянутому выше «стандартному» пути восстановления с помощью бэкапа. Алгоритм будет тривиален:

  1. Каждую ночь бэкап базы данных делается так, чтобы его можно было загрузить из локальной сети кластера.
  2. В момент создания review-окружения загружается и восстанавливается база данных из дампа.
  3. Когда дамп развернут, выполняются все остальные действия.


В таком случае скрипт для восстановления станет примерно следующим:

---
apiVersion: v1
kind: ConfigMap
metadata:
  name: postgresql-configmap
  annotations:
    "helm.sh/hook": "pre-install"
    "helm.sh/hook-weight": "1"
    "helm.sh/hook-delete-policy": before-hook-creation,hook-succeeded
data:
  review-load-dump.sh: |
    #!/bin/bash -x

    CREDENTIALS="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}/postgres"
    psql -d "${CREDENTIALS}" -w -c "DROP DATABASE ${POSTGRES_DB}" || true
    psql -d "${CREDENTIALS}" -w -c "CREATE DATABASE ${POSTGRES_DB}"

    curl --fail -vsL ${HOST_FORDEV}/latest_${POSTGRES_DB_STAGE}.psql -o /tmp/${POSTGRES_DB}.psql

    psql psql -d "${CREDENTIALS}" -w -c "CREATE EXTENSION ip4r;"
    pg_restore -U ${POSTGRES_USER} -h ${POSTGRES_HOST} -w -j 4 -d ${POSTGRES_DB} /tmp/${POSTGRES_DB}.psql
    rm -v /tmp/${POSTGRES_DB}.psql


Порядок действий соответствует тому, что уже был описан выше. Единственное изменение — добавлено удаление psql-файла после проведения всех работ.

Примечание: и в скрипте восстановления, и в скрипте удаления каждый раз удаляется база данных. Это сделано для избежания возможных конфликтов во время повторного создания review: необходимо убедиться, что база действительно удалена. Также эту проблему потенциально можно решить добавлением флага --clean в утилите pg_restore, однако будьте осторожны: этот флаг очищает данные только тех элементов, которые находятся в самом дампе, поэтому в нашем случае такой вариант не подходит.

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

MongoDB


Следующий компонент — это MongoDB. Главная сложность с ней заключается в том, что для этой СУБД вариант с копированием базы данных (как в PostgreSQL) существует скорее номинально, потому что:

  1. Он находится в состоянии deprecated.
  2. По итогам нашего тестирования мы не обнаружили большой разницы во времени восстановления базы данных в сравнении с обычным mongo_restore. Однако отмечу, что тестирование производилось в рамках одного проекта — в вашем случае результаты могут быть совершенно иными.


Получается, что в случае большого объема БД может возникнуть серьезная проблема: мы экономим время на восстановлении базы данных в PgSQL, но при этом очень долго восстанавливаем дамп в Mongo. На момент написания статьи и в рамках имеющейся инфраструктуры мы видели три пути (к слову, их можно совместить):

  1. Восстановление может идти долго, например, если ваша СУБД находится на сетевой файловой системе (для случаев не с production-окружением). Тогда можно просто перенести СУБД от stage«а на отдельный узел и использовать local storage. Раз это не production, для нас более критична скорость создания review.
  2. Можно вынести каждый Job восстановления в отдельный pod, позволив предварительно выполниться миграциям и другим процессам, которые зависят от работы СУБД. Так мы сэкономим время, выполнив их заранее.
  3. Иногда можно уменьшить размер дампа путем удаления старых/неактуальных данных — вплоть до того, что достаточно оставить только структуру БД. Конечно, это не для тех случаев, когда требуется полный дамп (скажем, для задач QA-тестирования).


Если же у вас нет потребности быстро создавать review-окружения, то все описанные сложности можно проигнорировать.

Мы же, не имея возможности копировать БД аналогично PgSQL, пойдем первым путем, т.е. стандартным восстановлением из бэкапа. Алгоритм — такой же, как с PgSQL. В этом легко убедиться, если посмотреть на манифесты:

---
apiVersion: v1
kind: ConfigMap
metadata:
  name: mongodb-scripts-on-delete
  annotations:
    "helm.sh/hook": "post-delete, pre-delete"
    "helm.sh/hook-weight": "1"
    "helm.sh/hook-delete-policy": before-hook-creation
data:
  review-delete-db.sh: |
    #!/bin/bash -x

    mongo ${MONGODB_NAME} --eval "db.dropDatabase()" --host ${MONGODB_REPLICASET}/${MONGODB_HOST}
---
apiVersion: v1
kind: ConfigMap
metadata:
  name: mongodb-scripts
  annotations:
    "helm.sh/hook": "pre-install"
    "helm.sh/hook-weight": "1"
    "helm.sh/hook-delete-policy": before-hook-creation,hook-succeeded
data:
  review-load-dump.sh: |
    #!/bin/bash -x

    curl --fail -vsL ${HOST_FORDEV}/latest_${MONGODB_NAME_STAGE}.gz -o /tmp/${MONGODB_NAME}.gz

    mongo ${MONGODB_NAME} --eval "db.dropDatabase()" --host ${MONGODB_REPLICASET}/${MONGODB_HOST}
    mongorestore --gzip --nsFrom "${MONGODB_NAME_STAGE}.*" --nsTo "${MONGODB_NAME}.*" --archive=/tmp/${MONGODB_NAME}.gz --host ${MONGODB_REPLICASET}/${MONGODB_HOST}


Здесь есть важная деталь. В нашем случае MongoDB находится в кластере и нужно быть уверенными, что подключение всегда происходит к узлу Primary. Если указать, например, первый хост в кворуме, то он может через некоторое время перейти из Primary в Secondary, из-за чего не получится создать БД. Поэтому нужно подключаться не к одному хосту, а сразу к ReplicaSet, перечисляя все хосты в нем. Уже только по этой причине требуется сделать MongoDB в виде StatefulSet, чтобы названия хостов всегда были одинаковыми (не говоря уже о том, что MongoDB является stateful-приложением по своей природе). В таком варианте вы гарантированно будете подключаться именно к узлу Primary.

Для MongoDB мы тоже удаляем БД перед созданием review — это сделано по тем же причинам, что и в PostgreSQL.

Последний нюанс: так как база данных для review находится в том же окружении, что и stage, требуется отдельное название для клонируемой базы данных. Если дамп не является BSON-файлом, то произойдет следующая ошибка:

the --db and --collection args should only be used when restoring from a BSON file. Other uses are deprecated and will not exist in the future; use --nsInclude instead


Поэтому в примере выше используются --nsFrom и --nsTo.

Других проблем с восстановлением мы не встречали. Напоследок, добавлю только, что документация по copyDatabase в MongoDB доступна здесь — на тот случай, если вы захотите попробовать такой вариант.

RabbitMQ


Последним приложением в списке наших требований стал RabbitMQ. С ним просто: нужно создавать новый vhost от имени пользователя, которым будет подключаться приложение. А затем его удалять.

Манифест для создания и удаления vhost«ов:

---
apiVersion: v1
kind: ConfigMap
metadata:
  name: rabbitmq-configmap
  annotations:
    "helm.sh/hook": "pre-install"
    "helm.sh/hook-weight": "1"
    "helm.sh/hook-delete-policy": before-hook-creation,hook-succeeded
data:
  rabbitmq-setup-vhost.sh: |
    #!/bin/bash -x

    /usr/local/bin/rabbitmqadmin -H ${RABBITMQ_HOST} -u ${RABBITMQ_USER} -p ${RABBITMQ_PASSWORD} declare vhost name=${RABBITMQ_VHOST}
---
apiVersion: v1
kind: ConfigMap
metadata:
  name: rabbitmq-configmap-on-delete
  annotations:
    "helm.sh/hook": "post-delete, pre-delete"
    "helm.sh/hook-weight": "1"
    "helm.sh/hook-delete-policy": before-hook-creation
data:
  rabbitmq-delete-vhost.sh: |
    #!/bin/bash -x

    /usr/local/bin/rabbitmqadmin -H ${RABBITMQ_HOST} -u ${RABBITMQ_USER} -p ${RABBITMQ_PASSWORD} delete vhost name=${RABBITMQ_VHOST}


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

Недостатки


Почему это решение не претендует на «лучшие практики»?

  1. Получается единая точка отказа в виде stage-окружения.
  2. Если приложение в stage-окружении работает только в одну реплику, мы становимся еще более зависимыми от узла, на котором работает это приложение. Соответственно, с увеличением количества review-окружений пропорционально увеличивается нагрузку на узел без возможности эту нагрузку сбалансировать.


Решить две эти проблемы с учетом возможностей инфраструктуры конкретного проекта полноценно не получилось, однако минимизировать потенциальный ущерб можно кластеризацией (добавлением новых узлов) и вертикальным масштабированием.

Заключение


По мере развития приложения и с увеличением количества разработчиков, рано или поздно повышается нагрузка на review-окружения и добавляются новые требования к ним. Разработчикам важно как можно быстрее доставлять очередные изменения в production, но чтобы это стало возможным, нужны динамические review-окружения, которые делают разработку «параллельной». Как следствие, растет и нагрузка на инфраструктуру, и увеличивается время создания таких окружений.

Эта статья была написана на основе реального и довольно специфичного опыта. Только в исключительных случаях мы выделяем какие-либо службы в статические окружения, и здесь речь шла именно о нем. Такая вынужденная мера позволила ускорить разработку и отладку приложения — благодаря возможности быстрого создания review-окружений с нуля.

Когда мы начинали делать эту задачу, она казалась очень простой, но по мере работы над ней обнаружили множество нюансов. Именно их и собрали в итоговой статье: пусть они не универсальны, но могут послужить примером для основы / вдохновения собственных решений по ускорению review-окружений.

P.S.


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

© Habrahabr.ru