company_banner

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



    Примечание: эта статья не претендует на статус лучшей практики. В ней описан опыт конкретной реализации инфраструктурной задачи в условиях использования 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.


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

    Флант
    DevOps-as-a-Service, Kubernetes, обслуживание 24×7

    Комментарии 2

      +1
      С точки зрения реализации основная проблема сводится к обеспечению идемпотентности при создании и удалении review-окружений.


      Можно уточнить, что такое «идемпотентность при создании...»? Второе «создание окружения» дает тот же результат, что и первое?
        +3
        Да, верно.

        Нам нужно, чтобы на момент повторного деплоя ревью с нуля (как пример можно взять ситуацию, когда мы перед данным деплоем полностью остановили ревью окружение) у нас гарантированно не было объектов (базы данных, vhost и так далее) из предыдущего деплоя. Именно это и является главной проблемой при реализации — если БД уже существует, то в момент деплоя мы получим ошибку/некорректно созданное окружение. В «обычном» варианте ревью это не проблема — объекты релиза находятся в одном namespace, достаточно полностью удалить namespace. В нашем варианте сложнее, так как еще есть база данных в PostgreSQL и MongoDB + vhost в RabbitMQ на отдельном хосте. Поэтому при повторном деплое должно гарантированно удаляться/создаваться по необходимости.

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

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