Запуск команд в процессе доставки нового релиза приложения в Kubernetes
В своей практике мы часто сталкиваемся с задачей адаптации клиентских приложений для запуска в Kubernetes. При проведении данных работ возникает ряд типовых проблем. Одну из них мы недавно осветили в статье Локальные файлы при переносе приложения в Kubernetes, а о другой, связанной уже с процессами CI/CD, — расскажем в этом материале.
Какие шаги необходимы для успешного релиза приложения, если его новая версия деплоится в кластер Kubernetes?
Запуск миграций до релиза
Неотъемлемой частью релиза любого приложения, работающего с базами данных, является обновление схемы данных. Стандартный деплой для приложений, которые применяют миграции с помощью запуска отдельной команды, подразумевает следующие шаги:
- обновление кодовой базы;
- запуск миграции;
- переключение трафика на новую версию приложения.
В рамках Kubernetes процесс должен быть такой же, но с поправкой на то, что нам надо:
- запустить контейнер с новым кодом, который может содержать новый набор миграций;
- запустить в нём процесс применения миграций, сделав это до обновления версии приложения.
Для реализации данной задачи в Kubernetes существует примитив Job, который позволяет запускать pod с контейнерами приложения и отслеживает завершение работы pod«а. Используя Helm для описания инфраструктуры, требуемой для приложений, и их непосредственного деплоя, можно обратиться к hooks. С их помощью части шаблонов могут применяться в определённый этап релизного процесса.
Рассмотрим вариант, когда база данных для приложения уже запущена и нам не надо разворачивать её в рамках релиза, который деплоит приложение. Для применения миграций подойдут два хука:
-
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
Примечание: Для деплоя приложений в Kubernetes мы в действительности используем не просто Helm, а утилиту werf. Приведённый выше YAML-шаблон создан с учётом этой специфики. Чтобы адаптировать его под «чистый» 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. Werf по умолчанию проследит за состоянием всех объектов: как только они перейдут в статус Ready
, запустится Job с миграциями, который описан выше.
Запуск миграций в процессе релиза
Вариант выше подразумевает применение миграций для случая, когда база данных уже запущена. А что, если нам необходимо выкатывать ревью ветки для приложения, и база данных выкатывается вместе с приложением в одном релизе?
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 кластере — это лишь один из этапов. Например, приложение может не запуститься по причине неправильной конфигурации или из-за сетевой проблемы, но для того, чтобы увидеть это придётся выполнить дополнительные действия.
Теперь вернёмся к применению миграций на post-хуках Helm. Проблемы, с которыми мы столкнулись:
- Многие приложения перед своим запуском тем или иным способом проверяют состояние схемы в базе данных. Поэтому без свежих миграций приложение может не запуститься.
- Поскольку werf по умолчанию следит, чтобы все объекты перешли в состояние
Ready
, post-хуки не сработают и миграции не выполнятся. - Слежение за объектами можно отключить через дополнительные аннотации, но тогда невозможно получить достоверную информацию о результатах деплоя.
В итоге, мы пришли к следующему:
- Миграции нужно запускать одновременно с созданием объектов в Kubernetes. Для этого необходимо удалить все 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: 'post-install,post-upgrade'
- 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.
Читайте также в нашем блоге: