Как мы столкнулись с версионированием и осознали, что вариант «просто проставить цифры» не работает

a8ff463bfb36d97815ce142ce7df4230.jpg

Всем привет, я Алексей Некрасов — Lead направления Python в МТС и старший архитектор в MTS AI.

Хочу поделиться своим опытом внедрения версионирования и рассказать, как сделать первый шаг в реализации стратегии blue/green или канареечного развертывания, что для этого нужно и какие есть инструменты.

Если вы используете в docker-образах тег latest, или у вас недоступна система во время деплоя нового релиза, то эта статья — отправная точка для улучшения вашего продукта.

Наши продукты содержат множество микросервисов (от 5 и больше в зависимости от продукта и его стадии). Мы должны обеспечивать определённый уровень доступности продукта, и для этого используем blue/green, канареечные или A/B стратегии развёртывания.

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

Почему мы занялись версионированием

В одном из проектов по расшифровке аудиозаписей произошел факап, и я подключился для решения проблем. Над ним работали одновременно несколько команд, каждая занималась созданием своей части MVP проекта.

Каждые 2 недели мы показывали результаты бизнес- заказчику на демо стенде. В какой-то момент на демонстрации стенд перестал работать. Когда у команд спросили, в чем проблема, и какой статус у сервисов, они ответили про свои сервисы: «Все работает, на стенде последняя версия». Но новая версия почему-то не работала. Начали разбираться. С помощью тега у docker-образов «latest» выяснили, что каждая команда выкатила последнюю версию своих сервисов, но во время разработки одна из команд использовала не самую последнюю версию сервиса другой команды, она и не могла её использовать, так как другая команда её ещё только разрабатывала. 

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

Знакома ситуация? Надеюсь, у вас версия проставляется с самого первого коммита.

Благодаря этому факапу, мы вскрыли 2 проблемы:

  1. отсутствие версионирования релизов;

  2. отсутствие понимания у команд, какие релизы обладают обратной/прямой совместимостью.

Дальше расскажу, как мы вышли из этой ситуации.

Как мы внедряли версионирование в наш сервис

Почему важно проставлять версии у релизов, все поняли. Но что писать в версии? Есть несколько популярных вариантов (более подробный список можно почитать на wiki):

  1. Семантическое версионирование, например 1.0.2, где:

    1. 1 — мажорная версия, может не сохранять обратную совместимость;

    2. 0 — минорная версия, увеличивается при добавлении функционала, которая обладает обратной совместимостью;

    3. 2 — патч версия, обладает обратной и прямой совместимостью в рамках минорной версии и увеличивается, например, при исправлении багов.

  2. Версионирование с помощью даты, например 2010–01–03 (используется схема ISO «год-месяц-день»)

  3. Указание стадии разработки, например 2.0 beta3, где:

    1. 2 — мажорная версия;

    2. 0 — минорная версия;

    3. вместо beta можно использовать alpha, beta, rc (выпуск-кандидат), r (для коммерческого распространения);

    4. 3 — означает количество исправлений.

8826b7b1082243d8e6e75b1cedf0b3df.jpeg

В своих продуктах мы остановились на первом варианте — semver, так как он достаточно прост и закрывает все наши потребности. Также это хорошо ложится на gitlab flow с релизными ветками для разработки сервисов и git flow для разработки библиотек. Вместо тега «latest» мы проставляем нужную версию релиза, к которой могут обратиться как тестировщики, так и разработчики в любой момент, так как в docker-репозитории все образы проставлены тегом с версией.

Почему проставление версий не работает, и что с этим делать

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

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

Хм…, а если нашим сервисом пользуется не одна команда, а несколько? Будет очень больно.

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

Что же такое обратная совместимость?

Если смотреть на определение обратной совместимости (Backward Compatible) API, то это изменения, при которых после выпуска новой версии сервиса потребитель может продолжить использовать сервис без изменений на своей стороне. К обратно совместимым изменениям относятся, например:

  • добавление нового API-интерфейса сервиса;

  • добавление новых методов в API-интерфейс сервиса;

  • добавление опциональных полей в тело запроса или ответа HTTP-сообщения;

  • изменение полей HTTP-сообщения с обязательных на необязательные;

  • исправление ошибок или оптимизация работы реализации сервиса (в этом случае контракт не изменяется).

А вот примеры изменений, из-за которых нарушается обратная совместимость — non-backwards compatible (несовместимые изменения):

  • переименование или удаление сервиса/метода/полей HTTP-сообщения;

  • добавление новых обязательных полей в тело запроса HTTP-сообщения;

  • переименование или удаление значения из списка типа enum;

  • изменение формата имени ресурса URL. Например, изменение длины идентификатора в /customers/{customerId}/accounts, когда изменение становится более ограничивающим по отношению к клиенту;

  • изменение формата локации ресурса URL. Например, /customers/{customerId}/accounts на /customers/{customer_Id}/accounts не влияет на замещаемое имя ресурса, но может влиять на генерацию кода;

  • изменение типов полей HTTP-сообщения;

  • изменение семантики полей HTTP-сообщения. Например, изменение семантики с customer-number на customer-id, когда оба идентификатора являются уникальными ключами для поиска клиента;

  • изменение кодов или структуры ответа возвращаемых ошибок.

Из-за несовместимых изменений нужно обновлять мажорную версию сервиса.

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

Как поддерживать прямую совместимость

Редко какой web-сервис обходится без базы данных, и наш сервис не исключение. Нам нужно было хранить данные о пользователях. Казалось бы, как связана тема версионирования, обратной и прямой совместимости с базами данных?

А вот как. Наш сервис перешел из стадии MVP в Production. Бизнес требовал от сервиса отсутствие временных простоев при деплоеи новых версий. Эту задачу мы решили с помощью blue/green деплоя. Почему выбрали его? Потому что он достаточно прост и предусматривает одновременное развертывание старой (зеленой) и новой (синей) версий сервиса.

225997213b93fa0b844a0490f4cdb8ae.jpeg

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

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

  1. Добавляем новую миграцию, которая создаст три новых атрибута у таблицы: name, surname, patronymic. Старое поле не удаляем, на случай, если релиз будет не удачный и нужно будет откатиться обратно.

  2. Добавляем функционал, который при добавлении новых пользователей будет сохранять их в новые поля.

  3. Добавляем функционал выгрузки пользователей, который будет выгружать как из старого атрибута full_name, так и из новых для обратной совместимости.

4ee3481aae59e00d4f43437b6c824b50.jpeg

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

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

Прямая совместимость (forward compatible) — это такие изменения, при которых старая версия сервиса способна обрабатывать данные, предназначенные для более поздней версии сервиса. Сервис поддерживает прямую совместимость, если ранняя версия сервиса может обрабатывать ввод, разработанный для более поздних версий, игнорируя новые части, которые он не понимает.

Уведомление зависимых сервисов об изменениях и новых версиях

Просто поддерживать правильное версионирование API с обратной поддержкой, работать с миграциями к БД, поддерживающие обратную и прямую совместимость недостаточно. Нужно уведомлять другие команды о новых изменениях при выходе новых версий. Можно на кухне за чашечкой кофе рассказать про изменения, но это быстро забудется. Лучше использовать стандартный инструмент — ChangeLog (лог изменений) — это файл, который содержит поддерживаемый, хронологически упорядоченный список изменений для каждой версии проекта. У него есть подробный манифест, переведённый на множество языков, и он хорошо сочетается с семантическим версионированием, которое мы выбрали ранее.

Резюме. Как правильно версионировать API

Наш сервис успешно работает в Production. Он не стоит на месте и динамично развивается. Старое API, созданное для MVP, тормозит дальнейшее развитие сервиса. Поэтому мы решаем убрать поддержку старого и неактуального функционала и выпускаем новую мажорную версию с лучшим и более гибким API.

А как быть с теми, кто пользуется прежней версией API, ведь они не смогут в моменте переехать на новую мажорную версию?

Все просто — использовать основной номер мажорной версии в URL. Так, потребители нашего API при соблюдении принципов обратной совместимости должны отслеживать только обратно несовместимые изменения, связанные с изменением мажорной версии потребляемого API.

Не советую указывать версию API:

  • в пользовательский заголовок HTTP для определения версии (например, X-Version: 1.1). Такой подход может привести к проблемам с кэшированием;

  • в параметрах запроса для определения версии (например, domain.com/API? version=1);

  • в поддомене для определения версии (например, v1.domain.com/API).

Что делать со старой версией и как долго ее поддерживать?

Предлагаю поддерживать старую версию API в течение какого-то времени, но не более полугода (задаётся на уровне бизнеса). А также уведомить команды ИТ-продуктов-потребителей и убедить их в преимуществах новой версии API. Они в итоге должны перейти на новую версию.

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

© Habrahabr.ru