Как мы делали API для облака
Привет, Хабр!
На связи Вячеслав Шмельцер, backend-разработчик, и Рамиль Алешкин (alewkinr), Product Owner Консоли управления #CloudMTS.
На этой неделе мы выкатили beta-версию API нашей облачной платформы. Да, это гигиенический минимум для провайдера, без которого разработчикам (и не только) облако будет малоинтересно. Поэтому хвастаться не будем. Вместо этого расскажем, чем руководствовались и на что обращали внимание при разработке API, чтобы сделать его функциональным, масштабируемым и удобным для наших пользователей.
Надеюсь, что наш подход окажется полезным тем, кому только предстоит написать API для своего сервиса.
Немного вводных
В Консоли управления пользователи могут создавать и управлять различными сервисами, мониторить и биллить потребляемые ресурсы. Сейчас клиентам доступно 11+ облачных сервисов. Каждый из них разрабатывается и поддерживается отдельной командой центра разработки. У каждой команды свои методики и подходы, в том числе и к разработке API. Собственно, нашей первой задачей стало — сформировать единый подход к проектированию архитектуры API, ее дальнейшей поддержке и развитию.
Вторая задача — это обеспечить слабую связность между компонентами консоли при сохранении единого интерфейса API. Мы поставили перед собой цель — создать гибкое решение, которое могло бы адаптироваться к уже запущенным и новым сервисам.
Третья — обеспечить эффективную коммуникацию между модулями API в условиях геораспределенной архитектуры платформы. Различные компоненты системы находятся в физически изолированных сетевых подсистемах, что приводит к увеличению задержек при нестабильности сети.
Чтобы решить эти задачи и задать основу единого фреймворка, мы обозначили общие принципы, требования и ограничения, на которые опирались в ходе проектирования и обсуждений с командами. Вот они:
API может быть только асинхронным. Для создания виртуальной машины, кластера Kubernetes или базы данных PostgreSQL на облачной платформе необходимо выполнить множество операций, чаще всего параллельно. Мои коллеги подробно рассматривали процесс в этой статье. Создание экземпляра облачного сервиса может занимать от нескольких минут до получаса, поэтому ключевые методы нашего API могут быть только асинхронными.
При отправке запроса от пользователя запускается операция, которую можно отслеживать в консоли, чтобы узнать, успешно ли она выполнилась. Для нас также важно быстро дать знать пользователю о некорректно введенной информации или невозможности выполнить команду через UI. Для этого входящий запрос сначала проходит ряд проверок в синхронном режиме: если все условия разрешают выполнение команды, то сервер отвечает HTTP-статусом 202 и отправляет команду в очередь на выполнение.
API минималистично и едино для всех продуктов платформы. Дизайн API — отдельный вид искусства, которому, к сожалению, не учат. Сложность состоит в том, чтобы создавать программный интерфейс, который будет интуитивно понятным, легким в использовании и функциональным. Единообразие в названиях методов и параметров, однотипная структура ошибок и минималистичность API упрощают понимание и использование. В результате пользователь тратит меньше времени на чтение документации, а команде разработки будет легче поддерживать решение. Но для этого нужно будет потратить больше времени на этапе проектирования API.
Мы сформулировали ряд принципов, которые, на наш взгляд, упростят API и которые мы сможем контролировать на стороне платформы:
- только одна точка входа для взаимодействия с API — единый api-gateway;
- одинаковый по структуре набор CRUD-методов для всех продуктов облака;
- одинаковые названия для одинаковых по смыслу атрибутов;
- общая функциональность, например пагинация, фильтрация, сортировка и прочее, работает единообразно для всех доступных методов;
- единообразная структура ошибок;
- запрашиваем у пользователя только те данные, которые влияют на конфигурацию ресурса и которые мы не можем получить самостоятельно;
- минимальный набор методов, который исходит из реальных юзкейсов и задач пользователей в платформе.
API следует общепринятым подходам проектирования REST. Мы решили взять за основу REST, так как с помощью него можно достичь единообразия, масштабируемости, гибкости, производительности и простоты использования API. Вот правила, которых мы придерживались:
- Указывали версии API в URL.
- В URL название сервисов-обработчиков использовали в единственном числе, а название ресурсов — во множественном.
- Не использовали глаголы в API-методах.
- Использовали стандартные коды ответа в виде HTTP-статусов: 2xx — для успешных запросов, 4xx — для ошибок со стороны клиента, 5xx — для ошибок на стороне сервера.
- Дату и время в ответе возвращаем с часовым поясом UTC в формате RFC-3339.
Однако некоторыми подходами пришлось пренебречь из-за специфики облачного провайдера. Например, мы изменили подход формирования URI-доступа к ресурсам. Так, в классическом REST-подходе рекомендуется отображать иерархию в пути запроса. Например, запрос на добавление новой книги для конкретного автора может выглядеть так:
POST /v1/authors/:12345/books
— books (книги) лежат на уровень ниже, чем авторы.
Для облачного провайдера такая упрощенная модель организации иерархии не подходит, так как многие ресурсы взаимосвязаны. Если оформлять URI способом, принятым в практике REST, API получится громоздким.
В нашем примере любой запрос на создание выглядит так:
POST /v1/services
При этом связи между ресурсами мы переместили в тело запроса, например
{
"parentBinding": {
"targetId":"{{ parentServiceId }}"
...
}
}
Рассмотрим более реалистичный пример подробнее.
Пользователь хочет создать сеть (network), подсеть (subnet) и зарезервировать IPv4 внутри подсети.
Для этого ему нужно отправить несколько запросов:
Запрос POST /v1/services
создает сеть.
Запрос POST /v1/services/:networkId/subnet
создает подсеть.
Запрос POST /v1/services/:networkId/subnet/:subnetId/ipv4
резервирует IPv4 в конкретной подсети.
На бумаге это выглядит логично и удобно, однако в реализации есть ряд подводных камней:
- Пользователю потребуется изучить документацию, чтобы понять иерархические зависимости между разными типами объектов. При этом максимальная глубина вложенности зависит от продукта и определить какие-то лимиты, чтобы поддержать механику на уровне платформы, сложно.
- Возникают проблемы при масштабировании API: усложнение связей внутри продукта потребует альтернативных механик. В приведенный выше пример сложно уложить задачу переноса подсети из одной виртуальной сети в другую.
Наш же подход уменьшает количество поддерживаемых API-методов, помогает организовать иерархичность любой глубины и позволяет внедрить множество разных типов связей, а не только иерархические.
Далее рассмотрим общие механики API применительно к нашей задаче.
Авторизация
Разработка публично доступного API накладывает требования на функциональность и работоспособность системы аутентификации и авторизации. Наш модуль Identity and Access Management (IAM) удовлетворяет этим требованиям, поэтому дополнительной работы, связанной с механикой входа, делать не пришлось, но кратко рассказать о подводных камнях, я думаю, будет полезно.
Можно выделить два ключевых требования:
- IAM-модуль должен выдерживать достаточный уровень нагрузки, поскольку теперь с API будут взаимодействовать не только внутренние пользователи, но и реальные клиенты облака.
- IAM должен поддерживать access_tokens с долгим сроком жизни, так как модель access+refresh может быть накладной для сценариев работы с API. У нас эта механика называется «сервисные учетные записи».
Мы решили использовать стандартные stateless JWT. В случае с аутентификацией по логину и паролю пользователя они выписываются на фиксированное время (10–20 минут). В случае сервисной учетной записи пользователь через консоль управления самостоятельно задает дату, когда токен перестанет быть активным.
Авторизация запросов выполняется на уровне общего API-шлюза, который получает токен из заголовка Authorization. Эта практика кажется самой простой и распространенной на текущий день. Для ошибки аутентификации мы используем код ответа 401 Unauthorized, а если у пользователя не хватает привилегий для доступа к указанному ресурсу, сервер вернет 403 Forbidden.
Текущая модель авторизации на 100% закрывает наши потребности в рамках разработки API.
Контроль доступа
Контроль доступа играет важную роль в обеспечении безопасности системы. Но на первых этапах разработки этому часто не уделяют должного внимания, надеясь на то, что защиту можно обеспечить на уровне UI. При разработке публичного API механику нужно внедрять повсеместно.
В нашем IAM реализована модель управления доступом на основе ролей (Role-Based Access Control, RBAC). Есть несколько ключевых ролей, которые регламентируют разные уровни доступа: администратор, редактор, наблюдатель.
Все привилегии пользователям выдаются на уровне ключевой сущности в нашей платформе — проекта. Таким образом, администратор проекта имеет полные права на управление всеми услугами внутри проекта, а наблюдатель может только просматривать по всем сущностям, внутри проекта.
С технической стороны наш модуль контроля доступа — собственная разработка. Мы постараемся раскрыть детали реализации в других статьях.
В настоящее время ролевая модель может быть не очень гибкой, однако мы планируем ее расширять, внедряя новые модели управления доступом, например ABAC. Подробности о планах по развитию API будут описаны в конце статьи.
Ограничение запросов (rate limiting)
Для справедливого доступа к API и для защиты от злоумышленников, следует продумать механизм ограничений и квот. Мы рассматривали несколько механизмов:
Leaky Bucket. Задача алгоритма — ограничить полосу пропускания канала и в то же время гарантировать некоторую скорость передачи данных.
Token bucket. Задача алгоритма — разделить запросы и ограничения, выставляемые платформой. Это делается за счет токенов, которые начисляются «на баланс» пользователя API. Работая с API, пользователь расходует токены, а следовательно, и свою квоту.
Fixed Window. Задача алгоритма — ограничить количество запросов в рамках временного интервала. Это помогает избегать непредвиденных всплесков в нагрузке на сервер.
Sliding Log. Алгоритм очень схож с Fixed Window, но тут появляется возможность установки индивидуальных ограничений по каждому пользователю.
Sliding Window. Гибридный подход, который сочетает в себе механики Fixed Window и Sliding Log. Алгоритм позволяет установить ограничения на каждого пользователя в единицу времени.
Для решения нашей задачи подошел бы любой из вышеописанных алгоритмов, поэтому ключевым фактором принятия решения была стоимость внедрения и поддержки. На этапе бета-версии мы выбрали наиболее простой и быстрый для внедрения подход — Sliding Window. Он гибкий и дает возможность плавно ограничивать скорость запросов в заданном временном окне. Также реализация этого алгоритма есть в gochi, который используется в нашей команде.
Ошибки
Чтобы покрыть все нюансы работы API, мы опираемся на стандартные коды HTTP-статусов, но расширяем их с помощью специфических кодов в теле ответа сервера. Вот некоторые примеры этих кодов:
RequestBodyIsNotValid
— тело запроса не прошло валидацию;ResourceNotFound
— ресурс, к которому выполняется запрос, не найден;ServiceIsBusyByAnotherOperation
— сервис занят другой операцией, повторите запрос позже;ConflictingData
— отправленные данные конфликтуют с данными на сервере;PaymentRequired
— недостаточно средств для выполнения операции.
Вот пример структуры, когда запрошенный ресурс не найден:
{
"code": "ResourceNotFound",
"domain": "hub",
"details": {
"cause": "service not found"
}
}
В примере возвращается HTTP-статус 404 с кодом внутри тела ResourceNotFound
. Это поможет отличить друг от друга два кейса с одним кодом 404: несуществующий URL и данного ресурса нет в платформе. Этот подход мы позаимствовали из API-гайда Google.
В нашем облаке много модулей, каждый из которых развивается отдельной командой. Но платформа должна агрегировать и типизировать ошибки этих модулей, поэтому взаимодействие между различными командами осуществляется по одному контракту ошибок.
Пагинация, фильтрация, сортировка
Нет общепринятых стандартов или принципов внедрения пагинации, фильтрации и сортировки в API, поэтому каждая команда разработчиков формирует свой взгляд.
Мы вдохновлялись подходом Google, который они используют при разработке своего API. Параметры пагинации и сортировки, которые мы передаем как query-параметры в GET-запросах, выглядят так:
pagination.offset
— смещение относительно начала отсортированного и отфильтрованного результативного набора данных. Например, 0 — для начала набора, 10 — начиная с 10-й записи набора данных. Значение смещения — абсолютное и не может быть отрицательным числом (в этом случае система должна вернуть 404).pagination.limit
— количество возвращаемых записей, отсортированного и отфильтрованного результативного набора данных. Лимит не может превышать определенного значения. Также если передать 0, вернется значение по умолчанию — 10 записей.pagination.order
— конфигурация сортировки отфильтрованного результативного набора данных в формате, который описан в Google. При указании сортировки вы должны указать список полей сортировки, разделенных через запятую, например: name, cost. Сортировка может быть двух видов: по возрастанию (asc) и по убыванию (desc). Сортировка по умолчанию — asc. Можно задать явно порядок сортировки, указывая его тип через пробел после имени поля, например: name asc, cost desc.
Также передаются query-параметры фильтрации. Например, поиск сервисов по определенным id можно передать, используя следующие query-параметры:
?filter.serviceId="123”&filter.serviceId="456”
В ответе для запросов, где возможна пагинация, в JSON обязательно присутствует вложенный объект pagination, например:
{
"data": [ ... ],
"pagination": {
"limit": 10,
"offset": 30,
"order": "name desc"
}
}
Версионирование и деприкация
Для обеспечения совместимости при изменении API мы используем мажорные (в терминах semver) версии в URL-адресах API-методов. Если вносятся изменения, которые не могут быть обратно совместимыми, мы добавляем новую версию эндпоинта. Однако важно не только добавлять новую функциональность в API, но и выводить из эксплуатации устаревшие механики, поддержка которых приводит к дополнительным расходам в разработке.
При разработке подхода к деприкации мы руководствовались двумя RFC: один и два. Мы объединили оба решения и немного доработали для соблюдения правила минимализма.
В нашем случае, если API-метод выводится из эксплуатации, сервер добавит заголовок SUNSET
, который может выглядеть следующим образом:
SUNSET: 2023-10-10T23:59:44+00:00; /v2/products; http://docs.cloud.mts.ru/api/public-hub.html
Таким образом, мы гарантируем совместимость при изменении API через версионирование эндпоинтов. При деприкации версии API сообщаем пользователям о сроке прекращения поддержки, рекомендуемых заменительных методах и соответствующей документации для миграции.
Документация и линтеры
В качестве инструмента для документирования нашего REST API мы выбрали Swagger и практически сразу автоматизировали генерацию спецификации из аннотаций в коде с помощью популярного на GitHub решения. Сначала это казалось очень удобным, поскольку код и документация не отставали друг от друга, а мы экономили ресурсы команды. Спустя несколько спринтов мы отказались от автогенерации по следующим причинам:
- Генерация производилась только в Swagger 2.0, а хотелось бы использовать OpenAPI 3.0.
- Когда сервис стал обрастать большим количеством моделей, утилита стала возвращать ошибки. По их описанию было сложно разобраться в причине проблемы, и мы тратили много времени на расследование.
- Аннотации, с помощью которых из кода генерируется документация Swagger, оказались несовместимыми с альтернативными библиотеками.
- Даже если перейти на другую утилиту автогенерации Swagger, нет уверенности, что мы не столкнемся с этими же проблемами.
После обсуждений было принято решение о том, что OpenAPI 3.0 будем писать вручную. Да, это означало, что код будет не совпадать с документацией и что мы будем тратить больше времени на написание и поддержку инструкций. Зато этот вариант даст нам больше гибкости и контроля в том, какую документацию получит пользователь.
Чтобы гарантировать корректность спецификации и минимизировать человеческий фактор, мы решили внедрить линтер для OpenAPI 3.0 документации и встроить его в CI/CD-процесс доставки на боевое окружение.
Вот что должен был уметь наш линтер мечты:
- давать полный и понятный отчет об ошибках;
- использовать в CI/CD для автоматической проверки swagger;
- работать с OpenAPI 3.0 и с другими версиями;
- быть производительным и надежным.
Подходящий линтер искали здесь. Всем нашим требованиям удовлетворил vacuum. Это довольно быстрый линтер, который выявил много важных замечаний и помог упростить ревью нашей спецификации.
Тестирование
Часто встречаюсь с заблуждением, что тестирование — наиболее простой этап разработки, но по нашему опыту это не так. Более того, это не бесплатно для продуктовой команды, поэтому на первых этапах разработки API стоит критически относиться к функциональности, которую вы планируете покрыть автотестами.
Для нашей бета-версии API мы определили следующие стейтменты:
- Функционально API должно работать так, как объявлено в API-спецификации.
- Программный интерфейс должен быть безопасен для использования: недопустимы ситуации неправомерного доступа к данным или ошибочные эскалации доступов.
Мы запускаем регрессионные тесты, которые проверяют эти аспекты при каждом релизе. Для тестирования используем pytest и общий фреймворк тестирования всех команд разработки в #CloudMTS.
Присматривались к тестированию API через Postman, но решили пойти путем, который уже отработан на других модулях. Будет интересно, если поделитесь своим опытом в комментариях.
Мониторинг ошибок и производительности
Графики и прочая визуализация взаимодействия пользователей с нашим программным интерфейсом помогает заметить проблемы до того, как это повлияет на пользовательский опыт. А еще это отличный способ корректировать роадмап разработки, опираясь на востребованные пользователями API-эндпоинты.
Мы придерживаемся итеративного подхода, поэтому на запуске beta-версии решили, что нам будет достаточно стандартных RED-метрик. При необходимости эту модель можно расширять дополнительными метриками или лейблами (для сегментации базы пользователей/запросов).
Это система метрик, используемая для измерения производительности и надежности работы системы, особенно в контексте распределенных систем обработки данных. Она состоит из следующих компонентов:
Rate измеряет скорость поступления событий или сообщений в систему. Обычно измеряется в количестве событий или сообщений, полученных за определенный промежуток времени. Скорость важна для понимания нагрузки, с которой система справляется, и может помочь определить, является ли она устойчивой или находится на грани перегрузки.
Errors отслеживает количество ошибок или отказов в обработке событий. Ошибки могут быть связаны с различными аспектами системы, включая потерю сообщений, превышение времени обработки или другие виды сбоев. Измерение ошибок позволяет идентифицировать проблемы в системе и принять меры для их устранения. Важно отметить, что ошибки бывают в любой более-менее сложной системе (сетевые сбои, баги, человеческий фактор, легаси в данных). Мы следим, чтобы их количество не превышало средние значения в 90, 95 и 99 перцентилей.
Duration отражает время, затраченное на обработку каждого события или сообщения в системе. Измерение продолжительности позволяет оценить производительность системы и определить, насколько быстро она обрабатывает поступающие запросы. Более длительная продолжительность может указывать на проблемы с производительностью или неэффективное использование ресурсов.
Для построения графиков мы используем Grafana. Все дашборды наших сервисов шаблонизированы с помощью grafonnet. Они хранятся в виде кода в репозитории сервиса и доставляются в Grafana в рамках CI/CD с помощью grizzly. Пример организации дашбордов можно посмотреть тут.
Мы также активно используем Sentry для мониторинга и более удобной работы с исключениями (exceptions), которые возникают на сервере или на клиенте. Sentry дает удобный инструментарий для отслеживания и трассировки ошибки внутри приложения и может быть полезна, если у вас сложная, многослойная архитектура приложений.
Для уведомлений о проблемах используется alertmanager, так как на уровне инфраструктуры используется prometheus-совместимый набор технологий.
Процесс внедрения и поддержка
Мы не выделяем публичный API в какой-то отдельный продукт или модуль, но понимаем, что это новый пользовательский интерфейс (как и web-интерфейс). Он может вызывать вопросы, с которыми не сталкивалась наша поддержка ранее.
В нашем облаке мы организовали отдельный уровень поддержки (developer level support, DLS) — это технические специалисты, которые занимаются сложными пользовательскими кейсами. Чтобы они лучше понимали, какие изменения происходят в продукте, мы нередко интегрируем их в наши scrum-ритуалы, проводим воркшопы и пишем внутреннюю документацию, которая разъясняет технические аспекты реализации различных модулей, в том числе и публичного API.
В результате такой организации работы мы получили два основных плюса:
- Разгрузили продуктовую команду от функции пользовательской поддержки по своему продукту.
- Получили еще один канал качественной обратной связи от пользователей. DLS может углубиться в кейс клиента и получить инсайты, до которых L1 не сможет добраться ввиду отсутствия нужных компетенций.
Релизноутсы, улучшения и обновления API
Продуктовые команды часто пренебрегают этими инструментами, поскольку считают, что спецификация openAPI в полной мере описывает контракт взаимодействия.
На старте мы сталкивались с похожей проблемой при разработке внутренней версии API, однако быстро осознали, что релизноутсы нужно писать подробно и понятно, погружаясь в конкретные юзкейсы пользователей.
У нас пока нет готового решения по автоматизации этого процесса. Надеюсь, что популяризация LLM поможет упростить эту задачу и генерировать контент, который нужно будет только вычитать редактору.
Заключение
Мы постарались сделать гибкий инструмент, который поможет пользователям автоматизировать управление их облачной инфраструктурой. В этой версии мы не успели реализовать широкий спектр функций, но заложили в основу все, чтобы быстро развивать и гибко подстраивать API под новые бизнес-требования.
В ближайшее время будем работать над следующими направлениями:
- Поддержка OAuth 2.0 flow в сценариях аутентификации и авторизации на базе нашего модуля Identity and Access Management.
- Развитие альтернативных моделей контроля доступа: ABAC, ReBAC и т. д.
- Разработка IaC-решений поверх стабильной версии API: ansible, terraform.
- Разработка SDK для популярных языков программирования: Golang, Python, JavaScript.
- Оптимизация производительности API и повышение отказоустойчивости решения.
- Улучшение безопасности решения в области передачи секретов и паролей.
Сейчас мы запускаем бета-версию API и будем постепенно добавлять новые паттерны использования, исправлять неточности и баги (куда без них).
Будем рады, если протестируете нашу бета-версию API для сервисов Cloud Compute (виртуальные машины) и Managed Service for Kafka и поделитесь обратной связью.
Тем более что для всех новых пользователей доступен приветственный грант в 5 000 рублей.