Привет, меня зовут Всеволод, я разработчик в Yandex Infrastructure — команде, которая создаёт платформу для разработчиков Яндекса. Последние 12 лет я занимаюсь внутренним облаком и за это время писал самые разные его компоненты: от базовой инфраструктуры хостов и до Control Plane.

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

Немного про подходы: Infrastructure as Data или Configuration as Data

Исторически у нас в Яндексе много разных и достаточно независимых инфраструктурных систем: несколько облаков, системы управления квотами, сертификатами, мониторингами, базами данных и многим другим. Всё это делалось в разное время, разными людьми, с разными принципами и технологиями. Где‑то gRPC API, где‑то хитрые http‑протоколы, а где‑то — встречается и такое — только веб‑форма в UI.

Между этими системами есть разного рода зависимости. И поднятие нового сервиса в современном мире, — как ни странно, не самая тривиальная задача. Вам надо получить квоту, создать для тестинга и прода то, что в облаках сейчас называют security groups, выписать необходимые доступы к другим сетям, поднять базы и поды, потом заказать сертификаты и домены, поднять балансировщики, которые будут смотреть в эти поды, и так далее. Получается куча действий в таком ориентированном графе. Хочется, чтобы всё можно было сразу описать в одном месте, а дальше случилась какая‑то магия.

Для этого у нас есть Kubernetes. Мы не используем его для классического всем знакомого управления контейнерами — для этого у нас есть своё большое облако, которое мы называем Yandex.Deploy. Но мы используем его для описания инфраструктурных систем, включая и спецификации того самого облака. Этот подход часто называют configuration as data или infrastructure as data. То есть пользователь заливает в наш Kubernetes конфиг, а операторы k8s уже разворачивают необходимые компоненты в других системах и следят, чтобы всё работало.

Если говорить о масштабах, то наша система пока довольно молодая, но уже с неплохой нагрузкой и ответственностью. Я на днях посмотрел статистику, нашёл пару круглых чисел. Если считать именно поднятые через нас контейнеры в системах деплоя, то под нашим управлением находится 1,5 млн ядер. А если говорить о фичах, то и по покрытию всех аспектов инфраструктуры, и по количеству управляемых нами инфраструктурных сущностей получается около 10% всей инфраструктуры Яндекса. Ещё есть куда расти, но мы стараемся.

Как вы знаете, Kubernetes — это не только и не столько механизм выкатки подов, но ещё и база данных для схематизированных документов — объектов — на основе etcd. Плюс, это хорошая модель, как с этими документами должны работать операторы.

Мы взяли Kubernetes в качестве такого хранилища и API‑сервера и стали готовить для него схемы описания различных ресурсов: custom resource definitions, или CRD.

В общих чертах всё выглядит как на картинке: пользователь заливает в k8s какие‑то объекты, объекты обрабатываются операторами и изменения доводятся до конечных систем. Посмотрим на CRD подробнее.

Гайд первый: не пишем OpenAPI 

В начале разработки у нас встал вопрос, как нам описывать схемы данных. Если у кого‑то нет опыта написания собственных CRD, то расскажу, что Kubernetes для описания моделей данных использует openapi‑формат на основе jsonschema — это такой не очень приятно выглядящий формат:

Для примера — крошечный кусочек нашего DeployStage
Для примера — крошечный кусочек нашего DeployStage

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

И тут самое время рассказать, что у нас довольно сложная инфраструктура. Объекты типа ReplicaSet, ну или Pod в обычном Kubernetes устроены очень просто — там настроек в общем‑то почти и нет. Взяли докер‑образ, намазали несколько переменных окружения, выставили наружу порт — и погнали!

С нашими моделями всё намного сложнее. Если говорить про наш DeployStage, то это что‑то вроде Deployment, разбитого на несколько независимых локаций. Так вот, если его полную схему сконвертировать для Kubernetes в JSON и компактифицировать, она будет занимать больше мегабайта. Аналогично и многие другие наши системы: везде очень широкие возможности конфигурации, это не три поля CronJob, которые фигурируют во всех гайдах по написанию операторов.

Мы посмотрели на эти схемы данных и на мировые практики. Тут мы оказались не первыми, кто не очень любит ванильные OpenAPI и YAML. Например, многие используют для описания OpenAPI более удобный язык описания CUE — этот подход долгое время популяризовали в Istio.

В итоге мы решили, что хорошая идея — описывать схемы на каком‑нибудь нормальном языке, а для схемы CRD использовать генератор.

В качестве языка описания схем мы взяли protobuf. Во‑первых, это хороший язык с отличными возможностями:

  • с ним легко переиспользовать модели чужих API внутри своих собственных;

  • из коробки есть система плагинов компилятора, позволяющая трансформировать схемы в разнообразный код;

  • есть система совместимого, что важно, расширения языка при помощи аннотаций.

А во‑вторых, что особенно важно, protobuf используется большинством самых лучших и самых сложных API в нашей дельта‑окрестности, а значит, нам придётся описывать меньше схем самостоятельно. Если вспомнить уже упомянутую модель объекта DeployStage, можно представить себе, каких трудов стоило бы поддерживать актуальную схему в 1 Мб, пиши мы её сами. Каких трудов это стоило при нашем подходе, вы увидите чуть позже.

Мы взялись за дело: в тот момент ожидаемо оказалось, что готовых плагинов трансляции protobuf в схемы k8s не существует, и пришлось написать его самостоятельно. Сейчас это уже немного не так, поскольку не так давно Istio тоже перешли на protobuf. На мой взгляд, их решение немного менее консистентное и не очень отделимое от их кода, но они и сами об этом пишут, так что никаких претензий.

Вспомним устройство объектов Kubernetes

Kubernetes как база данных устроен следующим образом: для каждого типа существует отдельное хранилище-табличка документов. Типы бывают глобальные (cluster-scoped), и привязываемые к «проекту» (namespace-scoped). Соответственно, у первых уникальным ключом является name, у вторых — пара namespace и name.

apiVersion: k.yandex-team.ru 
kind: DeployStage 
metadata:
 name: myname
 namespace: mynamespace
 generation: 42 
spec:
 field: value
 ... 
status:
 observed_generation: 42
 ... 

Структура документа при этом выглядит так:

type DeployStageV5 struct {
  // Стандартный тип Kubernetes, 
  // хранящий имя и версию типа 
  metav1.TypeMeta `json:",inline"` 

  // Стандартный тип Kubernetes, 
  // хранящий метаданные объекта 
  // (имя, неймспейс, аннотации и др.) 
  metav1.ObjectMeta `json:"metadata"` 

  // …другие специфичные 
  // для конкретного типа поля 
}

Есть общие метаданные: информация о типе, информация о конкретном экземпляре — и какие-то специфичные для типа поля.

На практике специфичные поля можно разделить на два подтипа. Бывают стандартные встроенные типы Kubernetes (pod, deployment, service и т.п.), у которых этот набор полей исторически разнится между типами. А бывают пользовательские типы (CRD), для которых уже придумали правила, и поэтому множество полей строго регламентировано и ограничено, и по сути сводится к двум полям: spec и status

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

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

При этом одна версия является основной (в ней хранятся данные), а все остальные умеют конвертироваться в неё и из неё. В теории это позволяет старому клиенту, который не умеет работать с новой версией, запрашивать и сохранять объекты в известной ему версии, а сервер должен на лету конвертировать данные в эту версию и обратно.

Долой версионирование схем 

Первым делом мы отказались от версионирования. Совсем.

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

Только представьте: вы хотите добавить, удалить или поменять поле где‑то на пятом уровне вложенности структур. Вам надо будет объявить новый тип для всех уровней объекта вплоть до пятого и либо плодить копипасту, либо учить свой код работать с какими‑то безумными интерфейсами, описывающими каждый уровень вложенности. Прибавьте к этому то, что вам надо будет описать две OpenAPI‑схемы объекта, которые тоже будут отличаться только одним этим полем, а использовать композиции в моделях OpenAPI (то, что называется $ref) в Kubernetes запрещено. Вспоминаем, что схема одной версии легко занимает один мегабайт, и умножаем на число версий.

Ну как, ещё хочется версионировать?

Во‑вторых, если задуматься, то такое версионирование по сути подходит только для каких‑то косметических изменений или изменений вроде read‑only данных в статусе объекта. Если клиент пользуется старой версией спеки и попытается читать или писать спеку объекта, у которого уже есть новые поля, то это неизбежно приведёт к потере данных, а значит никакие функции конвертирования между версиями не помогут. Потому что если нового поля в спеке нет, то и взять его неоткуда.

Если нет версий, то что на замену? Здесь нам на помощь приходит protobuf и его достаточно мудрые гайдлайны: любые изменения схемы обязаны быть обратно совместимы. Прекоммитные проверки на изменение схемы объекта у нас буквально проверяют, что все существующие объекты валидны с точки зрения новой схемы. Старый клиент также сможет читать объекты новой версии в той мере, в которой это позволяет protobuf.

Следующим шагом мы официально сделали protobuf‑модели нашим источником правды. Что это значит на практике? Модель объекта описывается как protobuf‑файл вот такого вида:

message Spec {
  // …произвольные поля, которыми вы хотите описать 
  // спецификацию своего объекта 
} 

message Status {
  // …аналогично произвольные поля для статуса объекта 
} 

message MyKind {
  option (protoc_gen_crd.k8s_crd) = {
    api_group: "k.yandex-team.ru", 
    kind: "MyKind", plural: "mykinds", singular: "mykind", 
    short_names: "my", 
    categories: ["myprovider", "infractl"] 
  };

  Spec spec = 1; 
  Status status = 2; 
} 

К этому файлу применяются наши proto‑плагины и генерируют OpenAPI‑схему объекта, а также go‑обёртку, которая умеет его правильно сериализовать в JSON‑формат для Kubernetes, и предоставляет необходимые интерфейсы и хелперы для написания контроллера.

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

import "yp_proto/yp/client/api/proto/stage.proto";

message DeployStage {
  option (protoc_gen_crd.k8s_crd) = { /* … */ }; 
  NYP.Nclient.Napi.Nproto.TstageSpec spec = 1; 
  NYP.Nclient.Napi.Nproto.TstageStatus status = 2; 
} 

Помните, я говорил, что у модели должны быть два поля: спека и статус? Тут мы их можем брать из модели провайдера в своё удовольствие. Благодаря нашему монорепозиторию мы всегда получаем актуальную версию моделей провайдера, а когда они обновляют свои модели, мы можем прогнать свои тесты и убедиться, что они действительно не сломают совместимость.

Генерим модели на разные случаи жизни 

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

В случае с OpenAPI это привело к тому, что теоретически модели объектов описываются в стандартном формате OpenAPI. На практике поддерживаются не все его возможности, например, как я уже говорил, запрещено переиспользование структур через ссылки. Из‑за этого в OpenAPI Kubernetes невозможно полноценно описать рекуррентные модели, которыми задаются, например, конфиги нашей балансировки трафика. Мы обходим эту проблему так: в тех точках, где случается рекурсия, описываем вложение как opaque‑поле неизвестной структуры. Реальную валидацию осуществляем не на стороне Kubernetes, а на стороне валидирующего вебхука, который парсит объект как protobuf‑сообщение и при необходимости возвращает ошибку схемы.

Другая проблема OpenAPI в Kubernetes заключается в том, что довольно долгое время основным форматом взаимодействия с сервером был OpenAPI 2.0, в котором нет валидации и разметки полей как nullable. А OpenAPI 3.0 был стабилизирован относительно недавно. Одновременно с этим в клиентском коде существовал и OpenAPI 3.0 с поддержкой валидации, а kustomize — дефолтный шаблонизатор для Kubernetes — опирается на эти OpenAPI 3.0-схемы. Вдобавок он поддерживает в них специальные аннотации, которые говорят, как при наследовании надо мержить списки и объекты.

Таким образом, для объекта нам нужны две несовместимые версии схемы: одна простая для сервера и вторая более сложная для клиента. Тут мы просто ещё раз порадовались, что OpenAPI‑модели мы генерируем, и добавили в наш proto‑плагин второй режим.

Вот, как пример, разница между серверной моделью для хранения и клиентской моделью для kustomize:

Кстати, обратите внимание на ещё одно различие: если в серверной модели мы разрешаем дополнительные неизвестные поля (ну, мало ли, клиент с новой версией протобуфа пришёл или наоборот пытается залить deprecated‑поле), то для клиента тут наоборот ограничения строгие. Он всегда знает свою актуальную модель и должен сразу выдать ошибку валидации пользователю, если тот опечатался.

Пишем расширенные схемы данных для клиента 

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

Дело в том, что просто YAML‑файлы моделей, и даже стандартная шаблонизация kustomize не дают достаточной функциональности. Часто мы хотим чего‑то странного: например, подставить в спецификацию inline‑конфиг, который удобно описывать в отдельном файле, или ещё какой‑то процессинг. Для этого мы раньше добавляли в файл отдельные поля с инструкциями, которые вырезали и использовали для препроцессинга текстового файла перед тем как попытаться распарсить его как объект Kubernetes. Это означало, что до определённого момента мы были вынуждены работать с объектом как со словарём неопределённого формата, а после препроцессинга информация об исходной структуре была уже утеряна.

Добавление клиентского режима развязало нам руки. Мы добавили новую protobuf‑аннотацию, которая помечает поля как client‑only, и научили плагин генерировать вторую go‑структуру — которая обладает правильным знанием о клиентской модели со всеми её дополнительными полями.

message L7Balancer {
  option (protoc_gen_crd.k8s_crd) = { /* ... */ }; 

  Spec spec = 1; 
  Status status = 2; 

  BalancerSettings default_balance = 3 
  [(protoc_gen_crd.schema) = SS_CLIENT]; 
} 

Теперь мы можем разобрать и провалидировать файл сразу в момент чтения, а в Kubernetes заливать уже «серверную» версию объекта, например:

То есть, здесь в CLI мы из пользовательского файла читаем объект типа L7BalancerClientManifest, как‑то его обрабатываем и на сервер шлём уже финальный объект L7Balancer. И оба они описываются одной схемой данных.

Вот тут можно посмотреть, как это используется на реальных объектах:

Мы описываем параметры по умолчанию, которые применяются ко всем апстримам, и дальше просто в каждом апстриме мы можем переопределить эти параметры. Что важно, это всё срендерится на клиенте, пользователь может посмотреть на конечный результат и уже срендеренную версию залить на сервер.

Организация типов объектов

Все объекты мы условно разделяем на два класса.

Первый у себя внутри мы называем hard mode. Оператор таких объектов работает непосредственно с каким‑то провайдером. Это позволяет сделать максимально простую и прямую интеграцию с внешней системой: hard‑mode‑объект должен соответствовать объекту в удалённой системе и синхронизироваться в неё без какой‑то сложной логики. И поддерживать максимальный набор возможностей этого провайдера.

В качестве примера можно привести модель DeployStage, которую я показывал раньше: она обладает всем спектром возможностей, предоставляемых Yandex.Deploy, но заставляет указывать большое количество тривиальных полей и самостоятельно подставлять какие‑то метаданные.

import "yp_proto/yp/client/api/proto/stage.proto"; 

message DeployStage { 
  option (protoc_gen_crd.k8s_crd) = { /* … */ }; 

  NYP.Nclient.Napi.Nproto.TstageSpec spec = 1; 
  NYP.Nclient.Napi.Nproto.TstageStatus status = 2; 
}

Вот эти TStageSpec и TStageStatus — это типы прямо из моделей нашего провайдера, мы их в нашем репозитории через импорт подключили. То есть мы по умолчанию поддерживаем тут всё, что есть.

Раз первый класс называется hard mode, то второй, легко догадаться, называется easy mode. Такие объекты как правило не взаимодействуют с конечной системой сами, а формируют 1..n спецификаций hard mode.

Это даёт возможность делать easy mode просто: оператор easy mode избавлен от необходимости непосредственного взаимодействия с внешним провайдером и всех сопутствующих этому сложностей. Вместо этого он может сосредоточиться на том, чтобы из простой модели сформировать сложную с применением какой‑то шаблонизации и обогащения спек, оставив применение на стороне провайдера контроллеру hard mode.

Пример такого объекта — тип, который мы назвали Runtime. Он позволяет в упрощённом виде описать схему предыдущего объекта. Данная модель уже гораздо больше похожа на привычный всем ReplicaSet из классических типов Kubernetes, хотя и всё равно сложнее.

Оператор Runtime берёт эту спеку, обогащает какими‑то метаданными из разных внешних систем и из самого Kubernetes и создаёт или обновляет hard‑mode‑объекты для конкретных провайдеров.

Если у вас есть опыт работы с crossplane, то наш hard mode очень похож на crossplane providers, а easy mode по смыслу немного похож на crossplane compositions.

Для облегчения связи между easy mode и hard mode у нас есть клей под названием medium mode.

kind: Runtime 
spec:
 replicas: {sas: 2, vla: 2}
 image: registry.y-t.ru/example:42@sha256:4457a31c0...bae653a
 override:
  deploy_stage:
   update:
   - path: spec.deploy_units."my-unit".mcrs.pod_template_spec
     value:
      ipv6_address_requests:
      - network_id: _EXAMPLE_TESTING_NETS_
        vlan_id: backbone
        enable_dns: true
        virtual_service_ids: [example-http-service.y-t.ru]

Это не какой‑то отдельный объект, а кодовое название для специальных патчей в схеме объекта. Логика тут простая: раз у нас есть огромная сложная схема данных в hard mode и маленькая красивая в easy mode, значит, в более простой чего‑то нет. Где‑то что‑то упростили, не стали тащить в простую спеку малопопулярную возможность и так далее. Но что делать, если пользователю не хватает ровно одной маленькой галочки? Переставать использовать простую схему и страдать со сложной?

Чтобы пользователь не испытывал боль, мы говорим, что можно написать в спеке easy‑mode‑объекта небольшой патч в формате JSONPatch, который применится к срендеренному hard‑mode‑объекту, чтобы выставить эту нужную вам галочку.

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

Как мы со всем этим обращаемся

Поскольку в основе нашей системы лежит Kubernetes, то для всех доступен стандартный набор инструментов: kubectl, kustomize, плагины к IDE и т. д.

Но kubectl — довольно абстрактный инструмент. Он прекрасно решает проблему листинга или простых CRUD‑операций. Однако все знания о типах и их схемах он получает в виде того же OpenAPI, лежащего на сервере, и это значит, что ничего сложнее он не может.

Поэтому у нас есть собственная утилита infractl, которая выполняет роль толстого клиента.

Здесь мы опять пользуемся тем, что нам с одной стороны доступна кодогенерация из protobuf, а с другой — рядом со сгенерированными структурами мы можем положить вручную написанные методы, специфичные для типа. При этом благодаря нашей системе сборки мы можем не коммитить никакие сгенерённые части кода. Так не очень принято в golang‑мире, зато чертовски удобно в плане поддержания актуальности сгенерированных данных. Мы всегда гарантируем, что они соответствуют источнику правды.

В зависимости от того, с каким объектом мы работаем, наш клиент может проверять и автоматически выписывать токены для управления системами провайдеров, выводить расширенный статус, валидировать необходимые секреты и подставлять артефакты. В kubectl легко доступны только очень простые таблицы с колонками на основе JSONPath, чьи возможности в реализации Kubernetes тоже урезаны до предельного минимума. А мы позволяем реализовать в статусе объекта произвольную логику, и ещё вытащить всех детей объекта и показать их статусы. Достаточно реализовать в go‑обвязке своей модели необходимый интерфейс — и CLI автоматически добавляет для типа новую функциональность.

У этого решения есть понятный минус: для поддержки новой модели она должна быть слинкована с нашим бинарником, равно как и обновление схемы требует обновления утилиты. Это особенно актуально с учётом того, что модели данных у нас пишет не только core‑команда, но ещё и внешние по отношению к нам разработчики провайдеров. Но с этим мы боремся тестами, плюс мы релизим CLI часто, практически каждый день, а раскатка обновления пользователям происходит автоматически, поэтому плюсы перевешивают.

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

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

Подытожим

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

  • Используйте хорошие композируемые языки для описания моделей данных, не используйте голый openapi.

  • Не используйте версионирование. Просто не используйте, оно ничему не помогает.

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

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

  • Толстые клиенты — это нормально. Не стесняйтесь написать толстый клиент, если вы видите в этом пользу. Ни один тонкий generic‑клиент не знает о вашей системе больше, чем вы и не сможет автоматизировать пользовательские сценарии или дать очень быстрый фидбек или даже сам исправить проблему. Только убедитесь, что вы можете оперативно доставлять пользователям свежую версию.

  • Ну и попробуйте наш генератор для CRD. Мы недавно выложили в опенсорс ту его часть, которая производит YAML‑схему, всё лежит на гитхабе по ссылке — пробуйте, приносите фидбек. Ту часть, которая производит go‑код, мы пока выложить не готовы — она слишком завязана на нашу систему сборки, но над этим тоже работаем.

Конечно, наш рецепт наверняка подойдёт не всем. Если у вас очень маленькая инфраструктура целиком на готовых облаках и их провайдерах, то вполне вероятно, что вам в жизни не надо ничего, например, кроме crossplane или helm charts.

А вот если ваша система масштабируется, и вы, скажем, управляете элементами собственной инфраструктуры, то скорее всего наш опыт сэкономит вам много сил.

Комментарии (0)