Pull to refresh

Развёртывание программных систем в Kubernetes с помощью Jsonnet

Level of difficulty Medium
Reading time 10 min
Views 4.1K

Kubernetes (k8s) является де-факто стандартом для развёртывания приложений. В терминах K8s приложение представляет собой набор сконфигурированных ресурсов нескольких типов — pod, service, deployment, secret, statefulset,… Конфигурация каждого ресурса задаётся унифицированно с помощью YAML (или JSON) манифеста. Kubernetes API и интерфейс командной строки позволяют манипулировать манифестами ресурсов, а сами ресурсы автоматически приводятся в состояние, соответствующее манифестам.


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


  • Helm — генерация манифестов на основе строковых шаблонов + "магазин" готовых компонентов (package manager).
  • Kustomize — ("Kubernetes cUSTOMIZation") — донастройка манифестов путём внесения исправлений в различные свойства.
  • Jsonnet — генерация манифестов с использованием легковесного функционального языка, предназначенного для генерации JSON.

Для синхронизации проектов, хранящихся в репозитории git, с фактическим состоянием инфраструктуры в каком-либо кластере может использоваться специальное приложение, работающее в рамках концепции GitOps, например ArgoCD. Причём поддерживаются как проекты, содержащие собственно манифесты, так и проекты, основанные на вышеперечисленных генераторах/модификаторах.


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


Рассмотрим, какие требования было бы разумно предъявить к языку описания инфраструктуры. (В этой заметке излагается взгляд автора, занимающегося разработкой программного обеспечения. Другие стороны, например, команды эксплуатации, могут иметь иной взгляд на этот вопрос.)


Требования к языку программирования инфраструктуры


Какие есть потребности у организаций с точки зрения развёртывания и поддержки приложений?


  1. Развёртывание сложной системы, состоящей из нескольких взаимосвязанных компонентов (сервисов/баз данных/...).
  2. Поддержка работы нескольких окружений, включая создание новых окружений, обновление версий компонентов и откат к предыдущим версиям.
  3. Поддержка нескольких конфигураций приложений (разного масштаба, разных типов баз данных, разного состава компонентов, ...).
  4. Быстрое развёртывание и удаление тестовых конфигураций — "эфемерные окружения". Причём для разных тестов и экспериментов могут использоваться отличающиеся конфигурации.

Какие возможности языка описания инфраструктуры хотелось бы видеть?


  1. Следование принципу DRY — возможность описания множества конфигураций таким образом, чтобы исключить дублирование кода.
  2. Параметризация — возможность влиять на конфигурацию с помощью параметров.
  3. Возможность повторного использования элементов конфигурации.
  4. Абстрагирование и подстановка — возможность дать имя части конфигурации и при упоминании имени подставлять содержимое.
  5. Инкапсуляция — возможность сфокусироваться на важных параметрах с точки зрения системы и исключить из области видимости менее важные параметры.
  6. "Генетический" или "генеративный" подход — порождение полной конфигурации из относительно небольшого объекта. Отличается от клонирования — (aka copy-paste), при котором новая конфигурация производится путём копирования и модификации существующей.
  7. Системный подход — система состоит из связанных между собой компонентов. Каждый компонент может представлять собой подсистему.

Очень краткое введение в Helm


Helm основан на использовании существующего в языке Go механизма строковых шаблонов. Вся конфигурация разделена на две части — набор шаблонов и values.yaml. Внутри шаблонов имеется доступ к .Values, .Release, .Chart.


Пример:


apiVersion: v1
kind: ConfigMap
metadata:
  name: {{ .Release.Name }}-configmap
data:
  myvalue: "Hello World"

Helm позиционируется также как менеджер пакетов для Kubernetes. Например, можно развернуть Postgres одной командой:


helm repo add bitnami https://charts.bitnami.com/bitnami
helm install my-postgres bitnami/postgresql

Более подробно с возможностями Helm можно ознакомиться в документации.


Недостатки Helm


  1. Непосредственно поддерживается ровно один уровень вызова функции-шаблона. Параметры записываются в values.yaml, а затем все шаблоны вызываются с этим одним большим аргументом. (Справедливости ради следует отметить, что имеется также экзотическая и редко используемая возможность вызова функции в Lisp-стиле.)
  2. Несмотря на декларируемую возможность использования Library charts, пользоваться ими практически неудобно и невозможно (см.например, Helm 3, the Good, the Bad and the Ugly). По-видимому, это связано с той же причиной.
  3. В основе шаблонизатора лежит обработка строк без строгой типизации и без удобной возможности создания структур данных, описывающих высокоуровневые концепции.
  4. Собственно язык шаблонов далёк от совершенства. Любые программные конструкции заключаются в esc-последовательности — {{ выражение }}. А чистый текст вокруг — это просто строка-литерал, которая будет представлена в итоговом документе "как есть". Т.е. в текст вкраплены кусочки кода. Для подхода copy-paste, это должно быть удобно, но при необходимости конструировать абстракции это только мешает. Собственно выражение пишется на более-менее удобном функциональном языке, который, к сожалению, может порождать только текст.
  5. Отдельно стоит упомянуть обработку пробелов (whitespace). Этот аспект, не играющий никакой роли с точки зрения архитектуры приложения, внезапно оказывается неисчерпаемым источником трудноуловимых багов. В синтаксисе предусмотрены специальные значки-операторы для обработки пробелов, в коде регулярно встречаются специальные команды, например, indent 12 для добавления необходимого числа пробелов. При использовании излюбленного приёма copy-paste эти команды, естественно, ломаются…

Очень краткое введение в kustomize


Kustomize придерживается принципа минимализма абстракций. Исходная конфигурация представлена напрямую в виде yaml-файлов, либо генерируется другими средствами. Затем Kustomize применяет "патчи", модифицирующие конфигурацию. Патчи представляют собой yaml-файл, который накладывается поверх исходной конфигурации — "оверлей" (overlay). Также поддерживаются несколько вспомогательных инструментов-команд ("фич"), например, для генерации configMap'ов, добавления префиксов/суффиксов, добавления меток (label).


Пример:


base.yaml:


apiVersion: apps/v1
kind: Deployment
spec:
  replicas: 1
...

set-replicas-to-7-patch.yaml:


apiVersion: apps/v1
kind: Deployment
spec:
  replicas: 7

kustomization.yaml:


bases:
- base.yaml
patches:
- set-replicas-to-7-patch.yaml

Недостатки kustomize


  1. Продукт не является полноценным генератором манифестов. Это лишь способ внести исправления в уже сгенерированные манифесты. Для этого используются "патчи" — указания, каким образом перезаписать поле, находящееся глубоко в структуре манифестов.
  2. Громоздкость конструкций ("патчей"). Чтобы исправить какое-то свойство, необходимо указать полный путь до этого свойства.
  3. Отсутствие поддержки массивов. Например, нет простой возможности изменить количество одинаковых компонентов.
  4. Для реализации сложных операций требуется отдельная "фича"/трансформатор. Например, некоторое время тому назад отсутствовала возможность изменения элементов в списке. Затем была реализована новая "фича" — replacements.
  5. Не "генеративный" подход, а "модифицирующий". Страдает от тех же недостатков, что и наследование. Например, при изменении базы, потребуются нетривиальные изменения во всей цепочке модификаторов.

Краткое введение в Jsonnet


Jsonnet — это язык программирования (на Хабре есть заметки — Гугл предлагает усилить JSON с помощью Jsonnet, Как описать 100 Gitlab джоб в 100 строк на Jsonnet), который можно охарактеризовать следующими свойствами:


  • чисто функциональный (функции — первоклассные, структуры данных неизменяемые, без побочных эффектов);
  • с динамической типизацией (типы — null, булевый, вещественное число, строки, массивы, объекты, функции);
  • с ленивыми вычислениями (вычисление значений откладывается как можно дальше);
  • аргументы функций — позиционные и именованные, могут иметь значения по умолчанию. При вызове позиционные аргументы должны быть указаны до именованных;
  • с поддержкой модулей — файлов jsonnet, включаемых (с логической точки зрения) целиком в точке импорта;
  • герметичный — проект jsonnet полностью включает все элементы, которые используются в программе. Все внешние зависимости должны быть разрешены до запуска jsonnet (например, с использованием пакетного менеджера jsonnet-bundler).

Совокупность этих свойств описывается дизайном языка (https://jsonnet.org/articles/design.html).


Язык в первую очередь предназначен для генерации конфигураций сложных многокомпонентных систем. Конфигурации генерируются в формате JSON, YAML, INI. В частности, можно генерировать YAML-манифесты Kubernetes (а затем отправить на сервер с использованием Tanka или Qbec). С помощью jsonnet можно полностью описать все детали сложной системы, соединить компоненты между собой требуемым образом, на основе параметров вычислить конфигурацию, которая будет обладать заданными свойствами (например, требуемой производительностью).


Пример:


local Person(name='Alice') = {
  name: name,
  welcome: 'Hello ' + name + '!',
};
{
  person1: Person(),
  person2: Person('Bob'),
}

Набор абстракций для программных систем в Kubernetes


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


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


Представление подсистем и компонентов


Каждый компонент представляется функцией, возвращающей манифесты для всей инфраструктуры, требуемой для работы этого компонента. Эту функцию удобно объявить в отдельном модуле в lib/<subsystem or component>/main.libsonnet. На вход такая функция принимает:


  • зависимости — связи с соседними компонентами;
  • метки и имена, идентифицирующие компонент в составе надсистемы;
  • параметры масштаба, выраженные в терминах предметной области;
  • версии образов;
  • иные параметры, влияющие на развёртывание компонента.

Рассмотрим эти параметры подробнее.


Представление зависимостей


Для установления связи с соседними компонентами обычно требуется ряд значений — адрес хоста, порт, учётные данные. Эти значения не являются независимыми, изменение одного значения не приведёт к переключению на другой компонент, а только лишь к ошибке. Для переключения связи на другой компонент необходимо заменить все значения сразу на другой согласованный набор.


Для исключения ошибок согласованный набор значений, описывающий подключение к другому компоненту, удобно представить одной строкой — URL, именем секрета или именем configmap'а. Если подключение не содержит секретного значения (пароля/токена), то достаточно использовать URL или configmap. Если хотя бы одно значение секретное, то необходимо сохранить весь набор в secret, а для описания зависимости использовать имя секрета.


Например, если необходимо представить связь с базой данных, то можно использовать имя секрета, содержащего такие поля:


  • host — адрес хоста,
  • port — порт,
  • database — имя базы данных,
  • user — имя пользователя,
  • password — пароль,
  • schema — имя схемы БД (своего рода "тип"). Позволяет убедиться, что подключение осуществляется к корректной базе данных.

Именование компонентов


Имя компонента указывается в генерируемом манифесте в метке "app.kubernetes.io/name". Так как компонент является частью системы верхнего уровня, и в одном кластере может находиться много экземпляров одного типа компонента, логичным выглядит включение имени родительской системы в качестве префикса имени компонента. Тем самым обеспечивается уникальность имён в кластере и удобная возможность понять, к какой подсистеме относится данный компонент. В Kubernetes имеется также стандартная метка "app.kubernetes.io/partof", которую можно использовать для указания на родительскую систему. Для удобства обработки имён и префиксов, можно в качестве параметра передавать путь к компоненту как список имени компонента и локальных имён всех надсистем, а затем с помощью функции std.join получить строковое представление.


Метки


Ключевым фактором, обеспечивающим корректную работу kubernetes, является использование меток на всех ресурсах. Метки позволяют соотнести компоненты между собой, отнести их к каким-либо подсистемам, организовать разграничение доступа, обеспечить переход на новые версии указанных систем ("выкатить", rollout), организовать автомасштабирование требуемых компонентов.


Для того, чтобы все компоненты системы получили правильные и согласованные метки, необходимо формировать эти метки централизованно. Создаётся локальный модуль (в папке lib/), предоставляющий объект systemLabels, который отвечает за формирование меток на уровне текущей системы. У этого объекта должен быть соответствующая функция, порождающая такой же объект для подсистемы. Т.к. имя компонента описывается меткой и определяется положением компонента в иерархии, логично именование осуществлять с помощью этого же объекта.


Детерминированное масштабирование


Kubernetes поддерживает автоматическое масштабирование (HorizontalPodAutoscaler) путём отслеживания параметров нагрузки. Для многих систем с переменной непредсказуемой нагрузкой такой подход к масштабированию является наилучшим. Тем не менее, сам процесс масштабирования занимает некоторое время, что может привести к отказам или задержкам, пока система не достигнет оптимального масштаба.


Если нам заранее известна требуемая производительность всей системы (например, необходимо обрабатывать N запросов в секунду), то мы можем вычислить параметры масштаба для каждого компонента системы, пользуясь экспериментально определёнными коэффициентами. Тем самым система изначально при развёртывании будет иметь оптимальный масштаб. (Безусловно, следует сохранять механизм автоматического масштабирования для купирования случайных отклонений нагрузки.)


Коэффициент масштаба становится ещё одним параметром уровня систем/компонентов.


Версионирование образов


Чтобы система функционировала стабильно и корректно, все компоненты должны использовать фиксированные версии образов, протестированные на совместимость. По-видимому, структура системы меняется реже, чем версии образов, и происходит это в значительной степени независимо. Исходя из принципа "разделения ответственности" (separation of concerns), удобно версии образов выделить в отдельный файл .libsonnet. В таком файле каждому типу образа будет соответствовать либо номер версии, либо полный url образа (в зависимости от того, допустимо ли указать адрес репозитория образов в виде константы).


{
  "component1": "v1.2.3",
  "component2": "1.2.3",
  "ourComponent3": "repo.example.com/path/image@2.3.4",
  "ourComponent4": importstr "file-with-image-url.txt"
}

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


Объект с версиями передаётся всем подсистемам и компоненам в неизменном виде, тем самым гарантируется, что все компоненты системы будут между собой совместимы.


Заключение


Jsonnet обладает достаточной гибкостью, и позволяет реализовать набор крупных блоков, из которых можно строить целое семейство систем, отличающихся составом компонентов, производительностью, и другими желательными функциональными и нефункциональными свойствами, и гарантировать совместимость всех компонентов по версиям. В сравнении с популярными альтернативами (Helm, kustomize), Jsonnet даёт возможность легко реализовать концепцию эфемерных окружений, создаваемых на короткий срок для проведения каких-либо экспериментов или регресионных тестов. За счёт следования принципу DRY и ортогонального проектирования, независимые аспекты конфигураций описываются в одном экземпляре и становятся доступны во всех окружениях, где это необходимо.

Tags:
Hubs:
+4
Comments 5
Comments Comments 5

Articles