В этой статье я расскажу и покажу, как при помощи Helm и некоторых дополнительных инструментов построить и настроить автоматическое развертывание в Kubernetes для системы из (микро)сервисов и не потеряться во множестве шаблонов и манифестов. Мы успешно реализовали такой подход у себя. Если у вас есть подобная задача или нечто похожее, надеюсь, статья окажется для вас полезной целиком или в качестве источника "рецептов".
Немного о системе и задаче развертывания
Меня зовут Михаил, я CTO в Exerica и Deputy CEO в Qoollo. В Exerica у нас построена система с сервисной архитектурой: больше десятка сервисов, NoSQL база данных и централизованные логирование с мониторингом.
Для работы с кодом и CI/CD мы используем собственный экземпляр GitLab. Каждое приложение имеет свой репозиторий, где происходит его сборка, в результате которой контейнер с приложением очередной версии помещается в registry. Версионирование автоматизировано: разработчик задает только старшую и младшую версии, а номер билда генерируется системой сборки. Для версий, выбираемых не из "основной" ветки также автоматически проставляется feature-версия из номера задачи в трекере.
Обычно у нас бывает несколько развертываний на промышленную среду в день. Также практически у каждого разработчика есть возможность развернуть полнофункциональный экземпляр системы. Поэтому нам нужен простой механизм сборки системы из набора сервисов конкретных версий. И удобный автоматический механизм отката изменений, если что-то пошло не так. Ранее у нас была разработана автоматизация развертывания в кластер Docker Swarm с помощью Ansible.
Она успешно проработала несколько лет, но стала приносить все больше проблем:
Невысокая отказоустойчивость схемы с одним входным Nginx
С ростом числа приложений появилось много однотипных плейбуков и шаблонных файлов для сервисов Docker Swarm, их стало сложно поддерживать
Просадки производительности оверлейной сети Docker Swarm: периодически без видимых причин она падала до нескольких мегабит/с на гигабитных линках
Недостаточная гибкость в управлении ресурсами для приложений
Неудобное управление внешним DNS для публикуемых сервисов через Ansible-модуль
Неудобное управление TLS-сертификатами
В итоге мы приняли решение перевести систему в self-managed Kubernetes. При переводе мы сразу поставили задачу максимально автоматизировать генерацию манифестов для приложений и ресурсов. В идеале для стандартных случаев включение сервиса в систему должно требовать от разработчика только задания его конфигурации, ограничения по ресурсам и размещению сервиса. БД, сервисы монторинга и сбора логов мы оставили как есть, вне Kubernetes.
С переходом на Kubernetes мы решили принципиально не изменять процесс сборки и развертывания. Исторически для процесса развертывания у нас был выделен отдельный git-репозиторий (деплой-репозиторий), в котором сосредоточены все скрипты и конфиги деплоя. Деплой выполняется задачами Gitlab CI. При запуске CI-пайплайна ветки develop развертывается промышленная среда, при запуске пайплайна для ветки, связанной с задачей в трекере (начинается с уникального номера), развертывается тестовая среда. Для идентификации тестовых сред используется тот самый номер ветки. Минимально, что нужно сделать релиз-менеджеру или разработчику - это вписать нужные версии приложений и выполнить коммит. Весь остальной процесс выполняет пайплайн Gitlab CI. Его успешное завершение говорит о том, что развертывание выполнено и развернутая система работоспособна.
Выбор инструментов
Одним из удобных инструментов, который позволял реализовать практически все вышеприведенные требования — Helm, пакетный менеджер для Kubernetes. Как написано на сайте helm.sh:
Helm помогает управлять приложениями Kubernetes — Helm Charts помогут вам определить, установить и обновить даже самое сложное приложение Kubernetes.
Helm позволяет описать как отдельные приложения, так и системы в целом в виде набора так называемых "диаграмм" (charts) или пакетов. В официальной документации это понятие в основном не переводится, а часто их называют просто "чартами". Я буду пользоваться этим термином, чтобы избежать терминологической путаницы с "пакетами" и "диаграммами".
Очень кратко, чарт представляет собой набор файлов, содержащих шаблоны и параметры, из которых helm собирает манифесты для всех необходимых объектов Deployment, Service, Ingress, ConfigMap и т.п. Широкие возможности по шаблонизации позволяют определить достаточно универсальный шаблон и следовать принципу DRY.
Helm позволяет управлять процессом развертывания и сделать его атомарным и обратимым. Развертывание запускается одной командой helm update. Если процесс развертывания завершился неудачно, helm может выполнить автоматический откат. При этом он возвращает все объекты системы в соответствующее предыдущее состояние. А если что-то пошло не так, откат можно выполнить одной командой helm rollback.
Подробнее про helm можно почитать в официальной документации или, например, здесь.
Структура чартов
Сначала сформулируем основные принципы, закладываемые в основу архитектуры развертывания:
Развертывание системы описывается единым чартом (так называемый umbrella chart), чтобы иметь возможность разворачивать и откатывать его атомарно.
Чарт системы должен содержать минимальное число определяемых объектов, чтобы поддерживать слабую связность приложений.
Развертывание каждого приложения описывается в своем чарте (так называемый subchart), это позволит нам независимо изменять приложения.
Каждое приложение имеет свое уникальное имя, с помощью которого можно легко найти связанные с ним параметры. Используя имя приложения другие приложения могут получить параметры, необходимые для связи с ним (например, имя его сервиса).
Если для приложения определяется ingress, то только через поддомен, а не через путь. Это правило упрощает шаблонизацию определения для ingress, но от него можно отказаться при необходимости.
Чарт приложения в идеале не должен содержать других приложений в качестве зависимостей (исключение - для sidecar-контейнеров).
Следуем DRY: если какая-то часть манифеста или шаблона является универсальной, она должна быть вынесена на более высокий уровень абстракции. Цель понятна — иметь одну точку изменений.
Минимизируем ссылки на глобальные параметры основного чарта (.Values.global), особенно, если эти параметры относятся к одному из приложений. В идеале их вообще не должно быть. Это позволит нам исключить сложно управляемые неявные зависимости.
Все конфигурационные параметры приложений и другие переменные (например, DNS-имена сервисов) находятся только в одном месте — едином файле конфигурации системы, все приложения получают конфигурацию только из него.
Чарты не содержат секретов (хотя могли бы) и они не находятся под управлением helm. Мы сделали это сознательно, чтобы разделить процессы управления секретами и развертывания.
Чарты не предназначены для размещения на общедоступных ресурсах и отдельные приложения могут быть не функциональны вне системы.
Таким образом в итоге у нас должен получиться один чарт с одним уровнем из нескольких сабчартов. Мы вынесли определение и сборку чарта для каждого приложения в отдельный репозиторий вместе с основным чартом. Это позволило переиспользовать унифицированные шаблоны, как я расскажу дальше.
Процесс сборки чартов
Как я уже отмечал, helm имеет широкие возможности по шаблонизации. Но, к сожалению шаблонизации, предлагаемой helm, оказалось недостаточно по нескольким причинам. Во-первых, невозможно шаблонизировать версии в файле Chart.yaml. Во-вторых, файлы values.yaml не могут содержать шаблонов. Существует обход этого через функцию tpl, однако он удобен не во всех случаях и имеет проблемы с производительностью. Иногда проще и понятнее написать шаблонизируемые значения в виде замещаемого текста (placeholder), например так:
host: "api-%envTag%.somedomain.com"
Результирующий yaml с замещенными значениями можно получить одним вызовом sed.
Также, как инструмент внешней шаблонизации удобно использовать утилиту yq для вставки, замены значений и объединения yaml файлов.
Алгоритм сборки всего чарта выполняется автоматически и в общем виде выглядит так:
Собираем сабчарт для каждого приложения используя типовые шаблоны.
Проставляем текущие версии во все Chart.yaml
На основе шаблонов готовим values.yaml для основного чарта.
Генерируем уникальную версию системы и вставляем ее в Chart.yaml для основного чарта.
Запускаем сборку и получаем релиз системы, описываемый чартом.
Если возникли вопросы по выбору технологий
Почему не использовать для этого kustomize? Кажется, что он более естественно решает задачи переопределения параметров конфигурации. Однако задачу начальной сборки однотипных манифестов без копирования кусков YAMLа он все равно не облегчает по сравнению с описанным в этой статье решением. И также kustomize не предоставляет средств управления развертыванием в кластере.
Зачем нужны yq и bash-скрипты, если все можно сделать через шаблонизацию самого helm’a? Ну во-первых, нам тогда придется использовать шаблоны в values.yaml, а это ухудшает читаемость, за счет многоуровневых конструкций с "|", либо выносом каждого такого шаблона в именованные, что также будет сложно поддерживать при большом числе почти однотипных шаблонов. При этом надо будет использовать "tpl" с известными проблемами с производительностью. Во-вторых, нам просто не очень нравятся выразительные средства такого {{ ... }} синтаксиса.
Почему sed, а не какой-нибудь шаблонизатор типа jinja? Потому, что нам надо было решить одну простую задачу - замену плейсхолдера, и прикручиваение дополнительных движков выглядит для этого несколько избыточным. К тому же синтаксис jinja конфликтовал бы с синтаксисом helm-шаблонов.
По ходу описания, я буду опускать некоторые "технические" части и детали, которые либо привязаны к нашей системе, либо легко могут быть дополнены читателем.
Основной chart и единая конфигурация
Для начала создадим директории для основного чарта, сабчартов и типовых шаблонов.
mkdir oursystem # Основной chart
mkdir subcharts # Charts для приложений
mkdir templates # Переиспользуемые (типовые) шаблоны
mkdir templates/subchart # Типовые шаблоны для chart приложения
Мы решили ,что для исключения путаницы имя директории основного чарта должно совпадать с его названием, именно под ним он будет помещен в репозиторий и будет использоваться в командах helm. У нас он называется exerica, здесь я буду использовать oursystem. Создадим для него следующую структуру:
Структура основного чарта
oursystem/
+-- .helmignore
+-- Chart.yaml
L-- templates/
+-- _helpers.tpl
+-- configmap.yaml
L-- NOTES.txt
Тут совсем мало файлов, поскольку в этом чарте определяются только:
зависимости в Chart.yaml
общие именованные шаблоны в _helpers.tpl
единый конфиг системы в configmap.yaml
сообщение, которое будет выведено после развертывания системы (NOTES.txt)
Параметры для всех приложений будут сгенерированы скриптом при сборке и записаны в values.yaml.
Определение единого конфига из configmap.yaml тоже совсем небольшое:
configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: oursystem-config
data:
{{- include "toPropertiesYaml" .Values.global.configuration | indent 2 }}
Шаблон toPropertiesYaml конвертирует произвольный (почти) yaml в массив пар "ключ-значение", где ключ формируется как последовательность ключей на пути от корня yaml-объекта:
Из oursystem/templates/_helpers.tpl
{{- define "joinListWithComma" -}}
{{- $local := dict "first" true -}}
{{- range $k, $v := . -}}{{- if not $local.first -}}, {{ end -}}{{ $v -}}{{- $_ := set $local "first" false -}}{{- end -}}
{{- end -}}
{{- define "toPropertiesYaml" }}
{{- $yaml := . -}}
{{- range $key, $value := $yaml }}
{{- if kindIs "map" $value -}}
{{ $top:=$key }}
{{- range $key, $value := $value }}
{{- if kindIs "map" $value }}
{{- $newTop := printf "%s.%s" $top $key }}
{{- include "toPropertiesYaml" (dict $newTop $value) }}
{{- else if kindIs "slice" $value }}
{{ $top }}.{{ $key }}: {{ include "joinListWithComma" $value | quote }}
{{- else }}
{{ $top }}.{{ $key }}: {{ $value | quote }}
{{- end }}
{{- end }}
{{- else if kindIs "slice" $value }}
{{ $key }}: {{ include "joinListWithComma" $value | quote }}
{{- else }}
{{ $key }}: {{ $value | quote }}
{{- end }}
{{- end }}
{{- end }}
(С) Этот шаблон был когда-то найден на просторах интернета и немного доработан. Буду признателен, если кто-нибудь укажет его авторство в комментах.
Создадим файл versions.yaml, определяющий версии приложений, которые будем развертывать.
versions.yaml
# Версии приложений для развертывания
versions:
webapi: "1.36.4082"
analytics: "0.1.129"
publicapi: "0.1.54"
# Аналогично для каждого приложения
...
# Условия развертывания приложений (что попадает в релиз, а что - нет)
conditions:
webapi:
enabled: true
analytics:
enabled: true
redis:
enabled: true
# Аналогично для каждого приложения
...
Все параметры конфигурации, которые будем выносить в единый конфиг системы, определим в одном файле templates/variables.yaml.
templates/variables.yaml
common:
envTag: "%envTag%"
envName: "oursystem-%envTag%"
internalProtocol: http
externalProtocol: https
...
webapi:
name: webapi
host: "api-%envTag%.somedomain.com"
path: "/api/v2"
analytics:
name: analytics
host: "analytics-%envTag%.somedomain.com"
path: "/api/v1"
redis:
name: redis
serviceName: redis-master
internalProtocol: redis
...
Этот файл мы пропустим через sed перед добавлением в чарт.
В дальнейшем в процессе сборки эти параметры дополняются "вычисляемыми" значениями, которые также нужны для конфигурирования приложений, например полный URL для ingress: ingressUrl. При сборке основного чарта эти параметры будут вставлены в секцию .Values.global.configuration и сформируют ConfigMap с единым конфигом системы. В частности, на них мы будем ссылаться в переменных окружения для контейнеров.
Таким образом можем передать параметры приложению webapi через переменные окружения, например такие:
DEPLOY_ENVIRONMENT — глобальное имя среды в которой функционирует приложение, берется из параметра common.envName
ProcessingRemoteConfiguration__RemoteAddress — адрес сервиса, с которым взаимодействует приложение webapi, берется из параметра processing.serviceUrl
Секция env из subcharts/webapi/values.yaml
env:
- name: DEPLOY_ENVIRONMENT
valueFrom:
configMapKeyRef:
name: oursystem-config
key: "common.envName"
- name: ProcessingRemoteConfiguration__RemoteAddress
valueFrom:
configMapKeyRef:
name: oursystem-config
key: "processing.serviceUrl"
...
Переменные среды считываются и передаются приложению при его запуске. Если при развертывании очередной версии системы какое-то приложение не поменялось, но поменялась конфигурация системы, то его под не будет перезапущен. Для решения этой проблемы удобно использовать контроллер Reloader, который отслеживает изменения объектов ConfigMap, Secret и производит плавающее обновление (rolling update) для подов, которые зависят от них. Мы включили его в состав системы, как внешнюю зависимость через helm chart. В нашем подходе это делается буквально в несколько строчек.
Добавление в систему "внешнего" приложения
В oursystem/Chart.yaml
...
dependencies:
...
- name: reloader
version: ">=0.0.89"
repository: https://stakater.github.io/stakater-charts
condition: reloader.enabled
...
В versions.yaml
...
conditions:
...
reloader:
enabled: true
...
Унификация шаблонов
Создадим первый сабчарт при помощи скаффолдинга helm (имя должно соответствовать имени приложения).
helm create subcharts/webapi
Получим каталог со следующими файлами:
Результат скаффолдинга Helm
webapi/
+-- .helmignore
+-- Chart.yaml
+-- values.yaml
+-- charts/
L-- templates/
+-- tests/
+-- _helpers.tpl
+-- deployment.yaml
+-- hpa.yaml
+-- ingress.yaml
+-- NOTES.txt
+-- service.yaml
L-- serviceaccont.yaml
Назначение всех этих файлов отлично описано в официальной документации. Обратим внимание на директорию templates: все шаблоны в ней, вообще говоря, слабо зависят от приложения. Чтобы не нарушать DRY вынесем их в templates/subchart:
mv subcharts/webapi/templates* templates/subchart
Тут можно удалить ненужные шаблоны. Например,можно не использовать NOTES.txt для отдельных приложений. Также я опущу все, что касается тестов. При необходимости шаблонизацию тестов можно реализовать полностью аналогично.
Создадим файл templates/resources.yaml, в котором соберем все определения для ресурсов, выделяемых приложениям.
Секция одного приложения в templates/resources.yaml
...
webapi:
replicaCount: 2
resources:
limits:
cpu: 6
memory: 4Gi
requests:
cpu: 500m
memory: 512Mi
...
Все фиксированные параметры для values.yaml соберем в отдельном файле values.templ.yaml, такие как параметры "внешних" зависимостей, например redis.
Конфигурация redis из values.templ.yaml
...
redis:
redisPort: 6379
usePassword: false
architecture: standalone
cluster:
enabled: false
slaveCount: 0
master:
extraFlags:
- "--maxmemory 1gb"
- "--maxmemory-policy allkeys-lru"
persistence:
enabled: true
size: 2Gi
metrics:
enabled: true
Также вынесем типовое определение для ingress из values.yaml в templates/values.ingress.yaml.
templates/values.ingress.yaml
enabled: true
annotations:
kubernetes.io/ingress.class: nginx
hosts:
- host: "%ingressHost%"
paths:
- path: /
backend:
service:
name: "%serviceName%"
tls:
- hosts:
- "%ingressHost%"
Этот файл мы пропускаем через sed перед добавлением в чарт.
Таким образом мы получим структуру шаблонных файлов из которых затем собирается единый чарт нашей системы:
oursystem — минимальный основной чарт
subcharts — файлы вложенных чартов, которые не могут быть унифицированы, либо их унификация нецелесообразна
templates — шаблоны для формирования параметров для всех сабчартов
templates/subcharts — унифицированные helm-шаблоны для объектов сабчартов
Общая струкрура шаблонных файлов
build_chart.sh
versions.yaml
oursystem/
+-- .helmignore
+-- Chart.yaml
+-- templates/
¦ +-- _helpers.tpl
¦ +-- configmap.yaml
¦ L-- NOTES.txt
+-- subcharts/
¦ +-- analytics/
¦ ¦ +-- Chart.yaml
¦ ¦ L-- values.yaml
¦ ¦ ...
¦ L-- webapi
¦ +-- Chart.yaml
¦ L-- values.yaml
L-- templates/
+-- resources.yaml
+-- values.templ.yaml
+-- values.ingress.yaml
+-- variables.yaml
L-- subchart/
+-- deployment.yaml
+-- hpa.yaml
+-- ingress.yaml
+-- service.yaml
L-- serviceaccount.yaml
Из файлов values.*.yaml, variables.yaml и resources.yaml из директории templates в итоге собирается файл values.yaml.
Создадим скрипт для сборки build_chart.sh, воспроизводящий алгоритм сборки, описанный выше. Привожу его частями для удобства восприятия.
build_chart.sh (часть 1)
#!/bin/bash
# Определяем "глобальные" переменные сборочного скрипта
SUBCHARTS_PATH="subcharts"
SUBCHART_SCRIPTS_PATH="subchart-scripts"
TEMPLATES_PATH="templates"
MAIN_CHART_PATH="oursystem"
VERSIONS_FILE="versions.yaml"
VALUES_TEMPL_FILE="$TEMPLATES_PATH/values.templ.yaml"
MAIN_CHART_FILE="$MAIN_CHART_PATH/Chart.yaml"
MAIN_VALUES_FILE="$MAIN_CHART_PATH/values.yaml"
VARIABLES_FILE="$TEMPLATES_PATH/variables.yaml"
TMP_PATH="tmp"
SUBCHARTS_TMP_PATH="$TMP_PATH/subcharts"
CONFIG_TMP_FILE="$TMP_PATH/configuration.yaml"
OVERRIDES_TMP_FILE="$TMP_PATH/overrides.yaml"
# Копируем charts приложений во временную директорию
cp -r "$SUBCHARTS_PATH/" "$SUBCHARTS_TMP_PATH"
# Заменяем тэг среды, читаем конфигурационные переменные и версии
# $BRANCH_NUM определяется в CI пайплайне как номер ветки
# Для ветки develop BRANCH_NUM=dev
yaml=$(sed "s/%envTag%/$BRANCH_NUM/g" "$VARIABLES_FILE" | yq r - --stripComments)
versions=$(yq r "$VERSIONS_FILE" "versions" --stripComments)
conditions=$(yq r "$VERSIONS_FILE" "conditions" --stripComments)
build_chart.sh (часть 2)
# Считываем значения общих параметров
env_name=$(echo "$yaml" | yq r - "common.envName")
internal_proto=$(echo "$yaml" | yq r - "common.internalProtocol")
external_proto=$(echo "$yaml" | yq r - "common.externalProtocol")
# Формируем секцию для единого конфига системы
echo "$yaml" | yq p - "global.configuration" > $CONFIG_TMP_FILE
echo "" > $OVERRIDES_TMP_FILE
for key in $(echo "$yaml" | yq r - -p p "*")
do
# Перебираем все секции из templates/variables.yaml
if [[ $key != "common" ]]
then
# Копируем типовые шаблоны в subchart
# Если в subchart какой-то шаблон переопределен, он не заменяется
mkdir -p "$SUBCHARTS_TMP_PATH/$key/templates"
cp -n templates/subchart/*.yaml "$SUBCHARTS_TMP_PATH/$key/templates"
# Определяем параметры для приложения
full_name="$env_name-$key"
version=$(echo "$versions" | yq r - "$key")
ingress=$(echo "$yaml" | yq r - "$key.host")
path=$(echo "$yaml" | yq r - "$key.path")
internal_proto_current=$(echo "$yaml" | yq r - "$key.internalProtocol")
external_proto_current=$(echo "$yaml" | yq r - "$key.externalProtocol")
service_name=$(echo "$yaml" | yq r - "$key.serviceName")
sidecar_name=$(echo "$yaml" | yq r - "$key.sidecar.name")
if [[ -z "$internal_proto_current" ]]
then
internal_proto_current=$internal_proto
fi
if [[ -z "$external_proto_current" ]]
then
external_proto_current=$external_proto
fi
# Записываем “вычисляемые” параметры приложения
if [[ -z "$service_name" ]]
then
# Полное имя сервиса, если оно шаблонное
yq w -i "$CONFIG_TMP_FILE" "global.configuration.$key.service" \
"$full_name"
# Полный URL для сервиса
yq w -i "$CONFIG_TMP_FILE" "global.configuration.$key.serviceUrl" "$internal_proto_current://$full_name$path"
else
# Полное имя сервиса, если оно задано в конфигурации
yq w -i "$CONFIG_TMP_FILE" "global.configuration.$key.service" "$env_name-$service_name"
# Полный URL для сервиса
yq w -i "$CONFIG_TMP_FILE" "global.configuration.$key.serviceUrl" "$internal_proto_current://$env_name-$service_name$path"
fi
if [[ ! -z "$ingress" ]]
then
# Полный URL для Ingress приложения
yq w -i "$CONFIG_TMP_FILE" "global.configuration.$key.ingressUrl" "$external_proto_current://$ingress$path"
fi
if [[ ! -z "$version" ]]
then
# Записываем текущую версию приложения
yq w -i "$CONFIG_TMP_FILE" "global.configuration.$key.version" "$version"
fi
# Переопределяем имя приложения
yq w -i "$OVERRIDES_TMP_FILE" "$key.nameOverride" "$key"
yq w -i "$OVERRIDES_TMP_FILE" "$key.fullnameOverride" "$full_name"
# Add an
ingress_host=$(echo "$yaml" | yq r - "$key.host")
if [[ ! -z "$ingress_host" ]]
then
# Добавляем определение для ingress на основе типового шаблона
sed "s/%ingressHost%/$ingress_host/g" "$INGRESS_TEMPL_FILE" | sed "s/%serviceName%/$full_name/g" - | yq r - --stripComments | yq p - "$key.ingress" | yq m -i "$OVERRIDES_TMP_FILE" -
else
# Отключаем ingress, если он не нужен
yq w -i "$OVERRIDES_TMP_FILE" "$key.ingress.enabled" "false"
fi
# Записываем версию в subchart
if [[ ! -z "$version" ]]
then
subchart="$SUBCHARTS_TMP_PATH/$key/Chart.yaml"
yq w -i "$subchart" 'appVersion' "${version}"
yq w -i "$subchart" 'version' "${version}"
fi
fi
done
Можно заметить, что секция configuration тут записывается в .Values.global, хотя мы ранее вводили принцип №8 (не использовать global). На самом деле это не обязательно, но для некоторых наших приложений приходилось "готовить" сложные конфигурации, собирая их из параметров секции configuration при помощи шаблонизатора helm (поскольку мы живем не в идеальном мире). Если такой необходимости нет, можно помещать configuration просто в .Values основного чарта.
build_chart.sh (часть 3)
# Формируем версию системы
version=$(yq r ${MAIN_CHART_FILE} "version" --stripComments)
IFS=. read major minor build <<< "${version}"
if [[ -z "${BRANCH_NUM}" ]]
then
build="${CI_PIPELINE_IID}" # Уникальный инкрементный номер пайплайна из Gitlab CI
else
build="${CI_PIPELINE_IID}-env${BRANCH_NUM}"
fi
version="${major}.${minor}.${build}"
# Записываем версию в основной chart
yq w -i "${MAIN_CHART_FILE}" 'appVersion' "${version}"
yq w -i "${MAIN_CHART_FILE}" 'version' "${version}"
# Формируем полный файл values.yaml для основного chart
yq r "$VALUES_TEMPL_FILE" --stripComments | yq m - "$OVERRIDES_TMP_FILE" "$RESOURCES_FILE" "$CONFIG_TMP_FILE" > $MAIN_VALUES_FILE
# Добавляем условия развертывания приложений
echo "$conditions" | yq m -i "$MAIN_VALUES_FILE" -
Собираем весь chart
helm dependency update ./oursystem --debug
helm package ./oursystem --debug
В результате работы скрипта мы получим релиз системы конкретной версии: скомпонованный чарт на всю систему, в котором определяются все зависимости и конфигурационные параметры для развертывания в кластере Kubernetes. При необходимости в процессе развертывания любые параметры основного чарта и сабчартов могут быть переопределены с помощью ключей --set или -f.
Атомарное развертывание релиза
Развертывание подготовленного релиза системы производится одной командой:
helm upgrade "${ENV_NAME}" "${CHART_NAME}" -i -n "${ENV_NAME}" --atomic --timeout 3m
В переменных окружения передаются:
ENV_NAME — имя среды для развертывания
CHART_NAME — полное имя собранного чарта системы (включая версию)
После подстановки переменных окружения команда будет выглядеть примерно так:
helm upgrade system-dev "system-1.2.3456.tgz" -i -n system-dev --atomic --timeout 3m
Как видно, каждая среда имеет свое пространство имен, таким образом даже одноименные объекты из разных сред в рамках одного кластера не конфликтуют по именам.
Флаг --atomic говорит о том, что helm будет ожидать в течение --timeout 3m успешного развертывания всех приложений. В случае ошибки или истечения таймаута произойдет автоматический откат на предыдущий релиз.
Варианты расширения функционала
Для разных сред, например, промышленной и тестовых, можно создать разные variables.yaml, resources.yaml и подключать в скрипте сборки соответствующий среде файл. Например так:
Дополнение в build_chart.sh
if [[ ! ${BRANCH_NUM} =~ ^-?[0-9]+$ ]] # Variable from Gitlab CI
then
VARIABLES_FILE="$TEMPLATES_PATH/variables.$CI_BUILD_REF_NAME.yaml" # Variable from Gitlab CI
RESOURCES_FILE="$TEMPLATES_PATH/resources.$CI_BUILD_REF_NAME.yaml" # Variable from Gitlab CI
else
VARIABLES_FILE="$TEMPLATES_PATH/variables.branch.yaml"
RESOURCES_FILE="$TEMPLATES_PATH/resources.branch.yaml"
fi
На самом деле, у нас как раз сделанно именно так, при этом используются разные параметры для промышленной и тестовых сред.
Можно отказаться от соответствия имен сабчартов и разворачиваемых приложений. Это может быть полезно при необходимости развернуть одно и то же приложение с разными настройками в рамках одного экземпляра системы.
Если будут заметны проблемы с производительнсотью сборки (а пока их нет), всю логику build_chart.sh можно реализовать другими седствами не изменяя подхода и форматов шаблонов, например на python.
Все что описано в этой статье можно применить и при другом подходе к управлению версиями, когда допустимо автоматическое развертывание при выходе новой версии каждого приложения. В этом случае можно запускать CI пайплайн сборки чарта и развертывания по триггеру. А в скрипте сборки чарта поменять источник актуальных версий приложений.
Собранный чарт можно использовать для развертывания в совершенно другой среде, например, создавать отдельную среду под клиента/заказчика, которому нужна изоляция данных, либо для развертывания в инфраструктуре заказчика. Минимально достаточно для этого переопределить при деплое DNS-имена для ingress (например с помощью helm install -f ...). Данные для подключения в БД и т.п. приложения получают из объектов Secret, которые, как я упоминал выше, управляются отдельно от чарта.
Заключение
При помощи различных инструментов шаблонизации мы можно создать довольно удобное в плане поддержки и масштабирования решение по описанию развертывания систем в Kubernetes. Широкие возможности для этого предлагает менеджер пакетов Helm. Но если добавить дополнительную шаблонизацию для генерации однотипных чартов helm, автоматически компоновать чарт для системы в целом, то довольно просто получается построить непрерывный процесс развертывания системы в Kubernetes, требующий минимального участия человека. При этом мы получаем возможность управления развертыванием: формирование релиза из любого набора конкретных версий компонентов, автоматическую проверку успешности развертывания и удобный откат к одному из предыдущих релизов. Переход на Kubernetes также сделал для нас доступным широкий спектр решений для автоматизации многих инфраструктурных задач. Например, для управления внешними DNS записями мы взяли ExternalDNS, а для для управления TLS-сертификатами — cert-manager.
jidckii
Если уже взяли баш и yq, то проще было использовать kustomize, было бы более читаемо. А так кажется совсем магия присходит и без бутылки не разобраться. Шаблонизация хельм + yq + sed, что там на выходе получится совсем непонятно. В итоге про весь зоопрарк будет понимать 1-2 человека, читаемость нулевая…
А ролауты спокойно можно контролировать и kubectl
if ! kubectl -n ${ENV} rollout status --timeout=120s deployments.apps app-name; then
kubectl -n ${ENV} rollout undo deployments.apps app-name
fi
как и прохождение миграций и даже логи в ci показывать
kubectl -n "${ENV}" wait --for=condition=complete --timeout=300s job/migrations
if [[ $? -ne 0 ]]; then
echo "ERROR migrations"
CONTROLLER_UID=$(kubectl -n "${ENV}" get job/migrations -o jsonpath='{.spec.selector.matchLabels.controller-uid}')
POD_ERR_LIST=$(kubectl -n "${ENV}" get pods -l controller-uid=${CONTROLLER_UID} --sort-by='.metadata.creationTimestamp' -o jsonpath='{.items[*].metadata.name}')
for pod in $(echo "${POD_ERR_LIST}"); do
echo -e '\e[0;31m' "pod_name: ${pod}" '\e[0m'
kubectl -n "${ENV}" logs "${pod}"
done
exit 1
fi
Я не осуждаю, но кажется вы всё усложнили. И в декларативной копипасте проще разбираться, чем в вашем велосипеде.