
Всем привет! Я Алексей Босенко, DevOps-инженер в компании KTS. В этой статье я покажу, как комплексно настроить быструю и эффективную сборку проектов в Kubernetes с использованием BuildKit, которая учитывает не только производительность, но и стоимость ресурсов.
Под этой громкой фразой я подразумеваю целый комплекс решений: как создать и настроить экономичный кластер Kubernetes для сборок (ведь цена вопроса всегда важна), как настроить GitLab Runners и как сделать эффективное масштабирование сборок. Особый акцент будет на том, почему мы выбрали BuildKit, какие варианты использования он предлагает, и как непосредственно настроить один из них.
Будет много подробностей о том, почему мы принимали эти решения и как внедряли их у себя, так что статью можно использовать в качестве Production-ready-мануала.
Оглавление
Создаем базу — кластер Kubernetes
Для начала создадим базу. Весь код сюда нет смысла выкладывать, так что я просто оставлю ссылку на репозиторий с очень хорошо прокомментированным кодом, разобраться в котором не составит труда. В статье я буду останавливаться только на ключевых моментах и объяснять их.
Мы создадим кластер на Yandex Cloud — одной из наиболее популярных платформ в российском сегменте. Подробно расписывать каждый шаг тоже не буду, благо у Яндекса хватает документации. Перейдем к важным моментам в плане цены и эффективности.
-
Для обеспечения выхода нод в интернет мы выбрали Yandex Managed NAT Gateway. Он дешевле, чем instance compute.
resource "yandex_vpc_gateway" "nat_gateway" {...}. В разделе
yandex_kubernetes_clusterвсе стандартно, перейдем кyandex_kubernetes_node_group. Стоит обратить внимание на типы дисков и их стоимость. Нам нужна большая скорость, и для этого подходят SSD IO (NVMe) и нереплицируемые SSD. Нам репликация для сборок не нужна, так что мы выбрали второй вариант — он дешевле.


Мы выбрали размер 186 ГБ (он должен быть кратным 93 ГБ), чтобы удвоить скорость относительно минимальной, и чтобы ее хватало для 5–10 одновременных джобов.
Процессора в 8vCPU и памяти в 16 ГБ RAM нам хватало для запуска 5–7 параллельных сборок в зависимости от их объема. Позже мы увеличили объем памяти до 32Гб RAM, т.к. очень часто количество одновременных сборок стало достигать 10-20
Опция preemptible — пожалуй, самая важная для экономии средств среди всех пунктов (сокращает стоимость до 60 %). Она отвечает за прерываемость нод. В переводе на русский язык это значит, что нода может быть остановлена облаком в любой момент со всеми вытекающими последствиями.
На практике на момент написания статьи мы наблюдаем 1–2 прерывания в неделю во время сборок. Для нас это некритично. На скриншоте выше видна примерная разница в стоимости с выключенной и включенной опцией preemptible.
Пришлось проинструктировать разработчиков, что им нужно просто перезапускать джобу при получении ошибки следующего вида:
ERROR: Job failed (system failure): pod "gitlab-runner/runner-t1sfz-project-current-0-6dukiluy" is disrupted: reason "TerminationByKubelet", message "Pod was terminated in response to imminent node shutdown."
Стоит упомянуть, что по умолчанию нода после остановки не восстанавливается сама, но это легко поправить опцией auto_repair = true в maintenance_policy — облако следит за нодой, и в случае ее потери/падения создает ее заново.
-
Политика автоскейлинга нод
scale_policy. Здесь все просто:указываем, что на старте у нас будет одна нода (
initial = 1);минимальное количество нод всегда должно быть равным единице (
min = 1);максимально ноды могут скейлится до трех (
max = 3).
Опциональные пункты —
taints/tolerationsиNodeSelector. Их стоит использовать, когда нужно добавить еще одну или более группу нод для других целей.taints/tolerationsнужен для того, чтобы поды с приложениями не попадали на узлы с раннерами, аNodeSelector— чтобы поды с раннерами попадали только на узлы, предназначенные для раннеров.
Сразуtaintsмы указывать не будем, т.к. на практике оказалось, что с ними системные поды не могут запланироваться на нашу ноду, хоть документация Яндекса и утверждает обратное.
Итого, все наши ключевые требования выглядят в конфигурации следующим образом:
resource "yandex_kubernetes_node_group" "node-group-runner" {
...
instance_template {
...
boot_disk {
type = "network-ssd-nonreplicated"
size = 186
}
scheduling_policy {
preemptible = true
}
maintenance_policy {
auto_repair = true
}
scale_policy {
auto_scale {
initial = 1
max = 3
min = 1
}
}
node_taints = [
"node-purpose=runner:NoSchedule"
]
node_labels = {
"node-purpose" = "runners"
}
...
}
}
Для раскатки ресурсов также нужно создать в директории с инфраструктурой файлы terraform.tfvars с переменными token и cloud_id и экспортировать переменные окружения YC_CLOUD_ID и YC_TOKEN. Все то же самое проделываем в директории с чартами и манифестами, только дополнительно экспортируем переменную окружения YC_FOLDER_ID после раскатки кластера.
GitLab Runners
Сильно углубляться не будем, т.к. все конфиги откомментированы в репозитории. Остановимся только на ключевых моментах.
Ресурсы
Нет смысла выделять большие ресурсы основному Runner-поду, который контролирует весь процесс. Ему мы даем по минимуму, ограничиваясь 256 Mi памяти и 0,4 ядра.
А вот на поды с джобами скупиться не стоит. По нашему опыту, в среднем для yarn-подобных и Python-проектов необходимо 2 ядра и 4 ГБ памяти, поэтому мы сразу выбираем эти честные значения и для реквестов и лимитов, чтобы между джобами не было борьбы за ресурсы.
Также стоит дать программистам возможность влиять на ресурсы для сборок, ведь им приходится работать и над объемными проектами с большим количеством зависимостей. Этот механизм включается путем добавления параметров, которые отвечают за ограничения ручных изменений ресурсов через пайплайн. Ограничения в виде 4 ядер и 8 ГБ памяти для нас более чем приемлемы — этих ресурсов хватит для самых сложных сборок.
runners:
config: |
[[runners]]
...
[runners.kubernetes]
# Значения ресурсов для подов с джобами по умолчанию
cpu_request = "2"
cpu_limit = "2"
memory_request = "4Gi"
memory_limit = "4Gi"
# Максимально допустимые значения для перезаписи лимит ресурсов через пайплайн
cpu_limit_overwrite_max_allowed = "4"
cpu_request_overwrite_max_allowed = "4"
memory_limit_overwrite_max_allowed = "8Gi"
memory_request_overwrite_max_allowed = "8Gi"
...
Для самых простых сборок программисты или девопсы могут добавить в пайплайне в разделе variables: ресурсы с меньшими значениями в целях экономии:
variables:
KUBERNETES_CPU_REQUEST: "1.4"
KUBERNETES_CPU_LIMIT: "2"
KUBERNETES_MEMORY_REQUEST: "1900Mi"
KUBERNETES_MEMORY_LIMIT: "3Gi"
В конце статьи я также приложу более подробную инструкцию, которую мы предоставляем нашим программистам.
Overprovisioning
Небольшое уточнение. Чтобы не ждать, пока развернется новая нода, когда ресурсы на первой закончатся, мы применяем манифест с подом, который имеет очень низкий приоритет.
Под запрашивает ресурсы (к примеру, 2 CPU и 2 ГБ памяти). Если эти ресурсы хочет занять уже под с джобой, т.е. с полезной нагрузкой, то джоба получает эти ресурсы, а под overprovisioning вытесняется из-за низкого класса обслуживания и пытается переехать на другую ноду.
Это действие и заставляет запустить новую ноду в группе, фактически резервируя место для реальных подов с джобами. Когда большая нагрузка прекращаяется и поды уходят с дополнительных нод, количество этих нод постепенно снижается автоматически.
s3 cache
GitLab Runners могут использовать разные виды кэшей: registry, локальный и s3-кэш. Последний мы можем использовать, например, для того, чтобы сохранять все скачиваемые пакеты, а не перекачивать их постоянно при каждой повторной джобе. Мы можем также сохранить нашу директорию node_modules в кэш и в дальнейшем подгружать уже готовую.
Использование такого кэша в сборках — достаточно спорный вопрос, т.к. время, затрачиваемое на загрузку сохраненного кэша и его подготовку, может быть больше, чем время без его использования. Но и эту особенность можно обратить в пользу, например, если использовать кэш в линте или сборке.
Касаемо сборки: немного забегая вперед скажу, что при использовании BuildKit все слои очень хорошо кэшируются, и с ним нам уже не нужен кэш от раннера.
Вернемся к нашему s3-кэшу и посмотрим пример того, как его можно задействовать. Указываем настройки подключения к s3-бакету (или minio, если у вас он есть):
...
[runners.cache]
Type = "s3"
Path = ""
Shared = true
[runners.cache.s3]
ServerAddress = "storage.yandexcloud.net"
AccessKey = "asdfasdfasdfadfasdf"
SecretKey = "asdfasdfasdfasdfadfsdfsdfasdfasdfa"
BucketLocation = "ru-central1"
BucketName = "runners-cache"
Insecure = false
...
Рассмотрим на примере простых джоб с использованием yarn install:
stages:
- install
- lint
install:
stage: install
image: ${IMAGE_PREFIX}${NODE_IMAGE}
cache:
key:
files:
- yarn.lock
paths:
- node_modules/
- .yarn/
script:
- yarn install
.base-cache-pull:
cache:
policy: pull
key:
files:
- yarn.lock
paths:
- node_modules/
- .yarn/
before_script:
- if [ ! -d "node_modules" ]; then echo "node_modules not found, running yarn"; yarn;
- else echo "node_modules found, skipping yarn install"; fi
lint:
extends: .base-cache-pull
stage: lint
image: ${IMAGE_PREFIX}${NODE_IMAGE}
cache:
policy: pull
key:
files:
- yarn.lock
paths:
- node_modules/
- .yarn/
script:
- yarn check-quality
Здесь files: yarn.lock — своего рода ключ, по нему можно определить валидность кэша. Если он не изменился с прошлого раза, то кэш можно использовать; в ином случае кэш инвалидируется и создается заново.
Сборка с BuildKit
BuildKit — это современный движок для сборки контейнеров от Docker и Moby-проектов. Он заменяет старый механизм docker build, давая больше контроля, производительности и возможностей кастомизации.
Почему BuildKit
После глубокого и продолжительного рисерча всех актуальных инструментов сборок мы решили остановиться на dockerfile-ориентированных. Если бы мы начали изобретать какой-то новый флоу, пусть даже более гибкий и трендовый, это стало бы выстрелом себе в ногу. Сейчас наш DevOps-юнит и так тратит немало времени на починку docker-файлов для программистов, а с новыми возможностями мы просто утонули бы в этих задачах.
BuildKit тоже имеет расширенный флоу в docker-файлах, но он их понимает в нативном чистом виде, т.е. программисты не вынуждены лезть за пределы синтаксиса dockerfile. Если же у вашей команды есть время и желание экспериментировать, то рекомендую обратить внимание на Earthly — инструмент под с BuildKit под капотом, обладающий еще более внушительным списком апгрейдов и возможностей. Звезд на GitHub у них примерно одинаково. Возможно, мы в будущем тоже решимся его попробовать. Однако пока мы выбрали BuildKit, так что сейчас перейдем непосредственно к нему.
Подробной информации о BuildKit хватает в открытых источниках, поэтому я кратко опишу только основные фишки.
Параллельная сборка слоёв: BuildKit анализирует зависимости между шагами (RUN, COPY, ADD) и выполняет независимые операции одновременно. Это заметно ускоряет процесс сборки, особенно в крупных проектах.
Эффективное кэширование: кэш работает на уровне графа зависимостей, а не отдельных слоёв. Он может храниться локально, в s3 или в registry.
Инкрементальные сборки: пересобираются только измененные части проекта.
Секреты в сборке (
--secret): позволяют передавать ключи и пароли без сохранения в истории образа.Rootless-сборка: снижает риски безопасности.
Изоляция через namespaces: улучшает изоляцию процессов сборки. На практике BuildKit успешно собирает даже очень большие проекты, которые не всегда удаётся собрать с помощью Docker или Kaniko.
Мультиплатформенность: поддержка arm64, amd64 и других архитектур.
Расширяемость: возможность подключать внешние компоненты (например, для работы с SSH).
Экономия ресурсов: меньше нагрузки на систему по сравнению с Docker Build.
Оптимизация образов: улучшенное управление слоями и автоматическое удаление промежуточных файлов уменьшают размер итогового образа.
Нативная сборка в Kubernetes без Docker: работает напрямую через containerd, обеспечивая лучшую совместимость и производительность. Сборка возможна даже без buildx.
Инструменты: CLI-плагин для Docker, управляющий билдерами BuildKit, и утилита buildctl — нативный клиент, работающий напрямую без оберток.
Выбор архитектуры
Варианты использования buildkit в Kubernetes
BuildKit можно развернуть в Kubernetes тремя способами: с помощью Deployment, StatefulSet или джобы.
В случаях с Deployment и StatefulSet запускается один большой по ресурсам под — долгоживущий сервис, внутри которого выполняются все сборки. В этой архитектуре по команде buildctl --addr tcp://buildkitd:1234 build джоба подключается к поду с BuildKit через сервис и инициирует сборку.
У StatefulSet есть определенные преимущества по сравнению с Deployment, например, возможность использования локального кэша. Однако оба подхода имеют большой минус — отсутствие гибкости в управлении ресурсами. Приходится заранее выделять много CPU и памяти для пода с BuildKit, даже если они используются не постоянно.
Вариант с джобой решает эту проблему: для каждой сборки можно задавать собственные лимиты ресурсов и более рационально утилизировать их.
Подытожим сравнительной таблицей возможностей:
Deployment |
StatefulSet |
Job |
|
Rootless |
✅ |
✅ |
✅ |
Daemonless |
✅ |
||
Remote Layer Caches |
✅ |
✅ |
✅ |
Local Layers Caches |
✅ |
✅ |
|
Mount Caches |
✅ |
✅ |
|
Dynamic Resources |
✅ |
Для нас выбор варианта с джобой оказался очевидным: сборок у нас много, и при необходимости нам важно задействовать ресурсы всех нод. При этом мы решили не создавать постоянную сущность Job в кластере, а использовать сам BuildKit в качестве образа для GitLab Runner. Все необходимые действия выполняются прямо внутри него, и это дополнительно экономит наши ресурсы.
Варианты использования кеша
У BuildKit есть свой кэш промежуточных слоев, который задается в команде сборки, а также кэш зависимостей, указываемый в Dockerfile (подробнее о нем можно прочитать в официальной документации). Для наших задач пока достаточно кэша слоев — на нем и остановлюсь подробнее.
В качестве хранилища для кэша промежуточных слоев можно использовать локальный кэш, s3/MinIO или registry. Если использовать hostPath-ноды, то вероятность попасть в кэш стремится к нулю: ноды у нас часто создаются и схлопываются. Более того, использование подом хранилища ноды может повлиять на downscale узлов нодгруппы (при желании можете почитать документацию cluster autoscaler).
Теоретически можно организовать кэш через PVC на базе NFS и подключать его ко всем нодам как локальный кэш, но настроить BuildKit для такого сценария и добиться стабильной работы крайне непросто. Если у вас есть желание поэкспериментировать — делитесь, будем рады обсудить. А пока мы выбрали надежный вариант с использованием s3 и обкатываем его. Если в будущем что-то поменяется, то я сделаю апдейт статьи.
Реализация архитектуры
Переходим к практике. Как обычно, обращу внимание только на ключевые моменты, все остальное можно прочитать в коде репозитория.
Пока что мы не стали использовать rootless для сборки и выбрали привилегированную опцию. В некоторых случаях в rootless применяются совместимые инструменты, но с чуть меньшей производительностью. Для этого мы добавляем GitLab Runner в наш toml-конфиг:
[[runners]]
...
[runners.kubernetes]
privileged = true
...
2. BuildKit может использовать нестандартные системные вызовы, которые AppArmor может блокировать. Установка unconfined повышает совместимость и может ускорить сборку. Добавляем необходимую аннотацию к нашим подам:
[[runners]]
...
[runners.kubernetes]
...
[runners.kubernetes.pod_annotations]
"container.apparmor.security.beta.kubernetes.io/build" = "unconfined"
3. Также в values.yaml указываем адрес своего GitLab gitlabUrl: your_url. Создаем новый раннер newrunner в своем GitLab, добавляем секрет gitlab-runner-token с его токеном через terraform и указываем его в values.yaml: secret: gitlab-runner-token.
4. Применяем все ресурсы terraform. Переключаемся на использование нужного folder_id утилитой yc. Проверяем, виден ли наш кластер. Получаем Kubernetes-конфиг кластера в ~.kube/config для дальнейшей работы с ним.
terraform init
terraform apply
yc config set folder-id <folder-id>
yc k8s cluster list
yc k8s cluster get-credentials buildkube --external
5. Можем переходить к пайплайну со сборкой:
a. Создаем ~/.docker/config.json с авторизацией.
b. В качестве основной команды для сборки при использовании варианта daemonless используется не buildctl build, а buildctl-daemonless.sh build. Если бы у нас был сервис с демоном, то команда бы была buildctl --addr tcp://buildkitd:1234 build.
c. --frontend dockerfile.v0 — используется фронтенд для Dockerfile (dockerfile.v0).
d. --local context=./ — контекст сборки (файлы) берется из текущей директории (./).
e. --local dockerfile=./ — Dockerfile также берется из текущего каталога.
f. --progress=plain — вывод логов в простом формате (без прогресс-баров).
g. --opt build-arg:... — аргументы сборки.
h. ${DOCKER_PARAMS} — опционально. В помощь программистам, чтобы они могли передавать свои аргументы при включении этого пайплайна.
i. --opt filename=${DOCKERFILE_PATH} — явное указание пути к Dockerfile при необходимости.
g. --output type=image ... — пушим наш образ в registry.
k. --import-cache ... — если ранее создавался кеш с промежуточными слоями, то он подгружается при очередной сборке.
l. --export-cache... — экспортируем наш кеш со слоями. Чтобы использовались промежуточные слои, указываем mode=max. Также указываем upload_parallelism c нужным количеством одновременно загружаемых слоев.
stages:
- build
.buildkit_build:
- mkdir -p ~/.docker
- echo "{\"auths\":{\"${REGISTRY_ADDR}\":{\"auth\":\"$(printf "%s:%s" "${REGISTRY_USER}" "${REGISTRY_PASSWORD}" | base64 | tr -d '\n')\"}}}" > ~/.docker/config.json
- buildctl-daemonless.sh build
--local context=./
--local dockerfile=./
--progress=plain
--frontend=dockerfile.v0
--opt build-arg:IMAGE_PREFIX=${IMAGE_PREFIX}
--opt build-arg:NODE_IMAGE=${NODE_IMAGE}
--opt build-arg:NODE_OPTIONS=${NODE_OPTIONS}
--opt build-arg:NGINX_IMAGE=${NGINX_IMAGE}
--opt build-arg:API_URL=${API_URL}
--opt build-arg:CI_COMMIT_REF_SLUG=${CI_COMMIT_REF_SLUG}
--opt build-arg:CI_COMMIT_SHORT_SHA=${CI_COMMIT_SHORT_SHA}
--opt filename=${DOCKERFILE_PATH}
${DOCKER_PARAMS}
--output type=image,name=${IMAGE_REPO}:${IMAGE_TAG},push=true
--output type=image,name=${IMAGE_REPO}:latest,push=true
--import-cache type=s3,region=ru-central1,bucket=runners-cache,name=buildcache,endpoint_url=https://storage.yandexcloud.net,access_key_id=${S3_CACHE_ACCESS_KEY_ID},secret_access_key=${S3_CACHE_SECRET_ACCESS_KEY}
--export-cache type=s3,region=ru-central1,bucket=runners-cache,name=buildcache,endpoint_url=https://storage.yandexcloud.net,access_key_id=${S3_CACHE_ACCESS_KEY_ID},secret_access_key=${S3_CACHE_SECRET_ACCESS_KEY},mode=max,upload_parallelism=8
build:
stage: build
image:
name: moby/buildkit:master
entrypoint: [""]
script:
- set -x
- !reference [.buildkit_build]
tags:
- newrunner
6. Для удобства можно оформить этот пример в качестве отдельного подключаемого ci.yaml-файла в отдельном проекте. Например, создать в GitLab группу mount, в нее добавить проект ci, в нем создать какую-нибудь директорию yarn и положить туда наш файл, чтобы затем подключать его в пайплайнах других своих проектов через include:
include:
project: mount/ci
file: yarn/ci.yaml
ref: newrunner
Бонус: наша инструкция для разработчиков по сборке с новым раннером в Kubernetes
Как и обещал, прикладываю нашу инструкцию для программистов — возможно, и вам она пригодится.
Использование нового раннера
Чтобы использовать новый раннер, нужно в своем пайплайне в разеделе include добавить ref: internal_runners, если у вас подключается front/ci.yaml или tools/ci.yaml. В остальных случаях требуется поддержка DevOps-команды.
include:
project: mount/ci
file: front/ci.yaml
ref: newrunner
Передача аргументов
Дополнительные аргументы нужно передавать не так, как вы передавали ранее для сборки через Docker:
DOCKER_PARAMS: '--build-arg NPM_TOKEN --build-arg APP_ROOT=${APP_ROOT}'
Теперь аргументы передаются следующим образом:
DOCKER_PARAMS: '--opt build-arg:NPM_TOKEN=${NPM_TOKEN} --opt build-arg:APP_ROOT=${APP_ROOT}'
Тюнинг ресурсов для джоб
Вы можете влиять на количество ядер CPU и оперативную память через переменные окружения.
По умолчанию мы настроили раннер на создание подов для джоб с 2 ядрами CPU и 4 ГБ оперативной памяти (как по лимитам, так и по реквестам), но эти характеристики можно менять. Если у вас слишком большая джоба (к примеру, и она выполняется слишком долго или вообще падает по OOMKill), то можно увеличить ресурсы, добавив переменные в раздел variables: после раздела include:
variables:
KUBERNETES_CPU_REQUEST: "3"
KUBERNETES_CPU_LIMIT: "3"
KUBERNETES_MEMORY_REQUEST: "5Gi"
KUBERNETES_MEMORY_LIMIT: "5Gi"
Здесь:
REQUEST— это минимальное значение количества доступных ядер или памяти сразу на старте джобы;LIMIT— это максимальное количество, до которого джоба может разгоняться при необходимости
Стоит помнить, что физические возможности не безграничны, да и слишком большие числа ядер и памяти не нужны. Максимально можно выставить следующие значения (это искусственное ограничение, если нужно больше, то напишите девопсам):
variables:
KUBERNETES_CPU_REQUEST: "4"
KUBERNETES_CPU_LIMIT: "4"
KUBERNETES_MEMORY_REQUEST: "8Gi"
KUBERNETES_MEMORY_LIMIT: "8Gi"
Также помните о том, что часто выполняется много параллельных сборок, и ресурсы должны расходоваться рационально. К слову о рациональности: если у вас простая сборка, то уменьшайте ресурсы до минимально необходимых, например:
variables:
KUBERNETES_CPU_REQUEST: "1.4"
KUBERNETES_CPU_LIMIT: "2"
KUBERNETES_MEMORY_REQUEST: "1900Mi"
KUBERNETES_MEMORY_LIMIT: "3Gi"
Как вы уже поняли, задавать размеры можно по-разному. Количество ядер CPU можно задать либо через десятые доли (например, 1.2, 2.7 и т.д), либо через millicpu (1200m, 2700m по аналогии с предыдущим примером). Память удобно выставлять либо в гигабайтах (1.9Gi, 4Gi), либо в мегабайтах (1900Mi, 4000Mi соответственно).
Заключение
Надеюсь, моя статья пригодится вам в качестве мануала по созданию экономичного Kubernetes-кластера с автоматическим масштабированием, по внедрению GitLab Runners и по интеграции BuildKit. Само собой, в экосистеме Kubernetes и BuildKit существует множество способов организации пайплайнов, и выбор конкретной архитектуры зависит от задач команды. Я постарался продемонстрировать один из проверенных на практике вариантов, который сочетает экономичность, надежность и эффективность.
Экспериментируйте, пробуйте разные механизмы кеширования и настройки, делитесь опытом с коллегами — именно так рождаются самые успешные инженерные решения. А если вам понравился этот материал, то рекомендую вам почитать и другие статьи о том, как мы работаем с инфраструктурой:
Infrastructure as Code на практике: как мы рефакторили сложный Ansible-репозиторий
Firezone, или как спрятать свою инфраструктуру от посторонних глаз
Как дать разработчикам свободу при деплое приложений и ускорить процессы в команде
JupyterHub на стероидах: реализация KubeFlow фич без масштабных интеграций
Список использованных источников
Kubernetes
https://yandex.cloud/ru/docs/compute/concepts/preemptible-vm
https://registry.terraform.io/providers/yandex-cloud/yandex/latest/docs
https://yandex.cloud/ru/docs/managed-kubernetes/qa/cluster-autoscaler
https://github.com/kubernetes/autoscaler/blob/master/cluster-autoscaler/FAQ.md#table-of-contents
Gitlab Runners
https://docs.gitlab.com/runner/configuration/advanced-configuration/
https://gitlab.com/gitlab-org/charts/gitlab-runner/blob/main/values.yaml
https://docs.gitlab.com/runner/install/kubernetes_helm_chart_configuration/