Привет, Хабр! Расскажу про единый деплой для всех приложений в кластер K8s и поделюсь, как нам удаётся жить без OPA rules и не допускать дичи некорректных манифестов в кластере.
Есть несколько подходов к тому, как организовать и автоматизировать деплой приложений в случаях, когда их счёт переходит на десятки и сотни. Совсем без автоматизации тут не обойтись, и, когда приложений уже больше десятка, а команд разработки больше одной, в каждой компании начинают складываться какие-то общепринятые практики деплоя приложений.
Я встречал три паттерна организации процесса деплоя:
Каждый сам за себя. Под каждую команду разработки выделена отдельная независимая среда для деплоя, и дальше они реализуют деплойный процесс самостоятельно по собственному разумению — хоть руками файлы с ноута разработчика по FTP на хост копируют, хоть в облаке запускаются. Обычно в команде или группе команд есть несколько людей в шляпе «DevOps-инженер», которые пишут скрипты/плейбуки/кукбуки, настраивают пайплайны и отвечают за окружение, в которое производится деплой.
Инфраструктурная платформа. Обычно среда для деплоя в таких платформах — это K8s или его аналоги, но могут быть и виртуалки в Openstack или VMWare либо какая-то экзотика. В этом случае окружение заранее подготовлено и настроено отдельной командой инженеров, для деплоя в него есть ряд требований и не меньше ограничений. Как правило, здесь в командах разработки тоже есть DevOps/SRE, которые организуют процесс доставки в соответствии с требованиями платформы: разбираются в её документации, пишут манифесты по заданным правилам, настраивают пайплайны и скрипты выкладки.
Унифицированный механизм деплоя. Такой механизм позволяет деплоить в разные среды одинаковым для разработки способом. Заранее не только подготовлены окружения, в которые деплоятся приложения, но и создан уровень абстракции, позволяющий не держать в командах разработки DevOps-инженеров. Для деплоя приложения достаточно описать необходимые ему ресурсы (как системные, так и сетевые), собрав описание из заранее определённых кубиков. Это не требует от команд разработки знания нюансов конкретного окружения для деплоя. Наоборот, переезд приложения из одного окружения в другое может проходить с минимальным привлечением ресурса разработки, а иногда и вовсе только силами инженеров эксплуатации.
Как можно догадаться из названия статьи, речь сегодня пойдёт как раз о последнем случае.
Унифицированный деплой
В ЮMoney есть ряд требований к микросервису со стороны эксплуатации. Соблюдение этих требований позволяет эксплуатации работать с микросервисами единообразно: они начинают выглядеть для инженеров этакими кубиками — пусть разных цветов и размеров, но всё ещё кубической (или хотя бы параллелепипедной) формы.
С другой стороны, разработке тоже становится не важно, где именно запустится приложение. Требования к микросервисам одинаковые независимо от того, на какое окружение они будут деплоиться в итоге. Главное, чтобы существовал соответствующий артефакт, который можно выложить на целевое окружение: для виртуалок на Linux и LXC-контейнеров это DEB-пакеты, для Docker и Kubernetes — образы контейнеров, для Windows — NuGet-пакеты.
Несомненно, очень помогает наличие платформ разработки — большинство наших микросервисов создаётся из стандартных заготовок, в которых заранее заложено соответствие требованиям эксплуатации. Однако и другие, неплатформенные микросервисы, несложно допилить. Проблемы возникают только с коробочными решениями сторонней разработки. Там, конечно, приходится изобретать кастомный деплой, и для подобных сервисов у нас есть обособленные среды выполнения.
Базово мы требуем соблюдения четырёх пунктов:
Слушать на сервисном TCP-порту.
Отвечать по HTTP по определённым путям для получения информации о приложении.
Иметь Jinja-шаблоны конфигов и Ansible-переменные.
А также YAML с описанием приложения.
Сервисный порт и HTTP-эндпоинты
Помимо основного TCP-порта, на котором отдаётся основной главный функционал, приложение должно слушать на сервисном порту, едином для всех приложений. На этом порту приложение может отдавать разную служебную информацию, но минимально должно отвечать на запросы по таким HTTP-эндпоинтам:
/ready
— отвечать HTTP 200, если приложение готово принимать запросы. Используется в readiness-пробах K8s и в health check'ах балансировщиков haproxy и Nginx;/state
— отдавать JSON с именем и версией приложения.
Пример ответа на /state
:
$ curl http://sweet-app:1234/state
{
"service": {
"name": "sweet-app",
"version": "1.52.0",
"type": "backend"
},
"status": {
"code": 0
},
"started": "2024-09-13T08:45:01.604",
"ip-address": "1.2.3.4"
}
Шаблоны и переменные
Следующее требование растёт из того, что наш деплой в своей основе построен на Ansible: в артефакте приложения должен иметься каталог ansible, внутри которого — каталоги group_vars и templates:
ansible/
├── group_vars
│ ├── dev.yml
│ └── prod.yml
└── templates
├── config
│ ├── sweet-app.properties.j2
│ ├── log4j2.xml.j2
│ └── server.properties.j2
└── service.conf.j2
Люди, знакомые с Ansible, сразу догадались, для чего они нужны: в первом хранятся переменные для деплоя на prod|dev|whatever среды, во втором — шаблоны конфигов приложения, в которые эти переменные потом подставляются в зависимости от того, куда деплоим.
Конечно, не забываем про секретные переменные: такие мы храним в Hashicorp Vault и забираем их оттуда.
Во время деплоя Ansible-плейбук забирает шаблоны и переменные из артефакта приложения, а также секретные переменные из Vault, после чего выполняет include_vars в правильном порядке с учётом приоритета переменных.
YAML с описанием приложения
Последний, но не менее важный камень в фундаменте нашего унифицированного деплоя — файл описания приложения, он же desc.yml. Это YAML'ик, лежащий в репозитории приложения, в котором содержится вся необходимая эксплуатации информация о нём: имя, тип, целевой сетевой сегмент, требуемые системные ресурсы, необходимое количество реплик, на каких портах и протоколах слушает приложение, в какие БД ходит и т. д.
Немаловажная часть этого описания — список всех эндпоинтов, в которые приложение собирается ходить по сети. Это как наши внутренние приложения, так и внешние сайты. На основании эндпоинтов формируются настройки service mesh: это haproxy для linux/LXC, gobetween для Windows и Istio envoy в K8s.
В Istio на основе файла описания формируются правила доступа, поэтому, чтобы ребята и девчата из СБ спали спокойно, для внесения изменений в desc.yml требуется их обязательный апрув.
Пример (сильно урезанный) файла описания приложения:
---
version: 1
application:
name: sweet-app
type: backend
resources:
ram: S
cpu: S
min_replicas: XS
max_replicas: S
segment: base
listen:
- name: http
endpoints:
- name: sour-app
app: sour-app
port: http
- name: sweeties
host: sweeties.tld
port: 443
Деплой в Kubernetes
Приложения, отвечающие требованиям унифицированного деплоя, могут быть развёрнуты в любом поддерживаемом окружении, но сегодня поговорим прицельно про деплой в Kubernetes-кластеры.
Унифицированный деплой позволяет разработке не заботиться о том, какие манифесты нужно запихнуть в кластер, чтобы их приложение заработало. Но раз это не надо делать разработке, это ложится на плечи эксплуатации.
Для деплоя в K8s мы используем Ansible-плейбук, в результате работы которого получаем весь набор манифестов, требующихся для полноценной работы приложения в кластере:
Namespace
Deployment
Service
Secret
ConfigMap
Pod Disruption Budget
Horizontal Pod Autoscaler
NetworkPolicy
-
Манифесты Istio
Gateway
VirtualService
DestinationRule
ServiceEntry
пачка ServiceEntry
Все эти манифесты заполняются на основе информации, полученной из файла описания приложения и умолчаний, принятых для всех приложений в компании. Например, для Deployment:
реквесты и лимиты заполнены в соответствии с размерной майкой, запрошенной в desc.yml;
добавлены корректные startup- и readiness-пробы (от liveness мы отказались, решив, что каскадный рестарт приложений из-за liveness хуже, чем призрачная вероятность того, что приложение в контейнере повисло, но не упало, чего на практике не наблюдали ни разу);
настроены Volume и VolumeMounts для конфигов и секретов;
указан безопасный securityContext;
настроены Affinity и AntiAffinity, чтобы все поды одного приложения не оказались на той самой ноде кластера, которая по неудачному стечению обстоятельств решила прилечь;
и, конечно же, выставлены правильные лейблы для подов.
Так мы получаем заведомо корректные манифесты, без дыр в безопасности, с правильно заполненными лейблами и аннотациями, разрешающие приложению только то, что должно быть разрешено. Это избавляет нас от необходимости устанавливать в кластер OPA Gatekeeper и писать для него десятки правил на Rego в попытке учесть всё, что может пойти не так в манифестах.
Атомарность деплоя
Разработка идёт, добавляются фичи, правятся баги, у микросервиса появляются всё новые и новые версии. Иногда, несмотря на все этапы тестирования, до прода добирается версия с багами, проявляющими себя только на продовой среде. Тогда версию надо откатить на предыдущую.
Во всех этих активностях очень важно, чтобы деплой был атомарным. Под этим я понимаю, что сгенерированные в процессе работы автоматики манифесты, конфигурационные и исполняемые файлы, образы контейнеров должны быть единым целым: выкладываться и откатываться все вместе.
Действительно, если представить, что каждый манифест — это самостоятельная сущность и что он может быть обновлён или откачен независимо, мы попадаем в dependency hell, где надо как-то понимать, какие версии одних манифестов/конфигов совместимы с какой версией других манифестов/конфигов и совместимы ли они все с версией самого микросервиса.
Кроме того, если просто копировать файлы на сервер или деплоить в кубер стандартной CLI командой kubectl -f, может оказаться, что для новой версии старый конфиг не нужен, но он остался болтаться на сервере. Или при откате выяснится, что манифест, который всё ломал, остался в кластере.
Поэтому каждый деплой приложения должен быть единой неделимой сущностью.
Для выкладки приложений на виртуалки и в LXC-контейнеры атомарность обеспечивалась переключением symlink'ов со старой версии на новую после копирования файлов. Когда наши приложения поехали в кубер, пришлось искать способ выкладывать приложения атомарно и туда.
Прекрасным инструментом для организации такого деплоя в кластеры K8s служит Helm, который уже давно стал стандартом. Мы просто заворачиваем все сгенерированные манифесты приложения в Helm chart и из коробки получаем практически всё, что нам было нужно. Теперь мы оперируем не кучей YAML-манифестов, а Helm-релизами. Кроме того, такой подход позволяет откатиться не просто на старую, стабильную версию приложения, но и на те же манифесты кластера, которые были при его выкладке. Нам не придётся перегенерировать их все и молиться, чтобы заново сгенерированные манифесты не ломали ничего для старой версии приложения, ведь между выкладкой старой и новой версии могло что-то поменяться в шаблонах манифестов.
У Helm есть единственный недостаток: при запуске с флагом --atomic=true он, если по каким-то причинам приложению не удалось запуститься, автоматом откатывается на предыдущую версию (что хорошо), но в той консоли, где запускался Helm, нет никакой информации о том, почему же новая версия не смогла выложиться. Более того, поды уже убиты, а приложения при неожиданном крахе часто пишут в логи такое, что не пролезает через штатный пайплайн доставки логов. Искать же логи упавшего пода, который к тому же удалён, очень неудобно. Приходится каждый раз представлять себя Шерлоком Холмсом и распутывать детективную историю. А хочется иметь какой-то простой способ увидеть, что же пошло не так.
Удачным средством прокинуть логи контейнеров в консоль, из которой выполняется Helm, оказался запуск деплоя не чистым Helm, а с использованием Werf поверх него. Теперь при падении релиза в консоли Jenkins можно сразу увидеть, что случилось, а не заниматься детективными расследованиями по следам преступления. Я хочу покаяться перед ребятами из Flant'а: мы используем их невероятно мощный инструмент едва ли на один процент от его функционала, но он вписался в наши процессы в этом качестве практически идеально, нам нравится.
Канареечный деплой
Сама тема, как мне кажется, довольно заезжена, однако статью могут читать не только DevOps-инженеры, но и другие люди. Поэтому сначала вкратце расскажу про то, какие бывают стратегии деплоя, а затем уже про то, как мы в ЮMoney организовали этот процесс.
О чём вообще речь?
Есть три основные стратегии того, как выложить приложение в прод и ограничить влияние на пользователей тех случаев, когда что-то пошло не так и какой-то (или весь) функционал не работает.
Первая — наивная. Мы просто выкладываем новую версию и, когда видим пачки ошибок в логах, аномалии в графиках и получаем звонки от пользователей, откатываемся на предыдущую.
Стратегия вполне рабочая, но, когда у вас много пользователей, совсем не хочется огорчать их всех разом и надолго, хочется как-то уменьшить влияние пусть даже самого ужасного релиза.
Для этого придумали стратегии канареечного деплоя и сине-зелёного деплоя.
В канареечном мы раскатываем экземпляр приложения с новым функционалом параллельно со старыми и направляем на него небольшую часть пользователей. Убедившись, что ничего не сломали, раскатываем новую версию вместо старой. Если же что-то пошло не так — прибиваем канарейку и идём чинить баги.
В сине-зелёном деплое у нас каждая новая версия приложения попеременно назначается синей или зелёной. Пусть первый деплой приложения был синим. Теперь, когда мы хотим выкатить новую версию, мы раскатываем её рядом и называем зелёной. После того как зелёные экземпляры полностью поднялись, переключаем пользовательский трафик на них. Сине-зелёный деплой даёт возможность очень быстро откатиться на предыдущую версию, ведь какое-то время после зелёного релиза реплики синего остаются живыми и в случае чего готовы снова принять на себя нагрузку.
Обычно переключение с синей версии на зелёную и наоборот сначала делают для небольшой части пользовательского трафика, по аналогии с канарейкой, и только убедившись, что всё в порядке, переключают полностью.
Как это сделали мы
В ЮMoney мы используем канареечную стратегию деплоя, которую реализуем с помощью Istio service mesh.
У Istio есть весьма мощные инструменты управления трафиком, и для канареечного деплоя нам нужно два из них: DestinationRule и VirtualService. Первый позволяет разбить поды сервиса на подмножества в зависимости от лейблов на них, так, чтобы можно было отделить канареечные поды от остальных. Второй даёт возможность управлять тем, в какое подмножество подов и с каким весом трафик будет направляться.
Таким образом, в основном деплое приложения содержится DestinationRule, разделяющий сервис приложения на два сабсета: canary и main.
apiVersion: networking.istio.io/v1
kind: DestinationRule
metadata:
name: sweet-app
spec:
host: sweet-app.sweet-ns.cluster.tld
subsets:
- labels:
canary: 'false'
name: main
- labels:
canary: 'true'
name: canary
Рядом с ним есть VirtualService, направляющий 100% запросов в сабсет main.
apiVersion: networking.istio.io/v1
kind: VirtualService
metadata:
name: sweet-app
spec:
http:
- route:
- destination:
host: sweet-app.sweet-ns.cluster.tld
subset: main
weight: 100
- destination:
host: sweet-app.sweet-ns.cluster.tld
subset: canary
weight: 0
При деплое новой версии приложения мы сначала деплоим канареечный Helm chart. В отличие от основного, в канареечном чарте есть только Deployment, ConfigMap'ы и Secret'ы. Все эти сущности для канарейки отдельные и не пересекаются с обычными манифестами приложения.
Канареечный Deployment запускает один под с новой версией приложения и лейблом canary: true, который смотрит на канареечные же конфиги и секреты.
Если канарейка успешно стартовала, на неё направляется 5% от всего числа запросов, прилетевших в дата-центр (у нас их два). Для этого в Virtual Service соответствующим образом меняются веса сабсетов: weight: 95 для main и weight: 5 для canary.
Группа мониторинга и ответственный за релиз разработчик наблюдают за работой канареечной реплики и, если всё хорошо, запускают деплой основной версии. Как только в кластер выкладывается основная версия, VirtualService приходит в своё основное состояние и трафик на канарейку идти перестаёт. После успешного завершения выкладки Deployment с канарейкой скейлится в ноль реплик и канарейка выключается, экономя ресурсы кластера.
Тикет-система, Jenkins и релизный бот
Когда feature-ветка слита в master, для неё запускается выполнение пайплайна, который, помимо прочего, создаст следующие задачи в тикет-системе:
LOAD — задача-задание на проведение нагрузочного тестирования;
INT — задачи-задания на проведение приёмочного тестирования на каждом из регионов, в которые возможен деплой;
DEPLOY — задача-задание на выкладку компонента на production.
Созданные в тикет-системе задачи INT и LOAD автоматически подхватят боты отделов интеграционного и нагрузочного тестирования.
Когда задачи INT и LOAD будут завершены, статус задачи DEPLOY сменится с OPEN на WAITING RELEASE.
Когда-то в стародавние времена эта задача падала на дежурного админа, который руками запускал скрипты деплоя. Сейчас его работу взял на себя релизный бот. Он знает ответственных за каждое из приложений и запрашивает у них подтверждение на выкладку, запускает в Jenkins джобу для выкладки, а затем уведомляет о ходе деплоя — по мере прохождения через шаги пайплайна. Сначала разворачивается канарейка, а затем, после подтверждения от ответственных, что всё в порядке, приложение последовательно выкладывается в каждый из наших дата-центров. Если что-то пойдёт не так, ответственные могут дёрнуть ручку отката. Если же что-то случится с самим процессом деплоя, бот уведомит дежурных эксплуатации.
Тестовые окружения
Хочется сделать небольшое отступление про тестовые окружения — они у нас очень крутые. Для каждой команды разработки и части команд эксплуатации есть от одного до восьми тестовых окружений, каждое из которых архитектурно повторяет продакшен: с балансировщиками, кластерами K8s, базами данных и т. д. Поскольку эти тестовые окружения изолированы и независимы друг от друга, разработка и админы могут свободно тестировать изменения, не боясь своими действиями как-то поломать работу коллег. Всего у нас сейчас более 100 тестовых окружений.
У каждой команды свои тестовые схемы.
У каждой команды свой набор компонентов.
Тестовый стенд максимально актуален и отстаёт от боевой среды не больше чем на 24 часа.
Пользователи могут сами создавать/убивать тестовые стенды.
Развёртывание стенда занимает не более двух часов.
Деплойте приложения единообразно и атомарно, делайте канареечные релизы, позволяйте разработчикам не думать о написании манифестов Kubernetes. И да пребудет с вами сила! Буду рад, если статья оказалась полезной: делитесь впечатлениями в комментариях.
anna_lesnykh
Алексей, спасибо вам за статью и упоминание werf. Мы рады, что инструмент вам полезен :)