Примечание: эта статья не претендует на статус лучшей практики. В ней описан опыт конкретной реализации инфраструктурной задачи в условиях использования Kubernetes и Helm, который может быть полезен при решении родственных проблем.
Использование review-окружений в CI/CD может быть весьма полезным, причём как для разработчиков, так и для системных инженеров. Давайте для начала синхронизируем общие представления о них:
- Review-окружения могут создаваться из отдельных веток в Git-репозитории, определяемых разработчиками (так называемые feature-ветки).
- Они могут иметь отдельные экземпляры СУБД, обработчиков очередей, кэширующих сервисов и т.п. — в общем, всё для полноценного воспроизведения production-окружения.
- Они позволяют вести параллельную разработку, значительно ускоряя выпуск новых функций в приложении. При этом каждый день могут потребоваться десятки подобных окружений, из-за чего скорость их создания критична.
На пересечении второго и третьего пунктов зачастую и возникают сложности: поскольку инфраструктура бывает очень разной, её компоненты могут деплоиться долгое время. В это затрачиваемое время, например, входит восстановление базы данных из уже подготовленного бэкапа*. Статья — о том, каким увлекательным путем мы однажды отправились для решения такой проблемы.
* Кстати, конкретно о больших дампах БД в этом контексте мы уже писали в материале про ускорение bootstrap’а БД.)
Проблема и путь её решения
В одном из проектов нам поставили задачу «создать единую точку входа для разработчиков и QA-инженеров». За этой формулировкой скрывалось технически следующее:
- Для упрощения работы QA-инженеров и некоторых других сотрудников — вынести все базы данных (и соответствующие vhost'ы), используемые при review, в отдельное — статическое — окружение. По сложившимся в проекте причинам, такой способ взаимодействия с ними был оптимальным.
- Уменьшить время создания 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 }}
Комментарии по содержимому манифеста:
- Job предназначен только для review-окружений. Статус review устанавливается в CI/CD и дальше передается в виде одноименной Helm-переменной (см.
if
с.Values.global.review
в первой строке листинга). - Помимо Job мы создаем и другие объекты — например, ConfigMap. Мы их импортируем к себе в контейнер, а следовательно, они уже должны существовать на тот момент. Чтобы их создание происходило в первую очередь, задействован
hook-weight
. - В самом контейнере будут использоваться cURL и другие утилиты, которые могут не входить в базовый образ PostgreSQL, поэтому используется его аналог с предустановленными пакетами.
- Для работы с внешней инсталляцией PostgreSQL требуются данные для подключения: они перенесены в переменные окружения, которые будут использоваться в shell-скриптах ниже.
PostgreSQL
Самое интересное находится в уже упомянутом в листинге shell-скрипте (review-load-dump.sh
). Какие вообще есть варианты восстановления БД в PostgreSQL?
- «Стандартное» восстановление из бэкапа;
- Восстановление с помощью шаблонов.
В нашем случае разница между двумя этими подходами заключается в первую очередь в скорости создания базы данных для нового окружения. В первом — мы загружаем дамп базы данных и восстанавливаем ее с помощью 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 по какой-то причине не устраивает или не подходит, можно вернуться к упомянутому выше «стандартному» пути восстановления с помощью бэкапа. Алгоритм будет тривиален:
- Каждую ночь бэкап базы данных делается так, чтобы его можно было загрузить из локальной сети кластера.
- В момент создания review-окружения загружается и восстанавливается база данных из дампа.
- Когда дамп развернут, выполняются все остальные действия.
В таком случае скрипт для восстановления станет примерно следующим:
---
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) существует скорее номинально, потому что:
- Он находится в состоянии deprecated.
- По итогам нашего тестирования мы не обнаружили большой разницы во времени восстановления базы данных в сравнении с обычным
mongo_restore
. Однако отмечу, что тестирование производилось в рамках одного проекта — в вашем случае результаты могут быть совершенно иными.
Получается, что в случае большого объема БД может возникнуть серьезная проблема: мы экономим время на восстановлении базы данных в PgSQL, но при этом очень долго восстанавливаем дамп в Mongo. На момент написания статьи и в рамках имеющейся инфраструктуры мы видели три пути (к слову, их можно совместить):
- Восстановление может идти долго, например, если ваша СУБД находится на сетевой файловой системе (для случаев не с production-окружением). Тогда можно просто перенести СУБД от stage’а на отдельный узел и использовать local storage. Раз это не production, для нас более критична скорость создания review.
- Можно вынести каждый Job восстановления в отдельный pod, позволив предварительно выполниться миграциям и другим процессам, которые зависят от работы СУБД. Так мы сэкономим время, выполнив их заранее.
- Иногда можно уменьшить размер дампа путем удаления старых/неактуальных данных — вплоть до того, что достаточно оставить только структуру БД. Конечно, это не для тех случаев, когда требуется полный дамп (скажем, для задач 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 мы (пока?) не столкнулись. В целом, этот же подход может распространяться и на любые другие сервисы, в которых нет критичной завязки на данные.
Недостатки
Почему это решение не претендует на «лучшие практики»?
- Получается единая точка отказа в виде stage-окружения.
- Если приложение в stage-окружении работает только в одну реплику, мы становимся еще более зависимыми от узла, на котором работает это приложение. Соответственно, с увеличением количества review-окружений пропорционально увеличивается нагрузку на узел без возможности эту нагрузку сбалансировать.
Решить две эти проблемы с учетом возможностей инфраструктуры конкретного проекта полноценно не получилось, однако минимизировать потенциальный ущерб можно кластеризацией (добавлением новых узлов) и вертикальным масштабированием.
Заключение
По мере развития приложения и с увеличением количества разработчиков, рано или поздно повышается нагрузка на review-окружения и добавляются новые требования к ним. Разработчикам важно как можно быстрее доставлять очередные изменения в production, но чтобы это стало возможным, нужны динамические review-окружения, которые делают разработку «параллельной». Как следствие, растет и нагрузка на инфраструктуру, и увеличивается время создания таких окружений.
Эта статья была написана на основе реального и довольно специфичного опыта. Только в исключительных случаях мы выделяем какие-либо службы в статические окружения, и здесь речь шла именно о нем. Такая вынужденная мера позволила ускорить разработку и отладку приложения — благодаря возможности быстрого создания review-окружений с нуля.
Когда мы начинали делать эту задачу, она казалась очень простой, но по мере работы над ней обнаружили множество нюансов. Именно их и собрали в итоговой статье: пусть они не универсальны, но могут послужить примером для основы / вдохновения собственных решений по ускорению review-окружений.
P.S.
Читайте также в нашем блоге:
- «Kubernetes tips & tricks: ускоряем bootstrap больших баз данных»;
- «Беспростойная миграция RabbitMQ в Kubernetes»;
- «Беспростойная миграция MongoDB в Kubernetes»;
- «Запуск команд в процессе доставки нового релиза приложения в Kubernetes».
Автор: Ilya Andreev