Всем привет! Я Алексей Босенко, DevOps-инженер в компании KTS. В этой статье я покажу, как комплексно настроить быструю и эффективную сборку проектов в Kubernetes с использованием BuildKit, которая учитывает не только производительность, но и стоимость ресурсов.

Под этой громкой фразой я подразумеваю целый комплекс решений: как создать и настроить экономичный кластер Kubernetes для сборок (ведь цена вопроса всегда важна), как настроить GitLab Runners и как сделать эффективное масштабирование сборок. Особый акцент будет на том, почему мы выбрали BuildKit, какие варианты использования он предлагает, и как непосредственно настроить один из них.

Будет много подробностей о том, почему мы принимали эти решения и как внедряли их у себя, так что статью можно использовать в качестве Production-ready-мануала.

Оглавление

Создаем базу — кластер Kubernetes

Для начала создадим базу. Весь код сюда нет смысла выкладывать, так что я просто оставлю ссылку на репозиторий с очень хорошо прокомментированным кодом, разобраться в котором не составит труда. В статье я буду останавливаться только на ключевых моментах и объяснять их.

Мы создадим кластер на Yandex Cloud — одной из наиболее популярных платформ в российском сегменте. Подробно расписывать каждый шаг тоже не буду, благо у Яндекса хватает документации. Перейдем к важным моментам в плане цены и эффективности.

  1. Для обеспечения выхода нод в интернет мы выбрали Yandex Managed NAT Gateway. Он дешевле, чем instance compute.

    resource "yandex_vpc_gateway" "nat_gateway" {...}.

  2. В разделе yandex_kubernetes_cluster все стандартно, перейдем к yandex_kubernetes_node_group. Стоит обратить внимание на типы дисков и их стоимость. Нам нужна большая скорость, и для этого подходят SSD IO (NVMe) и нереплицируемые SSD. Нам репликация для сборок не нужна, так что мы выбрали второй вариант — он дешевле.

Мы выбрали размер 186 ГБ (он должен быть кратным 93 ГБ), чтобы удвоить скорость относительно минимальной, и чтобы ее хватало для 5–10 одновременных джобов.

  1. Процессора в 8vCPU и памяти в 16 ГБ RAM нам хватало для запуска 5–7 параллельных сборок в зависимости от их объема. Позже мы увеличили объем памяти до 32Гб RAM, т.к. очень часто количество одновременных сборок стало достигать 10-20

  2. Опция 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 — облако следит за нодой, и в случае ее потери/падения создает ее заново.

  1. Политика автоскейлинга нод scale_policy. Здесь все просто:

    • указываем, что на старте у нас будет одна нода (initial = 1);

    • минимальное количество нод всегда должно быть равным единице (min = 1);

    • максимально ноды могут скейлится до трех (max = 3).

  2. Опциональные пункты — 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 и обкатываем его. Если в будущем что-то поменяется, то я сделаю апдейт статьи.

Реализация архитектуры

Переходим к практике. Как обычно, обращу внимание только на ключевые моменты, все остальное можно прочитать в коде репозитория.

  1. Пока что мы не стали использовать 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 существует множество способов организации пайплайнов, и выбор конкретной архитектуры зависит от задач команды. Я постарался продемонстрировать один из проверенных на практике вариантов, который сочетает экономичность, надежность и эффективность.

Экспериментируйте, пробуйте разные механизмы кеширования и настройки, делитесь опытом с коллегами — именно так рождаются самые успешные инженерные решения. А если вам понравился этот материал, то рекомендую вам почитать и другие статьи о том, как мы работаем с инфраструктурой:

Список использованных источников

Kubernetes

Gitlab Runners

Buildkit

Комментарии (0)