Использование того или иного продукта в проекте - это всегда попытка найти лучшее решение, балансируя между ограниченным бюджетом, возможностями роста практически по любому сценарию и высотой "порога входа". Существует много продуктов, которые связаны с контейнерами, что выбрать подходящий инструмент становится всё сложнее и сложенее, а community с "промытыми мозгами" и мышлением "ёжиков жрущих кактус" только добавляет сложности (хотя в противовес оным есть хейтеры, которые наоборот обгадят решение, просто потому что в своё время оно им не подошло).
В целом, чтобы выбрать нужное решение чаще всего приходится поднять лабу, которая покроет 90% задач, чтобы понять насколько решение подходящее, а это значит пройти какую-то боль, потратить время и деньги. Но ещё помогают статьи, в которых рассматриваются частные случаи внедрения и подбор инструментов, чтобы научиться на чужих ошибках через объективный взгляд со стороны. Надеюсь, эта статья о том, почему и как мы пришли к решению с werf, поможет кому-то подобрать инструмент для своих нужд.
Дисклеймер
У меня нет цели прорекламировать какой-либо продукт. Это опыт, где-то положительный, а где-то негативный. Лучше всего рассматривать эту статью как частное исследование.
Я понимаю, что "всё можно было сделать по другому", но мне так больше нравится.
Нет, я не получу каких-то плюшек или бонусов от фланта за статью ;) Хотя, who knows...
С чего всё началось?
Поступил заказ на довольно обширный по нашим меркам проект, который нужно было спроектировать и написать так, чтобы начав работу с ним, можно было легко масштабироваться в случае успеха. Т.к. команда не большая, то вариант с микросервисами отпал сразу (да и смысла нет). Поэтому мы выбрали классическую сервисную архитектуру на подходящем нам стеке (Vue.js для фронта и Django Rest Framework для бэка) и разделили подвижные части на 4 основных сервиса: базы, кеши, очереди и S3-хранилища. В целом не много, но docker-compose быстро вырос до 7-10 сервисов + traefik для маршрутизации всего этого безобразия.
Естественно для развёртывания всего этого в production нужно было так же подобрать инструмент, который мог быть и достаточно простым, но в то же время хорошо скейлиться. Изначально, в силу скромных размеров проекта и нежелании плодить новые инструменты, выбор пал на docker swarm. В целом интересный инструмент, который хорошо подошёл бы для определённой ниши (например, если бы мы сидели на django-channels и одной точке входа, кмк). Однако для наших задач и ожидаемого формата масштабирования от данного решения пришлось отказаться.
Жмяк
Вообще масштабирование - это была не основная проблема. Самое обидное было в том, что адекватно перенести тестовое окружение разработчика на деплой в прод вообще было нельзя. Для swarm'а конфиг уж очень сильно отличался. Как итог, все профиты от использования одного инструмента были просто неприменимы. Можно было бы развернуть на каждой машине swarm кластер, но для нескольких нод уже надо было иначе действовать.
Короче говоря, если решение простое, то не всегда правильное. Мы начали об этом думать, когда пришлось прикрутить много костылей, чтобы завести всё это на сварме.
В мире контейнеров не сильно богатый выбор, поэтому решили не обращаться ко всякой экзотике и сразу перейти к эротическим отношениям с kNs (почему N, я расскажу дальше). С точки зрения инструментов, там было всё, что нам нужно, практически "из коробки" (маршрутизация по path'ам, сертификаты, сжатие, кеширование, независимость от расположения контейнера в кластере, мониторинг, автоскейлинг под нагрузкой, пакетный менеджер и т.д.). Если говорить о тестовом окружении, то есть куча готовых инструментов, типа rke и rancher, k3sup, k0s, minikube и т.п.
Почему k3sup
Изначально мы подсели на rke и rancher, но почему-то он оказался сильно прожорливым, поэтому самым оптимальным нашли для себя k3sup. Он достаточно экономичен по ресурсам, лёгкок в деплое на несколько машин и в целом очень приятный продукт.
k0s нужно было деплоить по сути руками, а разработчики по натуре ленивые (в хорошем смысле этого слова). Поэтому, дабы не плодить костыли, решили использовать готовое решение.
Был только один геморой. У них там какая-то бага с ключами RSA, поэтому самым простым решением является использование ecdsa. Чтобы развернуть среду из трёх нод, nginx-ingress и metallb требовалось 15 минут максимум. Если завернуть в скрипт, то минут 5. На скрипт не стали тратить время, потому что тестовый стенд развернуть надо 1 раз, а для локальной отладки можно и миникуб использовать.
Когда мы говорим о кубере, то что первое приходит на ум для развёртывания приложения? Helm и Kustomize наиболее популярные способы деплоя, но они не занимаются сборкой. Пришлось смириться, тем более было принято решение сохранить и поддерживать docker-compose для прям совсем локальной разработки. Остановились на helm, потому что популярнее, да и понравился больше.
Итак, подойдя к этому этапу, что мы имеем?
docker-compose для сборки и локальной отладки.
Helm chart с описанием всех сервисов и доступа к ним.
Отдельная репа, практически готовая для GitOps, с конфигом куба и values чартов.
Автоматизация и борьба с bus-factor
Команда, как я уже сказал, у нас небольшая, с x2 репликацией всего. Соответственно релизным менеджером был просто наиболее компетентный сотрудник. Нужно скейлить на второго, а мы уже знаем, что разработчики люди ленивые, поэтому написали простой скрипт, который будет собирать свежий образ и выкатывать всё на staging с помощью чарта. Скрипт получился довольно сложный, но учитывал всё самое важное:
#!/usr/bin/env bash
HELM_DEPLOY_TIMEOUT=${HELM_DEPLOY_TIMEOUT:-600s}
HELM_VALUES=${HELM_VALUES:-"values.yaml"}
COMPOSE_PATH=${COMPOSE_PATH:-"../project/docker-compose.yml"}
IMAGE_BASE=${CI_REGISTRY_IMAGE:-"registry.example.com/project"}
BACKEND_IMAGE="${IMAGE_BASE}:backend"
FRONTEND_IMAGE="${IMAGE_BASE}:frontent"
CHART_PATH=$(dirname ${COMPOSE_PATH})/chart/
KUBECONFIG=`pwd`/kube_config_cluster.yml
kubectl config set-context default
docker-compose -f $COMPOSE_PATH build --pull --progress plain backend frontend && \
docker tag frontend $FRONTEND_IMAGE && \
docker tag backend $BACKEND_IMAGE && \
docker push $FRONTEND_IMAGE && \
docker push $BACKEND_IMAGE && \
BACKEND_IMAGE=$(docker inspect --format='{{.RepoDigests}}' $BACKEND_IMAGE | sed 's/^\[//g' | sed 's/\]$//g' | awk '{ print $1 }') && \
FRONTEND_IMAGE=$(docker inspect --format='{{.RepoDigests}}' $FRONTEND_IMAGE | sed 's/^\[//g' | sed 's/\]$//g' | awk '{ print $1 }') && \
COMMAND=$(echo helm upgrade project $CHART_PATH \
-f $HELM_VALUES \
--set "project.image_back=${BACKEND_IMAGE}" \
--set "project.image_front=${FRONTEND_IMAGE}" \
--install \
--wait \
--timeout $HELM_DEPLOY_TIMEOUT
) && \
echo $COMMAND && \
helm template project $CHART_PATH \
-f $HELM_VALUES \
--set "project.image_back=${BACKEND_IMAGE}" \
--set "project.image_front=${FRONTEND_IMAGE}" && \
$COMMAND
Весь процесс можно описать следующими тезисами:
Собираем базовые образы. Так уж вышло, что мы собираем 2 образа на все сервисы. Если бы реализовывали сервисы в разных образах, то сэкономили бы всего с десяток килобайт. А так довольно удобно отладкой заниматься.
Ретагаем собранные образы и пушим их в репозиторий. Мы держим только один тег, потому что хранилище не резиновое, а чистить docker registry - та ещё боль. У нас self-hosted Gitlab, поэтому мы стараемся не превращать его в помойку. У них, конечно, есть система автоочистки, но она показалась не слишком гибкой.
Получаем RepoDigests образа и формируем переменные для чарта. Использование RepoDigests оказалось самым удачным решением, потому что так мы убили сразу двух зайцев: перевыкатку подов при изменении образа и привязку к конкретному состоянию кода.
Выводим рендер всего проекта (для отладки) и выкатываем проект.
В целом решение рабочее, трудилось несколько месяцев. Проект мог развернуть любой, но нужно было следить за состоянием ветки на рабочей машине. Попытки перенести это в CI были осторожными и требовали docker в сервисах. Хотелось скорости всего процесса. Более того, хотелось автоматизировать не только staging, но и production. Проще говоря - нужен был инструмент доставки. Что попало под выбор:
FluxCD - kubernetes оператор, который приводит кластер к нужному состоянию. Можно указать некоторое количество git-проектов. Flux не занимается сборкой и работает внутри кластера (или нескольких). В целом всё сводится к тому, что в ConfigMap'е описывается набор переменных, а в чём-то вроде деплоймента описывается то, к какому чарту этот набор применить. Если честно, хотя его и позиционируют как GitOps инструмент, я бы назвал его KubeOps, потому что практически всё содержится в самом кластере, а не в гите.
ArgoCD - по сути это тот же Flux (не в буквальном смысле), но с GUI (довольно красивым, хочу заметить). Хотя имеется cli-утилита, но я так и не увидел в ней каких-либо преимуществ. Арго разворачивает свой набор деплойментов и сервисов внутри кластера, предоставляет веб-морду, в которой можно настроить что, откуда и как развернуть. Если сравнивать с Flux, то у Арго сильно приятнее работа с semver, наличие GUI снижает порог входа, да и в целом продукт выглядит более простым в управлении. Система плагинов добавляет так же плюсов проекту, потому что можно легко реализовать гибридную систему со сборкой (внутри боевого кластера, ага). Единственное, насколько я понял, работа с несколькими кластерами куба проще реализуется во Flux.
Werf - cli утилита (в отличии от предыдущих двух) для сборки и доставки контейнеров. Если сравнивать с предыдущими, то верфь как ansible, а Argo и Flux - как Chef и Puppet. Недавно, они подружились с ArgoCD. Кардинальное отличие от предыдущих двух инструментов в том, что это наверное самый GitOps'ный инструмент из всех, потому что всё, что нужно, верфь берёт из гита и, в каком-то смысле, является stateless (registry не в счёт, потому что там хранятся образы и манифесты на основе гита). Проще говоря, если вы потеряете кластер и docker registry, то с вероятностью 99.9999% вы получите тот же результат при деплое приложения.
Выбирать сложно, но согласитесь, на наш выбор очень часто влияют наши привычки. У меня вот привычка использовать безагентные инструменты. Но, как видно из описания, это не единственное преимущество. Выбор пал на werf, потому как она ещё умеет в сборку образов. Но моё сердце растаяло перед сборкой в режиме buildah. Освободиться от docker была очень привлекательна перспектива.
Внедряем
Для начала нужно сразу обозначить мой подход к внедрению инструментов. Сейчас идёт повальная мода на то, чтобы привязываться к вендору, будь то GCP, AWS или Azure. Я против такого подхода, потому что не раз сталкивался с ситуациями, когда такая привязка заканчивалась плохо. Именно поэтому я уцепился за возможность использовать уже имеющийся чарт и пробросить в него переменные. Но обо всём по порядку.
Сборка
Всё начинается с файла werf.yaml
. Было необходимо описать все образы и их контекст.
configVersion: 1
project: project-deployment
---
image: backend
dockerfile: Dockerfile
context: .
---
image: frontend
dockerfile: Dockerfile
context: frontend
Теперь можно использовать werf build. А ещё и werf compose
, который является обёрткой над docker-compose. Правда пришлось немного подрихтовать сам конфиг, заменив образ на image: ${WERF_BACKEND_DOCKER_IMAGE_NAME:-backend}
(для фронтенда соответственноWERF_FRONTEND_DOCKER_IMAGE_NAME
). Это довольно удобно. Были некоторые проблемы с пересборкой, но оказалось, что у разработчиков неплохой канал связи в телеграмме. Вообще, я успел их изрядно там задолбать...
Субчарт
Итак, со сборкой разобрались, осталось сделать то, ради чего это всё затевалось - доставку приложения. Верфь умеет под капотом работать как helm, притом расширяет его возможности. Можно было пойти простым путём - использовать текущий чарт и двигаться дальше, но пришлось бы многое поменять внутри и заточить под верфь, а это не наш путь. Уверен, что способность использовать нативные инструменты нам ещё пригодится.
Используя документацию, мы создали чарт, в который сразу подтянули некоторые зависимости. В дальнейшем вижу возможность перенести это в основной чарт, а этот использовать как плацдарм для тестирования возможностей. В конечном счёте, werf-chart принял следующий вид:
apiVersion: v2
name: project-deployment
version: 0.1.0
dependencies:
- name: centrifugo
repository: https://centrifugal.github.io/helm-charts
version: 7.4.0
- name: deigmata
repository: file://../chart
version: 0.1.0
export-values:
- parent: global.project
child: project
- parent: werf.image.backend
child: project.image_back
- parent: werf.image.frontend
child: project.image_front
Хочу обратить внимание на export-values
, который позволяет пробрасывать в чарт любые переменные, в том числе из автогенерируемых верфью. Не хватает только циклов и маппинга, чтобы сопоставлять имена образов верфи и чарта, но, как по мне, и так неплохо. export-values
имеют наивысший приоритет, поэтому имено образы никак не перегрузить, что хорошо и исключает возможность нанести вред неосторожными перегрузками.
Кстати, с этим тоже возникли проблемы после обновления с 1.2.80 на 1.2.109, но ребята довольно быстро отреагировали в чате, и уже на следующий день всё работало как часы (не швейцарские конечно, но хорошие такие отечественные с качественным сервисом и душевными мастерами). Вообще, очень хочу похвалить их за терпение и оперативность, потому что некоторые моменты, которые были просто непонятны из документации, они довольно хорошо донесли, а если у них не получалось что-то воспроизвести, то они не просто кидали "у нас всё работает", а по прежнему пытались выяснить, что не так.
Есть одна немного раздражающая особенность у werf, которая называется гитерменизм. Всё, что используется для сборки или доставки, должно жить внутри Git, но это не всегда удобно. Конкретно в нашем случае, было бы удобнее прокидывать некоторые переменные в чарт, тем самым менять настройки в самом Gitlab, а не коммитить все изменения в репозиторий. Если для staging окружения ещё нормально, то для production - уже геморой. Единственное, что верфь "кушает" без проблем - это KUBECONFIG
. Для того, чтобы обходить эти ограничения, существует целый конфиг - werf-giterminism.yaml. Поэтому, если собираетесь делать проект переиспользуемым, то советую обратить внимание на то, как будете менять конфигурацию развёртываемого приложения, и учесь эту особенность в гитерминизме.
Доставка
Переходя непосредственно к доставке, я рассматриваю конкретно наш случай с Gitlab. В документации довольно хороший пример того, как надо пользоваться CI, в том числе для разных систем вроде Gitlab, Github и универсальный подход. В целом, они приводят хорошие примеры как для production/staging окружений, так и для Review.
Лично я пришёл к следующему конфигу (поясню некоторые моменты ниже):
.release_env_jobs: &release_template
stage: release
image: registry.werf.io/werf/werf
environment:
name: "$CI_COMMIT_BRANCH"
url: "$ENDPOINT"
before_script:
- type werf && source $(werf ci-env gitlab --as-file)
- werf version
ReleaseToProd:
<<: *release_template
script:
- werf helm repo add centrifugo https://centrifugal.github.io/helm-charts
- werf helm dependency build .helm
- werf build -p
- werf converge --skip-build --set "env_url=$(echo ${CI_ENVIRONMENT_URL} | cut -d / -f 3)"
rules:
- if: '$KUBECONFIG && $CI_PIPELINE_SOURCE != "schedule"'
when: manual
- when: never
Cleanup:
<<: *release_template
stage: cleanup
environment:
name: "$CI_COMMIT_BRANCH"
url: "$ENDPOINT"
action: access
script:
- werf cr login -u nobody -p ${WERF_IMAGES_CLEANUP_PASSWORD} ${WERF_REPO}
- werf cleanup
rules:
- if: '$KUBECONFIG && $CI_PIPELINE_SOURCE == "schedule"'
when: on_success
- when: never
Я отказался от многослойной сборки и доставки и остановился на одном ReleaseToProd (слитно для дальнейшего использования в ChatOps гитлаба). После какой-то проблемы, я решил, что использование werf version
- это обязательный элемент, который прям на уровне документации надо прописать.
К сожалению, токены, предоставляемые Gitlab CI, не позволяют удалять ничего из registry, поэтому приходится использовать костыль в виде WERF_IMAGES_CLEANUP_PASSWORD
, который по сути personal token с правами на насилие над registry. Не сказать, чтобы это было изящным решением, но позволяет контролировать процесс очистки. Для self-hosted можно прописать на уровне всего инстанса (но с осторожностью, осознавая все последствия).
Пример того, как выглядит pipeline job, можно посмотреть здесь. Там только сборка и рендер чарта, но выглядит очень красиво (по моему скромному мнению). Во время выката всего в кластер, верфь выводит периодически статус подов, сгруппированных по деплойментам, и, если есть, выводит логи этих самых подов. В целом это избавляет от необходимости лезть в Lens, чтобы смотреть, что же там сломалось.
Итог
Подводя итог, хочу заметить, что я не считаю этот инструмент чем-то "вау!" или "самым лучшим". Думаю, что по моим слегка язвительным высказываниям о некоторых особенностях проекта, можно заметить отсутствие эмоциональной любви к продукту. Однако, в целом проект крут тем, что использует безагентную систему доставки и сборки, и я бы его рекомендовал там, где много сервисов и даже микросервисов, а кластер эфемерная сущность, которая сегодня есть, а завтра нет.
Очень удобно, что можно описать несколько образов в одном конфиге, а потом просто прокинуть их в свой чарт как переменные. Наверное, если заморочиться, то можно прикрутить что-то вроде этого, для деплоя огромного количества сервисов.
Несмотря на раздражающий гитерминизм, можно с помощью некоторого набора переменных окружения клонировать проект и разворачивать его как продукт, что очень удобно. Сборка, хоть и имеет некоторые проблемы с кешированием слоёв, даёт предсказуемые результаты и не требует дополнительных сервисов (по крайней мере если использовать их образ для сборки).
Надеюсь, наш опыт поможет более объективно взглянуть на выбор инструментов доставки приложения в кластер куба, взвешивая все преимущества и недостатки верфи.