Привет, Хабр! Я  исполнительный директор по разработке департамента ML и BD Газпромбанка. Сегодня хочу поговорить с вами о деплое.

По данным, приведенным в Google SRE book, до 70% проблем происходит вследствие изменений в уже работающих системах. По моим личным ощущениям, это близко к правде: если у вас хорошо спроектированное и написанное приложение и стабильная, отлаженная инфраструктура, именно деплой — узкое место (которое можно улучшить). Для минимизации рисков Google SRE BOOK рекомендует использовать постепенные выкаты, быстро и точно анализировать проблемы, а в случае необходимости быстро откатываться на предыдущую весрию. 

Эта статья о деплое в Kubernetes, потому что это самая популярная инфраструктурная платформа, которая уже имеет множество возможностей для построения отказоустойчивых выкатов. А то, чего не хватает «из коробки», покрывается возможностями инструментов развитой экосистемы. 

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

Те, кто больше любит слушать, чем читать, могут посмотреть мой доклад про это на HighLoad++. Остальным — добро пожаловать под кат. 

Фактор 9

В The Twelve-Factor App в качестве 9-го фактора указывается утилизируемость (Disposability). Со следующей формулировкой:

«максимизируйте надежность с помощью быстрого запуска и корректного завершения работы (приложения)».

Мы должны помнить что:

  • экземпляры приложения эфемерны;

  • старт и остановка — нормальные операции;

  • старт и остановка должны быть быстрыми и надежными.

А еще — что приложения виснут и падают, ноды подвисают, поды эвиктятся, к ним приходит OOMKiller, и это норма, в которой мы живем.

Поэтому первое, о чем мы должны поговорить в этом контексте, это о том, как Kubernetes запускает и останавливает POD’ы с приложениями и как можно оптимизировать этот процесс. 

Оптимизация запуска пода

Как Kubernetes запускает и останавливает POD’ы? Эти вопросы прекрасно раскрыты в книге Марко Лукша Kubernetes in Action. Но пока не будем зарываться в фолиант и обсудим вопрос на достаточном для понимания уровне.

В начале произойдет определенное событие (например, создание нового ReplicaSet), на основе которого Kubernetes Controller Manager сформирует задание на генерацию POD’а и через Kubernetes API запишет его в ETCD. Далее его подхватит Scheduler, и POD перейдет в Pending (то есть для него будет искаться node — физический сервер для развертывания). Когда node найдена, POD переходит в статус PodScheduled. 

Далее последовательно запускаются init-контейнеры, после чего POD получает статус initialized. Затем запускаются рабочие контейнеры, проходят две пробы — Startup и Readiness, после чего POD переходит в статус Ready и на него начинает идти сетевой трафик.

Процесс понятный и знакомый, надеюсь, всем DevOps`ам. Что можно ускорить в этой схеме?

Init-контейнеры

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

Кейс из практики. Мы с командой отлаживали запуск сценариев популярного ETL-шедулера AirFlow. Он запускает новый контейнер на каждый шаг ETL-пайплайна. Мы добились ускорения запуска шагов пайплайна на 20 секунд, убрав единственный init-контейнер, отвечавший за создание Kerberos ticket для доступа к внешним сервисам. Дело в том, что этот тикет нужен был в очень ограниченном числе шагов, и убрав его из init-контейнера в код сценария, мы значительно ускорили запуск всех остальных шагов.

Процедура инициализации

Хорошие пробы — вопрос экзистенциальный (о том, как повысить их качество, я подробно рассказывал в этом докладе). На пробах мы экономить не можем, но можем ускорить процедуру инициализации.

Первая возможная проблема — это прогон миграций в init-процедуре. Очень плохая, но, к сожалению, очень частая практика. Миграции мы должны делать один раз при старте деплоя новой версии.

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

И последний момент — JIT-компиляция. Оцените, так ли она нужна и какие бонусы вы получаете в обмен на задержку старта приложения (особенно в режиме ограниченного потребления ресурсов процессора).

Оптимизация остановки пода

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

Остановка процесса в контейнере пода производится следующим образом. Процессу посылается сигнал SIGTERM, далее следует grace period (по умолчанию — 30 секунд), в течение которого процесс должен завершить работу. Если этого не происходит, процесс останавливается принудительно при помощи SIGKILL. Что может пойти не так? Прежде всего обработка SIGTERM.

Не ждем SIGKILL

Если ваше приложение не обрабатывает SIGTERM, может прийти SIGKILL и сделать больно.

Чтобы этого не произошло, приложение, получив SIGTERM, должно завершиться корректно: обработать все запросы и вернуть результат, закрыть сессии, удалить подписки на очереди, закрыть соединения с базами данных… В общем, сделать так, чтобы его остановка прошла незаметно для остальных участников обработки нагрузок.

На самом деле это неочевидный момент, потому что не все среды выполнения и фреймворки реализуют такое поведение по умолчанию. Например, у нас есть Java со Spring Boot. Эта связка, получив SIGTERM, просто убьет все, что находится внутри Java-машины. Но в Spring Boot недавно появилась опция server.shutdown=graceful.

Выставив ее, мы можем перехватить SIGTERM и корректно его обработать. То есть нужно знать ваш runtime, нужно знать ваши фреймворки, чтобы корректно делать остановку приложения в POD`е.

pre-stop hooks

Теперь о сигналах в Linux. Вот некоторые, посвященные остановке процессов:

SIGINT — интерактивное завершение процесса.

Например, если процесс работает в GUI-режиме, то на этот сигнал он может выдать окно «Вы точно хотите завершить работу?» и проигнорировать его, если вы выберете «Нет».

SIGTERM — штатное завершение процесса. Его мы можем перехватить и обработать.

SIGQUIT — быстрое завершение процесса. Чуть более настойчивое требование завершить процесс, но мы также можем его перехватить.

SIGKILL — немедленное безусловное завершение процесса (перехват невозможен).

Но не все придерживаются этих соглашений. Например, в Nginx штатное завершение (graceful shutdown) процесса это SIGQUIT.

То же самое поведение у PHP-FPM.

Чтобы корректно остановить эти сервисы в Kubernetes, мы можем использовать механизм pre-stop hooks.

Для Nginx мы можем использовать nginx cli для остановки (кстати, в последних официальных имиджах Nginx проблема с SIGTERM решена и описанный workaround можно не применять). 

Для остановки PHP-FPM мы имеем возможность при помощи утилиты kill послать сигнал SIGQUIT в PID #1, но этого может быть недостаточно. 

PHP-FPM работает по модели, в которой есть master-процесс, выполняющий оркестрацию, и child-процессы, выполняющие обработку запросов, и без настройки process_control_timeout мастер просто убьет воркеры, не дожидаясь, когда они выполнят текущие задачи.

Таким образом, нужно знать особенности не только runtime и фреймворков, но и базового ПО.

Удаление Endpoint

Важный момент в процессе остановки POD’а — удаление Endpoint’а. Они, как правило, порождаются при помощи kubernetes service. Объект service выполняет в Kubernetes множество функций. В данном случае нас интересуют две: Service Discovery и маршрутизация.

Service Discovery — это когда service ищет POD’ы на основе переданных ему меток и делает эндпоинты (Service -> Label -> POD -> Endpoint).

То есть эндпоинты — это POD’ы, которые соответствуют меткам (labels) сервиса и прошли Readiness-пробу. Их мы можем посмотреть при помощи команды kubectl get endpoints.

Удаление эндпоинта происходит одновременно с остановкой POD’а.

И здесь важно понимать, как этот процесс происходит в разрезе кластера:

Когда на Control Plane появляется задание на удаление POD’а, в ETCD записывается и задание на удаление эндпоинта. На ноде кластера работает Kubelet, который периодически опрашивает Kubernetes API, скачивает конфигурацию своей ноды и пытается ее применить. 

Он через Container Runtime Interface посылает в POD’ы сигналы остановки процессов и останавливает контейнеры. Кроме Kubelet на ноде работает процесс kube-proxy, который через CNI-плагин удаляет эндпоинт. Как правило, endpoint делается с помощью правил iptables с использованием conntrack, поэтому при удалении правил текущие сетевые соединения не разрываются до их завершения.

Проблема в том, что endpoint’ы — это cluster-wide-сущность, они фактически есть на каждой ноде кластера, и удаление происходит даже на нодах, на которых нет контейнеров, принадлежащих останавливаемому POD’у:

Но в основе работы кластера лежит PULL-основанная схема, поэтому некоторые ноды могут задержаться с обновлением конфигурации / удалением эндпоинта:

Кроме того, в кластере может работать дополнительное сетевое ПО вроде Ingress или Service mesh, которое тоже следит за конфигурацией кластера, состоянием POD`в, эндпоинтов и меняет свою конфигурацию в зависимости от того, что обнаружено.

То есть мы должны понимать, что трафик на POD может приходить даже после получения SIGTERM, и приложение должно уметь обрабатывать этот трафик. Если наше приложение этого не делает, мы, например, можем добавить задержку в pre-stop-хуки:

Но это увеличивает время остановки, а значит, увеличивает потребление ресурсов. 

Если мы разрабатываем приложение самостоятельно, правильнее всего будет реализовать стратегию обработки трафика при остановке на уровне кода. 

Варианты: либо обрабатывать запросы, надеясь успеть в grace-период, либо возвращать ошибку обработки запроса. То есть лучше получить ошибку и быстро сделать retry, чем ждать, когда приложение просто закончит «слушать» сеть, — в этом случае мы отвалимся по таймауту, и все равно придется сделать retry, просто намного позже. Кстати, про это я рассказывал в предыдущей статье.

Резюмируя все вышесказанное: быстрый старт и корректная остановка POD’ов в Kubernetes — это та база, на которой делается не только отказоустойчивый выкат, но и нормальная, стабильная эксплуатация приложений.

Стратегии деплоя

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

  1. Толерантность к сбоям и простоям — ключевой показатель и тема этой статьи.

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

  3. Ресурсоемкость. Количество оборудования, которое нужно для реализации той или иной стратегии.

  4. Простота отката. Возможность быстро вернуться к предыдущей версии, если что-то пошло не так (собственно, рекомендация из Google SRE book в начале статьи).

  5. «Прогрев» инфраструктуры. Окружение нашего приложения может содержать:
    • кеши, которые могут сбрасываться при деплое;
    • агрегаторы соединений, которые могут ломаться во время деплоя;
    • автоскейлеры, которые могут работать на основе метрик, просто не успевших накопиться для новой версии;
    • длительные соединения, требующие переустановки при смене версий.

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

    Классический пример метастабильного состояния — длительные соединения (например, websocket). Они требуют достаточно мало ресурсов на поддержку, но установка соединения — процесс из нескольких ресурсоемких действий, в которых могут участвовать множество связанных компонентов: нужно установить зашифрованный канал, произвести аутентификацию клиента, выбрать подходящий инстанс и привязать к нему соединение. И если из-за какого-то внутреннего возмущения (например, неосторожного деплоя) потребуется массовая переустановка длительных соединений, система может упасть и самостоятельно уже не подняться.

    Эти нюансы необходимо учитывать при проектировании процедуры деплоя.

    Выбор стратегии деплоя

    Самыми популярными стратегиями деплоя являются:

  • Recreate,

  • Rolling,

  • Blue/green,

  • Canary,

  • Dark (A/B).

Из коробки в k8s есть Recreate, RollingUpdate и частично canary. Два основных контроллера, при помощи которых мы разворачиваем приложения в k8s, — это Deployment для stateless и Statefulset для stateful-приложений. Настройки стратегий деплоя чуть различаются:

• Deployment — .spec.strategy:
Recreate
RollingUpdate

• Statefulset — .spec.updateStrategy:
OnDelete
RollingUpdate

В Stetefulset стратегия recreate возможна только при ручном удалении POD’ов, но если абстрагироваться от контроллеров и рассматривать непосредственно типы приложений, то деплой stateful-приложений очень сильно зависит от принципов их работы с данными: требований к консистентности данных, реализации репликации, шардирования, чтения и записи и, как правило, не сводится к простому выбору стратегии выката в настройках. 

Но stateful в k8s — это не самый распространенный случай (и чаще всего эта задача решается при помощи паттерна kubernetes operator), поэтому сфокусируемся на работе со stateless при помощи контроллера с типом Deployment.

Recreate 

При recreate у нас есть старая версия приложения:

мы ее выключаем:

и на ее месте запускаем новую:

Для ее настройки мы должны выставить Recreate в ключ .spec.strategy.type:

По критериям оценки у нас получается вот что. 

  • Толерантность к сбоям и простоям — плохая: Recreate — это гарантированный простой.

  • Консистентность — хорошая: в одно время работает только одна версия приложения. 

  • Ресурсоемкость — низкая: для реализации этой стратегии новое оборудование не нужно.

  • «Прогрев» инфраструктуры — по очевидным причинам проблемное место.

  • Простота отката — неоднозначная. Выкат новой версии — это зачастую не просто изменение версии докер-имиджа в подах приложения. Нередко вместе с ней идет изменение схемы данных (накат миграций), и когда мы придерживаемся стратегии recreate, у нас нет стимула делать схемы данных обратно совместимыми. Поэтому в случае отката старая версия приложения может просто не заработать на новой схеме, а хорошо организованный откат миграций встречается достаточно редко. То есть откат для Recreate нужно продумывать отдельно, с учетом специфики приложения.

Когда стоит использовать Recreate.

  • Нужна строгая консистентность. Например, если наше приложение — валидатор криптовалюты, и дублирование подтверждения в blockchain для нас гораздо хуже, чем небольшой простой.

  • Есть технологическое окно, в котором мы можем делать с нашей инфраструктурой все что угодно. В этом случае recreate — это просто, дешево и сердито.

  • Приложение не обрабатывает входящие запросы. Например, когда в нашем приложении есть асинхронные воркеры, работающие по PULL-модели, и мы можем обновиться быстрее, чем наступит SLA по времени обработки очереди заданий.

ROLLING

При RollingUpdate

мы постепенно отключаем старые экземпляры приложения:

На их место выкатываем новые:

При этом входящий трафик берут на себя работающие экземпляры:

И так постепенно переводим все рабочие инстансы нашего приложения на новую версию:

Для настройки этой стратегии нужно выставить в ключ .spec.strategy.type значение RollingUpdate.

Кроме этого, мы можем изменить параметры

  • maxUnavailable — количество POD`ов, которые можно отключить при обновлении;

  • maxSurge — количество POD`ов, которые можно запустить дополнительно.

Комбинируя эти значения, мы можем либо поднимать новые POD`ы и потом выключать старые, либо выключать старые, а на их месте запускать новые, либо совмещать это поведение, четко управляя потребностью в дополнительных ресурсах и мощностями, обслуживающими входящий трафик.

Для прогрева инфраструктуры можно использовать параметр minReadySeconds — промежуток времени, после которого POD считается работающим. А для отката на старую версию в случае появления проблем можно выставить progressDeadlineSeconds — если процесс обновления не завершится за указанное время, будет произведен автоматический откат.

Но, как правило, в релизе мы катим одновременно несколько объектов, и отката одного Deployment бывает недостаточно (например, в релизе может содержаться обновление Deployment и ConfigMap, который содержит конфигурацию для новой версии). Поэтому стоит использовать другие инструменты. Например, helm package manager с ключами timeout и atomic позволяет откатить релиз целиком (мой доклад на эту тему).

Теперь оценим Rollingupdate с точки зрения сформулированных ранее критериев.

  • Толерантность к сбоям и простоям — потенциально хорошая.

  • Консистентность — плохая: при выкате одновременно работают две версии приложения, между которыми по умолчанию трафик балансируется в случайном порядке.

  • Ресурсоемкость — неоднозначная. Можно реализовать сценарии как с потребностью в дополнительном оборудовании, так и без него.

  • Простота отката — высокая.

  • «Прогрев» инфраструктуры — хороший, с возможностью управлять процессом прогрева «из коробки».

Как улучшить консистентность при Rolling Update?

Есть несколько способов улучшить консистентность запросов при rolling update.

1. Версионирование / обратная совместимость API. Если ваше приложение предоставляет API, вы можете его версионировать: старые клиенты идут на api/v1, новые на api/v2, и таким образом коллизий не происходит.

2. Session Affinity. Можно реализовать привязку клиент-бэкенд. Например, с помощью ingress-nginx:

После того как выставлены эти аннотации, ingress начинает раздавать клиентам cookie, на основании которых связывает клиента с бэкендом, исключая переброс трафика клиента между разными POD`ми во время деплоя.

Чем это может быть чревато?  Допустим, есть старая версия приложения с идеально отбалансированным трафиком.

После переката первого экземпляра балансер по алгоритму round robin распределяет трафик между работающими экземплярами:

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

После переката третьего экземпляра и перераспределения его трафика ситуация может выглядеть так:

Как можно заметить, трафик в процессе деплоя на некоторые экземпляры приложения может увеличиться более чем в два раза. Это нужно учитывать при проектировании деплоя приложений с длительными соединениями и либо закладывать резерв по ресурсам, либо использовать «умные» балансировщики, умеющие работать в зависимости от загруженности бэкендов.

3. Feature toggles / feature flags. Эта техника пришла из trunk-based development — методики разработки через git, в которой используются экстремально короткоживущие ветки. День-два — и разработчик обязан смежить свою ветку с основной. Но фичу очень сложно реализовать за это время, поэтому в систему вводят некий переключатель, способный включать или отключать ее. Используя feature toggles, можно спокойно выкатить новую версию приложения, а потом включить новый функционал, доставляемый с релизом.

Когда стоит использовать RollingUpdate.

  • Проблема с консистентностью деплоя не стоит или решена.

  • Нужно минимизировать простой: RollingUpdate архитектурно одна из наиболее толерантных к простоям стратегий.

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

Kubernetes update strategies out of the box

Резюмируя рассказ про стратегии выката, доступные в k8s «из коробки», хочу заметить, что возможно их одновременное использование для различных компонентов приложения в рамках одного релиза.

Часто приложения строятся по модульной архитектуре, и отдельные компоненты (например, фронт и бэк) можно обновлять, используя RollingUpdate, pull-воркеры — используя Recreate. То есть знание о внутреннем устройстве приложения и взаимосвязях его компонентов — абсолютно необходимая вещь для построения нормального выката.  

Blue/Green

Blue/Green на первый взгляд выглядит достаточно просто. Есть работающая версия приложения, рядом с ней запускается новая:

Затем мы просто переключаем трафик:

Если все устраивает, останавливаем старую версию приложения и живем на новой:

Оцениваем.

  • Толерантность к сбоям и простоям — не все так просто, есть нюанс, о котором поговорим дальше.

  • Консистентность — хорошая. Трафик пускаем на строго определенную версию приложения.

  • Ресурсоемкость — высокая. Для реализации этой стратегии нужны двойные мощности.

  • «Прогрев» инфраструктуры — потенциально плохой.

  • Простота отката — экстремальная. Просто возвращаем трафик на старую версию.

Тонкости blue/green

Совместимость схем данных. Поскольку при blue/green одновременно работают две версии приложения, схема данных в БД должна подходить обеим.

Исходящие запросы от новой версии. Помимо входящего трафика, наши приложения сами могут делать какие-то запросы. Как описывалось ранее, в архитектуре приложения могут быть асинхронные пулл-воркеры, подписывающиеся на очереди и берущие оттуда задания, и в случае blue/green воркеры новой версии будут «подворовывать» задания у старой.

Деплой новой версии / остановка старой. В классическом подходе, который достался нам от эпохи серверов bare metal, мы делаем неймспейс blue, неймспейс green и попеременно деплоим то в один, то в другой. И если мы не обложим этот процесс определенным уровнем защитной автоматики, то случайно «передеплоить» рабочую версию приложения — вопрос времени. Избежать этого можно, изменив подход к наименованию неймспейсов: в k8s мы можем создавать их с нуля, используя (например) имя версии приложения в качестве суффикса.

Переключение трафика. Есть десятки способов переключения трафика между версиями приложения при реализации blue/green: 

  • можно менять запись в DNS (и это на самом деле не самый лучший способ из-за общей архитектурной инертности DNS); 

  • если наше приложение работает в облаке, мы можем использовать облачные балансировщики нагрузки;

  • в экосистеме k8s для переключения трафика можно использовать Kubernetes service, Ingress-контроллеры, API Gateway, Service mesh.

Но главное, о чем нужно помнить, проектируя методику переключения трафика, — она должна быть корректной. Соединения на старую версию приложения должны обработаться правильно: получить результат и только после этого закрыться. 

Поясню это на примере. Допустим, у нас есть два неймспейса. В старом есть ингресс, на который идет весь трафик:

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

Избежать этого можно, описав дополнительный «канареечный» ингресс:

Описав аннотации на новом ингрессе, мы направим в него 100% новых соединений:

nginx.ingress.kubernetes.io/canary: "true"
nginx.ingress.kubernetes.io/canary-weight: "100"

А когда трафик на старый ингресс завершится, можем удалить старый ингресс и аннотации с нового. 

Когда стоит использовать blue/green

  • Если у вас облака, и удвоение инфраструктуры на время деплоя стоит относительно дешево.

  • Редкий релизный цикл: стратегия blue/green требует определенной «раздумчивости».

  • Когда нужен легкий и быстрый откат на старую версию.

Canary и A/B

На первый взгляд Canary и A/B выглядят достаточно похоже: производится постепенная замена части экземпляров приложения на новые версии и на них направляется трафик.

Если нас все устраивает, полностью переходим на новую версию:

Все это очень сильно напоминает Rolling update. На технологическом уровне canary от A/B, как правило, отличается маршрутизацией трафика. При A/B трафик маршрутизируется на клиенте (или на основе клиентов), при canary чаще всего идет вероятностное распределение трафика на сервисе. Разница в том, что, в отличие от Rolling update, canary и A/B — это прежде всего тесты.

A/B — тест новой функциональности на фокус-группе реальных пользователей. Здесь основную роль играет мониторинг с метриками пользовательской удовлетворенности.

Canary — тест новой версии приложения на реальном трафике. Тут важен мониторинг стабильности и производительности. Canary от rolling update отличается как раз полнотой метрик и переключением экземпляров на новую версию в зависимости от данных мониторинга.

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

Плюс у canary будут проблемы с консистентностью, поскольку чаще всего там реализуется вероятностное распределение трафика.

При реализации canary и A/B есть ряд нюансов, которые стоит обсудить подробнее. Это управление трафиком и масштабирование версий. Давайте их разберем.

Управление трафиком при A/B и Canary

Можно выделить следующие возможности управления трафиком:

  • через масштабирование версий;

  • на основе весов;

  • на основе соединений.

Вариант через масштабирование версий приведен в документации kubernetes и является классическим примером реализации Canary в k8s «из коробки».

Допустим, у нас описано два объекта deployment с разными именами и версиями имиджей, но с одинаковыми метками (labels).

Если мы опишем Kubernetes service, ищущий POD`ы по указанным меткам, трафик через этот сервис будет распределяться равномерно на все найденные поды. Таким образом, изменяя количество реплик в каждом из deployment, можно управлять трафиком. Но этот случай хорош именно для примера и в действительности предоставляет слишком мало возможностей. Как правило, в реальных проектах используются более мощные и функциональные решения вроде ingress-контроллеров, service mesh, API Gateway.

В принципе, несмотря на обилие инструментов, реализации построены по общим принципам. Разберем их на примере ingress nginx.

Балансировку на основе весов мы рассматривали, когда говорили о Blue/Green:

Создав дополнительный ингресс-ресурс с аннотациями
nginx.ingress.kubernetes.io/canary: "true"

Для объявления canary-ингресса
nginx.ingress.kubernetes.io/canary-weight: "30"

Для определения размера трафика, направляемого на новый ингресс
nginx.ingress.kubernetes.io/canary-total: "100"

Для определения общего количества трафика мы направим 30% трафика на новую версию приложения. Это решение применяется, как правило, для стратегии типа canary.

Для стратегии A/B мы можем делать балансировку на основе анализа того, что приходит к нам в соединениях. Это могут быть headers или cookie. 

Для включения маршрутизации этого типа точно так же нужно описать ингресс с аннотацией nginx.ingress.kubernetes.io/canary: "true"

Для использования маршрутизации по заголовкам нужно описать аннотации 
nginx.ingress.kubernetes.io/canary-by-header — c именем заголовка.

nginx.ingress.kubernetes.io/canary-by-header-value — со значением, которое должен принять заголовок:

Для маршрутизации по cookie мы должны записать в аннотации только имя cookie nginx.ingress.kubernetes.io/canary-by-cookie (содержимое cookie нас не интересует):

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

Масштабирование версий при A/B и Canary

Поскольку бюджет на инфраструктуру, как правило, ограничен, плюс для чистоты эксперимента необходимо соблюдать пропорции между входящим трафиком и ресурсами версии приложения, на которые он направлен, управление масштабированием экземпляров версий достаточно важно при реализации A/B и canary. Масштабирование версий должно успевать за переключением трафика.

Хорошей практикой здесь является использование автоскейлеров (autoscaler): HPA, встроенный в k8s, если можно опереться на стандартные метрики потребления CPU/RAM, либо KEDA или Karpenter, если можно выделить более сложные метрики.

Резюме

Суммируя все вышесказанное, отметим, что в Kubernetes есть все необходимое для построения отказоустойчивых выкатов. Базовые стратегии выката — Rolling и Recreate — реализованы «из коробки» и прекрасно работают. Но если вам их не хватает, вполне можно реализовать что-то более подходящее при помощи экосистемы, сложившейся вокруг Kubernetes.

Если вы не готовы сами собирать пазл из экосистемы компонентов, можете использовать уже готовые решения. Такие как Argo Rollouts или Flagger.  В них уже реализованы механизмы управления трафиком и масштабирования версий в зависимости от метрик мониторинга. 

Например, Flagger реализует Canary, A/B, Blue/Green «из коробки», умеет переключать трафик при помощи большинства самых популярных инструментов для балансировки и работать с большинством топовых систем мониторинга.

Вот и все. Если вы нашли эту статью заслуживающей внимания, а также если вам интересно сравнение Argo Rollouts и Flagger — напишите об этом в комментариях, и я обязательно сделаю его в следующих статьях.

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