company_banner

Запуск команд в процессе доставки нового релиза приложения в Kubernetes



    В своей практике мы часто сталкиваемся с задачей адаптации клиентских приложений для запуска в Kubernetes. При проведении данных работ возникает ряд типовых проблем. Одну из них мы недавно осветили в статье Локальные файлы при переносе приложения в Kubernetes, а о другой, связанной уже с процессами CI/CD, — расскажем в этом материале.

    Произвольные команды с Helm и werf


    Приложение — это не только бизнес-логика и данные, но и набор произвольных команд, которые необходимо выполнить для успешного обновления. Это могут быть, например, миграции для баз данных, «ожидатели» готовности внешних ресурсов, какие-то перекодировщики или распаковщики, регистраторы во внешних Service Discovery — на разных проектах можно встретить разные задачи.

    Что же предлагает Kubernetes для решения таких задач? Kubernetes хорошо умеет запускать контейнеры в виде pod'ов, поэтому стандартное решение — это запуск команды из образа. Для этого в Kubernetes существует примитив Job, позволяющий запускать pod с контейнерами приложения и отслеживает завершение работы этого pod’а.

    Helm идёт чуть дальше и предлагает запускать Job'ы на разных этапах процесса деплоя. Речь про Helm-хуки, с помощью которых можно запустить Job до или после обновления манифестов ресурсов. По нашему опыту, это отличная фича Helm'а, которую можно использовать для решения задач деплоя.

    Однако в Helm нельзя получить актуальную информацию о состоянии объектов во время выката, поэтому мы используем утилиту werf, которая даёт возможность во время выката следить за состоянием ресурсов непосредственно из CI-системы и — в случае неудачи — быстрее диагностировать поломку.

    Как оказалось, эти полезные фичи Helm и werf иногда взаимоисключающие, но всегда есть выход. Рассмотрим, как можно следить за состоянием ресурсов и запускать произвольные команды на примере миграций.

    Запуск миграций до релиза


    Неотъемлемой частью релиза любого приложения, работающего с базами данных, является обновление схемы данных. Стандартный деплой для приложений, которые применяют миграции с помощью запуска отдельной команды, подразумевает следующие шаги:

    1. обновление кодовой базы;
    2. запуск миграции;
    3. переключение трафика на новую версию приложения.

    В рамках Kubernetes процесс должен быть такой же, но с поправкой на то, что нам надо:

    1. запустить контейнер с новым кодом, который может содержать новый набор миграций;
    2. запустить в нём процесс применения миграций, сделав это до обновления версии приложения.

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

    • pre-install — срабатывает при первом Helm-релизе приложения после обработки всех шаблонов, но до создания ресурсов в Kubernetes;
    • pre-upgrade — срабатывает при обновлении Helm-релиза и выполняется, как и pre-install, после обработки шаблонов, однако до создания ресурсов в Kubernetes.

    Пример Job с использованием Helm и двух упомянутых хуков:

    ---
    apiVersion: batch/v1
    kind: Job
    metadata:
     name: {{ .Chart.Name }}-apply-migrations
     annotations:
       "helm.sh/hook": pre-install,pre-upgrade
    spec:
     activeDeadlineSeconds: 60
     backoffLimit: 0
     template:
       metadata:
         name: {{ .Chart.Name }}-apply-migrations
       spec:
         imagePullSecrets:
         - name: {{ required ".Values.registry.secret_name required" .Values.registry.secret_name }}
         containers:
         - name: job
           command: ["/usr/bin/php7.2", "artisan", "migrate", "--force"]
    {{ tuple "backend" . | include "werf_container_image" | indent 8 }}
           env:
    {{ tuple "backend" . | include "werf_container_env" | indent 8 }}
           - name: DB_HOST
             value: postgres
         restartPolicy: Never

    Примечание: приведённый выше YAML-шаблон создан с учётом специфики werf. Чтобы адаптировать его под «чистый» Helm, достаточно:

    • заменить {{ tuple "backend" . | include "werf_container_image" | indent 8 }} на нужный вам образ контейнера;
    • удалить строку {{ tuple "backend" . | include "werf_container_env" | indent 8 }}, которая указана в ключе env.

    Итак, этот Helm-шаблон понадобится добавить в каталог .helm/templates, где уже содержатся остальные ресурсы релиза. При вызове werf deploy --stages-storage :local сначала выполнится обработка всех шаблонов, а затем они будут загружены в кластер Kubernetes.

    Запуск миграций в процессе релиза


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

    NB: С подобной проблемой можно столкнуться и при выкате в production-окружение, если для подключения к базе вы используете Service с endpoint, который содержит IP-адрес базы данных.

    В таком случае хуки pre-install и pre-upgrade нам не подходят, так как приложение будет пытаться применить миграции для ещё не существующей базы данных. Таким образом, надо производить миграции уже после выполнения релиза.

    При использовании Helm такая задача достижима, так как он не отслеживает состояние приложений. После загрузки ресурсов в Kubernetes всегда срабатывают post-хуки:

    • post-install — после загрузки всех ресурсов в K8s при первом релизе;
    • post-upgrade — после обновления всех ресурсов в K8s при обновлении релиза.

    Однако, как мы уже упомянули выше, в werf работает система отслеживания состояния ресурсов во время релиза. Остановлюсь на этом чуть подробнее:

    • Для отслеживания в werf используются возможности библиотеки kubedog, о которой мы уже рассказывали в блоге.
    • Эта фича в werf позволяет нам однозначно определить состояние релиза и отобразить в интерфейсе CI/CD-системы информацию об успешном или неудачном завершении деплоя.
    • Без получения этой информации нельзя говорить ни о какой автоматизации релизного процесса, поскольку успешное создание ресурсов в Kubernetes кластере — это лишь один из этапов. Например, приложение может не запуститься по причине неправильной конфигурации или из-за сетевой проблемы, но для того, чтобы увидеть это после helm upgrade, придётся выполнить дополнительные действия.

    Теперь вернёмся к применению миграций на post-хуках Helm. Проблемы, с которыми мы столкнулись:

    • Многие приложения перед своим запуском тем или иным способом проверяют состояние схемы в базе данных. Поэтому без свежих миграций приложение может не запуститься.
    • Поскольку werf по умолчанию следит, чтобы все объекты перешли в состояние Ready, post-хуки не сработают и миграции не выполнятся.
    • Слежение за объектами можно отключить через дополнительные аннотации, но тогда невозможно получить достоверную информацию о результатах деплоя.

    В итоге, мы пришли к следующему:

    • Job'ы создаются до основных ресурсов, поэтому нет необходимости в использовании Helm-хуков для миграций.
    • Однако Job с миграциями должен запускаться при каждом деплое. Чтобы это происходило, Job должен иметь уникальное имя (случайное): в таком случае для Helm это каждый раз новый объект в релизе, который будет создаваться в Kubernetes.
    • При таком запуске нет смысла волноваться, что будут копиться Job с миграциями, так как все они будут иметь уникальные имена, а предыдущий Job удаляется при новом релизе.
    • Job с миграциями должен иметь init-контейнер, который проверяет доступность базы данных — иначе мы получим упавший деплой (Job упадёт на init-контейнере).

    Получившаяся конфигурация выглядит примерно так:

    ---
    apiVersion: batch/v1
    kind: Job
    metadata:
    name: {{ printf "%s-apply-migrations-%s" .Chart.Name (now | date "2006-01-02-15-04-05") }}
    spec:
      activeDeadlineSeconds: 60
      backoffLimit: 0
      template:
        metadata:
          name: {{ printf "%s-apply-migrations-%s" .Chart.Name (now | date "2006-01-02-15-04-05") }}
        spec:
          imagePullSecrets:
          - name: {{ required ".Values.registry.secret_name required" .Values.registry.secret_name }}
          initContainers:
          - name: wait-db
            image: alpine:3.6
            сommand: ["/bin/sh", "-c", "while ! nc -z postgres 5432; do sleep 1; done;"]
          containers:
          - name: job
            command: ["/usr/bin/php7.2", "artisan", "migrate", "--force"]
    {{ tuple "backend" . | include "werf_container_image" | indent 8 }}
            env:
    {{ tuple "backend" . | include "werf_container_env" | indent 8 }}
            - name: DB_HOST
              value: postgres
          restartPolicy: Never

    NB: Строго говоря, init-контейнеры для проверки доступности базы данных лучше использовать в любом случае.

    Пример универсального шаблона для всех операций деплоя


    Однако операций, которые необходимо выполнить при релизе, может быть больше, чем запуск уже упомянутых миграций. Управлять порядком выполнения Job можно не только через типы хуков, но и задавая каждому из них вес — через аннотацию helm.sh/hook-weight. Хуки сортируются по весу в порядке возрастания, а если вес одинаковый — по именам ресурсов.

    При большом количестве Job’ов удобно сделать универсальный шаблон для Job’а, а конфигурацию вынести в values.yaml. Последний может выглядеть так:

    deploy_jobs:
      - name: migrate
        command: '["/usr/bin/php7.2", "artisan", "migrate", "--force"]'
        activeDeadlineSeconds: 120
        when:
          production: 'pre-install,pre-upgrade'
          staging: 'pre-install,pre-upgrade'
          _default: ''
      - name: cache-clear
        command: '["/usr/bin/php7.2", "artisan", "responsecache:clear"]'
        activeDeadlineSeconds: 60
        when:
          _default: 'post-install,post-upgrade'

    … а сам шаблон — так:

    {{- range $index, $job := .Values.deploy_jobs }}
    ---
    apiVersion: batch/v1
    kind: Job
    metadata:
      name: {{ $.Chart.Name }}-{{ $job.name }}
      annotations:
        "helm.sh/hook": {{ pluck $.Values.global.env $job.when | first | default $job.when._default }}
        "helm.sh/hook-weight": "1{{ $index }}"
    spec:
      activeDeadlineSeconds: {{ $job.activeDeadlineSeconds }}
      backoffLimit: 0
      template:
        metadata:
          name: {{ $.Chart.Name }}-{{ $job.name }}
        spec:
          imagePullSecrets:
          - name: {{ required "$.Values.registry.secret_name required" $.Values.registry.secret_name }}
          initContainers:
          - name: wait-db
            image: alpine:3.6
            сommand: ["/bin/sh", "-c", "while ! nc -z postgres 5432; do sleep 1; done;"]
          containers:
          - name: job
            command: {{ $job.command }}
    {{ tuple "backend" $ | include "werf_container_image" | indent 8 }}
            env:
    {{ tuple "backend" $ | include "werf_container_env" | indent 8 }}
           - name: DB_HOST
             value: postgres
          restartPolicy: Never
    {{- end }}

    Такой подход позволяет быстрее добавлять новые команды в релизный процесс и делает список выполняемых команд более наглядным.

    Заключение


    В статье приведены примеры шаблонов, которые позволяют описать частые операции, что требуется выполнить в процессе релиза новой версии приложения. Хотя они и стали результатом опыта по реализации CI/CD-процессов в десятках проектов, мы не настаиваем, что существует единственно верное решение для всех задач. Если описанные в статье примеры не покрывают потребности вашего проекта, будем рады увидеть в комментариях ситуации, которые помогли бы дополнить этот материал.

    Комментарий от разработчиков werf:
    В будущем в werf планируется внедрение конфигурируемых пользователем стадий деплоя ресурсов. С помощью таких стадий можно будет описать оба кейса и не только.

    P.S.


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

    Флант
    Специалисты по DevOps и Kubernetes

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

      0

      Вопросы из зала:


      • насколько жизненно необходимо выносить миграции в отдельный под? Тем более, если мы делаем zero-downtime деплой? Какие минусы, если миграции будут выполняться в основном деплойменте и не он зажжет готовность, пока они не пройдут (а, следовательно, и роллаут дальше не пройдет)? Какие аргументы можно привести разработчикам в пользу того, что миграции нужно выносить наружу?

      У коллег из Weave есть аналогичная статья, но она описывает более высокоуровневые вещи, а в настоящей статье — подкапотная машинерия больше.


      Ну, и интересно выглядит идея использовать оператор для этой машинерии — вроде https://github.com/quay/dba-operator

        0
        По поводу вынесения миграций отдельно есть простой пример. Допустим Deployment приложения запущен с достаточно большим количеством реплик, таком что при стандартных настройках RollingUpdate при релизе одновременно запускается больше одного пода приложения. Получается, что паралелльно запустится выполнение нескольких процессов наката миграций. И тут могут возникнуть проблемы.

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

          > Liquibase does implement an exclusive lock, using the ACID transactional features of your RDBMS. This prevents multiple instances of Liquibase from performing schema migrations concurrently. It is accomplished by doing transactional updates to the DATABASECHANGELOGLOCK table that is added to your schema by Liquibase.

          stackoverflow.com/a/25785040/698689

          Поэтому даже в роллинг апдейте пачками со встроенными миграциям ничего плохого не должно случиться. Если же используемый фреймворк для наката миграций не обладает такой функциональностью — ну, что ж, придется самому дейтвительно выносить миграции в отдельный под и обеспечивать руками семантику «at most one»
            0
            Я уверен, что есть много фреймворков, в которых данная проблема решена. Также я уверен в том, что данная проблема встречается в каком-то числе фреймворков, особенно в старых. Вот, например, обсуждение для laravel github.com/laravel/ideas/issues/1645

            Если есть уверенность в том, что на уровне фреймворка и в конкретно взятом приложении не возникнет проблем из-за паралелльного запуска миграций, то, конечно, можно запускать миграции в рамках пода приложения. Здесь как всегда нет единственно верного решения, я больше писал про универсальное решение, которое точно работает и не создаёт проблем.
        +1
        Есть ощущение, что для оркестрации всех этих дел логичнее задействовать Tekton — гибче и универсальнее.

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

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