Всем привет, я Алексей Некрасов - 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 - означает количество исправлений.

В своих продуктах мы остановились на первом варианте — 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 деплоя. Почему выбрали его? Потому что он достаточно прост и предусматривает одновременное развертывание старой (зеленой) и новой (синей) версий сервиса.

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

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

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

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

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

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

Чтобы решить данную проблему, нужно в новой версии при создании пользователей сохранять информацию не только в новые атрибуты, но и в старый атрибут 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. Поэтому старые версии нужно будет удалить из доступа в определенный момент. И нужно быть готовым к тому, что всегда будут те, кто захочет остаться на прежней версии.

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


  1. Bedal
    30.06.2022 16:42
    +2

    легко живёте… Есть заказчик А, у которого поставили версию Х.
    Прошло время… он хочет дополнительную функцию. Если поставить ему актуальную версию Y — задача решается легко. Но допуск изменённой версии в эксплуатацию означает проведение испытаний всего комплекса, для чего нужен месяц времени и энное количество аппаратного обеспечения. После испытаний — переобучение персонала на все происшедшие в комплексе изменения. Возможно, со сдачей экзаменов (хотя бы и формальных).

    Запустить же дополнительную функцию на сильно устаревшей версии Х — делать уйму кода, не нужного в версии Y и не совместимого с ней.

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

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


    1. fougasse
      30.06.2022 17:50

      Но живём же как-то. Отдельными ветками, выбором изменений, мержами, тестированием.

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

      Речь, естественно, о программно-аппаратных решениях с разными версиями железа/совместимости и прочим.

      Чисто софтовые решения намногт более проще поддерживаются.


      1. Bedal
        01.07.2022 09:57

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


        1. znbiz Автор
          02.07.2022 11:47

          Я рассматриваю семантическое версионнирование как часть составного решения, которое помогает определить, насколько сильно изменилась программа в новом релизе. В целом на проблему нужно смотреть комплексно и "проставление чисел" тому или иному релизу - это часть необходимого минимума при создании ПО/API


          1. Bedal
            02.07.2022 18:35

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


    1. znbiz Автор
      30.06.2022 22:49

      Тут может помочь backport service, который будет стоять перед новой версией бекенда и сторонним ПО, обеспечивая обратную совместимость и предоставляя новый функционал. Но всё зависит от конкретного случая, возможно такая реализация может не подойти.


      1. Bedal
        01.07.2022 10:02

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

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


        1. znbiz Автор
          02.07.2022 11:56

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


          1. Bedal
            02.07.2022 18:38

            Вы никак не можете выйти из простых случаев? Я всего лишь утверждаю, что «искаропки», а мобильные приложения как раз таковы, один из самых простых случаев, а бывают и другие.
            Приложения, замыкающиеся в IT-сфере, очень просты в этом смысле. Решения для материального мира — скачком сложнее.


  1. alexxxnf
    30.06.2022 17:22

    А вы не рассматривали альтернативы для RESTful API, которые изначально разрабатывались с учётом проблемы обратной совместимости? Например, GraphQL или gRPC.


    1. znbiz Автор
      30.06.2022 22:55

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


  1. amarao
    30.06.2022 17:27
    +8

    Мы столкнулись с несколькими Большими Серьёзными Проблемами, которые Требуют Серьёзной Работы, но срезали всюду где смогли и оно запустилось. рассказываем историю успеха.

    Во-первых версия API и версия ПО - две малосвязные вещи. Хотя бы потому, что версия у ПО всегда одна (если их больше чем одна, у вас факап в релизном пайплайне) и используется для идентификации артефакта и связного с ним исходного кода. Задача - глядя на артефакт сказать какой версии кода он соответствует. Ещё версия может сигнализировать о масштабе изменений, но эта задача - второстепенная. Первичная - это идентификация артефакта и сырцов.

    Версий API может быть несколько, потому что одно и то же приложение может поддерживать несколько мажорных версий API на разных минорных версиях. Искусство управления версиями в API - отдельная религия; лучшее, что я видел - микроверсии (openstack).

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

    А вы всё в одну кучу. latest, forward compatibility, легси-поля...


    1. dolfinus
      30.06.2022 17:33
      +1

      версия у ПО всегда одна

      Это если ПО развернуто в единственном экземпляре. А у каждого заказчика собственная копия, то все становится не так радужно


      1. amarao
        30.06.2022 18:08
        +4

        Вы не на то фокусируетесь. не "ПО может работать в единственной версии", а у данного экзепляра, запущенного на данном сервере должна быть единственная версия. Одна. Известно, из какого коммита собранная. Если у вас ПO по данным strings имеет версию 1.2.3, по мнению --version - 3.2.1, по мнению пакетного менеджера 1.2.4, по мнению опытного взгляда саппорта - вообще собрана из фичебранча мимо мастера, то ой.


        1. znbiz Автор
          30.06.2022 23:10

          А как же проводить канареечный деплой, если мы не можем развернуть одновременно несколько версий?


          1. amarao
            01.07.2022 11:50

            Вы канареечный деплой производите из разных бинарей? Вы запускаете (условно) три разных бинарных артефакта, ассоциированных с разными версиями исходного кода. У каждого бинарного артефакта своя версия. Каждый процесс запущен со своей версией и понятно, в какой коммит смотреть, когда какая-то из версий обсыпется.

            Если же вы делаете канареечный деплой из одного и того же бинарного артефакта, внутри которого мешанина из кода (например, потому что в jar'ник собирают код из разных веток гита и переключение между ними производится внутри по рандому), вот тогда, поздравляю, у вас канареечный деплой из "разных версий". Но когда он упадёт (целиком), то куда смотреть?


            1. znbiz Автор
              02.07.2022 12:09

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

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


              1. amarao
                02.07.2022 15:20

                Я-то вас понял. Но мой исходный тезис о том, что бинарный артефакт должен иметь 1-to-1 соответствие с версией кода канареечные деплои (и любые другие виды ci/cd гимнастики) никак не нарушают.


    1. fougasse
      30.06.2022 17:58
      -1

      Очень смелое заявление про единственность версии. Прямо из страны лошадок с рожками.

      Особенно в ситуации, когда функционал софта не изменяется, сорцы тоже, а версии разные. Тут прямо можно сказать, что в пайплайн релизный кто-то покакал радугой, но жизнь она такая, идентифицировать снепшот исходников — совершенно не проблема по версии. N-to-N в БД придумали давным-давно.


      1. amarao
        30.06.2022 18:05
        +2

        Это не смелое требование, это единственная здравомыслящая модель. Версия может обрастать уточнениями (версия 1.2.3 в гите, пятый maintainer build для ubuntu, собранный с третьей попытки в pipeline такой-то), но на выходе у вас у бинарного артефакта одна версия, которая соответствует одной ревизии кода.

        Если у вас один артефакт соответствует разным ревизиям кода - у вас проблемы (кто-то подвигал тег в гите).
        Если у вас на артефакте две несовместимые версии (например, вторая не "расширение" первой уточняющими деталями, а, "ну так получилось" при сборке), то какая из них правильная?

        Если возникает желание повесить несколько версий на артефакт (потому что он собирается из нескольких гитов), то это либо уродливая версионная многоножка (core-1.3.3-libs-3.4.5-payments-5.7.8), либо у вас бинарный артефакт (со своей версией), который состоит из компонент:

        core 1.3.3
        libs 3.4.5
        payments-5.7.8

        и имеет свою версию 1.0.2-pre33.

        Если же у вас где-то проскакивает проблема, что, например, вы не знаете, из какого бранча собирали версию 1.0.1 (из мастера или из бранча stable? И из какого коммита?), то у вас фундаментальная проблема передачи проблемы назад.

        Версия 1.0.1 при старте написала Segmentation Fault и завершилась. Куда смотреть программисту в поисках бага? Если вы эту связь ломаете, всё, у вас не версия, а продажная девка сиая.


      1. Gutt
        01.07.2022 15:25
        +2

        Особенно в ситуации, когда функционал софта не изменяется, сорцы тоже, а версии разные.

        Хорошо, если так. Есть (или была) такая контора -- Первый специализированный депозитарий. И вот как-то выпустили они тестовую версию (пусть будет 1.4.2) DTD для XML'ек, которыми мы с ними обменивались. Ну, хорошо, договорился о тесте, поставил, работает. Потом появляется новость -- "у нас новая версия DTD, 1.4.2, скачивайте и радуйтесь, скоро перейдём не неё!". Я сижу на стуле ровно, мне делать ничего не нужно. Через недельку с их стороны XML'ки перестают проходить валидацию -- говорят, не соответствуют DTD. Звоню, а они говорят -- "ставьте 1.4.2". Не нужно, говорю, мне ничего ставить, у меня 1.4.2 ещё со времён тестов стоит. "Ааа!", говорят, "это другая 1.4.2, тестовая, а мы теперь настоящую 1.4.2 выпустили!". На самом деле разговор занял около часа, в течение которого они подразумевали, что один и тот же номер версии может быть у разных версий кода, а я даже представить себе такого не мог. Где-то на 55-й минуте разговора до Остроухой Совы начало постепенно доходить...


        1. znbiz Автор
          02.07.2022 12:13

          Хорошая страшилка для новых разработчиков:)


    1. znbiz Автор
      30.06.2022 23:07
      +1

      Да, тут глобально три разных направления, связанные с версионированием и совместимостью в приложениях, API и изменениях структуры БД. Цель была показать необходимость в поддержке прямой и обратной совместимости при изменениях и не важно где они происходят: в приложении, структуре БД или API.


  1. Drazd
    01.07.2022 21:10
    +1

    Как же забавно, ностальгически и одновременно грустно читать было эту статью. Казалось бы, 2022 год на дворе - такие то технологии, модные словечки аля Docker, Load Balancer и все такое... А проблемы все те же самые, что мы с коллегами решали на заре моей карьеры лет 13 назад.

    Только тогда бэкграунд проблемы был немного другой. Дано: Множество предприятий по нашей необъятной стране, программные модули весом почти "АЖ В ГИГАБАЙТ", на удаленных концах дай бог диалап кое-как работает 2 часа в день. То есть возможности регулярно обновлять модули на предприятиях не было, и в результате каждое могло работать со своей версией.

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

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

    Для решения таких проблем нам приходилось писать специальные преобразователи. Они получали данные от старых версий и преобразовывали их в формат последней версии. И наоборот - для отправки на старые версии преобразовывали данные из нового формата в старый, определенной версии того предприятия, которое запрашивало обновления.


    1. znbiz Автор
      02.07.2022 12:19

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


    1. amarao
      02.07.2022 15:23

      Управление версиями и контролируемое обновление ПО - это сложная задача и никогда у неё не будет простого решения, просто в силу устройства домена проблемы.

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