Сделано на основе доклада Алексея Ефимова из Netcracker на Highload ++ 2022
Если вам доводилось быть в славном городе Мадриде, что на Испанщине, наверняка в галерее Прада вы видели это прекрасное произведение искусства, которое демонстрирует типичный рабочий день системного архитектора.
Здесь есть все, что обычно в жизни у него происходит. На левом полотне сам системный архитектор за работой. Похоже, его слегка отвлекли, и он слегка озадачен, почему так много народа на него смотрит.
В центре триптиха видна прекрасная архитектура, которую придумал архитектор. Он думает: «Вот так оно будет!»
Но на самом деле на первый день после продакшена получается так:
Но такова жизнь.
Это прекрасное полотно называется триптих «Service Mesh в корпоративных приложениях» (дерево, масло). Поскольку это триптих, он состоит из трех частей.
Часть первая «Non-Uniform Service Mesh»
Первая часть повествует о том, что такое Service Mesh и какие задачи он решает:
Неприкосновенность API;
Параллельное выкатывание фич (Canary deployment);
Удешевление железа для разработки;
Multi-tenancy;
Структурирование приложения;
IoD для приложений;
Работа с прогретыми кэшами;
Работа на нескольких DC;
Абстракция для интеграций.
Чтобы ознакомиться с первой частью, всего лишь нужно сесть в машину времени, вернуться на год назад на HighLoad на доклад про Service Mesh в корпоративных приложениях. Если машина времени занята, посмотреть доклад можно по ссылке.
Часть вторая «Zero Deployment Downtime в корпоративных приложениях *»
* Баааальшой энтерпрайз
Этот доклад посвящен как раз второй части триптиха — как вся эта штука используется для того, чтобы сделать Zero Deployment Downtime именно в корпоративных приложениях, в энтерпрайзах:
Неприкосновенность API;
Параллельное выкатывание фич (Canary deployment);
Удешевление железа.
В мире телекома, больших приложений и больших энтерпрайзов количество подписчиков измеряется десятками миллионов. Это немножко не стартаповый уровень, чуть повыше. Поэтому технологии слегка специфические, и разговор пойдет про параллельное выкатывание фич, как выкатывать фичи, не нарушая работоспособность системы.
Все любят эксперименты на продакшен.
Но не в телекоме. Там эксперименты на продакшене не любят. Это такая индустрия, в которой стабильность превыше всего, в том числе, превыше фич. Казалось бы, Canary deployment – наше всё.
Что может быть проще? Выкатили Canary deployment, раскатали рядом новую версию приложения, пустили на нее какую-то часть аудитории, посмотрели — заработало, значит, запустили в продакшен, не заработало — откатили. О чем вообще речь?
Речь о том, что чуть сложнее жизнь оказывается, особенно в больших энтерпрайзах.
Во-первых, фич может быть больше одной. Это бывает нечасто, но случается. Кроме того, один инстанс приложений — он большой, это сотни гигабайт памяти, десятки CPU, его не очень-то и выкатишь, особенно если у вас не какой-нибудь Амазон, а on-premise. Там нет такого запаса мощности. Нужно именно таргетирование фокус-групп, то есть выдавать фичу не абстрактным 5%, а конкретным людям с именами и фамилиями. Дальше развернуть инстанс приложения в телеком-индустрии — это тоже не сказать, что подвиг, но это событие. Просто так в один клик это тоже не делается.
И главное — мы не должны никак повлиять на интеграции, потому что в энтерпрайзе обычно десятки, до сотни интеграций, все разворачивания новых инстансов должны пройти совершенно незаметно для всех внешних интеграций.
Подсказка:
Когда выкатываем новую версию приложения, обычно деплоим 5-20% микросервисов, то есть меняется не так много. Убивать всего слона (деплоить целое приложение) ради одного маленького бивня не очень-то хочется. Поэтому будем деплоить только дельты! То есть то, что изменилось — деплоим, что не изменилось — не деплоим.
Из этого возникает такой сценарий:
Выкатываем новую фичу, в ней три микросервиса: два взаимодействуют по REST, два взаимодействуют через message bus. Мы должны деплоить только дельты, то есть микросервисы, которые изменились. Но важно, что эти новые микросервисы привносят новые контракты. В условиях мутирования контрактов нужно сделать так, чтобы система осталась работоспособной.
Первое правило архитектора:
Быть гуманным к прикладному разработчику
Разработчику есть чем заняться, они все заняты. Они знают, что делают, им не до наших системных фич. Если прийти к разработчику и сказать: «Ребята, напишите нам всякие «ифчики», поставьте флажки, фичу туда, фичу сюда», они, конечно, скажут, что сделают это когда-нибудь, но…
Нужно сделать так, чтобы разработчики ничего не заметили, чтобы все Canary deployment, Zero Deployment Downtime для разработчика прошли абсолютно прозрачно, бесплатно, и никаких «ифчиков» им писать не пришлось.
Немного терминологии
Введем доменно-специфический язык, как все любят:
Application – дистрибутив, содержащий согласованный и протестированный набор микросервисов с версиями, все контракты выверены, все прекрасно работает. У него есть название и версия (здесь CRM-3.22.1)
Контракт – это спецификация поведения интерфейса микросервиса. Это может быть API, формат данных, поведение. Обычно при выкатке новая фича расширяет контракт, но иногда его и ломает. В данном случае выкатывается новое мажорное поколение Application, а контракт по месседжу сломан. Вводится новое поколение контрактов. Опять же вспоминаем первое правило — никто этого не должен заметить, все должно остаться работоспособным и спокойно переключиться.
Внутренний специфический термин Netcracker:
Поколение — это просто счетчик, номер установки. Приложение поставили первый раз — это поколение №1, поставили поверх фичу (поколение №2), накатили багафикс (поколение №3), поставили новый мажор (поколение №4), и т.д. Это просто монотонно возрастающий список, дальше оперируют номерами поколений.
А почему это не обычный Rolling Update? Там ведь тоже есть поколения.
Например, есть два микросервиса. Начинаем выкатывать новую версию приложения, каждое новое поколение — это replica set в пределах deployment. Просто создается новый replica set, в нем запускаются pod’ы — все достаточно стандартно. Pod’ы взаимодействуют через сервисы, поколения сменяют друг друга, новые pod’ы стартуют, старые удаляются — где сложность?
Сложность в том, что это взаимодействие неуправляемое. Вы направляете нагрузку с произвольной версии pod’ы на произвольную версию pod’ы. Если у вас менялся контракт, то он будет сломан. Если вы подразумеваете, что фича в двух микросервисах, обязательно нужно, чтобы зеленый вызывал зеленый, иначе новая фича просто не заработает.
Поэтому нужно сделать так, чтобы этот контракт, это выкатывание было управляемым. Опять же первое правило — чтобы разработчик ничего об этом не знал.
Общая идея:
Выкатывая новую версию микросервисов, выкатываем новый deployment, а не replica set. У него у каждого есть свой сервис. Сервис и deployment, в отличие от replica set — штука персистентная. Она не умрет и не переключится сама собой. Перед этим сервисом стоит некий транспорт. Это может быть Service Mesh или какой-то мессенджинг. Транспорт маршрутизирует запросы к конкретному поколению. Он это делает потому, что в реквесте есть соответствующая аннотация. Она говорит, куда на какое поколение нужно смаршрутизировать запрос.
Поколения живут своей жизнью.
Как в человеческой жизни поколения сменяют друг друга по некому конвейеру. У поколения помимо номера есть роль, как у мужчины есть роль дед, отец, сын, внук.
Первая роль — кандидат. Фича приходит в этот мир в виде кандидата, когда деплоится первый раз. Кандидат, потому что фича в продакшен может и не пойти. После того, как задеплоили кандидат, запустили тестовую нагрузку, проверили, понравилось — пустили в продакшен, не понравилось — откатили.
После того, как кандидат проверен, он становится Active-поколением. Это наш обычный продакшен — поколение, которое обрабатывает нагрузку и на которое приходят основные продакшен-запросы.
По мере того, как продакшен отрабатывает свое время, это поколение переходит в разряд legacy — это предыдущее поколение. Оно дорабатывает оставшиеся запросы и нужно на случай «а мало ли что».
Поколения сменяют друг друга по конвейеру.
Есть две операции:
Promote сдвигает все поколения вперёд;
Как сын становится отцом, отец дедом и т.д., так кандидат становится active-версией, active-версия уходит в legacy, а предыдущее legacy замещается. Мы пробовали удлинять хвост, сделали несколько legacy поколений — не прижилось, ни к чему это. Обычно этого достаточно.
Rollback — обратная операция (движение назад).
К сожалению, этой операции не бывает в жизни, но у нас есть, когда мы возвращаем назад. Например, фичу вывели в продакшен, а она не пошла. Тогда возвращаем, как было — дергаем рубильник, колесо Сансары поворачивается назад. Как известно, мясорубку можно провернуть назад, опять получится кошка.
Для того, чтобы это все происходило не хаотично, есть таблица поколений.
Это единый реестр, в котором записаны все существующие в данный момент в deployment микросервисы, какие версии микросервисов в каких поколения принимают участие. Самое главное, что active-поколение всегда заполнено. Это значит, что оно основное. В продакшене всегда должно быть все самое стабильное, никаких выкрутасов с продакшеном. Все остальные поколения — это дельты. Их можно легко сбросить, сказать, что все это от лукавого и оставлять только в продакшен. Сбрасываем поколения — продакшен остается нетронутым.
Кстати, кандидатов может быть несколько. Хотя в телекоме не любят эксперименты на продакшене, но иногда любят эксперименты на продакшене. Поэтому там могут выкатить несколько кандидатов, выбрать тот, который понравился, и его отправить в активное поколение на продакшен.
Пример
Первый deployment application.
Когда мы делаем первый deployment, поколение сразу становится active — это исключение из правил, но мы же любим исключения из правил!
Приходит новая версия.
В ней (вспомним предыдущий пример) три микросервиса. Они задеплоились в виде кандидата, мы сравнили дельту между текущим поколением и поколением, которое пришло, видим, что больше ничего не менялось, только 3 микросервиса. Деплоим именно их, записываем в таблицу поколений, что есть 3 дельты.
Дальше выполняем promote.
Те микросервисы, которые не поменялись, как были active, так и остались, а те, которые менялись, вытеснились вниз по конвейеру поколений.
Деплоим еще одну фичу.
В конвейере из трех элементов есть новый кандидат. Ввели в мессенджинге новое поведение, работаем, и тут — оба! — бага на продакшене.
Дергаем рубильник, врубаем резко заднюю, газ в пол — возвращаем все, как было. Самая исходная версия становится active, предыдущий active дельты возвращается в кандидат, а предыдущий кандидат удаляется.
Самое главное, что все эти откаты и promote — это просто логические операции. Они выглядят достаточно тяжело, но на самом деле они достаточно простые. Это просто перелейбливание и изменение в таблице поколений. Каких-то disruptive действий, тяжелых изменений на продакшене не происходит. Мы просто переразвешиваем ярлыки (мы же любим вешать ярлыки!).
Итого, мы посмотрели, как поколения сменяют друг друга, что такое технически является поколением. Теперь посмотрим, как сделать так, чтобы прикладной программист ничего об этом не знал, чтобы он задеплоил, а сама среда сделала так, чтобы на нужное поколение пришли нужные запросы.
Этим занимается фреймворк. Поговорим, что там происходит.
Главное, что это динамически маршрутизируемая среда. В каждой точке мы определяем, куда должны идти. Фреймворк квази-«ифчики» расставляет, на правильное поколение отправляет запрос. Для этого он использует аннотированные запросы, то есть информация, куда нужно идти, приходит в запросе.
Есть основной архитектурный паттерн. Поговорим, как он работает для REST и для мессенджинга.
В центре архитектуры находится транспорт. Этот транспорт является концентратором сообщений. Все сообщения приходят в транспорт, и он потом распределяет сообщения. Транспорт работает по статической таблице взаимодействия, это очень важно. В момент перемаршрутизации сообщений он никуда не ходит. Его заполнили нужной таблицей, и он работает только по ней. У него нет походов куда бы то ни было, control plane отделен от data plane.
Сообщение содержит метаинформацию о том, к какому поколению оно относится.
Control plane — это сердце Service Mesh. Оно управляет транспортами и таблицами взаимодействия. Когда происходит движение по конвейеру поколений, deployment, мы перезаливаем в транспорт новую таблицу и транспорт статически начинает маршрутизировать сообщения между поколениями.
В Control plane есть две внутренние таблицы:
Таблица поколений, о которой уже говорили;
Специфическая таблица маршрутизации для каждого транспорта.
Если микросервисов несколько, то примерно так все работает: транспорт — микросервис — транспорт — микросервис. Микросервис всегда работает между транспортами. Именно это дает изолированность, что прикладной девелопер ни о чем не думает. Он пишет свой микросервис даже не зная о том, что будет какой-то Blue-green. Ему гарантировано, что транспорт правильно свяжет его контракты, версии API, взаимодействия.
Активное поколение есть всегда, потому что это продакшен. Это самое основное, у каждого микросервиса всегда есть активное поколение.
Поколение содержится в запросе. Каждый запрос содержит информацию. Микросервис должен принять целевое поколение из входящего транспорта и передать его в исходящий транспорт. Это обязанность микросервиса, но для этого микросервису даем свой собственный SDK. Он пробрасывает контексты, знает, какой транспорт у него с севера, какой с юга, берет из транспорта сообщение поколения и пробрасывает его.
Если вдруг в запросе не оказывается поколения, значит, этот запрос будет отправлен на активное поколение, то есть метаинформации нет, она случайно пропала или ее и не было, значит, всегда в активное поколение отправляемся. Никогда запрос не будет потерян, всегда будет обработчик.
Например, на схеме в микросервис C деплоили новую фичу, и микросервис не менялся. Если даже на зеленое поколение придем, дойдем до микросервиса C, транспорт посмотрит — ну, не менялся микросервис C, он не содержится в этой фиче, значит, отправим запрос на активное поколение через транспорт.
Анатомия REST-транспорта
Посмотрим, как это сделано в REST-транспорте.
Тут виртуальная отсылка к прошлому докладу. Там очень подробно про это рассказано, а сейчас в общих чертах.
Так выглядит это взаимодействие:
Микросервисы B и C взаимодействуют по REST. Технически это выглядит так:
Каждое поколение — это новый deployment, и у каждого есть свой Kubernetes-сервис. У этого сервиса есть постфикс, то есть, когда деплоим микросервис, deployment, pod’у или сервис, в хвостик дописываем имя. По сути, при deployment микросервис переименовывается, к нему дописывается хвостик — имя поколения, потому что нельзя задеплоить два deployments с одинаковым именем.
Взаимодействуют они через gateway (в компании Netcracker используется Envoy в качестве gateway). Соответственно, gateway является концентратором, и именно у него сеть сервис с именем Microservice. Microservice C принадлежит gateway, а не самому deployment. Все сходится на gateway, он раздает всем запросы — по таблице маршрутизации, по поколению перенаправляет запрос в нужный сервис, в нужное поколение. Control plane вычисляет таблицу маршрутизации.
В REST-транспорте основная таблица — это таблица маршрутизации.
Это статическая таблица, которая задает, на какой downstream API нужно пойти в зависимости от входящих параметров запроса. Основным входящим параметром запроса является header. Поколение передается в малюсенький header, состоящий из 10 байт. Там нет огромной информации, просто номер. Есть еще одна мулька, но о ней чуть позже.
Если в header ничего нет, значит, отправляем на активное поколение. Для каждого поколения в таблице маршрутизации будет запись. Может быть даже такая тема, что, например, микросервис B начинает взаимодействовать в какой-то фиче-кандидате с микросервисом F. Для этого в таблице поколений будет отдельная запись, где записано, что для поколения, допустим, 3, downstream таргетом является не микросервис B, а микросервис E или F. Изменение контракта между поколениями возможно.
Если происходит движение по конвейеру, то перестраивается таблица поколений. Она control plane перевычисляет и скидывает всем gateway. Переключение мгновенное, нет никакой задержки.
Откуда же берутся поколения в сообщениях?
Тут есть несколько паттернов. Во-первых, не всегда поколения задаются в виде номера. Поколение можно задавать в виде имени. Для этого есть специальный header.
Первый вариант — тестировщики. Они обычно берут в браузере плагин, втыкают туда поколение и какую-то фичу тестируют.
Второй хинт — в identity менеджменте просто юзерам, которые входят в фокус-группу, выдаем атрибут, что это фокус-группа. Gateway берет GVT-токен, достает оттуда атрибут, смотрит — ага, это фокус группа! — и навешивает, допустим, header, что эта фокус-группа идет на поколение кандидат. Можно конкретно таргетировать конкретному Иванову дать на тестирование конкретную фичу и конкретное поколение, а потом отправить это в продакшен.
Не забываем про триптих, поэтому о мессенджинге поговорим поверхностно, возможно, это будет темой следующей части триптиха.
RabbitMQ – транспорт In a nutshell
Что происходит с RabbitMQ?
RabbitMQ – тот же самый паттерн — транспорт. Предположим, что есть очередь ордеров и микросервисы взаимодействуют через нее. Естественно, там есть exchange и очередь все-таки в RabbitMQ.
Есть продюсер и консьюмер. Продюсер посылает сообщения, консьюмер их потребляет. Концентратором здесь является exchange. Все месседжи всех продюсеров приходят в один exchange. Месседж в header (а в RabbitMQ тоже есть header) содержат имя поколения. Очередь у каждого поколения своя. Есть поколения, есть очередь. Exchange общий, очереди у каждого свои. Маршрутизация происходит посредством bindings, то есть на binding идет метаинформация и есть правило, какому поколению на какую очередь нужно отправить данный месседж.
Взаимодействие происходит через таблицу подписок, в отличие от REST, где таблица маршрутизации.
Что такое таблица подписок?
Здесь справа таблица подписок, а слева таблица поколений.
Таблица подписок показывает, какой микросервис является продюсером в какую очередь, и в какую очередь он является консьюмером. В зависимости от этого вычисляется, какие очереди и bindings нужно создать, и какие правила нужно прописать на bindings.
Например, микросервис E может легко в какой-то фиче отписаться от очереди ордеров, а подписаться на очередь юзеров. Но, напоминаю, программист об этом не думает. Он просто сказал — вот версия CRM №4, у меня такая таблица подписок. Что с этим делать, это моя головная боль — у программиста не боли, у архитектора боли. Такой у нас гуманизм!
Kafka – транспорт In a nutshell
В общих чертах про Kafka.
Kafka не такая крутая как RabbitMQ, не содержит внутренних средств маршрутизации. Поэтому тут паттерн транспорт немножко сместили в сторону консьюмеров.
Как это работает?
Есть общий топик. Он один на все поколения. Все продюсеры шлют все сообщения в один топик. Консьюмеры тоже потребляют все сообщения и на своей консьюмерской стороне их фильтруют.
Сразу возникает вопрос — а что с перформансом? Если вы потребляете меньше тысячи сообщений в секунду, вообще не заметите потребления CPU на фильтрацию. Если вы потребляете 10 тысяч сообщений, будет уже ощутимый процент, но, скорее всего, ваша бизнес-логика начнет валиться, когда вы будете потреблять на один pod 10 тысяч сообщений. Поэтому скалирование — это наше все, решается достаточно просто.
Есть интересная тема, которая относится не только к Blue-green, но вообще к любому взаимодействию, когда идет замещение одной версии микросервиса на другой, когда вы делаете rolling update — как сделать правильно graceful shutdown микросервиса и graceful замену микросервиса на новую версию, чтобы не потерять существующие сессии и запросы.
Предположим, есть два микросервиса A и B. Микросервис A шлет микросервису B запрос на его сервис. В какой-то момент времени микросервис B заменяется на новую версию. Kubernetes должен сделать два действия:
Пойти на сервис и в API tables табличке удалить downstream API, pod’у исключить из таблицы маршрутизации
Отправить pod’е SEGTERM сигнал на то, чтобы она завершилась и ушла в небытие.
Проблема в том, что эта операция асинхронная, и последовательность не гарантирована. Нередко бывает так, что сперва шлепнули pod’у, и только потом удалили ее из таблицы маршрутизации. То есть есть некоторый момент времени, когда запросы идут на отсутствующую pod’у, потому что она еще есть.
Вообще в целом не очень хорошо, что шлепнули pod’у, на которой был активный запрос от микросервиса A, потому что придется на микросервисе A делать какую-то логику с хендлингом ошибок, с ретраем. А мы помним первое правило и чем занимаются прикладные программисты. Им не до ретраев. Поэтому мы должны сделать это у себя.
Что же мы делаем? Такой трюк:
В pod’е делаем задержку 30с между сигналом, который приходит на pod’у, и сигналом, который уходит на процесс, то есть SEGTERM задерживаем. Первым делом отключается сервис. Как только отключается сервис, перестают идти входящие запросы, а обратные запросы идут не на API сервиса, а на API входящей pod’ы. Поэтому с ними ничего не происходит, они спокойно дорабатывают, 30 с достаточно. Если вдруг у вас синхронный рез запрос работает больше 30c, что-то в вашей жизни пошло не так. И правильно, что придет Kubernetes и кильнет вашу pod’у к чертям, а не будет ждать какие-то SEGTERM, отправит kill -9 — птичка, будь здорова!
Эпилог
Подведем итоги:
Zero Deployment Downtime делается за счёт Canary-deploymentа.
Это обычный подход, но немножечко сложнее.
Есть несколько поколений, сменяющих друг друга.
Фича — это поколение. Протестировали фичу, либо запромоутили ее в продакшен, либо откатили. Может быть несколько фич, но в продакшен пойдет только одна.
Поколения деплоятся как дельты.
В этом самый сок, почему мы ввязались в этот нелегкий путь — для того, чтобы сэкономить железо. Железо — это деньги, деньги в телеком тоже умеют считать, а не только в банковской сфере. Телеком тоже не благотворительная организация.
Транспорты сообщений связывают поколения.
Поколения работают через транспорты: микросервис — транспорт — микросервис — транспорт.
Graceful pod shutdown.
Все перегрузки микросервисов делаются только через Graceful shutdown.
За скобками: поколения в базах данных.
Сегодня мы совсем не затронули базы данных, потому что формально база данных может меняться между поколениями микросервисов. Оставим это на сладкое.
Что дальше?
Все-таки у нас триптих, как никак, поэтому третья серия, be continue, что называется, back in future, part 3. О чем нужно поговорить:
RabbitMQ ZDT in Details: один поставщик, много потребителей.
Kafka ZDT in Details: обеспечение консистентности при переключении, многопоточка
Kafka и RabbitMQ мы рассмотрели в очень общих чертах. Понятно, что может быть много подписчиков и может быть много поставщиков в одну и ту же очередь. Они могут динамически появляться и динамически исчезать, быть многопоточными в момент переключения. Как ни крути, все-таки момент переключения — это не 0, а какое-то количество миллисекунд. Как система ведет себя в это определенное время, чтобы последовательность сообщений не нарушить — очень сложная тема.
Поколения в базах данных — тоже непаханое поле.
dph
Спасибо за статью!
Пока еще не понятно, как именно работает фильтр на kafka-consumer.
Вот у меня consumer из активного поколения (g4), из топика пришло сообщение с g3. По идее, должно быть два сценария:
1) если есть где-то еще консьюмер из legacy-поколения g3, то нужно игнорировать это сообщение.
2) если нет консьюмеров из legacy-поколений, то нужно его попробовать обработать.
Но для этого каждый консьюмер должен знать про всех других консьюмеров (и синхронно). Как это реализуется?
Или это тема следующей статьи?
ggo
это избыточно. надо обеспечить синхронность настроек сеттера поколений и консьюмеров, ориентирующихся на поколения. плюс мониторинг зависших сообщений в кафке.
dph
Что значит "избыточно"? Как именно избегаем проблемы получения в консьюмере из g4 сообщения из g3?
И в кафке не бывает "зависших сообщений", что имеется в виду?