Horchestra: когда Kubernetes нельзя, а Ansible становится второй кодовой базой

от автора

Последние три года я занимаюсь разработкой и поставкой enterprise-продуктов. И относительно недавно понял одну вещь.

Самая сложная часть проекта — вовсе не разработка. Самая сложная часть — доставка и поддержка.

Пока приложение работает на ноутбуке разработчика, всё выглядит прекрасно. Собрали, запустили, прогнали тесты, отправили изменения в Git.

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

  • У одного заказчика уже есть Kubernetes, и достаточно передать Helm‑чарт.

  • У другого контейнеры запрещёны требованиями ИБ.

  • Третий принципиально использует только RPM‑пакеты.

  • Четвёртый работает исключительно на Alt Linux.

  • Пятый — на РЕД ОС и Astra.

  • Шестой до сих пор живёт на CentOS 7, потому что обновление инфраструктуры — отдельный многолетний проект.

Иногда разрешены контейнеры. Иногда разрешены, но есть нюанс. Иногда наоборот — запрещены любые контейнерные рантаймы.

И чем больше становится заказчиков, тем сильнее начинаешь понимать одну неприятную вещь: проблема уже давно не в том, как написать приложение. Проблема в том, как доставить одно и то же приложение во все эти совершенно разные инфраструктуры. А потом это ещё и поддерживать.

Две кодовые базы

Давно заметил интересную закономерность.

Практически каждый enterprise-продукт начинает жить сразу в двух кодовых базах:

  1. Собственно приложение — исходный код, бизнес-логика, тесты.

  2. Инфраструктурный код — всё, что отвечает за установку, настройку и обновление.

И очень часто именно вторая начинает расти быстрее первой.

Десятки ролей, сотни переменных, поддержка пяти ОС — и вот уже установочный сценарий превратился в недокументированный API, который почти невозможно протестировать целиком

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

Почему установка приложения вообще должна быть настолько сложной?

Kubernetes не виноват

С Kubernetes я работаю уже больше восьми лет — ещё со времён версии 1.9.

За это время мне довелось не только эксплуатировать кластеры, но и разрабатывать собственные расширения Kubernetes API, контроллеры и Aggregated API. Поэтому всё, что будет сказано дальше, — это не попытка доказать, что Kubernetes плохой или переоценённый.

Наоборот.

На сегодняшний день Kubernetes — это, пожалуй, лучшая платформа для оркестрации контейнеризированных приложений. Он решает огромный спектр задач: распределённое размещение приложений, автоматическое восстановление после сбоев, масштабирование, сервис-дискавери, управление сетевым взаимодействием, декларативное управление инфраструктурой.

Если ваша система состоит из десятков или сотен сервисов, должна переживать отказ отдельных узлов и масштабироваться под нагрузкой, Kubernetes — вполне естественный выбор.

Но есть одна особенность, о которой иногда забывают.

Как и любой другой инструмент, Kubernetes не является универсальным решением.

Тот факт, что некоторую систему можно запустить в Kubernetes, вовсе не означает, что это будет лучшая архитектура.

Именно поэтому я разделяю два совершенно разных вопроса:

Можно ли это запустить в Kubernetes?

и

Нужно ли это запускать в Kubernetes?

Во многих случаях ответы совпадают. Но далеко не всегда.

За последние несколько лет мне неоднократно приходилось поставлять программное обеспечение в инфраструктуры, где Kubernetes либо отсутствовал, либо его использование было сознательно исключено.

Причины были самыми разными:

  • требования информационной безопасности;

  • отсутствие компетенций для сопровождения платформы;

  • нежелание усложнять инфраструктуру;

  • приложение устанавливается на один-два сервера, и функциональность Kubernetes остаётся невостребованной.

При этом контейнеры использовать разрешалось. OCI-образы использовать разрешалось. Container Registry использовать разрешалось. Но Kubernetes — нет.

Когда начинается ансиБОЛЬ

Если Kubernetes по тем или иным причинам не подходит, следующим очевидным выбором становится Ansible.

И это абсолютно логично.

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

Проблемы начинаются тогда, когда Ansible перестаёт описывать инфраструктуру и начинает описывать само приложение.

В начале проекта всё выглядит достаточно просто. Появляется playbook, который:

  • создаёт пользователя;

  • устанавливает зависимости;

  • копирует конфигурацию;

  • запускает сервис.

Такой playbook редко вызывает сложности.

Но enterprise-продукты имеют неприятную особенность — они постоянно растут.

Появляется интеграция с LDAP. Поддержка нескольких баз данных. TLS. Внешние системы аутентификации. Мониторинг. Резервное копирование. Режимы высокой доступности. Различные способы хранения данных.

Каждая новая возможность приносит с собой новые параметры установки. Десять. Пятьдесят. Сто. В одном из проектов количество параметров превышало три сотни.

Проблема уже давно не в Ansible.

Проблема в том, что playbook перестал быть обычным сценарием установки. Он превратился в публичный интерфейс продукта.

Каждый новый параметр необходимо:

  • задокументировать;

  • объяснить, когда его использовать;

  • описать допустимые значения;

  • сохранить обратную совместимость;

  • протестировать.

Именно последний пункт оказался самым неприятным.

Автоматически проверить, что документация соответствует реальному поведению продукта, очень сложно. А если параметров установки несколько сотен, покрыть все возможные сценарии автотестами становится практически невозможно.

Конечно, существуют Molecule, Testinfra и другие инструменты. Можно протестировать роли, проверить идемпотентность, убедиться, что сервис успешно запускается. Но всё это проверяет процесс установки, а не интерфейс установки.

Если посмотреть на набор переменных Ansible глазами разработчика, то становится видно, что это такой же API, как REST или gRPC.

Только его потребителем является не приложение, а администратор. А любой API требует сопровождения: версионирования, документации, обратной совместимости, тестирования.

Получается любопытная ситуация. Мы тщательно проектируем API приложения. Используем OpenAPI. Пишем контрактные тесты. Следим за обратной совместимостью. Но одновременно рядом существует ещё один API — интерфейс установки продукта, — который зачастую описывается обычным Markdown-файлом и проверяется вручную.

Чем дольше живёт проект, тем дороже становится сопровождение этого интерфейса.

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

При этом постепенно стало понятно, что одной технологией решаются две совершенно разные задачи.

Первая — подготовка операционной системы: установка пакетов, настройка сервисов, создание пользователей, конфигурирование окружения.

Вторая — доставка самого приложения.

Для первой задачи Ansible подходит практически идеально.

А вот во второй ему приходится описывать то, что уже давно описано где-то ещё.

Именно это и стало отправной точкой для поиска другого подхода к доставке приложений.

OCI уже решил проблему упаковки

В новогодние праздники 2020–2021 годов я занимался изучением внутреннего устройства контейнерных образов, о чём позже написал небольшую статью на Хабре. Уже тогда появилась мысль использовать OCI-образ не только как формат контейнера, но и как универсальный способ упаковки и доставки приложений.

Спустя несколько лет, столкнувшись с проблемой сопровождения всё более сложных сценариев установки, я решил посмотреть, существуют ли уже решения, реализующие подобный подход. В процессе поиска выяснилось, что идея использовать OCI как универсальный формат доставки артефактов уже далеко не нова. Мне попалось несколько проектов, а также интересный доклад, посвящённый поставке отдельных бинарных файлов в air-gap окружения через Container Registry.

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

За последние годы OCI фактически стал стандартом упаковки контейнеризированных приложений.

Сегодня с OCI-образами умеют работать практически все современные инструменты:

  • Docker;

  • Podman;

  • containerd;

  • CRI-O;

  • Kubernetes;

  • практически любой Container Registry;

  • большинство CI/CD-систем.

Даже если для сборки не используется Docker, её результатом всё чаще становится именно OCI-образ.

И это неудивительно. OCI оказался достаточно простым и универсальным, чтобы стать общепринятым форматом упаковки приложений.

Если посмотреть на содержимое OCI-образа, становится понятно, что в нём уже есть практически всё необходимое для запуска приложения:

  • файловая система;

  • само приложение;

  • все необходимые зависимости;

  • требуемая версия рантайма;

  • структура каталогов;

  • точка входа.

Другими словами, приложение полностью подготовлено к запуску ещё на этапе сборки образа.

Возникает закономерный вопрос.

Если приложение уже полностью собрано, зачем во время установки снова выполнять действия по его подготовке?

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

При использовании OCI эта работа выполняется один раз — во время сборки образа. На этапе развёртывания серверу остаётся лишь получить уже готовый артефакт и запустить приложение.

Получается, что ответственность начинает естественным образом разделяться.

  • OCI отвечает за упаковку приложения.

  • Ansible — за подготовку операционной системы.

  • Остаётся только решить ещё одну задачу — декларативно управлять жизненным циклом уже готового приложения.

Именно здесь сегодня выбор чаще всего сводится к двум вариантам.

Либо использовать полноценный Kubernetes.

Либо реализовывать собственную логику управления на Ansible.

При этом между ними остаётся довольно большая ниша — сценарии, в которых необходимо декларативно развернуть одно или несколько OCI-приложений на обычных Linux-серверах без полноценной контейнерной платформы.

Именно попытка закрыть эту нишу и привела к появлению Horchestra.

Так появился Horchestra

Идея Horchestra родилась не как попытка написать ещё один оркестратор. Скорее, это была попытка ответить на вопрос, который возник после нескольких лет работы с enterprise-клиентами.

Если:

  • приложение уже упаковано в OCI-образ;

  • операционная система умеет управлять процессами через systemd;

  • Linux предоставляет cgroups, namespaces и остальные необходимые механизмы изоляции;

то зачем между ними обязательно должен находиться полноценный Kubernetes?

Ведь в моём сценарии не требовалось:

  • распределять нагрузку между сотнями узлов;

  • автоматически масштабировать приложение;

  • обеспечивать отказоустойчивость кластера.

Требовалось значительно более простое действие:

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

По сути, именно этим и занимается Horchestra.

Не контейнерный рантайм и не Kubernetes

Когда я начал прорабатывать архитектуру, первое, от чего сознательно отказался, — идея написать собственный Container Runtime или CRI-плагин для Kubernetes.

Такой путь выглядит вполне логичным. Можно было бы реализовать RuntimeClass, подключиться к kubelet и использовать весь существующий Kubernetes как платформу.

Но довольно быстро стало понятно, что это не отвечает на исходный вопрос.

Если убрать всё, что связано с распределённой оркестрацией, высокой доступностью и планированием размещения приложений, то для моего сценария от Kubernetes остаётся прежде всего декларативная модель управления.

Именно она мне и была интересна.

Поэтому Horchestra не использует Kubernetes как основу своей архитектуры. Он заимствует только саму идею:

  • есть желаемое состояние;

  • есть агент;

  • есть цикл reconciliation.

Задача агента — привести систему к описанному состоянию и поддерживать её в нём. Ничего больше.

Один ресурс вместо десятка

Наверное, первое, что бросится в глаза человеку, привыкшему к Kubernetes, — отсутствие привычных ресурсов.

Нет Pod. Нет Deployment. Нет StatefulSet. Нет DaemonSet. Нет Job.

И это сделано сознательно.

На момент создания MVP Horchestra поддерживал только один сценарий:

Одно приложение запускается на одной машине.

Для такого сценария все перечисленные сущности оказываются избыточными. Пользователю совершенно неинтересно выбирать между Deployment и StatefulSet. Ему нужно описать приложение.

Поэтому появился единственный ресурс — Application:

apiVersion: orch.ks-tool.dev/v1kind: PersistentVolumemetadata:  name: pg-dataspec:  size: 10Gi  node: sav-01---apiVersion: orch.ks-tool.dev/v1kind: Applicationmetadata:  name: postgres-18spec:  image: docker.io/library/postgres:18-alpine  nodeName: sav-01  restartPolicy: Always  env:    POSTGRES_HOST_AUTH_METHOD: trust    PGDATA: /var/lib/postgresql/data  ports:    - name: postgres      port: 5432  resources:    requests:      cpu: "1"      memory: 1Gi    limits:      cpu: "1"      memory: 1Gi  volumeMounts:    - pv: pg-data      path: /var/lib/postgresql    - tmpfs:        size: 64Mi      path: /run/postgresql

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

Почему Application, а не Pod

В Kubernetes есть Pod, Deployment, StatefulSet — и каждый из них решает свою задачу. В Horchestra сознательно выбран единый ресурс Application по двум причинам.

Первая: чтобы не думать. Не нужно гадать, что именно создавать для вашего приложения. Оно одно, запускается как один процесс на одной ноде — значит, это Application.

Вторая: чтобы не ошибиться. Application — это ресурс, которого нет в ванильном Kubernetes. Если вы забыли переключить kubeconfig и работаете с продакшен-кластером, команда kubectl get app просто не сработает — вы сразу увидите, что что-то не так.

Да, с точки зрения совместимости Pod был бы удобнее — например, для kubectl logs пришлось реализовать частичную поддержку API Pod’ов. Но концептуально называть это Pod было бы неправильно: Pod в Kubernetes — это группа контейнеров, а здесь у нас один процесс.

Совместимость с kubectl

Следующим вопросом стал интерфейс управления.

Практически любой подобный проект создаёт собственный CLI. Это привычный путь. Но он означает ещё один инструмент, который необходимо изучить, документировать и интегрировать в существующие процессы.

Мне хотелось этого избежать.

Поэтому Horchestra предоставляет API, совместимый с привычной моделью работы kubectl:

kubectl apply -f application.yamlkubectl get applicationskubectl describe application my-servicekubectl logs application/my-service

Конечно, Horchestra не реализует весь Kubernetes API. Да это и не требуется. Совместимость ограничивается только теми возможностями, которые действительно нужны для управления приложениями.

Зато разработчику или администратору не приходится изучать ещё один интерфейс. Если вы уже умеете пользоваться kubectl, то практически сразу сможете работать и с Horchestra.

Архитектура

Одной из целей Horchestra было сохранить архитектуру максимально простой.

В результате система состоит всего из двух компонентов:

  • Controller — хранит желаемое состояние системы, предоставляет API и принимает команды от kubectl.

  • Agent — работает непосредственно на узле и отвечает за приведение системы к описанному состоянию.

Такое разделение получилось вполне естественным.

Controller ничего не знает о внутреннем устройстве операционной системы. Он хранит декларативное описание ресурсов и принимает решения на уровне API.

Agent, наоборот, ничего не знает о пользователях, REST API или CLI. Его задача — получить описание приложения и привести локальную систему в соответствие с ним.

Именно поэтому Agent можно рассматривать как исполнительный компонент, аналогичный kubelet в Kubernetes.

Взаимодействие Controller и Agent

Архитектура взаимодействия между Controller и Agent изначально строилась по аналогии с Kubernetes.

Как и kubelet, Agent самостоятельно устанавливает исходящее соединение с Controller, наблюдает за изменениями ресурсов и приводит локальную систему к желаемому состоянию.

Такой подход оказался удобен сразу по нескольким причинам.

Во-первых, Controller не требуется устанавливать соединения с каждым управляемым узлом.

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

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

Однако во время разработки возник ещё один вопрос — как получать журналы приложения.

Хотелось, чтобы для пользователя это выглядело так же привычно, как в Kubernetes:

kubectl logs my-service

Самым очевидным решением было бы открыть на Agent отдельный HTTP API, по аналогии с kubelet, и получать журналы напрямую. Но тогда каждый Agent превращался бы ещё в один сетевой сервис, доступный извне, а этого как раз хотелось избежать.

Поэтому взаимодействие между Controller и Agent было построено на gRPC с использованием bidirectional streaming.

Одно постоянно открытое соединение используется сразу для всех сценариев взаимодействия:

  • Agent получает изменения желаемого состояния;

  • передаёт текущий статус приложения;

  • отправляет события;

  • потоково передаёт журналы приложения.

В результате всё взаимодействие между компонентами происходит через один защищённый канал, инициированный самим Agent. Никаких дополнительных API или открытых портов на управляемых узлах не требуется.

Жизненный цикл Application

После получения нового ресурса Application Agent последовательно выполняет несколько шагов:

  1. Получает описание приложения.

  2. Загружает OCI-образ из Container Registry.

  3. Подготавливает файловую систему приложения.

  4. Создаёт необходимые каталоги и подключает тома.

  5. Формирует systemd unit.

  6. Запускает приложение.

  7. Начинает отслеживать его состояние.

  8. Передаёт текущий статус обратно Controller.

Вся дальнейшая работа строится вокруг reconciliation loop. Agent периодически сравнивает фактическое состояние системы с желаемым и, при необходимости, приводит их в соответствие.

Именно эта идея была заимствована из Kubernetes практически без изменений.

Почему systemd

Ещё одним сознательным решением было не реализовывать собственный механизм управления процессами.

Современный Linux уже предоставляет практически всё необходимое для управления приложениями. systemd отвечает за жизненный цикл процессов, журналирование и интеграцию с cgroups, а механизмы Linux namespaces обеспечивают необходимую изоляцию.

Фактически Horchestra использует systemd как исполнительную подсистему, не пытаясь заменить её собственной реализацией.

Текущее состояние проекта

На момент публикации статьи Horchestra находится на стадии MVP.

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

Главное из них — один экземпляр приложения на одной ноде.

На первый взгляд это выглядит серьёзным ограничением. Однако именно этот сценарий хотелось сначала довести до рабочего состояния и проверить саму идею декларативного управления приложением без Kubernetes, прежде чем переходить к более сложным механизмам распределённой оркестрации.

Несмотря на это, текущая версия уже реализует полный цикл развёртывания приложения:

  • получение ресурса Application;

  • загрузку OCI-образа из Container Registry;

  • подготовку окружения выполнения;

  • создание и настройку systemd-сервиса;

  • настройку ограничений через cgroups;

  • подключение постоянных томов;

  • мониторинг состояния приложения;

  • потоковую передачу журналов через kubectl logs.

После применения ресурса Application Agent самостоятельно приводит локальную систему к описанному состоянию и продолжает контролировать его, периодически выполняя reconciliation.

Именно этот механизм и хотелось проверить в первую очередь. Не планировщик, не высокую доступность и не распределённую оркестрацию, а саму возможность использовать декларативную модель управления приложениями на обычном Linux-сервере.

Что дальше

Несмотря на то, что текущая версия Horchestra намеренно остаётся минималистичной, её архитектура изначально проектировалась с расчётом на дальнейшее развитие.

Ограничение «одно приложение — одна нода» было введено сознательно, чтобы сначала проверить основную идею проекта — декларативное управление приложением без Kubernetes. После того как этот сценарий заработал, стало понятно, что дальнейшее развитие не требует пересмотра архитектуры.

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

Следующим — появление механизма планирования размещения приложений. При этом нет никакого смысла заново решать задачи, которые Kubernetes уже решил за годы своего развития. Фильтрация узлов, учёт доступных ресурсов, affinity, anti-affinity, taints и tolerations — это удачные инженерные решения, которые вполне можно адаптировать, не воспроизводя всю архитектуру Kubernetes целиком.

Ещё одно направление, которое мне кажется особенно интересным, связано с использованием OCI как универсального формата доставки.

Сегодня Container Registry воспринимается прежде всего как место хранения контейнерных образов. Но если OCI уже стал универсальным форматом упаковки, то следующим логичным шагом выглядит использование Registry как единого механизма доставки не только контейнеров, но и полноценных сервисов.

Именно в этом направлении мне сейчас и интересно развивать Horchestra.

Вместо заключения

Когда я начинал работу над Horchestra, мне казалось, что я просто ищу более простой способ запускать контейнеризированные приложения без Kubernetes.

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

OCI уже решил проблему упаковки приложений.

Kubernetes блестяще решает задачи распределённой оркестрации.

Ansible остаётся одним из лучших инструментов для подготовки инфраструктуры.

Однако между ними по-прежнему существует довольно большая ниша.

Ниша, в которой приложение уже упаковано в OCI-образ, но для его запуска не нужны десятки узлов, сложная сетевая модель, автоматическое масштабирование и вся экосистема Kubernetes.

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

Именно эту нишу и пытается занять Horchestra.

Не как замена Kubernetes.

Не как замена Ansible.

И уж точно не как очередной «убийца Kubernetes».

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

Получится ли из этого что-то большее, чем исследовательский проект, покажет только практика.

Но именно ради ответа на этот вопрос Horchestra и появился.

Код открыт на GitHub.

ссылка на оригинал статьи https://habr.com/ru/articles/1059876/