В этой статье я хотел бы рассказать о том, как мы меняли подход к оркестрации на нашем стартап-проекте, зачем мы это делали и какие проблемы по дороге решали. Претендовать на уникальность эта статья вряд ли может, но все же думаю, что она может быть кому-то полезна, так как в процессе решения задачи материал собирался нами с приличным скрипом.
Что мы имели и о чем вообще речь?
А имели мы стартап-проект с примерно 2-летней историей разработки из advertisement области. Проект изначально строился как микросервисный, и серверная его часть написана на Symfony + немного Laravel, Django и нативного NodeJs. Сервисы представляют из себя в основном API для мобильных клиентов (их в проекте 3) и нашего собственного SDK для IOS (встраивается в приложения наших кастомеров), а также веб-интерфейсы и разные дашборды этих самых кастомеров. Все сервисы были изначально докеризированы и работали под управлением docker-compose.
Правда, docker-compose использовался не везде, а только в локальном окружении у разработчиков, на тестовом сервере и внутри pipeline при сборке и тестировании сервисов. А вот в production окружении использовался Google Kubernetes Engine (GKE). Причем настройку GKE на старте проекта мы делали полностью через его web-интерфейс, что было довольно быстро и, как нам тогда казалось, удобно. Автоматизирован тут был только процесс сборки docker images для запуска сервисов в GKE.
В определенный момент возникла необходимость сделать sandbox окружение, в котором наши кастомеры могли бы пользоваться системой в ознакомительном режиме, отлаживать интеграцию своих систем и приложений с нашей системой, а после этого как-то бесшовно переключаться в production. Мы решили, что хорошей идеей будет сделать копию кластера, которая будет обновляться из тех же самых docker images, что и production, но будет иметь отдельную конфигурацию и отдельный набор баз данных (мы использовали Cloud SQL и Cloud Memory в production и локальные контейнеры с MySQL и Redis в остальных окружениях). Сказано - сделано. Но для того, чтобы вручную развернуть и настроить копию кластера, у меня ушло около 8 часов и очевидно, что любые изменения (например, добавление новой переменной окружения у какого-нибудь из сервисов) теперь нужно было дублировать в 2 местах. Также не забываем про docker-compose в других окружениях, где использовались .env файлы для передачи переменных окружения в контейнеры. Как и многие программисты, я ленив, и стало очевидно, что ситуация движется в неприятном для меня направлении. Проблему надо было решать.
Что хотелось получить?
Ну тут все было в целом понятно. Хотелось, чтобы не было дублирования и любые изменения в конфигурации могли быть внесены в некий файл после чего CI (в нашем случае Cloud Build) применил бы их к нужному окружению. Причем сделал бы это единообразно для всех окружений. Очевидно, что один из подходов к оркестрации должен был уйти. В prod окружении проект должен был иметь возможность автоматического масштабирования для реакции на изменение нагрузки, обновление сервисов с нулевым downtime… ну вы поняли. Оставалось сформулировать задачи и приступать. Если в production/sandbox окружении мы использовали непосредственно GKE, то для запуска проекта локально и для тестирования мы решили использовать Minikube.
Как быть с конфигурацией?
Конфигурация Kubernetes кластеров описывается в yaml файлах и при помощи kubectl применяется. Это очень удобно, так как ее можно положить в git репозиторий и версионировать, а CI-сервер сможет без проблем применять ее к нужному окружению или даже к нескольким окружениям.
Также очень удобно все получалось с переменными окружения, которые должны были передаваться в контейнеры при старте. Переменные окружения вносились в ConfigMap объекты и при описании Deployment объектов использовались только ссылки на них, чтобы позволяло избежать дублирования. Дублирование же могло возникать по двум причинам. Во-первых, некоторые переменные нужны сразу нескольким сервисам. Например, параметры соединения с СУБД или сервером очередей (в нашем случае Beanstalkd). Во-вторых, некоторые сервисы состоят из группы контейнеров, которые запускаются на базе кода одного и того же приложения, но выполняют разные задачи или делят задачи между собой. Например, контейнер с API, который принимает аналитические данные от мобильного приложения и отправляет их в очередь и воркеры, которые уже асинхронно эти данные обрабатывают и складывают в базу или выполняют какую-то другую более медленную с ними работу. Объекты ConfigMap отлично устранили дублирование в переменных окружения. Получилось как-то так:
![Список конфигурационных объектов в интерфейсе Google Cloud Список конфигурационных объектов в интерфейсе Google Cloud](https://habrastorage.org/getpro/habr/upload_files/130/f3b/a62/130f3ba6280b4e9dead1f6177679180a.jpg)
Все общие переменные были вынесены в common-config-map, а переменные специфичные для сервиса в соответствующий <service-name>-config-map. Правда возникла трудность с частью переменных окружения, которая содержала информацию, которую класть в репозиторий не стоит. Например, пароли, сертификаты, токены внешних API и тд. Тут задача решается при помощи Secret объектов (они тоже есть на скриншоте). Вот только как они должны попадать на машины разработчиков и в новые кластеры при первом развертывании (или в случае изменения)? Честно говоря, с этим мы ничего пока не придумали лучшего чем просто передавать их в виде zip архива с папками. То есть архив с секретами просто распаковывается поверх папки с остальной конфигурацией дополняя ее. Вероятно, можно использовать некое централизованное хранилище с секретами (например, Google Secret Manager), из которого все окружения будут скачивать/обновлять свои локальные копии секретов через его API. Вот так выглядит конфигурация и структура каталогов с ней:
![](https://habrastorage.org/getpro/habr/upload_files/316/4cf/3be/3164cf3be6553ae22a3ce034221f71eb.jpg)
Неочевидная трудность
Однако, далее возникли неочевидные (как минимум для меня) трудности. Выяснилось, что Kubernetes не заботится о том, как именно конфигурация будет получена. Другими словами, у него нет средств для повторного использования частей конфигурации, ее наследования от базовой конфигурации или частичного переопределения для разных окружений. Для решения этих задач существуют различные сторонние решения. Одним из первых и, возможно, наиболее мощным из таковых является Helm – это пакетный менеджер для Kubernetes, который позволяет как автоматизировать генерацию конфигурации для kubectl так и создавать пакеты конфигурации, которые сразу настраивают все, что необходимо для запуска приложения в кластере. Однако, мы пошли своей дорогой и выбрали kustomize + skaffold (про него ниже отдельно). Причины на то были следующие: во-первых, kustomize начиная с версии kubectl 1.14 имеет нативную поддержку и чтобы выполнить генерацию конфигурации нужно лишь добавить флаг -k при вызове kubectl apply. Во-вторых, функциональность именно пакетного менеджера нам была не нужна и выбор kustomize выглядел более логичным.
Выбор сделан, что дальше?
Дальше мы начали описывать правила генерации нашей конфигурации. Ключевая концепция kustomize – это создание конфигурации по принципу слоев, когда у нас есть основная (базовая) конфигурация и для каждого окружения она адаптируется по средством наложения патчей. Патчи – это отдельные файлы, которые по имени находят Kubernetes объект и меняют его поля. Вот так выглядит, например, наша иерархия слоев. Тут видно, что есть каталог «common» с общей конфигурацией и каталоги для конфигурации окружений, в которых есть патчи и файл «kustomization.yaml», задача которого собрать всю конфигурацию заданного окружения воедино. Сразу бросается в глаза небольшое неудобство: нельзя указывать файлы, используя glob формат (во всяком случае на момент написания статьи, а дискуссии об этом ведутся еще с 2018 года) из-за чего приходится перечислять все конфигурационные файлы явно.
![](https://habrastorage.org/getpro/habr/upload_files/fda/700/b37/fda700b37b9ef8489bea6f2afec037d8.jpg)
Патчи хорошо подходят для модификации единичных объектов, например, для изменения ConfigMap или Secret объектов в разных окружениях. Или для изменения единичных Deployment объектов. Например, вот так мы в staging окружении добавляем к Deployment объекту дополнительные initContainers, чтобы он ждал пока запустятся и начнут принимать соединения локальные Deployment объекты с MySQL и Redis:
![](https://habrastorage.org/getpro/habr/upload_files/b59/722/d98/b59722d98d2c66a85e4edc9d255a684c.jpg)
Однако, они могут модифицировать только единичные объекты, которые ищутся по совпадению имен. При этом вариант поиска по селектору не поддерживается (во всяком случае на момент написания статьи). Другими словами, если у вас есть 10 Deployment объектов и всем им нужно добавить одинаковые initContainers придется делать 10 патчей.
Частично решается эта проблема при помощи механизма replacements. Фактически с его помощью можно описать какие поля в каких объектах должны быть заменены. Основное отличие от патчей тут в том, что можно заменить указанные поля сразу у группы объектов. Однако, использовать селекторы для выборки объектов все равно нельзя (я опускаю то, что там есть reject позволяющий инвертировать выборку). Другими словами, для каждой замены нужно делать отдельный replacement. Вот так у нас выглядит указание переменных окружения для CronJob объектов по средством replacements:
![Пример общей конфигурации CronJob объекта Пример общей конфигурации CronJob объекта](https://habrastorage.org/getpro/habr/upload_files/d40/7c0/d18/d407c0d18ebc21c4b524351023954d7c.jpg)
![Пример копирования переменных окружения из объекта шаблона в остальные CronJob объекты сервиса Пример копирования переменных окружения из объекта шаблона в остальные CronJob объекты сервиса](https://habrastorage.org/getpro/habr/upload_files/6a4/192/552/6a41925528a21110b5179e32f646993d.jpg)
Тут переменные окружения копируются из специально созданного в качестве шаблона CronJob объекта «template» во все остальные CronJob объекты того же сервиса системы. Но перечислять все CronJob объекты, в которые нужно выполнить копирование приходится вручную, а их у нас более 30 и таких замен выполняется по несколько для каждого. При этом для замена docker image в разных окружениях kustomize даже поддерживает специальную опцию images.
Хорошо, со сборкой конфигурации понятно, а что со сборкой образов?
Во всех окружениях, кроме локального, сервисы запускаются на базе docker images, которые были предварительно собраны CI-сервером и загружены в приватный docker registry. В нашем случае это Google Cloud Container Registry (внезапно). В локальном окружении при разработке это не очень удобно, так как код приложения все равно в локальном окружении монтируется внутрь образа с помощью volumes (а не записывается внутрь образа) и сами изменения могут происходить как в коде приложения, так и в dockerfile и конфигурации контейнера. И если измененный код приложения сразу будет интерпретирован заново, и разработчик сразу увидит внесенные изменения[1], то как быть с docker image? В docker-compose с этим все просто – при описании контейнера можно указать путь к dockerfile и при запуске будет собран и локально сохранен docker image, который будет автоматически пересобран если в нем или его зависимостях произошли изменения. Конфигурация Kubernetes же требует указания именно готового образа, что логично и понятно так как заботится о сборке образов не его ответственность, да и Docker не единственная технология контейнеризации. Вот тут на сцену выходит skaffold, о котором я упоминал выше. Его назначение – обеспечение удобного процесса разработки приложений, использующих Kubernetes благодаря автоматизации сборки и обновления образов приложений. Разработан и поддерживается он также Google, так что искать альтернативы мы толком не стали. Возможность автоматизации обновления образов на внешних серверах нас мало интересовало, так как это было уже реализовано на базе Cloud Build, но вот возможность локальной сборки образов и отслеживания изменений в них было именно то, что нужно. Конфигурация локального окружения на базе skaffold получилась следующая:
![](https://habrastorage.org/getpro/habr/upload_files/d12/1cc/e01/d121cce01812bd2ba08661b0eb855d09.jpg)
То есть при запуске проекта локально, после старта Minikube, выполняется вызов:
$ skaffold dev
В результате чего skaffold проверяет наличие собранных образов в локальном кеше (если образа нет или не совпала хеш-сумма, то выполняет сборку с сохранением в кеш), собирает конфигурацию при помощи kustomize (с которым skaffold умеет работать из коробки) и передает ее kubectl для применения к кластеру. Остается только пропатчить конфигурацию всех workload объектов, чтобы выполнить монтирование volumes и заменить образы:
![](https://habrastorage.org/getpro/habr/upload_files/49d/33d/77f/49d33d77f92e979af235c917c7681fe3.jpg)
Правда, тут еще есть важный нюанс с imagePullPolicy, который должен быть выставлен в IfNotPresent или Never, так как иначе Docker будет пытаться скачать его из dockerhub вместо использования локального кеша, созданного skaffold.
А еще у нас есть сron задачи, что с ними?
А с ними произошли определенные метаморфозы. Дело в том, что под управлением docker-compose нет отдельного механизма для их реализации и можно найти в Интернете много различных реализаций от запуска настоящего cron процесса в foreground до самодельных скриптов (как у нас).
Мы для docker-compose использовали реализацию с контейнером, в качестве entrypoint которого использовался shell скрипт с бесконечным циклом. Внутри цикла был вызов нужной команды (синхронно) и sleep на заданный интервал времени. Такая реализация себя неплохо оправдывала на практике (хотя из-за синхронности вызова и не гарантировала точное соблюдение временных интервалов) и в целом соответствовала Docker best practice, в той ее части, что внутри контейнера должен быть только один рабочий процесс, жизненный цикл которого, собственно, и будет отслеживаться (именно поэтому вызов и делался синхронно). Конфигурация была крайне проста:
![Пример конфигурации cron-контейнера в docker-compose.yaml Пример конфигурации cron-контейнера в docker-compose.yaml](https://habrastorage.org/getpro/habr/upload_files/790/cac/024/790cac02452d54beb17aaec3f21dbd45.jpg)
А вот сам код entrypoint:
#!/usr/bin/env sh
while :
do
/var/www/app/bin/console $1
sleep $2
done
А вот в Kubernetes механизм запуска cron задач есть и представлен отдельным объектом CronJob. Разумеется, идея тянуть за собой старую реализацию, которая являлась явным костылем, никого не прельщала и было принято решение переписать конфигурацию cron задач с нуля, чтобы использовать родной для Kubernetes механизм. Он оказался немного замысловат для первого осознания, так как представляет собой порождение целой цепочки объектов, работа которых в конечном итоге все равно приводит к созданию pod объекта, внутри которого выполняется указанная команда. Но в целом все довольно логично, а главное очень конфигурируемо. Можно настраивать множество различных параметров от периодичности запуска (опять неожиданно) до правил конкурентности запуска и числа сохраняемый отработанных контейнеров (для отладки). Общая конфигурация CronJob получилась вот такая:
![Пример конфигурации CronJob объекта для локального окружения разработки Пример конфигурации CronJob объекта для локального окружения разработки](https://habrastorage.org/getpro/habr/upload_files/b10/aab/04e/b10aab04ea240a1b82f2b100bbb44abc.jpg)
Правда, после тестирования на Minikube меня ждала еще одна «неочевидная трудность». При попытке применить новую конфигурацию на GKE ничего не вышло. Дело оказалось в том, что версия GKE несколько отстает от актуальной версии Kubernetes. На тот момент у нас версия кластера была 1.20.10, а поддержка CronJob объектов в GKE появилась с версии 1.21. Беспокойства добавило и то, что наши кластеры работали в недавно появившемся у Google режиме GKE Autopilot, а изменить версию для autopilot кластера невозможно - он обновляется полностью автоматически на основании установленного для кластера параметра «Release channel». У нас был выбран «Regular channel», который на тот момент имел основную версию 1.20.10. Версия 1.21.4 в «Regular channel» уже тоже была доступна (с 01.10.2021), но кластер сам до нее не обновлялся. Версия 1.21 должна стать основной для «Regular channel» только в декабре 2021 года. Как показали дальнейшие тесты основная проблема была даже не в самой версии. При создании нового кластера в autopilot mode с достаточной версией CronJob объекты все равно не создавались и кластер отвечал:
…no kind "CronJob" is registered for version "batch/v1" in scheme "pkg/runtime/scheme.go:100"
Что в свою очередь прямо противоречило документации GKE. Хотели даже пересоздавать кластеры и отказываться от autopilot mode (так как для новосозданного кластера в manual mode объекты создавались и работали нормально). Но в конечном итоге оказалось, что можно просто использовать вместо "batch/v1" более старый вариант Kubernetes API "batch/v1beta1". Главное теперь не забыть заменить его обратно, когда Google будет обновлять минорную версию GKE и отключать поддержку всего, что deprecated, дабы каким-нибудь зимним утром не было мучительно больно.
И что в результате?
А в результате мы полностью перешли на Kubernetes оркестрацию как в промышленных, так и локальных окружениях. Конфигурация всех окружений стала единообразной, хотя я не уверен, что она стала проще и понятнее для команды. Пока сложно сказать, что это нам даст в будущем, но надеюсь, что проделанная работа сократит издержки на поддержание инфраструктуры и работу с ней в дальнейшем. Буду рад комментариям и вопросам :)
[1] Напоминаю, что все сервисы в проекте написаны на интерпретируемых языках (PHP7, NodeJs, Python). Для сервисов на Java или .Net тут, конечно, может быть своя специфика.