Привет, Хабр! Меня зовут Георгий Меликов, я из VK Cloud и руковожу там инфраструктурной разработкой (IaaS) облака, где мы создаём:

  • SDN (Software Defined Networks) — программно определяемые сети;

  • SDS (Software Defined Storages) — программно определяемые хранилища;

  • и другие решения.

А ещё на досуге я — контрибьютор файловой системы OpenZFS с 2016 года.

В этой статье поговорим о наших подходах к разработке на примере создания нашего SDN. Мы ставим перед собой несколько целей:

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

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

  • Использовать принцип «Дизайн на отказ». Система должна уметь переживать любой возможный сбой и проблему, т.е.обладать так называемым качеством самовосстановления (self-healing).

Предлагаю пройтись эволюционно от простого примера "облака на минималках" до нашей production среды на несколько ЦОДов, собрав по пути проблемы из нашей реальной жизни.

Облако на минималках

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

Что такое контейнер?

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

  • Сеть, чтобы можно было выйти в интернет или сходить в соседний контейнер;

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

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

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

Дальше клиент захочет запросить (GET /containers) — а что же он создал, посмотреть, достаточно ли ему этого. Пока что мы будем для этого ходить в dockerd и забирать оттуда эту информацию. Это реализуемо, особой сложности нет.

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

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

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

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

Теперь наша система делится на две части:

  1. Control plane — это управляющая часть, куда клиент обращается. Там работает вся бизнес-логика, хитрые бизнес-сценарии, биллинг и всё что угодно, не относящееся к нижележащему слою.

  2. Data plane — это часть, где непосредственно бегают байтики и работают контейнеры.

За счёт этого мы можем всё четко разделить. Dockerd здесь как пример исчезает, вместо него может быть что угодно, в дальнейшем будем оперировать понятием «Data plane». Для нас всегда удобнее иметь абстракцию с чёткими интерфейсами вместо завязки на конкретную реализацию.

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

Следующий шаг — масштабирование, потому что одного сервера нам, конечно же, не хватит. Первое, что нужно сделать — вынести общую логику. Это значит, что control plane (API и storage) переедет в централизованное место. Но на сервере должно быть что-то, чтобы им управлять. Для этого появляется агент, назовём его Server agent, который будет управлять Data plane. Но ведь ему надо ещё как-то доставлять информацию. Поэтому добавляем на сервер API и пушим в неё события.

Например, мы можем отправить примерно такой же запрос на создание сущности, каким он поступил от клиента на control plane, только на сам сервер, тут уже не сильно важны некоторые детали, например принадлежность сущности конкретному клиенту. А затем остаётся настроить на сервере всё так, как нам нужно.

 

И вот мы масштабируемся на целых три сервера. Каждому даём имя, холим их и лелеем, как домашних животных. На картинке можно увидеть, что теперь у нас есть 3 сервера с именами Oscar, Kitty и Luna. Также мы видим контейнеры разных цветов, каждый цвет — это принадлежность контейнера конкретному клиенту, т.е. все зелёные контейнеры принадлежат одному клиенту.

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

Когда у нас много серверов, холить и лелеять каждый мы уже не готовы. Вспомним про подход Cattle vs Pet (домашнее животное против стада). 

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

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

Но это не значит, что мы можем разделять клиентов просто по разным серверам, нам важна эффективность утилизации. Значит, контейнеры клиента могут работать на всём облаке. В таком случае сеть клиента между его контейнерами может выглядеть так:

Эта клякса в жизни на самом деле так и выглядит — сущности одного клиента могут быть размазаны ровным слоем по вашему облаку. И, к сожалению, когда клиент захочет что-то изменить в этой сущности, он сделает один запрос в ваше API. Например, может изменить подсеть, или адресацию, или что-то ещё. Этот единственный запрос клиента перерождается в шторм запросов внутри вашей системы, потому что сеть настроена (в худшем случае) на всех серверах. А значит мы вынуждены идти и настраивать его на всех этих серверах, т.к. серверов — много, то получается «ад» из соединений. 

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

Event broker

Чтобы это сделать, можно добавить брокер сообщений. Брокер обычно даст нам много полезных свойств:

  • Единый интерфейс обмена, всё в одном месте;

  • Обмен событиями через каналы (queues):

    • publish (публикация) события в канал;

    • subscribe (подписка) на событие со стороны сервера на канал;

    • возможен даже one to many, т.е. доставка одного события многим серверам!

  • Ну и вишенка на торте — отсутствие нагрузки в простое, нет событий — делать нечего.

Event — это факт какого-то события, то есть когда что-то произошло. У события обычно есть тип, например, событие создания нового контейнера: type: new_container

А ещё у события часто есть полезная нагрузка:

payload:
  cpu: 2
  image: service:1.0.0
  environment:
    db_name: my_db_url

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

Так в схеме у нас появляется новый компонент — event-брокер — конкретная реализация на свой вкус. На нём серверы подписываются на нужные каналы и получается примерно однонаправленное общение.

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

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

Конечно, события бывают разные. Например, некоторые способны просто попробовать ещё раз обработать (retry), например, когда не прошёл HTTP запрос. Есть много других кейсов — отказ нужного нам сервиса, проблемы с самой очередью, проблемы с БД, даже DNS резолвинг может иногда подвести, и это не конец списка. Мы видим, что ошибок может быть большое количество, и нам надо уметь их обрабатывать. Сделать это можно так:

  • Просто пропустить сообщение, (drop). Самый очевидный вариант. Но так можно дропнуть то, что попросил создать пользователь, а это точно не наш вариант.

  • Вечная повторная обработка (retry). Но так одно событие может застопорить обработку следующих. Бэклог растёт, работа не делается. Обрабатывать события вне очереди (out of order) тоже приведёт к большой сложности и проблемам. 

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

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

К сожалению, полностью отказать может и конкретный компонент. API или data plane могут просто крашнуться, и с этим тоже нужно что-то делать. 

Но и это не всё: диски летят постоянно. На масштабах — это ежедневная проблема. Память тоже — заряженной частицей из космоса выбился битик и произошёл так называемый bit flip, причём ECC не всегда спасает. В конечном итоге накрывается и CPU. Пострадать могут и другие компоненты — HBA контроллер, материнская плата или что-то ещё.

Итог один: система — в неконсистентном состоянии. В лучшем случае она полностью не работает, в худшем — находится в промежуточном состоянии и с этим тоже надо что-то делать, как-то приводить её в нужное состояние. 

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


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

Тут можно вспомнить интересный кейс — ввод нового сервера в кластер. Новый сервер тоже появляется у нас в неизвестном состоянии. С оговорками, но это и вправду может быть старый сервер чуть ли не с контейнерами старых клиентов. Эти два события можно уложить в один-единственный механизм — так называемый bootstrap нового сервера или системы, то есть процесс первоначальной настройки.

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

Сначала серверу нужно узнать, а что должно быть настроено. Для этого идём из сервера в control plane и спрашиваем — какие контейнеры должны на нём быть. Далее control plane отвечает отдельным событием с требуемой информацией серверу. Если после этого всё успешно настроилось — прекрасно, мы восстановились. Но есть важный нюанс.

Events: перезапрос данных

Мы ходим и в обратную сторону, то есть шина получается двунаправленной. По сути мы делаем подобие RPC (Remote Procedure Call) с сервера на control plane. К сожалению, у этого подхода есть проблемы. 

Примеры таких сообщений:

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

  • get_all_containers_for_server — запрос информации о всех контейнерах, как в предыдущем примере с bootstrap.

Есть и более частные случаи:

  • make_heartbeat — весточка, что сервер ещё жив;

  • add_server_to_cluster — добавляем сервер в кластер;

  • create_container_error — у сервера случилась критическая ошибка и он больше  ничего не может сделать с этим сам.

Допустим, у нас есть проблемный сервер, с которым что-то происходит.

Важно: на таком сервере мы не можем гарантировать работоспособность сущности клиента. Соответственно, его контейнеры нужно переселить на другие рабочие серверы. По этой причине часто на шедулер накладывается ответственность ещё и за ре-шедулинг, то есть он начинает заниматься тем, что переносит и уже запущенную нагрузку тоже.

Есть интересный кейс, который называется «идеальный шторм». Это когда начинает отказывать не только один сервер, а что-то большее — например, по сети недоступна целая стойка или её часть. 

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

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

А потом прибегают админы и говорят: «Ребята, у вас код так интересно написан, что облако легло. Мы вроде сеть подняли, а облако не воскрешается — по кругу ходит, что‑то делает. В результате нагрузка на брокер сообщений максимальная, и всё встало колом». А вы им отвечаете: «Как же так, вы несёте на руках эту сеть, как она у вас могла в очередной раз упасть? Да и брокер сообщений — ваш инфраструктурный сервис, сделайте с ним что‑нибудь срочно!»

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

«Штатная» работа. Это обычная стандартная нагрузка — что-то изменилось, какое-то событие прилетело.

Bootstrap всего облака. Этот кейс почему-то не считается дефолтным, но на самом деле является худшим сценарием. Если у вас тысячи сущностей, тысячи серверов, то это х1000 нагрузка на вашу шину. Это очень много — шина, скорее всего, не выдержит, т.к. тестировалась на «штатной нагрузке».

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

Openstack Neutron — это часть опенсорсного решения, которое позволяет получить облако прямо на вашем железе. Это весьма удобно. В любом облаке должна решаться задача подключения сети к вашим сущностям. SDN Neutron как раз это делает. 

SDN — это программно-определяемая сеть, то есть Neutron отвечает за то, чтобы у каждого клиента изолировано была своя сеть, и он считал, что это железо — только его, а не чьё-то еще. Neutron, конечно, внутри устроен более хитро, в нём много компонентов, но стоит отдельного упоминания факт, что его архитектура стоится вокруг обмена событиями через единый брокер. В данной статье останавливаться на Neutron подробнее не будем, мой коллега Александр Попов рассказывал о нём ещё в 2019 году.

Event-driven development

Так, у нас получилась работающая архитектура с такими преимуществами:

  • Эффективность: работает только на полученное событие. Система ничего не делает, когда ничего не надо делать — это хорошее свойство.

  • Единая шина для общения. Это удобно и сокращает администрирование.

  • Новая функциональность ~= новое событие. То есть, чтобы разработать новую функциональность, вы пишете ещё одно событие — и профит!

  • Новая функциональность ~= новое событие. Это не только плюс, но и дополнительная разработка: в худшем случае вам нужно написать по событию на каждый «чих». 

  • Логика каждого события размазана ровным слоем по системе. И проблема даже не в событии, а в том, что логику обработки этого события вы в худшем случае размажете на всю систему — и на control plane, и на data plane. Или будете дописывать обработчик к существующему событию. Это, к сожалению, может быть весьма трудозатратно.

  • Вам всё равно нужен bootstrap. Не только для ввода новых серверов, но и потому что инцидент произойдёт в самый неожиданный момент и даст х1000 нагрузку в моменте.

Лучшая архитектура

Хочется сделать хорошо, и мы хотим идеальную архитектуру, которая будет решать все проблемы и не иметь минусов.

Попробуем обозначить критерии такой идеальной системы:

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

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

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

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

На следующей картинке вы можете увидеть пример идеальной архитектуры с идеальным кодом:

Да, картинка правильная, я считаю, что лучший код — это ненаписанный :)
Да, картинка правильная, я считаю, что лучший код — это ненаписанный :)

Это значит, что если есть требование к системе и вы можете его удовлетворить, написав 0 строчек кода, то это будет самый лучший код. Он будет вас радовать вечность и не создавать проблем, потому что багов в несуществующем коде не бывает.

Конечно, такой архитектурой и кодом все наши бизнес-сценарии не покроешь, так что давайте попробуем отсечь всё лишнее от событийной архитектуры, к которой пришли ранее.

 Пойдём от обратного и определим, что точно не выбросим:

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

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

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

Действие клиента! = действие в облаке

Теперь, имея только лишь одно действие, наша система становится декларативной -  клиент задаёт желаемое состояние и только! Так как bootstrap происходит не по команде клиента, а по заданной нами логике и в заданный момент, действие клиента больше не равняется действию в облаке. Есть маленький минус: теперь событие происходит не сразу, но управление идёт декларативно, то есть клиент нам сообщает только то, что хочет получить, а не что облако должно сделать. У такого решения большой набор плюсов, вот некоторые:

«Status» сущностей не нужен! Он в такой ситуации становится опциональным.  Потому что мы гарантируем клиенту, что за определённый срок его сущность обязательно будет создана, переведена в какой-то статус и прочее.

Control plane теперь явно отделён интерфейсом от Data plane. В результате наш агент на сервере просто постоянно bootstrap’ится по кругу, то есть ходит в Control plane, получает всю необходимую информацию о том, что ему надо создать. А получив, полностью настроится, и делает это снова — и так по кругу. По большому счету это весь интерфейс, который должен быть между data plane и control plane! Это даже звучит просто. Соответственно, ваш интерфейс становится полностью явным, поэтому разделяйте data plane и control plane по ответственностям, это очень удобно.

Но есть задача со звёздочкой — сделать bootstrap идемпотентным.

Идемпотентный bootstrap

Как разработчик я хочу воспользоваться любимым многими методом — привнести ещё одну абстракцию! В самом простом случае это будет выглядеть примерно так:

  1. Получаем желаемое состояние системы из control plane (target state);

  2. Получаем фактическое состояние сервера (actual state);

  3. Сравниваем их;

  4. Разница и есть набор действий (что удалить, что создать).

В переводе на псевдокод это будет всего 4 строчки:

to_delete = target_state - actual_state
to_create = actual_state - target_state
delete_objects(to_delete)
create_objects(to_create)

Сначала сравниваем две сущности, потом наоборот, получаем список к удалению и к созданию сущностей. Верхнеуровнево это весь алгоритм! Конечно, здесь будет сложность в diff сущностей, их сравнении.

Приведу пример на Python, как это можно дёшево и легко сделать:

В Python есть sets — это уникальные множества. Они строятся внутри на основе hashmaps (хеш-таблиц). Их можно сравнивать вычитанием с ожидаемой сложностью. Для сравнения сетов вы можете просто сравнить уникальные хэши объектов, а для этого нужно уметь посчитать хэш для вашего объекта. Это основная задача, которую вы реализуете один раз и получаете профит.

Такой подход с бесконечным циклом настройки системы можно описать и более абстрактно.

Control loop (Цикл управления) 

У нас получается система из трёх основных компонентов:

  • Система, которой мы управляем;

  • Сенсор, который умеет собрать целевое состояние (actual state);

  • Контроллер, где реализуется вся бизнес-логика по сравнению сущностей.

Пройдёмся пошагово.

  1. Получаем actual state.

  2. Передаём actual state в контроллер.

  3. Контроллер идёт за целевым состоянием (target state) в control plane, сравнивает его с фактическим, получает разницу.

  4. Контроллер пробует через полученную разницу привести состояние системы к целевому.

Тут появляется интересное слово reconcile, которое на русский переводится плохо и означает что-то вроде согласовать/утрясти/примирить. Самый интересный вариант — примирить. Наш контроллер пытается примирить нашу систему с тем, что ей всё-таки надо быть в этом состоянии, а не в каком-то другом.

Рассмотрим для простоты на примере умного чайника.

У нас есть умный чайник, у которого мы хотим поддерживать температуру. Есть target state на control plane. Мы смотрим, какая температура сейчас, оцениваем разницу, и либо добавляем мощности к нагревательному элементу, либо, наоборот, уменьшаем. Получаем результат.

Система, у которой есть обратная связь, называется системой с замкнутым контуром управления. Если обратной связи нет, это открытый либо разомкнутый контур.

Разберём, как в такой системе обрабатывать ошибки.

Control loop: обработка ошибок 

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

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

Вы можете посчитать меня профнепригодным — мол зачем так делать. Но у нас есть идемпотентный bootstrap, а ошибки будут в любом случае так давайте просто попробуем выполнить наш bootstrap ещё раз, по сути это и есть retry, только без частного кейса! Давайте просто делать это в цикле:

while True:
  to_delete = target_state - actual_state
  to_create = actual_state - target_state
  delete_objects(to_delete)
  create_objects(to_create)
  sleep()

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

Есть важный нюанс, в какой-то момент мы можем подумать, что нужно добавить sleep, то есть если нечего делать — система ненадолго уйдёт в сон и не будет зря греть воздух. Но тут возникает вопрос: а как правильно спать? Хватит ли нам sleep(1), достаточно ли этого, или мы хотим это делать не раз в секунду, а как-то иначе?

На самом деле тема очень глубокая, но ключевое слово в ней — Exponential backoff. Но, как ни странно, для нашего кейса предпочтителен именно статичный sleep, иногда даже наоборот — уменьшающийся в случаях, если есть что делать

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

Мониторинг и оповещения 

У нашей системы есть огромное преимущество — мы получаем несколько вариантов мониторинга, которые вообще не зависят от бизнес-логики!

  1. Явный критерий: длительность 1 цикла. У нас есть понятие длительности сходимости цикла, т. е. сколько цикл bootstrap»а работает. Например, было 5 секунд, а стало 30 секунд. Это явный сигнал, что что‑то не в порядке, так как этот показатель из статистики выбивается — либо у нас проблема с производительностью, либо откровенный баг, либо что‑то ещё случилось. Но главное, что всё это можно было посмотреть.

  1. Постоянное количество изменений ~= баг. Есть количество сущностей, которые мы каждый цикл создаём либо удаляем, то есть изменяем. Если на графиках количество сущностей превращается в ровную горизонтальную планку (как на картинке выше), т. е. начинает создаваться/удаляться одно и то же количество, это значит, что у вас, скорее всего, баг. Мы просто идём, разбираемся, чиним и проблемы больше нет. При этом такой баг не влияет на успешность изменения других сущностей.

Мы получаем абсолютно прозрачный мониторинг в этом месте по умолчанию на кейсе — вы пишите новый демон, сервис, и у вас автоматически есть базовый мониторинг, это очень удобно.

Архитектура с Reconciliation loop:

Но вернемся к событийной архитектуре. В случае, когда мы говорим про архитектуру с Reconciliation loop, первое основное отличие в том, что у нас появляется внутренняя API, в которую начинают ходить серверы за информацией о том, что им надо сделать. У нас происходит удобное однонаправленное общение.

Нам нужно поддерживать доступность только одной точки — это internal API. Так мы можем прогнозировать нагрузку на неё. В VK Cloud мы руководствуемся этим принципом и делаем большинство новых продуктов именно так.

На изображении выше верхнеуровнево представлена архитектура нашего SDN Sprut. Она чем‑то похожа на Neutron, но в основе всего у нас используется reconciliation loop. Это позволяет серьёзно удешевить разработку. Каких‑то частых ошибок, которые в Neutron обрабатываются постоянно и в каждом частном случае, мы просто не имеем, потому что есть reconciliation loop, который на следующем цикле просто попробует ещё раз.

В IT есть 2 проблемы…

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

Обычно встречаются такие названия:

  • Closed/open‑control loop (замкнутый/открытый контур управления, на английском).

  • Reconciliation loop (более актуальное название для IT).

  • Замкнутый контур управления (ЗКУ, мы зовём его так).

Все эти термины родом из Теории Автоматического Управления (ТАУ).

Pros and cons of Reconciliation loop

Начнём с плюсов этого подхода:

  • быстрая разработка — нужна реализация только метода reconcile;

  • архитектура системы минимальная;

  • нет промежуточных состояний;

  • self-healing каждый цикл;

  • стандартные интерфейсы, явно выделенные слои системы;

  • ожидаемая стоимость ресурсов (всегда максимальна).

С одной стороны, система чётко ограничивает разработчика по интерфейсам. Но, с другой, наличие чётких интерфейсов — это хорошо. Из постоянной работы по циклу и вытекают плюсы — по сути, это автоматический self-healing. Мы просто каждый цикл проверяем, всё ли хорошо. Если админ что-то изменил и система больше не работает, всё автоматически поменяется обратно, как и была написана логика.. 

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

Из этого следуют нейтральные моменты:

  • Нужен честный идемпотентный bootstrap + sensor. Bootstrap здесь более требовательный, мы должны его делать абсолютно честно. А ещё сделать честные сенсоры, про которые говорили ранее. На самом деле это нужно делать в  любой архитектуре. Просто в некоторых не нужно настолько качественно и сразу, но всё равно придётся.

  • Нагрузка на control plane постоянна.

Минусы:

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

  • Делаем работу каждый цикл. У нас нет понятия бэклога, мы всегда делаем всё. Это и плюс, и минус.

Из этого вытекает, что мы постоянно «прожигаем» ресурсы на полную (CPU/сеть). Поскольку каждый цикл мы делаем всё, очевидно, тратим много CPU на конечных серверах на сравнение наших стейтов, а ещё постоянно передаём весь стейт через сеть. Нам нужно получить целевое состояние с нашего control plane и как-то с ним поработать. 

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

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

Но всё же нам хочется получить идеальную архитектуру. Приходим к вопросу, а можно ли reconciliation loop удешевить и как это сделать?

Closed loop: удешевление

Вариант удешевить reconciliation loop — это те самые события, про которые я рассказал в первой половине статьи, и мы пришли к реконциляции как раз с целью от событий избавиться… Давайте попробуем посмотреть на разные подходы на примере нескольких уровней громкости стоимости:

  1. Стоимость разработки (дешевле/дороже).

  2. Обратно пропорциональная градация — количество ресурсов (серверов), на которых наша система будет исполняться (от дороже к дешевле).

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

Вопрос — где же та самая золотая середина, и можно ли её достичь? Что лучшее мы можем взять от reconciliation loop, потому что bootstrap нам всё равно нужен, и как оптимизировать процесс diff и получения информации с control plane?

И методы есть: мы можем использовать либо кэш, либо версионность для нашей информации. Важный момент, что версия не равна событию. 

На самом деле примеры таких систем есть — это тот самый Kubernetes, который я вставил в название статьи, потому что его контроллеры работают ровно по этому принципу. Любой контроллер в K8s — это reconciliation loop, который в какой-то период должен уметь обработать вообще всё и привести состояние к нормальному. Да, есть оптимизации, которые ускоряют эту работу, но Kubernetes — это очень интересный пример такого подхода.

Итоги:

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

  • Loop automation позволяет удешевить разработку:

    • Реализация только нужного в начале;

    • Стандартизация подходов и интерфейсов;

    • Общая обработка ошибок;

    • Принцип «постоянной работы» с ожидаемой постоянной стоимостью.

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

  • Инструменты гибче, чем «всё-в-одном». Набор специализированных инструментов, из которых вы что-то соберёте сами (PaaS сервисы, библиотеки), всё равно эффективнее решения “всё в одном” (комбайны). Даже некоторые разработчики Kubernetes честно говорят, что истина в эволюции систем, то есть на место Kubernetes когда-то придет следующий Kubernetes, который будет написан, скорее всего, кем-то другим. Считаю, что эволюция в разработке — это очень важный момент. Мы должны быть готовы к тому, что наши системы будут переписаны с нуля, а нами или не нами — уже другой вопрос.

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