Перейти к содержанию

Один под, два раннера и где-то Kaniko: почему это норма, а не дичь

Война-стори и смена ментальной модели. Про то, как один pod gitlab-runner тащит два зарегистрированных раннера, при этом сам почти ничего не делает, а Kaniko и обычные сборки никогда не сидят в одном поде. Спойлер: manager — это прораб, а не каменщик.

Момент «стоп, что?»

Мигрировал я как-то GitLab Runner из docker в Kubernetes. Открываю Argo, смотрю на приложение runner — а там один Deployment, один под. Лезу в GitLab → Admin → Runners — а там два раннера: ci и docker/build. Лезу обратно в кластер — под по-прежнему один.

Первая реакция была примерно как у вас в заголовке: «два раннера в одном поде, да ещё и Kaniko с обычной сборкой вперемешку — это что вообще такое?»

Оказалось — всё правильно. Просто я держал в голове неправильную картинку. Давайте её поменяем, и «дикость» превратится в «а, ну логично же».

Главная подмена понятий

В голове сидело: раннер = воркер, который собирает. Поэтому «два раннера в одном поде» звучит как «два грузчика в одном теле».

А на самом деле в Kubernetes-исполнителе:

gitlab-runner (manager pod)  ← ПРОРАБ. Не собирает. Опрашивает GitLab, раздаёт работу.
        │
        ├── создаёт job-pod ──►  node:22-alpine   (сборка фронта)   ← КАМЕНЩИК №1
        ├── создаёт job-pod ──►  kaniko:debug     (билд образа)     ← КАМЕНЩИК №2
        └── создаёт job-pod ──►  alpine/git       (деплой)          ← КАМЕНЩИК №3
                                  ↑ временные, рождаются и умирают под каждую джобу

Manager-под ничего не собирает. Это процесс gitlab-runner run, который:

  1. опрашивает GitLab: «есть для меня джобы?»,
  2. под каждую взятую джобу создаёт отдельный под с образом из image: этой джобы,
  3. следит за ним и подчищает.

Каменщики (job-поды) — эфемерные. Появились, отработали джобу, исчезли. Kaniko и node-сборка никогда не оказываются в одном поде — это два разных временных пода, под две разные джобы, с двумя разными образами. В manager-поде их нет вообще.

Хотите убедиться — запустите пару пайплайнов и смотрите в namespace раннера:

kubectl -n ci get pods -w
# gitlab-runner-xxxx        1/1   Running      ← прораб, висит всегда
# runner-abc123-project-... 0/1   Pending      ← каменщик появился под джобу
# runner-abc123-project-... 1/1   Running
# runner-abc123-project-... 0/1   Completed    ← отработал и умер

Прораб один и тот же. Каменщики приходят и уходят.

Тогда что такое «два раннера»?

Это две регистрации в одном config.toml — два блока [[runners]], каждый со своим токеном и своим набором тегов:

concurrent = 6

[[runners]]
  name = "n2-docker-builder"
  token = "glrt-…"          # теги: docker, build
  executor = "kubernetes"
  [runners.kubernetes]
    namespace = "ci"

[[runners]]
  name = "n2-ci"
  token = "glrt-…"          # тег: ci
  executor = "kubernetes"
  [runners.kubernetes]
    namespace = "ci"

Один процесс (прораб) держит два «удостоверения» и опрашивает GitLab по обоим. Для GitLab это два раннера (две сущности, два токена, разные теги). Для Kubernetes это один Deployment. Обе картинки верны одновременно — просто они про разные слои.

Теги — это не разделение кластера пополам. Тег решает только одно: какую джобу прораб имеет право взять. ci берёт джобы с tags: [ci], docker/build — с tags: [docker]/[build]. Размер пода, лимиты, на какой ноде он окажется — теги на это не влияют вообще.

А ресурсы-то как делятся?

Вот это вопрос по делу. Ресурсы живут на двух уровнях, и их легко перепутать.

Уровень 1. Manager-под (тот самый 1 под в Argo)

Лёгкий. Он только оркестрирует. Типичные реквесты — копейки:

resources:
  requests: { cpu: 100m, memory: 128Mi }
  limits:   { cpu: 500m, memory: 256Mi }

Оба зарегистрированных раннера делят один и тот же manager-под и один общий пул параллелизма.

Уровень 2. concurrent — главный кран

concurrent = 6

Это максимум одновременных джоб суммарно по всем [[runners]]. Не «6 на ci и 6 на build», а 6 на всё. Из коробки квоты «50/50» между регистрациями нет — оба раннера конкурируют за общий concurrent.

Ситуация (concurrent = 4) Что будет
ci + 1×docker все 4 параллельно
ci 4 идут, 5-я ждёт в очереди раннера
2 тяжёлых build + 3 лёгких ci лимит 4, без отдельной квоты на тип

Хотите потолок на конкретную регистрацию — есть limit внутри [[runners]]:

[[runners]]
  limit = 2        # этот раннер — не больше 2 джоб одновременно

…но сумма всё равно не перепрыгнет глобальный concurrent. Если concurrent = 3, а limit = 2 у обоих — реальный максимум 3, а не 4.

Уровень 3. Job-поды — где реально горит CPU/RAM

Тут и тратятся ресурсы на сборки. Дефолты на все джобы раннера — в config.toml:

[runners.kubernetes]
  cpu_request = "500m"
  cpu_limit   = "2"
  memory_request = "1Gi"
  memory_limit   = "4Gi"

И переопределение на конкретную джобу — прямо в .gitlab-ci.yml:

publish:image:
  tags: [docker, build]
  variables:
    KUBERNETES_CPU_REQUEST: "1"
    KUBERNETES_MEMORY_LIMIT: "8Gi"

Поскольку у нас две регистрации, у ci и у docker/build можно задать разные [runners.kubernetes]: лёгкому ci — поменьше, тяжёлому build (Kaniko) — побольше. Это и есть «два раннера с разными аппетитами», хотя прораб у них общий.

Полная картина одной схемой

        доступные ресурсы нод (общий пул)
                    ▲
                    │ scheduler кладёт job-поды по requests/limits
        ┌───────────┴───────────────────────────────┐
        │  gitlab-runner pod (прораб)                │
        │  requests ~100m / 128Mi                    │
        │  concurrent = 6  (общий на ci + build)     │
        └───────────┬───────────────────────────────┘
            ┌────────┴────────┐
            ▼                 ▼
      ┌──────────┐      ┌──────────────┐
      │ job pod  │ ...  │ job pod      │   ← каждый со своим образом и лимитами
      │ image:   │      │ image:       │
      │ node     │      │ kaniko       │
      │ tag: ci  │      │ tag: docker  │
      └──────────┘      └──────────────┘
  • Прораб — фиксированный маленький кусок на одном поде.
  • Параллелизм — глобальный concurrent (+ опционально limit на регистрацию).
  • CPU/RAM сборок — в каждом job-поде, и у ci с docker/build настройки могут отличаться.
  • Кластер общий: 10 тяжёлых Kaniko-сборок могут вытеснить лёгкие ci-джобы в Pending — даже при разных тегах. Теги делят очередь, а не ноды.

Две очереди, в которых джоба может зависнуть

Когда «не едет», виноват один из двух заторов:

  1. Очередь раннера — уже занят concurrent (или limit). Джоба в GitLab висит pending / waiting for runner.
  2. Очередь Kubernetes — прораб под создал, но на нодах нет места по requests → под в Pending, джоба висит.

Коварство: прораб не всегда сверяется со свободной ёмкостью нод заранее. Он может взять джобу «по concurrent», создать под — а тот зависнет в Pending. Поэтому concurrent обычно ставят с запасом относительно реальной ёмкости, а не «впритык».

Если надо жёстко разделить ресурсы

Чтобы тяжёлые build не забивали лёгкие ci:

  • разные limit на [[runners]];
  • разные nodeSelector / taints — увести build-поды на отдельные ноды;
  • разные [runners.kubernetes] cpu/memory у двух регистраций;
  • в пределе — второй Deployment раннера (вот тогда будет 2 пода) с привязкой к своим нодам и своим concurrent.

То есть «2 пода» — это осознанный выбор ради изоляции, а не вариант по умолчанию. По умолчанию один прораб тащит всё, и это нормально.

Что проверять при диагностике

  • concurrent и limit в каждом [[runners]];
  • cpu_* / memory_* в [runners.kubernetes] по регистрациям;
  • KUBERNETES_* переменные в .gitlab-ci.yml;
  • kubectl top pods и зависшие job-поды в Pending;
  • в GitLab: Runners → сколько джоб одновременно у каждого раннера.

Мораль

«Один под — два раннера — и где-то Kaniko» дичью кажется ровно до тех пор, пока держишь в голове «раннер = воркер». Поменяйте картинку на прораб + временные бригады, и всё встаёт на места:

  • manager-под = прораб: один, лёгкий, только раздаёт работу;
  • два раннера = два удостоверения у прораба (разные теги), а не два тела;
  • Kaniko и node-сборка = два разных каменщика, каждый в своём временном поде, никогда не вместе;
  • ресурсы = прораб почти ничего не ест; реальная нагрузка — в эфемерных job-подах, а их число режет concurrent, размер — kubernetes-настройки, а ноды у всех общие.

Самое приятное: когда модель верная, и тюнинг становится очевидным. Надо больше параллелизма — крути concurrent. Надо, чтобы билды не душили тесты — дай им limit или отдельные ноды. Надо железную изоляцию — заведи второй Deployment. Никакой магии, просто прораб и бригада.