В своей практике мы часто сталкиваемся с задачей адаптации клиентских приложений для запуска в 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 иногда взаимоисключающие, но всегда есть выход. Рассмотрим, как можно следить за состоянием ресурсов и запускать произвольные команды на примере миграций.
Запуск миграций до релиза
Неотъемлемой частью релиза любого приложения, работающего с базами данных, является обновление схемы данных. Стандартный деплой для приложений, которые применяют миграции с помощью запуска отдельной команды, подразумевает следующие шаги:
- обновление кодовой базы;
- запуск миграции;
- переключение трафика на новую версию приложения.
В рамках Kubernetes процесс должен быть такой же, но с поправкой на то, что нам надо:
- запустить контейнер с новым кодом, который может содержать новый набор миграций;
- запустить в нём процесс применения миграций, сделав это до обновления версии приложения.
Рассмотрим вариант, когда база данных для приложения уже запущена и нам не надо разворачивать её в рамках релиза, который деплоит приложение. Для применения миграций подойдут два хука:
-
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.
Читайте также в нашем блоге: