Автоматизированное развертывание в Kubernetes с помощью Helm и дополнительной шаблонизации

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

В этой статье я расскажу и покажу, как при помощи Helm и некоторых дополнительных инструментов построить и настроить автоматическое развертывание в Kubernetes для системы из (микро)сервисов и не потеряться во множестве шаблонов и манифестов. Мы успешно реализовали такой подход у себя. Если у вас есть подобная задача или нечто похожее, надеюсь, статья окажется для вас полезной целиком или в качестве источника "рецептов".

Немного о системе и задаче развертывания

Меня зовут Михаил, я CTO в Exerica и Deputy CEO в Qoollo. В Exerica у нас построена система с сервисной архитектурой: больше десятка сервисов, NoSQL база данных и централизованные логирование с мониторингом.

Для работы с кодом и CI/CD мы используем собственный экземпляр GitLab. Каждое приложение имеет свой репозиторий, где происходит его сборка, в результате которой контейнер с приложением очередной версии помещается в registry. Версионирование автоматизировано: разработчик задает только старшую и младшую версии, а номер билда генерируется системой сборки. Для версий, выбираемых не из "основной" ветки также автоматически проставляется feature-версия из номера задачи в трекере.

Обычно у нас бывает несколько развертываний на промышленную среду в день. Также практически у каждого разработчика есть возможность развернуть полнофункциональный экземпляр системы. Поэтому нам нужен простой механизм сборки системы из набора сервисов конкретных версий. И удобный автоматический механизм отката изменений, если что-то пошло не так. Ранее у нас была разработана автоматизация развертывания в кластер Docker Swarm с помощью Ansible.

Развертывание под Docker Swarm
Развертывание под Docker Swarm

Она успешно проработала несколько лет, но стала приносить все больше проблем:

  • Невысокая отказоустойчивость схемы с одним входным Nginx

  • С ростом числа приложений появилось много однотипных плейбуков и шаблонных файлов для сервисов Docker Swarm, их стало сложно поддерживать

  • Просадки производительности оверлейной сети Docker Swarm: периодически без видимых причин она падала до нескольких мегабит/с на гигабитных линках

  • Недостаточная гибкость в управлении ресурсами для приложений

  • Неудобное управление внешним DNS для публикуемых сервисов через Ansible-модуль

  • Неудобное управление TLS-сертификатами 

В итоге мы приняли решение перевести систему в self-managed Kubernetes. При переводе мы сразу поставили задачу максимально автоматизировать генерацию манифестов для приложений и ресурсов. В идеале для стандартных случаев включение сервиса в систему должно требовать от разработчика только задания его конфигурации, ограничения по ресурсам и размещению сервиса. БД, сервисы монторинга и сбора логов мы оставили как есть, вне Kubernetes.

С переходом на Kubernetes мы решили принципиально не изменять процесс сборки и развертывания. Исторически для процесса развертывания у нас был выделен отдельный git-репозиторий (деплой-репозиторий), в котором сосредоточены все скрипты и конфиги деплоя. Деплой выполняется задачами Gitlab CI. При запуске CI-пайплайна ветки develop развертывается промышленная среда, при запуске пайплайна для ветки, связанной с задачей в трекере (начинается с уникального номера), развертывается тестовая среда. Для идентификации тестовых сред используется тот самый номер ветки. Минимально, что нужно сделать релиз-менеджеру или разработчику - это вписать нужные версии приложений и выполнить коммит. Весь остальной процесс выполняет пайплайн Gitlab CI. Его успешное завершение говорит о том, что развертывание выполнено и развернутая система работоспособна.

Развертывание под Kubernetes
Развертывание под Kubernetes

Выбор инструментов

Одним из удобных инструментов, который позволял реализовать практически все вышеприведенные требования — Helm, пакетный менеджер для Kubernetes. Как написано на сайте helm.sh:

Helm помогает управлять приложениями Kubernetes — Helm Charts помогут вам определить, установить и обновить даже самое сложное приложение Kubernetes.

Helm позволяет описать как отдельные приложения, так и системы в целом в виде набора так называемых "диаграмм" (charts) или пакетов. В официальной документации это понятие в основном не переводится, а часто их называют просто "чартами". Я буду пользоваться этим термином, чтобы избежать терминологической путаницы с "пакетами" и "диаграммами". 

Очень кратко, чарт представляет собой набор файлов, содержащих шаблоны и параметры, из которых helm собирает манифесты для всех необходимых объектов Deployment, Service, Ingress, ConfigMap и т.п. Широкие возможности по шаблонизации позволяют определить достаточно универсальный шаблон и следовать принципу DRY.

Helm позволяет управлять процессом развертывания и сделать его атомарным и обратимым. Развертывание запускается одной командой helm update. Если процесс развертывания завершился неудачно, helm может выполнить автоматический откат. При этом он возвращает все объекты системы в соответствующее предыдущее состояние. А если что-то пошло не так, откат можно выполнить одной командой helm rollback. 

Подробнее про helm можно почитать в официальной документации или, например, здесь.

Структура чартов

Сначала сформулируем основные принципы, закладываемые в основу архитектуры развертывания:

  1. Развертывание системы описывается единым чартом (так называемый umbrella chart), чтобы иметь возможность разворачивать и откатывать его атомарно. 

  2. Чарт системы должен содержать минимальное число определяемых объектов, чтобы поддерживать слабую связность приложений.

  3. Развертывание каждого приложения описывается в своем чарте (так называемый subchart), это позволит нам независимо изменять приложения. 

  4. Каждое приложение имеет свое уникальное имя, с помощью которого можно легко найти связанные с ним параметры. Исползьзуя имя приложения другие приложения могут получить параметры, необходимые для связи с ним (например, имя его сервиса).

  5. Если для приложения определяется ingress, то только через поддомен, а не через путь. Это правило упрощает шаблонизацию определения для ingress, но от него можно отказаться при необходимости. 

  6. Чарт приложения в идеале не должен содержать других приложений в качестве зависимостей (исключение - для sidecar-контейнеров).

  7. Следуем DRY: если какая-то часть манифеста или шаблона является универсальной, она должна быть вынесена на более высокий уровень абстракции. Цель понятна — иметь одну точку изменений. 

  8. Минимизируем ссылки на глобальные параметры основного чарта (.Values.global), особенно, если эти параметры относятся к одному из приложений. В идеале их вообще не должно быть. Это позволит нам исключить сложно управляемые неявные зависимости.

  9. Все конфигурационные параметры приложений и другие переменные (например, DNS-имена сервисов) находятся только в одном месте — едином файле конфигурации системы, все приложения получают конфигурацию только из него.

  10. Чарты не содержат секретов (хотя могли бы) и они не находятся под управлением helm. Мы сделали это сознательно, чтобы разделить процессы управления секретами и развертывания.

  11. Чарты не предназначены для размещения на общедоступных ресурсах и отдельные приложения могут быть не функциональны вне системы.

Таким образом в итоге у нас должен получиться один чарт с одним уровнем из нескольких сабчартов. Мы вынесли определение и сборку чарта для каждого приложения в отдельный репозиторий вместе с основным чартом. Это позволило переиспользовать унифицированные шаблоны, как я расскажу дальше.

Процесс сборки чартов

Как я уже отмечал, helm имеет широкие возможности по шаблонизации. Но, к сожалению шаблонизации, предлагаемой helm, оказалось недостаточно по нескольким причинам. Во-первых, невозможно шаблонизировать версии в файле Chart.yaml. Во-вторых, файлы values.yaml не могут содержать шаблонов. Существует обход этого через функцию tpl, однако он удобен не во всех случаях и имеет проблемы с производительностью. Иногда проще и понятнее написать шаблонизируемые значения в виде замещаемого текста (placeholder), например так:

host: "api-%envTag%.somedomain.com"

Результирующий yaml с замещенными значениями можно получить одним вызовом sed.

Также, как инструмент внешней шаблонизации удобно использовать утилиту yq для вставки, замены значений и объединения yaml файлов.

Алгоритм сборки всего чарта выполняется автоматически и в общем виде выглядит так:

  1. Собираем сабчарт для каждого приложения используя типовые шаблоны.

  2. Проставляем текущие версии во все Chart.yaml

  3. На основе шаблонов готовим values.yaml для основного чарта.

  4. Генерируем уникальную версию системы и вставляем ее в Chart.yaml для основного чарта.

  5. Запускаем сборку и получаем релиз системы, описываемый чартом.

Если возникли вопросы по выбору технологий
  1. Почему не использовать для этого kustomize? Кажется, что он более естественно решает задачи переопределения параметров конфигурации. Однако задачу начальной сборки однотипных манифестов без копирования кусков YAMLа он все равно не облегчает по сравнению с описанным в этой статье решением. И также kustomize не предоставляет средств управления развертыванием в кластере.

  2. Зачем нужны yq и bash-скрипты, если все можно сделать через шаблонизацию самого helm’a? Ну во-первых, нам тогда придется использовать шаблоны в values.yaml, а это ухудшает читаемость, за счет многоуровневых конструкций с "|", либо выносом каждого такого шаблона в именованные, что также будет сложно поддерживать при большом числе почти однотипных шаблонов. При этом надо будет использовать "tpl" с известными проблемами с производительностью. Во-вторых, нам просто не очень нравятся выразительные средства такого {{ ... }} синтаксиса. 

  3. Почему sed, а не какой-нибудь шаблонизатор типа jinja? Потому, что нам надо было решить одну простую задачу - замену плейсхолдера, и прикручиваение дополнительных движков выглядит для этого несколько избыточным. К тому же синтаксис jinja конфликтовал бы с синтаксисом helm-шаблонов.

По ходу описания, я буду опускать некоторые "технические" части и детали, которые либо привязаны к нашей системе, либо легко могут быть дополнены читателем.

Основной chart и единая конфигурация

Для начала создадим директории для основного чарта, сабчартов и типовых шаблонов.

mkdir oursystem          # Основной chart
mkdir subcharts          # Charts для приложений
mkdir templates          # Переиспользуемые (типовые) шаблоны
mkdir templates/subchart # Типовые шаблоны для chart приложения

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

Структура основного чарта

Тут совсем мало файлов, поскольку в этом чарте определяются только:

Параметры для всех приложений будут сгенерированы скриптом при сборке и записаны в values.yaml.

Определение единого конфига из configmap.yaml тоже совсем небольшое:

configmap.yaml

Шаблон toPropertiesYaml конвертирует произвольный (почти) yaml в массив пар "ключ-значение", где ключ формируется как последовательность ключей на пути от корня yaml-объекта:

Из oursystem/templates/_helpers.tpl

Создадим файл versions.yaml, определяющий версии приложений, которые будем развертывать.

versions.yaml

Все параметры конфигурации, которые будем выносить в единый конфиг системы, определим в одном файле templates/variables.yaml.

templates/variables.yaml

В дальнейшем в процессе сборки эти параметры дополняются "вычисляемыми" значениями, которые также нужны для конфигурирования приложений, например полный URL для ingress: ingressUrl. При сборке основного чарта эти параметры будут вставлены в секцию .Values.global.configuration и сформируют ConfigMap с единым конфигом системы. В частности, на них мы будем ссылаться к переменных окружения для контейнеров.

Таким образом можем передать параметры приложению webapi через переменные окружения, например такие:

Секция env из subcharts/webapi/values.yaml

Переменные среды считываются и передаются приложению при его запуске. Если при развертывании очередной версии системы какое-то приложение не поменялось, но поменялась конфигурация системы, то его под не будет перезапущен. Для решения этой проблемы удобно использовать контроллер Reloader, который отслеживает изменения объектов ConfigMap, Secret и производит плавающее обновление (rolling update) для подов, которые зависят от них. Мы включили его в состав системы, как внешнюю зависимость через helm chart. В нашем подходе это делается буквально в несколько строчек.

Добавление в систему "внешнего" приложения

Унификация шаблонов

Создадим первый сабчарт при помощи скаффолдинга helm (имя должно соответствовать имени приложения).

helm create subcharts/webapi

Получим каталог со следующими файлами:

Результат скаффолдинга Helm

Назначение всех этих файлов отлично описано в официальной документации. Обратим внимание на директорию templates: все шаблоны в ней, вообще говоря, слабо зависят от приложения. Чтобы не нарушать DRY вынесем их в templates/subchart:

mv subcharts/webapi/templates* templates/subchart

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

Создадим файл templates/resources.yaml, в котором соберем все определения для ресурсов, выделяемых приложениям.

Секция одного приложения в templates/resources.yaml

Все фиксированные параметры для values.yaml соберем в отдельном файле values.templ.yaml, такие как параметры "внешних" зависимостей, например redis.

Конфигурация redis из values.templ.yaml

Также вынесем типовое определение для ingress из values.yaml в templates/values.ingress.yaml.

templates/values.ingress.yaml

Таким образом мы получим структуру шаблонных файлов из которых затем собирается единый чарт нашей системы:

Общая струкрура шаблонных файлов

Из файлов values.*.yaml, variables.yaml и resources.yaml из директории templates в итоге собирается файл values.yaml.

Создадим скрипт для сборки build_chart.sh, воспроизводящий алгоритм сборки, описанный выше. Привожу его частями для удобства восприятия.

build_chart.sh (часть 1)
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)

В результате работы скрипта мы получим релиз системы конкретной версии: скомпонованный чарт на всю систему, в котором определяются все зависимости и конфигурационные параметры для развертывания в кластере Kubernetes. При необходимости в процессе развертывания любые параметры основного чарта и сабчартов могут быть переопределены с помощью ключей --set или -f. 

Атомарное развертывание релиза

Развертывание подготовленного релиза системы производится одной командой:

helm upgrade "${ENV_NAME}" "${CHART_NAME}" -i -n "${ENV_NAME}" --atomic --timeout 3m

В переменных окружения передаются:

После подстановки переменных окружения команда будет выглядеть примерно так:

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

На самом деле, у нас как раз сделанно именно так, при этом используются разные параметры для промышленной и тестовых сред.

Можно отказаться от соответствия имен сабчартов и разворачиваемых приложений. Это может быть полезно при необходимости развернуть одно и то же приложение с разными настройками в рамках одного экземпляра системы. 

Если будут заметны проблемы с производительнсотью сборки (а пока их нет), всю логику build_chart.sh можно реализовать другими седствами не изменяя подхода и форматов шаблонов, например на python.

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

Собранный чарт можно использовать для развертывания в совершенно другой среде, например, создавать отдельную среду под клиента/заказчика, которому нужна изоляция данных, либо для развертывания в инфраструктуре заказчика. Минимально достаточно для этого переопределить при деплое DNS-имена для ingress (например с помощью helm install -f ...). Данные для подключения в БД и т.п. приложения получают из объектов Secret, которые, как я упоминал выше, управляются отдельно от чарта.

Заключение

При помощи различных инструментов шаблонизации мы можно создать довольно удобное в плане поддержки и масштабирования решение по описанию развертывания систем в Kubernetes. Широкие возможности для этого предлагает менеджер пакетов Helm. Но если добавить дополнительную шаблонизацию для генерации однотипных чартов helm, автоматически компоновать чарт для системы в целом, то довольно просто получается построить непрерывный процесс развертывания системы в Kubernetes, требующий минимального участия человека. При этом мы получаем возможность управления развертыванием: формирование релиза из любого набора конкретных версий компонентов, автоматическую проверку успешности развертывания и удобный откат к одному из предыдущих релизов. Переход на Kubernetes также сделал для нас доступным широкий спектр решений для автоматизации многих инфраструктурных задач. Например, для управления внешними DNS записями мы взяли ExternalDNS, а для для управления TLS-сертификатами — cert-manager.

Что еще почитать по теме

  1. The Chart Template Developer's Guide

  2. The Art of the Helm Chart: Patterns from the Official Kubernetes Charts (перевод)

  3. Создание пакетов для Kubernetes с Helm: структура чарта и шаблонизация

  4. Продвинутая Helm-шаблонизация: выжимаем максимум

Источник: https://habr.com/ru/post/564294/


Интересные статьи

Интересные статьи

Есть вопрос, который мне постоянно задают в Твиттере: как создавать приложения с крутым дизайном с помощью Xamarin.Forms? Это отличный вопрос, ведь любой может создавать ...
Распознавание рукописных цифр с помощью TensorFlow и MNIST стало довольно распространённым введением в искусственный интеллект (ИИ) и ML. «MNIST» — это база данных, которая содержит 7...
Предисловие: — У меня есть небольшой заброшенный паблик (26к подписчиков), раньше там стоял пранк бот от чатуса, это приносило мне 300-800 рублей в день пассивного заработка, если сдела...
Примечание: эта статья не претендует на статус лучшей практики. В ней описан опыт конкретной реализации инфраструктурной задачи в условиях использования Kubernetes и Helm, который может быть ...
„Я унаследовал эту неразбериху, начиная с бессовестных Zello; LinkedIn и кончая «всеми прочими» на платформе Telegram в моём мире. А потом икнув, чиновник торопливо и громко добавил: но...