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

Меня зовут Станислав Иванкевич. Я старший программист в команде разработки DataMasters компании VK Tech. В этой статье я расскажу, как мы применили подход с декомпозицией при разработке своего mini-k8s для автоматизации создания и поддержки приложений в пользовательских кластерах Kubernetes.

Контекст: окружение

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

Мы работаем в облаке. В нашем случае всех клиентов облака можно условно разделить на две большие группы:

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

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

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

  • Создать для пользователя то, что он хочет на своих мощностях: выделить ресурс, предоставить инструмент, развернуть рабочую среду и так далее. 

  • Дать пользователю доступ (иногда даже полный) к созданным ресурсам.

  • Поддерживать работоспособность созданных ресурсов.

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

  • Железо. Физическое оборудование не вечное и непредсказуемое — даже несмотря на обслуживание и регулярное обновление «парка железа», в любой момент, в любом дата-центре, что угодно может выйти из строя.

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

  • Пользователь. Пользователь сам решает, как ему использовать выделенные ресурсы. Это создает риск, что действия клиента (удаления, изменения, создания) неосознанно спровоцируют сбои.

Чтобы минимизировать подобные риски, но все так же решать задачи пользователей, в облаке существует строгое разделение на два слоя: data plane и control plane.

  • Control plane (управляющий слой). К нему относятся компоненты, которые обеспечивают работоспособность всего облака: сервисы, API, скрипты и другие. К ним пользователь имеет доступ только посредствам публичного API.

  • Data plane (слой пользовательских данных). Это компоненты, которыми пользователь облака владеет и пользуется: виртуальные машины, кластеры Kubernetes, базы данных и прочее. Этими ресурсами пользователь может распоряжаться по своему усмотрению.

В таком разделении важно, что в data plane нет управляющих конструкций (за редким исключением, например, в виде агентов мониторинга), а создание и управление ресурсами data plane выполняется с помощью control plane.

От контекста к задаче

Исходя из требования разделения слоев на data plane и control plane, любые операции в качестве поставщика облака мы должны выполнять через control plane. 

Вместе с тем, в какой-то момент мы столкнулись с необходимостью автоматизировать создание и поддержку приложений в пользовательских кластерах Kubernetes. Первым таким приложением стала Apache Kafka.

В теории реализовать это несложно — в Kubernetes есть механизм операторов, поэтому достаточно установить на пользовательский K8s оператор, через который дальше можно будет создавать Kafka и управлять ей. Нюанс в том, что кластер K8s размещен в data plane, то есть пользователь потенциально может удалить оператор или даже весь кластер. Это, очевидно, недопустимо, поэтому от нас, как от облачного провайдера, требуется особый контроль за упомянутым оператором.

Аналогичная ситуация и с дисками, сетями, хранилищами, доступами и другими компонентами — в теории автоматизировать все процессы можно через оператор пользовательского Kubernetes. Но оператор K8s расположен в data plane, поэтому нам нужен дополнительный «гарант стабильности». Таким является инструмент для полного контроля со стороны control plane.

Так мы пришли к необходимости создания своего mini-k8s для Kubernetes и всего вокруг.

Архитектура

При построении своей реализации mini-k8s мы вдохновлялись отдельными функциями и возможностями, заложенными в основу Kubernetes. Помимо этого, мы заимствовали декларативную систему управления через манифесты и шаблонизацию через helm, который подключили как библиотеку в нашем сервисе. Такое комбинирование потенциально позволяло бы нам полностью решить проблему с непредсказуемым data plane.

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

  • Gate. Входная точка сервиса, через которую поступают все запросы. Например, на создание кластера или установку приложения. Именно здесь на запросы пользователя из наших helm чартов генерируются наборы манифестов.

  • Storage. Абстракция над базой данных, которая умеет работать с манифестами. В нашем случае мы вынесли ее в отельный микросервис. В storage прилетают сгенерированные в gate манифесты, здесь же они и хранятся.

  • Controllers. По аналогии с контроллерами в операторах Kubernetes, controllers — главная рабочая лошадка всей нашей системы. Controllers представляет собой набор контроллеров, выполняющих всю основную работу. Контроллеры получают манифесты из storage, обрабатывают их и приводят data-plane к заданному в манифестах состоянию.

Концепция

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

Главное условие — чтобы каждую задачу можно было выполнить независимо от других.

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

При проецировании подобного алгоритма декомпозиции на задачи по созданию Kafka на Kubernetes мы получили следующую схему. 

Для примера, самым верхним блоком схемы является «Сеть и подсеть», от которого зависит блок «Кластер k8s» — такая иерархия отлично демонстрирует, что прежде чем создать кластер Kubernetes, надо создать сеть и подсеть. При этом, например, балансировщики нагрузки или IP можно создавать параллельно и независимо. 

Манифесты и контроллеры

Двумя основными сущностями всей нашей системы являются манифесты и контроллеры.

  • манифесты содержат описание того, как должно быть;

  • контроллеры знают, как из практически любой ситуации прийти к состоянию, описанному в манифесте.

По сути, таким образом работают операторы в самом Kubernetes.

При этом у нас:

  • манифесты схожи с crd в k8s, но обрабатываются не операторами в пользовательском Kubernetes, а нашим сервисом в control plane;

  • один контроллер обрабатывает строго один тип манифестов.

Но есть и исключения. Например, apply controller просто применяет манифест k8s в пользовательском кластере. В этом случае в спеке нашего манифеста apply просто лежит нужный манифест k8s — например, для создания namespace или установки ingress.

Следующим важным этапом является согласование работы манифестов и контроллеров. В операторах Kubernetes для этого предусмотрен reconciliation loop — цикл согласования или цикл сверки, выполняемый контроллером в Kubernetes-операторе.

В своей системе мы реализовали нечто подобное — task puller. 

Он отвечает за две задачи:

  • Решает, когда нужно передать задачу на обработку манифеста нужному контроллеру. Например, в случае изменения содержимого манифеста или изменения сущности, которая была создана по этому манифесту.

  • С определенной периодичностью отправляет манифесты на обработку контроллерам, даже если никаких событий по ним не происходило. На всякий случай.

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

Собрав эти манифесты вместе и шаблонизировав их, мы получили новое приложение в helm чарте.

Код

Теперь от абстрактных вещей перейдем к более прикладным и рассмотрим, что находится «под капотом» каждого компонента нашей схемы на уровне кода.

Storage

Для storage мы реализовали простой grpc api с двумя методами:

  • для применения манифеста;

  • для получения списка манифестов.

Изначально мы думали сделать реализацию, при которой могли бы принимать и возвращать манифесты без преобразований — в yaml. Но в таком случае работа с API была бы сильно осложнена неструктурированными запросами, поэтому от этой идеи мы отказались.

Вместо этого мы выделили основные обязательные элементы манифеста и жестко их указали в контракте. Относительно свободным остается только содержимое поля spec — его структура будет уникальной для каждого типа манифестов.

service Storage {
 rpc Apply(ApplyReq) returns (ApplyRes) {}
 rpc List(ListReq) returns (ListRes) {}
}

message ApplyReq {
 repeated Manifest manifests = 1;
}

message ListReq {
 uint64 offset = 1;
 uint64 limit = 2;
 repeated string groupIds = 3;
 repeated string ids = 4;
}

message Manifest {
 string apiVersion = 1;
 string kind = 2;
 Metadata metadata = 3;
 google.protobuf.Struct spec = 4;
 Status status = 5;
}

Примечательно, что структура наших манифестов идентична структуре манифестов Kubernetes. К тому же они у нас напрямую мапятся на grpc структуру. Наши манифесты включают следующие поля:

  • apiVersion — версия обработчика;

  • kind — имя обработчика;

  • ​​metadata — метаданные, идентификаторы или лейблы;

  • spec — спецификация, основная информация, требуемая контроллеру для работы;

  • status — целевое состояние и текущий статус.

Причем в ​​metadata есть два обязательных поля:

  • uid — идентификатор самого манифеста;

  • guid — идентификатор группы манифестов (в группу входят манифесты из одного helm чарта).

apiVersion: magnum.vkcs.cloud/v0
kind: Cluster
metadata:
 uid: "{{ .Values.clusterID }}"
 guid: "{{ .Values.groupID }}"
 dataPlatform:
   projectID: "{{ .Values.projectID }}"
 name: "cluster"
spec:
 ...
status:
 goal: exists

message Metadata {
 string uid = 1;
 string guid = 2;
 map<string, string> labels = 3;
 string name = 5;
}

message Manifest {
 string apiVersion = 1;
 string kind = 2;
 Metadata metadata = 3;
 google.protobuf.Struct spec = 4;
 Status status = 5;
}

Controller 

Теперь о контроллерах. 

Контроллеров может быть много — под разные проверки, условия и действия. Но все они имеют одинаковую базовую структуру. 

Фактически контроллер должен реализовывать лишь 3 метода интерфейса:

  • HandleExist() — вызывается, если по манифесту необходимо производить операции приведения системы к нужному состоянию.

  • HandleDelete() — вызывается, когда нужно удалиться и, по возможности, «прибрать за собой».

  • Kinds() — возвращает список kinds манифестов, которые этот контроллер обрабатывает (в большинстве случаев kinds только один).

type Controller interface {

  HandleExist(context.Context, Req) (*Res, error)

  HandleDelete(context.Context, Req) (*Res, error)

  Kinds() []string

При этом оба handle метода контроллера (HandleExist и HandleDelete) получают на вход некий request и должны вернуть некий response.

  • Request представляет собой небольшой интерфейс, часть манифеста, которая важна контроллеру для работы. Spec здесь представлена, как any (Spec() any).

  • Response — готовая структура с набором полей, которые контроллер может изменить в манифесте. Например, статус и время следующего принудительного запуска.

type Controller interface {

  HandleExist(context.Context, Req) (*Res, error)

  HandleDelete(context.Context, Req) (*Res, error)

  Kinds() []string

}

// ---

type Req interface {

  Spec() any

  Meta() domain.Meta

}

type Res struct {

  Status  string

  StartUp *time.Time

}

Реализацией request является сам манифест — фактически интерфейс request просто ограничивает спектр доступных для контроллера данных.

type Req interface {

  Spec() any

  Meta() domain.Meta

}

type Res struct {

  Status  string

  StartUp *time.Time

}

// ---

type Manifest struct {

  spec   spec

  meta   Meta

  status Status

}

func (m Manifest) Kind() string {

  return m.spec.Kind()

}

type spec interface {

  Kind() string

}

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

Один из примеров самой простой спеки — спека для манифеста namespace.

type Manifest struct {

  spec   spec

  meta   domain.Meta

  status *domain.Status

}

func (m Manifest) Kind() string {

  return m.spec.Kind()

}

type spec interface {

  Kind() string

}

// ---

type Namespace struct {

  kind[Namespace]

  Namespace string `json:"namespace"`

}

type kind[T any] struct{}

func (m kind[T]) Kind() string {

  return reflect.TypeOf(*new(T)).Name()

}

Она содержит всего одно поле с именем namespace и эмбедит простую дженерик структуру kind[T any]. Причем kind[T any] является небольшим хаком, который позволяет не писать вручную имя для каждой спеки.

Здесь особо интересен момент преобразования grpc struct в конкретную структуру спеки — он подразумевает последовательное преобразование grpc структуры в json, и уже из json в структуру нужной спеки (pb -> json -> spec). 

func specFactory[T Spec](str *structpb.Struct) Spec {

  s := new(T)

  j, err := str.MarshalJSON()

  if err != nil {return nil}

  if err := json.Unmarshal(j, s); err != nil {return nil}

  return *s

}

Примечательно, что это медленная, но универсальная операция. У нас есть механизм позволяющий, в случае необходимости, перейти на «быстрые» преобразования, но в данном случае это неоправданно.

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

func (n Namespace) HandleExist(

  ctx context.Context, 

  req Req,

) (*Res, error) {

  s, ok := req.Spec().(spec.Namespace)

  if !ok {

     return fmt.Errorf("invalid manifest spec")

  }

  _, err := n.k8sClient.NSGet(ctx, s.Namespace)

  if err == nil {return nil}

  if !k8sErrors.IsNotFound(err) {

    return nil, fmt.Errorf("get namespace, %w", err)

  }

  err = n.k8sClient.NSCreate(ctx, s.Namespace)

  if err != nil {

     return fmt.Errorf("create namespase, %w", err)

  }

  return &Res{

    Status: domain.StatusSuccess,

  }, nil

}

Здесь все относительно просто:

  • получаем спеку манифеста и сразу кастим ее к нужному типу;

  • далее дергаем kube-api, чтобы проверить, есть ли запрашиваемый namespace;

  • если есть — ничего не делаем, если такого манифеста нет и запрос не вернул ошибку — пытаемся его создать;

  • в случае успешного создания — помечаем манифест, как успешно выполненный;

  • затем воркер проверяет внесенные контроллером в статус изменения и сохраняет их в базу.

Gate

Теперь подробнее о gate и том, как мы используем helm.

Начнем с небольшого экскурса в helm.

Итак, helm использует известный фреймворк Cobra. 

Для работы с helm есть много команд, но в нашем случае важна одна — helm install. Например: 

helm install panda bitnami/wordpress

Она запускает установку и накатывает манифесты на кластер. Нюанс в том, что нам нужен несколько другой сценарий выполнения, поэтому мы добавляем к команде dry-run и debug:

helm install panda bitnami/wordpress --dry-run –-debug

В таком случае вместо применения манифестов helm будет передавать контент в консоль.

На уровне фреймворка Cobra код с нашей командой helm install выглядит примерно следующим образом:

cmd := &cobra.Command{

  Use:   "install [NAME] [CHART]",

  Short: "install a chart",

  Long:  installDesc,

  Args:  require.MinimumNArgs(1),

  ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {

     return compInstall(args, toComplete, client)

  },

  RunE: func(_ *cobra.Command, args []string) error {

    …

    rel, err := runInstall(args, client, valueOpts, out)

    if err != nil {

      return errors.Wrap(err, "INSTALLATION FAILED")

    }

    …

  },

}

Здесь самое главное — метод runInstall. Именно в нем скрывается главная «магия» установки. Его рассмотрим подробнее.

func runInstall(

  args []string, 

  client *action.Install, 

  valueOpts *values.Options, 

  out io.Writer,

) (*release.Release, error) {

  ...

  vals, err := valueOpts.MergeValues(p)

  ...

  chartRequested, err := loader.Load(cp)

  ...

  return client.RunWithContext(ctx, chartRequested, vals)

}

На примере видно, что главная логика сосредоточена в экшен клиенте — в его метод RunWithContext нужно передать загруженный chart и values.

func runInstall(

  args []string, 

  client *action.Install, 

  valueOpts *values.Options, 

  out io.Writer,

) (*release.Release, error) {

  ...

  vals, err := valueOpts.MergeValues(p)

  ...

  chartRequested, err := loader.Load(cp)

  ...

  return client.RunWithContext(ctx, chartRequested, vals)

}

Этот клиент располагается в pkg (helm.sh/helm/v3/pkg/action), что упрощает для нас его использование. 

Пример использования

Теперь можно перейти от теории и общего обзора к примеру использования.

1. Первым делом импортируем нужные пакеты из helm. Для базовых сценариев использования нужно всего два: action и chart, который содержит структуру, как ни странно, чарта.

import (

 "helm.sh/helm/v3/pkg/action"

 "helm.sh/helm/v3/pkg/chart"

2. Далее создаем экземпляр структуры install. Если нужно только сгенерировать манифест — обязательно добавляем в true dry-run и client-only. Вместе с тем, с Kubernetes через helm инсталлер можно работать и из своего приложения — в таком случае упрощается работа с kube-api.

На этом же этапе обязательно проставляем release-name (без этого запустить не выйдет) и namespace.

client := action.NewInstall(&action.Configuration{})

client.DryRun = true

client.ClientOnly = true

client.ReleaseName = "release-name"

client.Namespace = "namespace"

Значения из релиза можно получить в темплейтах. Примечательно, что значения релиза также часто используются в helm чартах, поэтому лучше ими не пренебрегать.

3. На следующем этапе загружаем файлы нашего чарта и экземпляр чарта. Способ зависит от конкретного кейса. Так, если чарты доступны рядом с бинарным кодом — можно воспользоваться загрузчиком, который использует сам helm. Если нет — нужно придумывать вариант реализации. Например, можно использовать стандартный loader. Но в таком случае файлы нужно будет передавать вручную из кода (а не просто указывать путь).

chartReq, err := loader.Load("helmName")

if err != nil {log.Fatal(err)}

...

type Loader interface {

  Load(chartName string) (*chart.Chart, error)

}

4. После подготовки клиента инсталлятора и экземпляра чарта можно запускать установку. Примечательно, что в качестве value можно ничего не передавать — в таком случае будут использованы значения из соответствующего файла. При передаче valueMap эти значения будут переопределены.

rel, err := client.RunWithContext(ctx, chartReq, valueMap)

if err != nil {log.Fatal(err)}

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

5. Далее мы выполняем преобразование набора yaml манифестов в grpc структуры и отправляем их в storage.

manifests, err := manifestsFromYAML(rel.Manifest)

if err != nil {

  log.Fatal(err)

}

...

cc := NewClientConnect()

storageClient := storage.NewStorageClient(cc)

req := storage.ApplyReq{Manifests: manifests}

_, err = storageClient.Apply(ctx, &req)

if err != nil {

  log.Fatal(err)

}

Отдельно стоит рассмотреть метод преобразования набора yaml манифестов в слайс grpc манифестов для последующей отправки в storage. Упрощенно алгоритм следующий.

  • Сначала один большой yaml файл разбивается на слайс отдельных yaml манифестов.

  • Затем, в цикле, преобразуем его в json.

  • Далее, поскольку структура yaml манифеста должна быть идентична grpc структуре, анмаршалим (unmarshal) получившийся json в proto.

  • Собираем все вместе в один большой слайс и возвращаем результат.

type ProtoMessage[T any] interface {

  protoreflect.ProtoMessage

  *T

}

func ManifestsFromYAML[T any, S ProtoMessage[T]](manifestYAML string) ([]S, error) {

  yamls := strings.Split(manifestYAML, "\n---\n")

  manifests := make([]S, 0, len(yamls))

  for _, yamlStr := range yamls {

     if yamlStr == "" {continue}

     jsonBytes, err := yaml.YAMLToJSON([]byte(yamlStr))

     if err != nil {...}

     var man T

     if err := protojson.Unmarshal(jsonBytes, S(&man)); err != nil {...}

     manifests = append(manifests, &man)

  }

  return manifests, nil

}

«Грабли» на нашем пути

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

  • Чрезмерная надежда на хореографию. Мы слишком сильно положились на хореографию и полную независимость контроллеров. Главная проблема здесь в том, что часто проверки приходилось дублировать. Например, если манифест, отвечающий за создание кластера, и манифест, отвечающий за установку приложения на кластер, полностью независимы, то они оба должны проверить, существует ли кластер и полноценен ли он. А это, как минимум, два запроса, вместо одного. Помножим это на пару десятков манифестов, потом еще на сотню развернутых у нас сервисов — и так каждую минуту. На выходе получаем само-DDoS.

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

  • Взрывной рост чартов при добавлении функционала. Чарты приложений имеют тенденцию разрастаться при росте функциональности. Из-за этого их становится все труднее обслуживать. Мы решили делать более специализированные и, соответственно, менее крупные чарты. Например, чарт для создания кластера или чарт для установки конкретного приложения (например, Kafka). Это упростило чарты и добавило еще один уровень конструирования — из чартов.

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

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

Итоги и немного мыслей из нашего опыта

Пытаться «съесть слона целиком» — не лучшая идея. Поэтому мы изначально решили идти другим путем:

  • взяли комплексную задачу в облаке и разбили ее на набор атомарных шагов;

  • для каждого шага создали свой манифест в helm-стиле;

  • для каждого манифеста написали собственный обработчик.

В результате мы получили набор маленьких универсальных блоков, из которых, как из кубиков Lego, можно составлять большие приложения. Это позволило получить все преимущества helm. Причем в процессе реализации мы смогли использовать общедоступные наработки и принципы. 

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

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

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