Настройка self-hosted gitlab runner

Вторая часть материала о CI/CD в котором мы рассмотрим настройку self hosted gitlab-runner через docker.

О серии статей

Все найденные мной русскоязычные гайды не дают базового понимания того, как это работает, по большому счету это просто инструкции по настройке, причем под какой-то конкретный продукт и кейс: .net, Java, Node JS, etc.

Целью серии статей является детальное и схематичное описание того, как все это устроено. Главная задача — вооружить читателя фундаментальным пониманием, что конкретно ему требуется сделать в его конкретном случае. Помимо самой инструкции по настройке, это будет так же справочник для погружения в DevOps, охватывающий:

  • Максимально много функционала, которыми обладает Gitlab (общие абстракции актуальны и для его аналогов).

  • Инструменты которые нужны: Bash, Docker, Kubernetes и другие.

  • Общую теорию и практику с конкретными сценариями.


Материал разбит на 4 части.

  1. Настройка GitLab CI/CD: понимаем принципы работы и запускаем первый pipeline

  2. [Вы здесь → ] Настройка self-hosted gitlab runner (CI/CD)

  3. [in progress 70%] Работа с registry (CI/CD)

  4. [todo] Горизонтальное масштабирование CI/CD (высоко-нагруженный продакшн)

Оглавление

  • Настройка собственного gitlab runner

  • Запуск и проверка self hosted раннера


Если мы запускаем pipeline на gitlab.com (SaaS-инстанс), используются так называемые shared runners (общие для всех пользователей GitLab). Но не всегда хочется делить раннеры с остальными — иногда нужен свой. Для этого случая и написана статья.

Когда вам нужен свой gitlab runner?
  • Полный контроль и независимость — ваше железо, ваш раннер.

  • Доступ к внутренним ресурсам — развёртывание в приватную сеть, обращение к базам данных или API, недоступным извне

  • Контроль над железом — нужны GPU, много RAM или определённая архитектура (ARM, RISC-V)

  • Безопасность и комплаенс — код/артефакты не должны покидать инфраструктуру компании

  • Стоимость — большие объёмы CI/CD дешевле на своих мощностях, чем на SaaS-минутах GitLab

  • Производительность — устранение сетевых задержек, кеширование на быстрых локальных дисках

Настройка собственного gitlab runner

Есть множество способов установки gitlab runner, мы выберем вариант через docker container.

Подключаемся к серверу и создаем директорию inf-config — это будет рабочая директория в из которой мы будем запускать инфраструктурные сервисы:

mkdir inf-config

Создадим docker-compose файл с конфигурацией запуска нашего gitlab реннера:

touch docker-compose.yml

Содержимое:

services:
  gitlab-runner:
    image: gitlab/gitlab-runner:alpine3.21-v18.11.0
    container_name: gitlab-runner
    restart: always
    volumes:
      - /srv/gitlab-runner/config:/etc/gitlab-runner
      - /var/run/docker.sock:/var/run/docker.sock

Что бы этот контейнер запустился, от имени администратора создаем директорию:

sudo mkdir -p /srv/gitlab-runner/config

В нем так же потребуется создать конфиг нашего ранера:

sudo touch config.toml

В gitlab нужно создать раннер для нашего репозитория, в нем мы возьмем токен.

Создаем Gitlab runner для проекта
Создаем Gitlab runner для проекта
Настраиваем Gitlab runner
Настраиваем Gitlab runner

Содержимое:

concurrent = 1
check_interval = 0

[[runners]]
  name = "laptop-dc"
  url = "https://gitlab.com/"
  token = "ВАШ_РАННЕР_ТОКЕН_ИЗ_НАСТРОЕК_GITLAB"
  executor = "docker"
  [runners.docker]
    image = "alpine:3.22.4" 
    privileged = false
    disable_entrypoint_overwrite = false
    disable_cache = false
    volumes = ["/cache"]
Детальный разбор конфигурации

Документация по конфигурации gitlab runner в toml подробно объясняет все значения конфигурации, я кратко опишу только то, что использовал в нашем случае.

check_interval = 0 — Интервал времени в секундах для проверки раннером новых jobs. Иными словами как часто runner будет стучаться на gitlab с вопросом “есть ли для меня работа?”. Так как у нас 0, будет использовать значение по умолчанию (3 секунды).

concurrent = 1 — Лимит на кол-во параллельных jobs. Мы можем установить его внутри секции отдельного раннера что бы применить лимит на конкретный раннер. Так как мы указали значения в корне конфигурации, а не для отдельного раннера — мы установили глобальное ограничение для всех раннеров, на случай если их в будущем будет несколько, пока что у нас только 1.

url = "https://gitlab.com/" — Тут нужен URL Gitlab, если у вас self-hosted Gitlab, то нужен ваш адрес. Мы же используем Gitlab.com (SaaS-инстанс) по этому просто глобальный https://gitlab.com к которому наш раннер будет подключаться.

token = ВАШ_РАННЕР_ТОКЕН_ИЗ_НАСТРОЕК_GITLAB — сюда мы скопировали токен из настроек Gitlab (на последнем скриншоте действие 5).

executor = docker — Мы уже разобрали тему экзекьютеров выше, наш выбор Docker.

image = "alpine:3.22.4" — Образ по умолчанию который будет использован для всех джобов. Используем alpine — самая легковесная linux версия. На всякий случай конкретная версия, а не latest — так меньше вероятность что в какой-то момент что-то перестанет работать, в ранее работающих конфигурациях.

privileged = false — Это привилегированный режим запуска контейнеров. В обычном докере контейнер в привилегированном режиме запускается так: docker run --privileged alpine:3.22.4, в docker-compose.yml у сервиса ставиться флаг privileged: true. Что бы понимать все особенности этого режима, нужно хорошо ориентироваться в системе контроля доступов linux, об этом у меня есть статья на Хабре. Если совсем коротко, privileged:

  • Включает все capabilities (привилегии) ядра Linux — речь о механизме ограничения отдельных низкоуровневых операций процессов. Например, управление сетевыми настройками, но не уровне изменения какого нибудь etc/network/interfaces, а напрямую на уровне операций, которые процесс может попросить выполнить ядро линукса (включить, отключить интерфейс, изменить MAC адресс и тд).

  • Отключает стандартный профиль secure computing mode (seccomp) — механизм безопасности ядра, ограничивающий системные вызовы (syscalls). Примеры syscalls это: reboot, mount, open, read, write и т.д.

  • Отключает AppArmor.

  • Отключает метку процесса SELinux (Security-Enhanced Linux).

  • Предоставляет доступ ко всем устройствам хоста.

  • Делает виртуальную файловую систему (sysfs) доступной для чтения и записи.

  • Делает монтирования cgroups доступными для чтения и записи.

Думаю, сложилось понимание, что с привилегированным режимом нужно быть очень осторожным.

disable_entrypoint_overwrite = false — если здесь true, то нельзя переопределять entrypoint контейнеру из .gitlab-ci.yml. В shared runner стоит true, что ограничивает возможности в целях безопасности. Про entrypoint хорошо описано здесь. Кому интересно можете отследить появление этой фичи: MR c добавлением флага.

disable_cache = false — Данная опция сохраняет возможность использовать cache, который указывается в .gitlab-ci.yml. Этот кэш позволяет расшаривать данные между джобами. Например:

# Использование кэша в .gitlab-ci.yml

cache:
  paths:
    - node_modules/
    - .m2/repository/
    - .pip-cache/

Кэш может работать через Docker volume или S3. Docker volume не имеет смысла в shared runners, так как там все джобы создаются в виртуалках и полностью изолированы друг от друга. В нашем случае — self-hosted runner, и мы можем использовать кэш для ускорения работы пайплайнов. Но стоит быть аккуратным и осознанно использовать кэш: без тонкого понимания жизненного цикла кэша можно создать трудноуловимые баги. Более совершенная реализация кэша — через S3. Подробнее: Advanced configuration/ the [runners.cache] section.

Запуск и проверка self hosted раннера

Необходимые конфигурации созданы:

  • /home/gtosss/inf-config/docker-compose.yml

  • /srv/gitlab-runner/config/config.toml

Осталось только проверить, что все работает. В директории где лежит docker-compose.yml запускаем docker compose up. В моем случае рабочей директорией является /home/gtosss/inf-config или сокращенно ~/inf-config под пользователем gtosss. Я использую fedora и вместо docker у меня podman.

Переходим в рабочую директорию

cd ~/inf-config

Запускаем podman-compose up

gtosss@laptop-dc:~/inf-config$ podman-compose up 
[gitlab-runner] | Runtime platform          arch=amd64 os=linux pid=2 revision=249f0215 version=18.11.0
[gitlab-runner] | Starting multi-runner from /etc/gitlab-runner/config.toml...  builds=0 max_builds=0
[gitlab-runner] | Running in system-mode.                            
[gitlab-runner] |                                                    
[gitlab-runner] | FATAL: Service run failed     error=stat /etc/gitlab-runner/config.toml: permission denied

Я погорячился создавая config.toml от имени супер пользователя. Причина ошибки в том, что podman в fedora работает в более безопасном режиме, чем docker в Ubuntu по дефолту. Podman разделяет управление контейнерами на отдельные режимы rootles и rootful, мы работаем от имени пользователя gtosss, podman поднимая контейнер назначает текущего пользователя его владельцем — это и есть режим rootles. Контейнеру нужен доступ к toml файлу, а мы его создали от имени рута.

Исправляем ситуацию руководствуясь подсказками из статьи — Права в Linux: chown/chmod, SELinux context, символьная/восьмеричная нотация, DAC/MAC/RBAC/ABAC. Если последующая информация вызывает вопросы, ответы будут как раз в этой статье.

Для начала посмотрим, какие права вообще назначены
gtosss@laptop-dc:~/inf-config$ stat /srv/gitlab-runner
  File: /srv/gitlab-runner
  Size: 12              Blocks: 0          IO Block: 4096   directory
Device: 0,36    Inode: 5930632     Links: 1
Access: (0755/drwxr-xr-x)  Uid: (    0/    root)   Gid: (    0/    root)
Context: unconfined_u:object_r:var_t:s0
Access: 2026-05-05 16:45:47.714091050 -0400
Modify: 2026-04-18 09:28:48.179756650 -0400
Change: 2026-04-18 09:28:48.179756650 -0400
 Birth: 2026-04-18 09:28:36.917840577 -0400

Access: (0755/drwxr-xr-x) Uid: ( 0/ root) Gid: ( 0/ root) — владелец root.

Стоит обратить внимание на Context: Context: unconfined_u:object_r:var_t:s0

sudo chown -R gtosss:gtosss /srv/gitlab-runner

Пробуем запуститься:

gtosss@laptop-dc:~/inf-config$ podman-compose up
[gitlab-runner] | cannot open `/run/user/1000/crun/78f1016f972c126ea6f02713931a3180dfee34b8ebaf40b2b8ba6e47c405939c/exec.fifo`: No such file or directory
[gitlab-runner] | Error: unable to start container 78f1016f972c126ea6f02713931a3180dfee34b8ebaf40b2b8ba6e47c405939c: `/usr/bin/crun start 78f1016f972c126ea6f02713931a3180dfee34b8ebaf40b2b8ba6e47c405939c` failed: exit status 1

Решение нашлось в issues github (podman-compose репозиторий). Советуют просто compose down и затем compose up. Так себе решение, не особо понятна природа проблемы, но придется довольствоваться тем что есть.

Пояснение для тех, кого совсем уж не устраивает этот черный ящик.

/run/user/1000 — Это директория для временных файлов которые хранят информацию с момента загрузки системы (run-time variable data). После /user фигурирует UID (пользовательский ID), обычно это 1000. Почему именно тысяча обсуждалось например на linuxquestions.org в 2014. Есть конфигурация в /etc/login.defs, где среди прочих параметров указываются значения:

GID_MIN  1000
GID_MAX  6000

Проблема решилась, но мы опять сталкиваемся с ошибкой: error=stat /etc/gitlab-runner/config.toml: permission denied

В документации к podman о волумах говориться о метке z или Z. Ее нужно указывать в SELinux. То же самое и в документации docker. Согласно документации:

  • z (маленькая) - если volume разделяют несколько контейнеров (наш случай, т.к раннер будет создавать много контейнеров).

  • Z (большая) - если volume использует только один контейнер.

Изменяем наш docker-compose.yml добавив метку z:

services:
  gitlab-runner:
    image: gitlab/gitlab-runner:alpine3.21-v18.11.0
    container_name: gitlab-runner
    restart: always
    volumes:
      - /srv/gitlab-runner/config:/etc/gitlab-runner:z
      - /var/run/docker.sock:/var/run/docker.sock

Для docker.sock указывать не нужно, так как в нем уже настроен нужный SELinux контекст из коробки. Теперь все заработало. Кстати если сейчас посмотреть информацию о файле через stat:

gtosss@laptop-dc:~/inf-config$ stat /srv/gitlab-runner/config
  File: /srv/gitlab-runner/config
  Size: 56              Blocks: 0          IO Block: 4096   directory
Device: 0,36    Inode: 5930634     Links: 1
Access: (0755/drwxr-xr-x)  Uid: ( 1000/  gtosss)   Gid: ( 1000/  gtosss)
Context: system_u:object_r:container_file_t:s0

То можно заметить, что после запуска docker-compose up Context сам поменялся: unconfined_u:object_r:var_t:s0system_u:object_r:container_file_t:s0.

После запуска контейнера с gitlab-runner переходим в gitlab и смотрим есть ли соединение:


Спасибо, что дочитали!

Кто уже подписан на телеграм-канал — отдельная благодарность. Помимо технических статей поднимаем и социально важные темы.

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

С вами был Тимофей. Кто я?

Разрабатываю с 2015 года. Стартовал как front-end разработчик на React, после 6-лет переключился на full-stack, последние годы — чаще DevOps. Мой публичный WakaTime.


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


  1. gtosss Автор
    07.05.2026 13:32

    С техническим материалам в тренды не пробиться и много плюсов не заработать. Там всегда статьи о блокировках, о том что Claude до конца недели автоматизирует человечество и прочее. Поэтому не стесняйтесь плюсовать если материал изложен понятным языком и оказался полезен.


  1. NAI
    07.05.2026 13:32

    Эммм, вы бы хоть объяснили зачем эти пляски с podman<->docker если есть более простые (на вид) решения уровня:

    • воткнуть раннер напрямую на хост (или в специально выделенную VM) с executor = "docker"

    • использование did (docker in docker)


    1. gtosss Автор
      07.05.2026 13:32

      воткнуть раннер напрямую на хост

      Ну ведь в том и смысл docker, мы все что угодно может воткнуть напрямую на хотс, а docker позволяет воткнуть готовый контейнер внутри которого конткретная версия раннера и настроеное окружение. В случае если мы решим обновить версию нашего раннера мы просто гасим старый контейнер с раннером и запускаем новый более свежий. А конфигурации всех наши переиспользуются за счет волума.

      А касательно dind — мы как раз и не лишаемся возможности его использовать, за счет того что выносим в волум docker socket. Ну если быть более точным, то не dind, а его более легковесную альтеративу (через docker.sock)

      Но вы правы, если такой вопрос все еще остается, то наверное имеет смысл добавить пояснение об этом.


      1. NAI
        07.05.2026 13:32

        контейнер внутри которого конткретная версия раннера...если мы решим обновить версию ... гасим старый контейнер с раннером и запускаем новый более свежий

        Ну как бы все еще не очевидно. Обновление раннера на хосте еще проще apt-get update && apt-get uprgade (ну или для пугливых unattended-upgrade, или dnf update --security --bug-fix)это выглядит сильно проще чем cd <dir_with_compose> && docker compose down && docker compose pull && docker compose up - и то это сработает если у вас :latest в compose-файле. А у вас версия прибита руками, т.е. каждый секюрити\баг-фикс иди правь руками... ну такое себе, с учетом того как часто гитлаб обновляет все. Там за год такое количество минорных обновлений набегает что просто жуть.

        А касательно dind — мы как раз и не лишаемся возможности его использовать, за счет того что выносим в волум docker socket.

        Вы немного не поняли... вопроса... у нас есть три сценария.

        • (просто) развернуть раннер на хосте

        • (чуть сложнее) развернуть раннер на хосте в режиме did

        • (сложно)запариться с podman

        Возникает вопрос - а собственно зачем все эти пляски с подманом? я так тонко намекну, что а нафига давать контейнеру подмана рут права, если фишка подмана (основная) что контейнеры как раз таки не имеют рутовых прав в отличии от докера. Может быть тогда имело смысл сразу ставить докер использовать did


  1. Rob123
    07.05.2026 13:32

    Доходчиво и понятно пишете, спасибо у вас талант