Предисловие
OpenStack — широко распространенная облачная платформа. На ее базе построены десятки, а то и сотни проектов: приватных и публичных. Ее многочисленные модули позволяют достаточно просто наращивать функциональность конечного решения: от понятных DBaaS (Database as a Service) до специфичных AXaaS (Acceleration as a Service).
По мере развития CLO возникла необходимость доработки платформы под собственные нужды. В процессе написания патчей мне сильно не хватало понимания архитектурных принципов OpenStack. Часть информации я смог найти в документации, часть мне приходилось вытаскивать из исходников, поэтому я решил систематизировать знания и поделиться с сообществом.
В цикле статей мы рассмотрим основные компоненты OpenStack, механизмы их взаимодействия и как именно разработчиками был реализован основной функционал. Статья будет интересна инженерам, разработчикам облачных сервисов и всем тем, кто хотел заглянуть под капот, но стеснялся.
А так как Nova (он же Compute) является едва ли не основным сервисом, который эксплуатируют все остальные, с него и начнем.
Краткий обзор архитектуры
Логика платформы со временем эволюционирует и развивается, поэтому от версии к версии функциональность может значительно разниться.
В официальной документации можно увидеть описание логической архитектуры OpenStack.
На схеме видно, что Nova является самым объемным сервисом из всех. Compute, как и другие, состоит из нескольких компонентов, связанных между собой очередями (Queue на схеме). За связь отвечает отдельный пакет oslo.messaging, который инкапсулирует в себе логику работы RPC.
Кодовая база платформы изобилует абстракциями, одинаковыми названиями методов на каждом слое и «ленивыми» инициализациями, которые разрешаются по имени модуля в рантайме. Например, вот так выглядит инициализации API-клиентов Scheduler’а:
def __init__(self):
self.queryclient = LazyLoader(importutils.import_class(
'nova.scheduler.client.query.SchedulerQueryClient'))
self.reportclient = LazyLoader(importutils.import_class(
'nova.scheduler.client.report.SchedulerReportClient'))
От этого процесс выяснения, куда же мы попадаем по ветке, зачастую крайне утомителен, и быстрые переходы в IDE никак не могут помочь исправить ситуацию — IDE, как и ты, не знает, в какой из десятка функций с одинаковой семантикой приземлится в итоге запрос.
Запросы к REST-API
В рамках Nova через WSGI запускается несколько различных контроллеров API, каждый из которых отвечает за конкретный логический домен платформы, например: rescue-режим, плавающие IP или bare-metal. На логике их запуска мы не будем останавливаться подробно, скажу лишь, что это весьма «крафтовая» вещь: со своими схемами инициализации роутов и валидацией параметров.
Первый шаг — обработка запроса к REST-API. Логика эндпоинта достаточно типовая: проходит валидация по схеме, авторизация пользовательского токена. Далее запрос передается на один из обработчиков: данные насыщаются и формируются в модели, которые в дальнейшем передаются на нужный сервис. В общем и целом это типичный хэндлер, который находится в рамках API.
Здесь, на мой взгляд, разработчики поступили логично, разделив логику обработчиков на несколько разных сабсетов, хотя принцип разделения лично мне не совсем понятен.
На этом слое агрегируется вся необходимая информация для дальнейшей передачи на целевой compute-узел или в nova-conductor.
Межсервисное взаимодействие
Oslo.messaging — часть проекта Oslo, который ставит своей целью создание и поддержку необходимого комплекса низкоуровневых библиотек для работы платформы. Messaging предоставляет API для организации RPC-вызовов.
В типичной конфигурации библиотека работает поверх RabbitMQ, однако можно эксплуатировать и другой транспорт. Messaging достаточно гибко настраивается: от банальных SSL до экспериментальных kombu_compression.
В типовой инсталляции nova-compute располагается непосредственно на compute-нодах, что обеспечивает ему прямой доступ к гипервизору; в то время как управляющий код: nova-api, nova-scheduler, nova-conductor, nova-console — лежит на контроллерах. Абстракции очередей полностью размывают границы между разными нодами и сервисами, позволяя абстрагироваться от их физического размещения.
Oslo.messaging выделяет пользователю два основных метода: rpc.call() и rpc.cast(). Основное отличие этих методов, как можно догадаться, в семантике возврата. Первый ожидает ответа, ошибки или таймаута, поэтому его нельзя отправлять в fanout (ответ должен быть только один); второй — fire-and-forget, нам не нужен результат, просто выполняем действие. Выглядит это в достаточной степени.. кхм, магически.
Ниже пример кода, который отправляет запрос на attach сетевого диска к серверу.
def attach_volume(self, ctxt, instance, bdm):
version = self._ver(ctxt, '4.0')
cctxt = self.router.client(ctxt).prepare(
server=_compute_host(None, instance), version=version)
cctxt.cast(ctxt, 'attach_volume', instance=instance, bdm=bdm)
Узел, на который приземлится запрос, вычисляется динамически и «зашивается» в контекст. Контекст много раз перепаковывается в рамках процесса формирования сообщения.
На этом же этапе может быть проверена возможность клиента отправлять сообщения этой версии, как, например, здесь.
Вызов call(), в свою очередь, инкапсулирует вызов нижележащего транспорта send() и блокируется до получения результата. Метод, который необходимо вызывать, передается в теле сообщения; обратная связь устанавливается через msg_id — поднимается обратная очередь, по которой вызывающая сторона ожидает ответа.
Подробная схема и описание того, как это устроено, есть в официальной документации. И пример использования голого oslo.messaging во одном вьетнамском блоге.
В подавляющем большинстве случаев запрос после передачи по RPC будет получен объектом класса Manager того или иного компонента Nova: compute, network, conductor. Для работы с виртуальными машинами, например, будет вызван метод класса ComputeManager. Здесь нам вновь поможет магия oslo.messaging. Manager регистрируется в общей системе RCP через Target, который инкапсулирует всю логику «подписки» на конкретный namespace — сет методов, которые предоставляет RPC-интерфейс.
@profiler.trace_cls("rpc")
class ComputeTaskManager(base.Base):
"""Namespace for compute methods.
This class presents an rpc API for nova-conductor under the 'compute_task'
namespace. The methods here are compute operations that are invoked
by the API service. These methods see the operation to completion, which
may involve coordinating activities on multiple compute nodes.
"""
target = messaging.Target(namespace='compute_task', version='1.20')
def __init__(self):
super(ComputeTaskManager, self).__init__()
self.compute_rpcapi = compute_rpcapi.ComputeAPI()
self.volume_api = cinder.API()
self.image_api = image.API()
self.network_api = network.API()
self.servicegroup_api = servicegroup.API()
self.scheduler_client = scheduler_client.SchedulerClient()
self.report_client = self.scheduler_client.reportclient
self.notifier = rpc.get_notifier('compute', CONF.host)
Вызов приземляется и выполняется на compute-узле в соответствующем классе или передается на Conductor (о нем ниже), чтобы сформировать «долгую» задачу.
После передачи на конкретный узел путешествие запроса не заканчивается. В зависимости от задачи он может быть насыщен данными из других сервисов или компонентов, например, информацией о сети. В этом случае нас ожидает все тот же поход по RPC за порцией необходимых данных.
Nova-conductor и механизм задач
В OpenStack есть концепция сервиса Conductor, который должен был забрать на себя задачу проксирования вызовов до базы данных со стороны компонентов Compute. Основная идея сервиса — аггрегировать часть логики, которая отвечает за доступ к базе и обработку данных от разных сервисов в одном месте. Работает это все по тому же принципу RPC-вызовов: nova-compute отправляет вызов в nova-conductor и ждет результатов запроса.
Однако даже в последних версиях платформы мы видим, что полной замены так и не произошло и все еще есть прямые вызовы в базу данных с compute-узлов. Так, например, здесь мы получаем UUID из базы.
В документации приведен пример того, как легко инкапсулируется логика создание инстанса через новый сервис. По факту же мы наблюдаем, что логика все равно протекает и теперь она размазана на 2 части: в nova-compute и в nova-conductor.
Не всегда получается однозначно определить, что именно нужно выносить в nova-conductor, однако длительные задачи: миграция или ребилд виртуальной машины — уходят туда. С задачами все организовано достаточно просто: компонент Nova обращается к conductor-api, который передает запрос на conductor-manager через RPC. Manager заворачивает вызов в нужную реализацию интерфейса Task и ожидает выполнения. Специальный декоратор отвечает за то, чтобы запустить rollback, если поймал исключение.
Драйверы виртуализации
OpenStack поддерживает не так много платформ виртуализации. При этом к «полностью поддерживаемым» относится лишь QEMU/KVM через драйвер QEMU.
На каждой конкретной compute-ноде выполняется вызовы libvirt через python-binding.
Есть замечательная статья с кратким обзором механизмов виртуализации, где libvirt уделено внимание. Советую прочесть тем, кто далек от тематики.
При каждой перезагрузке, миграции или ресайзе (что почти одно и то же в контексте платформы) XML домена полностью пересобирается силами OpenStack из данных, которые хранятся в базе. Таким образом, если вы хотите добавить новую функциональность, которая реализуется непосредственно через libvirt, нужно учитывать все возможные кейсы пересоздания виртуальной машины и насыщать XML данными из базы.
В остальном на этом уровне все достаточно топорно. Настолько, что некоторые вызовы — это непосредственный exec бинарников.
Nova-placement. Выделение и контроль ресурсов
В заключение предлагаю рассмотреть некоторые особенности работы Placement-сервиса, с помощью которого OpenStack распределяет ресурсы.
Для начала, несколько ключевых концепций.
Flavor
Логическая единица конфигурации инстанса; шаблон, если можно так назвать — сколько vCPU/RAM выделить, какого размера диск. Объект Flavor имеет поле для метаданных, и достаточно большая часть логики тех или иных параметров инстанса может быть настроена с помощью этих метаданных, например, параметры шейпинга. Scheduler сопоставляет метаданные, хранящиеся во флейворе запускаемого инстанса в формате kev:value, с доступными для размещения узлами.
Это крайне негибкая концепция, о которую наша команда не единожды спотыкалась. Основная проблема заключается в необходимости создания кастомных flavor каждый раз, когда тебе нужно реализовать новую конфигурацию.
Cells
Концепция Cells появилась в платформе достаточно давно и уже успела претерпеть ряд изменений. На текущий момент актуальной является Cells v2.
Если коротко, это шардирование в рамках одной инсталляции, которая может быть single-cell или multi-cell.
В рамках концепции multi-cell мы получаем еще один сервис — super-conductor, который является общим управляющим звеном для всех шардов.
Коммуникация с сервисами в рамках Cell осуществляется с помощью того же RPC поверх oslo.messaging. Правда, API-клиента уже другой, если сравнивать с RPC-API внутри конкретной cell. Да и сама логика работы с механизмами Cells выполняется в рамках отдельного сервиса.
В документации по ссылке выше вы можете прочитать о нюансах шардирования платформы.
Availability zones
Зоны доступности (AZ) — доступные пользователю абстракции, которые позволяют логически группировать серверы. Например, local-storage/volume-storage или linux-hosts/windows-hosts.
Работа с зонами доступности в основном происходит в рамках задач Conductor’а, однако можно заметить и вызовы в рамках nova-compute (мы помним, что compute в базу ходить не должен), и даже на уровне API.
AZ не является обязательным параметром. Если сервер создан или «взят с полки» (unshelved :)) с указанием конкретной зоны доступности, а также в некоторых других случаях, он может быть перемещен между разными AZ.
Host aggregate
Host aggregate (агрегат) является еще одним механизмом объединения серверов в логические группы. В отличие от availability zones, этот механизм не доступен конечному пользователю.
Серверы разделяются, основываясь на метаданных, которые администратор описывает во flavor’ах. Один compute-узел может принадлежать нескольким агрегатам (в отличие от AZ: один узел — одна зона), а также может иметь вес, указываемый в параметре xxx_weight_multiplier.
Provider tree
Каждый узел регистрируется в Placement как ResourceProvider. И каждый же узел отвечает за то, чтобы резервировать ресурсы и поддерживать в актуальном состоянии информацию в Placement-сервисе.
Провайдеры представляют собой древовидную структуру, и если новый сервис планирует использовать ресурсы, например, Compute, он должен зарегистрироваться как дочерний объект. Так Placement-сервис будет представлять зависимость между провайдерами.
Кроме того, существует возможность разделять ресурсы между несколькими провайдерами. Если один из них зарегистрировался с ключом MISC_SHARES_VIA_AGGREGATES, его ресурсы становятся доступны для всех деревьев провайдеров в рамках одного агрегата. Таким образом можно реализовать, например, sharing storage.
Концепции логики Nova-Placement
Как и у большинства других сервисов OpenStack, у Placement есть API, RPC-API и manager. На последнем происходит непосредственная обработка логики запросов.
У компонентов Nova есть возможность обращаться к Placement. Каждый из них эксплуатирует 2 типа клиентов Placement-API: SchedulerQueryClient и SchedulerReportClient. Первый отвечает за передачу по RPC информации о запущенных или удаленных на узле инстансах. Второй же используется для HTTP (!) взаимодействия с placement-api: обновления информации о ресурсах, доступных на провайдере.
У nova-scheduler в предыдущих версиях был интерфейс driver.Scheduler, который реализовал конкретную логику запроса ресурсов. Эта настройка была доступна на уровне файла конфигурации. Здесь мы опять могли наблюдать динамическую инициализацию объектов, которая разрешается относительно настроек в рантайме (тяжелый вздох):
def __init__(self, scheduler_driver=None, *args, **kwargs):
client = scheduler_client.SchedulerClient()
self.placement_client = client.reportclient
if not scheduler_driver:
scheduler_driver = CONF.scheduler.driver
self.driver = driver.DriverManager(
"nova.scheduler.driver",
scheduler_driver,
invoke_on_load=True).driver
super(SchedulerManager, self).__init__(service_name='scheduler',
*args, **kwargs)
В версии Queens, например, было доступно 3 варианта на выбор:
CachingScheduler — может быть понятно из названия, текущее состояние ресурсов нод кэшировалось каждым отдельным инстансом объекта. Таким образом, мы могли получить кучу проблем с неконсистентностью.
ChanceScheduler — выбирал узел случайным образом.
FilterScheduler — выбирает узел исходя из набора фильтров и весов. На текущий момент это вариант по умолчанию. Два предыдущих были объявлены как deprecated, начиная с версии Pike, и отсутствуют в поздних версиях платформы.
У FilterScheduler огромное количество разнообразных настроек, что позволяет достаточно гибко управлять размещением виртуальных машин на узлах. Если, конечно, удастся осознать все то количество возможных комбинаций и их влияние на конечный результат.
В новых версиях OpenStack вся логика выделения ресурсов обрабатывается прямо в рамках метода объекта SchedulerManager, который, по сути, реализует логику FilterScheduler’а. Как раз на этом этапе применяются все фильтры, что указаны в конфигурационном файле.
Таким образом, например, при миграции обработка запроса будет выглядеть следующим образом.
Запрос из API попадает на Conductor и выполняется как Task — долгая задача. Conductor собирает всю необходимую информацию из базы, например, о флейворе; проверяет, будут ли изменяться параметры виртуальной машины при миграции; разбирает параметры метаданных и пр.
После этого, с помощью все того же RPC, вызов уходит на Placement-сервис и попадает на SchedulerManager. При вызове метода select_destinations() возвращается список списков с объектами Selection — простые NovaObjects из базы. Если параметр return_alternatives=True, возвращаются и альтернативные варианты. Первый элемент в списке — самый подходящий.
Compute-manager на каждом конкретном узле занимается выделением ресурсов и поддерживает актуальность данных в Placement-сервисе. Поэтому информация о доступных ресурсах на уровне Placement-сервиса консистентна лишь в конечном итоге (eventual consistency).
Conductor перебирает узлы, которые вернул Placement в попытке зарезервировать ресурсы. Если провайдер «согласился принять» новую виртуальную машину к себе, он самостоятельно аллоцирует ресурсы под нее, уведомляет Placement и возвращает управление на Conductor. Запускается непосредственная логика миграции данных и пересоздания ВМ. Если в процессе миграции что-то пошло не так, запускается код из rollback ветки и ресурс освобождается.
Если на узле не удалось аллоцировать нужные ресурсы, он исключается из списка в рамках логики задачи Conductor’а и запрос повторяется с оставшимися в списке узлами, пока не выполнится или не будут выбраны все узлы в списке.
Заключение
Сегодня мы взглянули на общие принципы работы Compute-сервиса OpenStack.
Безусловно, и с точки зрения архитектуры, и с точки зрения реализации можно видеть огромный простор для улучшений. Некоторые идеи кажутся избыточными, некоторые — сырым техническим авантюризмом. Стоит, однако, помнить, что платформе пошел второй десяток лет, и легаси в таких масштабах — вполне обыденное дело.
Тем не менее это чудесный пример Open Source проекта, который принес неоценимый вклад в развитие облаков в целом. А если хватит смелости потратить пару вечеров на работу с исходниками, можно отыскать несколько неплохих реализаций в личную коллекцию.
На этом все. Если у сообщества будет интерес, в следующий раз расскажу про то, как устроены другие сервисы в рамках платформы.