Всем привет! Меня зовут Максим, я релиз-инженер Ozon, и в этой статье я расскажу про впихивание невпихуемого, или про оптимальную с точки зрения используемых ресурсов стратегию деплоя в Kubernetes, которая позволила нам сэкономить тысячи ядер CPU и терабайты RAM.

Что было «до»?
Наш отдел CI/CD предоставляет всем командам Ozon CI/CD as a service, а именно готовые пайплайны для сборки, валидации и, главное, доставки кода всех сервисов Ozon, которых уже больше 5000. У нас есть разные стратегии развёртывания сервисов:
Rolling Update — когда реплики со старыми версиями приложения поочерёдно заменяются на новые;
Ramped Deploy — как rolling update, но все реплики новой версии поднимаются сразу, а старые удаляются по мере готовности новых;
Blue-Green — когда рядом со стабильной версией приложения поднимается вторая и все пользователи направляются на одну из версий, что позволяет быстрее откатываться в случае возникновения проблем;
Canary — как blue-green, только трафик не целиком направляется на одну версию, а распределяется между двумя.
В Ozon все сервисы живут в Kubernetes, и год назад, когда начиналась эта история, у нас было четыре кластера, распределённых между тремя дата-центрами (сейчас кластеров уже около десяти).
Сервисы у нас разные — от однорепличных экспортёров, которые в худшем случае потребляют 100 milliCPU, до композера с тремя сотнями реплик, каждая из которых потребляет от 12 CPU и 32 Гб RAM, что значительно больше, чем показатели среднестатистического ноутбука, и таких «требовательных» сервисов в Ozon достаточно много.
Проблема
Внимательный читатель уже мог заметить проблему. Мы поддерживаем канареечное развёртывание сервисов, когда два релиза поднимаются одновременно. И если один «прожорливый» сервис занимает значительную часть кластера, то, когда он существует в двух релизах со всеми своими уже 600 репликами, другим сервисам остаётся гораздо меньше ресурсов. А если такие «требовательные» сервисы выкатываются одновременно, то для других в кластере может вообще не остаться ресурсов.
В конечном итоге так и начало происходить. Можно сказать: «Да это не проблема! Залейте кластер ресурсами — и будет вам счастье. Если проблему можно решить деньгами, это не проблема, а затраты». Но это применимо, если траты сводятся максимум к покупке пары-тройки стоек. А если необходимое количество вычислительных ресурсов может сравниться с частью дата-центра, то это бессмысленные траты.
В тот момент у нас уже были алерты о том, что в кластере прода заканчивается место, и графики с его процентной загрузкой и незакрытыми канареечными релизами, которые занимали лишние ресурсы.
Сначала мы просто добавили ограничение продолжительности канареечного деплоя — до двух дней. Если после заблаговременного уведомления о задержавшемся релизе по истечении этого срока он не был закрыт, мы отменяли выкатку, удаляя канареечный релиз.
Однако это не решило проблему полностью, поскольку, если одновременно решали выкатиться три из топ-10 «требовательных» сервисов, то в лучшем случае они занимали весь кластер, а в худшем — один из них займёт все оставшиеся ресурсы и заблокирует выкатку всех остальных сервисов.
Владельцы некоторых таких сервисов решили «ужиматься» на время выкатки с помощью IT-Crowd (платформенный интерфейс для работы с сервисами и их ресурсами), скейля релизы таким образом, чтобы ресурсов для выкатки точно хватило. Так, например, в начале выкатки скейлили канареечный релиз до нуля реплик. А когда решали направлять на новый релиз больше трафика, то сначала скейлили его до нужного количества реплик, нажимали джобу в пайплайне, например Сanary 10%, направляя больше трафика, а потом скейлили стабильный релиз до меньшего числа реплик. И вот так выкатывали релиз, перекладывая поды со старого на новый.
Но это хорошо работает до того момента, пока кто-то не ошибётся, что у нас и произошло. Случился инцидент. Его быстро «потушили», но факт остаётся фактом: он был, и надо было сделать что-то, чтобы подобное не повторилось.
Как решили
Поскольку у нас была своя реализация канареечного деплоя через наши скрипты и балансировщики NGINX Ingress Controller и Warden, своя реализация service mesh, а также исправить проблему нужно было как можно быстрее, мы решили написать свой механизм. Мы назвали его «тонкой канарейкой», а впоследствии переименовали в «лёгкую канарейку».
Суть решения заключается в том, что мы в автоматизированном режиме воспроизводим логику со скейлингом двух релизов, описанную выше:
0. Деплоим новый канареечный релиз с минимальным числом реплик.
1. Если хотим увеличить трафик на канареечный релиз до x%, то:
1. Скейлим вверх канареечный релиз до x% числа реплик от максимального.
2. Ждём готовности новых подов.
3. Направляем x% трафика на канареечный релиз.
4. Скейлим вниз стабильный релиз до (100 – х) %.
2. Если хотим уменьшить трафик на канареечный релиз до х%, то:
1. Скейлим вверх стабильный релиз до (100 – х) % числа реплик от максимального.
2. Ждём готовности новых подов.
3. Направляем x% трафика на канареечный релиз.
4. Скейлим вниз канареечный релиз до х%.
Как реализовали
Чтобы объяснить, как реализовано наше решение, нужно сначала напомнить, как работает обычный канареечный деплой.
Как уже упоминалось, у нас для канареечных деплоев используются балансировщики NGINX и Warden:

При использовании NGINX Ingress Controller для реализации канареечной балансировки необходимо иметь два Kubernetes сервиса, которые указывают на стабильную и канареечную версии приложения, а также два ингресса, первый из которых указывает на сервис основного релиза и определяет, по какому адресу/хосту будет доступен стабильный релиз, а второй — указывает на сервис канареечного релиза и дублирует адрес/хост первого, но содержит аннотации, которые сообщают NGINX, что этот ингресс является канареечным и перетягивает на себя x% трафика с основного:
metadata:
annotations:
nginx.ingress.kubernetes.io/canary: "true"
# x — любое число от 0 до 100
nginx.ingress.kubernetes.io/canary-weight: "x"
Аналогично мы добавляем на канареечный сервис эти аннотации для Warden, чтобы он тоже понимал, что сервис — канареечный.
Теперь надо разобраться, как манипулировать деплойментами так, чтобы поддерживать минимально необходимое количество реплик на два релиза. Первое, что приходит в голову, — это:
- kubectl scale --replicas <COUNT> deployment <NAME>
для увеличения числа реплик
и
- kubectl wait --for=condition=ready deployment <NAME>
для ожидания поднятия новых подов.
Однако есть проблема со второй командой, поскольку, если один из старых подов будет висеть в неготовом статусе, то она зависнет и отвалится по тайм-ауту, если зададим его.
Думаем дальше. Мы хотим, чтобы новые поды раскатились. Для этого есть другая похожая команда:
kubectl rollout status deployment <NAME>
Она делает ровно то, что нам нужно: следит за новыми подами до тех пор, пока они не будут готовы. И теперь мы можем реализовать алгоритм, описанный выше. Набросаем его на Bash, опустив математические и прочие подробности:
if [ "$cur_weight" -le "$new_weight" ]; then
kubectl scale --replicas $canary_scale deployment $canary_deploy
kubectl rollout status $canary_deploy
kubectl annotate ingress $canary_ingress
kubectl annotate service $canary_service
kubectl scale --replicas $stable_scale deployment $stable_deploy
else
kubectl scale --replicas $stable_scale deployment $stable_deploy
kubectl rollout status $stable_deploy
kubectl annotate ingress $canary_ingress
kubectl annotate service $canary_service
kubectl scale --replicas $canary_scale deployment $canary_deploy
fi
Нечто подобное мы быстро реализовали на Bash, хорошенько протестировали и внедрили в наши «требовательные» сервисы:

С какими проблемами мы столкнулись
Первая — это то, что NGINX достаточно долго обновляет свою конфигурацию, поскольку ингрессы отображаются в соответствующие записи в конфиге NGINX и таких ингрессов минимум по три на сервис, которых было порядка 4000. Таким образом, неудивительно, что NGINX долго обрабатывал конфиг для 12 000 ингрессов — порядка двух минут.
Из-за этого, когда мы начинали удалять поды старого релиза, трафик либо не успевал уйти с новых подов, либо продолжал идти на IP адресса, за которыми уже не было подов, которые могли бы его принять. Мы не нашли как это можно было быстро исправить, поэтому решили что просто не будем внедрять механизм «лёгкой канарейки» в сервисы, у которых входящий трафик проходит через NGINX, а внедрим только в те, где он идёт через Warden. В долгосрочной перспективе мы начали процесс уменьшения количества ингрессов за счёт удаления неиспользуемых.
Вторая проблема не связана напрямую с нашим механизмом. Она больше про написание чего-то серьёзного на Bash. Чтобы её объяснить, нужен листинг небольшого куска кода на Bash:
set -e # устанавливаем опцию интерпретатора, чтобы, если выполнение одной из команд завершится ошибкой, то выполнение скрипта
func(){
echo "10"
exit 1 # завершаем выполнение скрипта ошибкой
}
main(){
local var="$(func)" # сохраняем результат функции в локальную переменную rc
echo "$var"
}
main
Незнающий человек может подумать, что мы остановимся на восьмой строке и не дойдём до девятой, однако local — это тоже функция, которая завершится успехом, и интерпретатор не остановится, а продолжит выполнение скрипта, что может привести к очень неприятным последствиям. Поэтому я вывел для себя золотое правило: никогда не писать ничего важного на Bash, кроме однострочников, которые выполняются в консоли один раз и больше не выполняются никогда.
Похожие решения
Поскольку нам требовалось решить проблему быстро, мы не особо смотрели на готовые продукты, так как понимали, что, скорее всего, внедрить их будет не так просто.
Но даже сейчас, оценив готовые инструменты, такие как Argo Rollouts и Flagger, мы поняли, что они не удовлетворяют некоторым нашим потребностям, таким как, например, мультикластерный деплой, и в целом сильно отличаются от нашего текущего подхода к канареечным релизам. Кроме того, их внедрение требует гораздо больше времени, чем разработка собственного решения, так как нужна адаптация и инструмента к нашим внутренним продуктам, таким как Warden, так и наших систем к инструменту.
Также есть риск, что внедрение стороннего инструмента ограничит нас в будущем, в то время как собственное решение мы можем развивать в нужном нам направлении.
Однако, если вы заинтересованы в повышении стабильности релизов и экономии вычислительных ресурсов, рекомендую обратить внимание на упомянутые инструменты, поскольку они имеют достаточно интересных фич, которые могут быть весьма полезны.
Итоги
Итак, мы решили нашу проблему с чрезмерным потреблением ресурсов при канареечных выкатках, благодаря чему сервисы перестали «толкаться» при деплое в Kubernetes. Для понимания масштабов, сервисы, которые живут на «лёгкой канарейке», когда не выкатываются используют примерно 26 000 CPU. Раньше, если бы все они решили выкатиться, то начали бы «толкаться» в кластере и кому-то точно не хватило бы места. Но теперь «толкучки» не будет — и с большой вероятностью места хватит всем.
Также, если вам интересно, как устроен внутри наш service mesh Warden, то очень рекомендую почитать статью Ильяса или послушать его доклад (когда появится его запись с Saint HighLoad++ в свободном доступе), где он достаточно подробно и понятно объясняет, как наши сервисы взаимодействуют, позволяя делать вот такие вот канареечные релизы.