Self-hosted runners для GitHub Actions: CI/CD на собственном железе без лимитов
Семёнов Евгений Сергеевич, директор АйТи Фреш. Last month мы с командой добили очередной переезд CI/CD — команда разработки e-commerce платформы из десяти человек улетала с платных GitHub Actions runners на собственные раннеры в нашем дата-центре. В результате экономия 34 000 руб. в месяц и скорость сборки образов выросла в 2,7 раза. В этой статье рассказываю, как это делается на практике.
Когда собственные раннеры реально нужны
GitHub даёт 2000 бесплатных минут в месяц на публичных репозиториях и 3000 на Pro-планах. Звучит много, пока ваш монорепо не начинает собираться по 20 минут в PR. Дальше счётчик сгорает за неделю, и команда переходит на платные слоты по $0,008 в минуту (плюс множитель 2x для Windows и 10x для macOS).
У меня в практике был клиент — финтех-стартап в Москве, 14 разработчиков. Ежемесячный счёт на GitHub за минуты вырос до $470. Мы поставили три self-hosted раннера на старом Dell R640 с их серверной, и через месяц лицензия обнулилась.
Кроме денег есть ещё причины:
- Приватная сеть. Если CI должен ходить в закрытый VPN, корпоративный NuGet или внутренний Docker Registry — shared runner туда не попадёт.
- Специфичное железо. GPU-сборки, ARM-кросс-компиляция, проверка на реальных ARMv7 — GitHub такое не предоставляет.
- Требования безопасности. ГОСТ-шифрование, ФСТЭК-контур, закрытые репо с ДСП-кодом — только своё железо.
- Длинные сборки. Публичные раннеры убивают job по лимиту 6 часов, self-hosted держат сколько нужно.
Установка агента на Linux
Процесс прямой. В настройках репозитория или организации жмёте «New self-hosted runner», получаете токен и команды. На выходе — ссылка на tarball. Для Ubuntu 22.04 LTS под systemd:
sudo useradd -m -s /bin/bash github-runner
sudo -iu github-runner
mkdir actions-runner && cd actions-runner
curl -o actions-runner.tar.gz -L \
https://github.com/actions/runner/releases/download/v2.321.0/actions-runner-linux-x64-2.321.0.tar.gz
tar xzf actions-runner.tar.gz
./config.sh --url https://github.com/yourorg/yourrepo \
--token AAAA... \
--name runner-dell-r640-01 \
--labels self-hosted,linux,x64,docker \
--work _work \
--unattended
exit # выходим из под github-runner
sudo /home/github-runner/actions-runner/svc.sh install github-runner
sudo /home/github-runner/actions-runner/svc.sh start
Всё. Через 10 секунд агент светится зелёным в UI. Systemd-юнит переживает ребут, логи пишутся в _diag/.
Запуск в Docker-контейнере
Я предпочитаю контейнерный подход — проще поднимать копии, проще изолировать. Использую готовый образ myoung34/github-runner с автоматической регистрацией:
docker run -d --restart=always \
--name gha-runner-01 \
-e REPO_URL="https://github.com/yourorg/yourrepo" \
-e RUNNER_NAME="runner-docker-01" \
-e RUNNER_TOKEN="AAAA..." \
-e RUNNER_WORKDIR="/tmp/runner/work" \
-e LABELS="self-hosted,linux,docker" \
-v /var/run/docker.sock:/var/run/docker.sock \
-v /tmp/runner/work:/tmp/runner/work \
myoung34/github-runner:latest
Флаг -v /var/run/docker.sock:... разрешает сборку образов изнутри job, но по сути даёт root на хост. Для доверенных команд это нормально. Для open-source — лучше DinD или Podman rootless.
Windows-агент и MSI-пакеты
Для .NET-проектов нужен Windows-раннер. Принцип тот же, только PowerShell. Я обычно ставлю на Server 2022 Core — меньше поверхности атаки:
# PowerShell от имени администратора
New-Item -Path C:\actions-runner -ItemType Directory
Set-Location C:\actions-runner
Invoke-WebRequest -Uri https://github.com/actions/runner/releases/download/v2.321.0/actions-runner-win-x64-2.321.0.zip `
-OutFile actions-runner.zip
Expand-Archive -Path actions-runner.zip -DestinationPath .
.\config.cmd --url https://github.com/yourorg/yourrepo `
--token AAAA... `
--name win-runner-01 `
--labels self-hosted,windows,x64,dotnet `
--runasservice
# Сервис называется actions.runner.*, стартует автоматически
Метки, Runner Groups и маршрутизация
Самое интересное начинается, когда раннеров становится больше трёх. Как направить тяжёлые сборки Docker на жирный хост, а простые lint-проверки — на слабый? Через метки.
В workflow пишем:
jobs:
build-image:
runs-on: [self-hosted, linux, docker, high-cpu]
steps:
- uses: actions/checkout@v4
- run: docker build -t app:${{ github.sha }} .
lint:
runs-on: [self-hosted, linux, lightweight]
steps:
- uses: actions/checkout@v4
- run: npm run lint
Регистрируете раннер с нужным набором --labels — GitHub сам выберет подходящий. Runner Groups (в Enterprise/Team-планах) добавляют слой: можно запретить репозиторию использовать определённые группы раннеров.
| Сценарий | Метки | Железо |
|---|---|---|
| Docker build, тесты integration | self-hosted, linux, docker, high-cpu | 16 vCPU, 32 ГБ RAM, SSD |
| Lint, typecheck | self-hosted, linux, lightweight | 2 vCPU, 4 ГБ RAM |
| Сборка .NET | self-hosted, windows, dotnet | 8 vCPU, 16 ГБ RAM |
| ARM-кросскомпиляция | self-hosted, linux, arm64 | Raspberry Pi 5 / Ampere Altra |
| GPU-инференс | self-hosted, linux, gpu, cuda12 | RTX 4090 или A100 |
Автоскейлинг через ARC в Kubernetes
Когда команда переваливает за 20 человек и параллельных PR становится 15+, статичные раннеры перестают справляться. Тут подключается Actions Runner Controller (ARC) — он поднимает поды-раннеры по требованию и гасит после простоя.
helm repo add actions-runner-controller https://actions-runner-controller.github.io/actions-runner-controller
helm install arc actions-runner-controller/gha-runner-scale-set \
--namespace arc-runners --create-namespace \
--set githubConfigUrl="https://github.com/yourorg" \
--set githubConfigSecret.github_token="ghp_XXXX" \
--set minRunners=2 --set maxRunners=20
Под каждый job поднимается pod из шаблона, после завершения — удаляется. Минимум два раннера крутятся постоянно, чтобы первый PR дня не ждал холодного старта. У нашего клиента с этим пайплайном пиковые 15 параллельных сборок отрабатывают за 7 минут вместо 28, которые были на трёх статичных агентах.
Безопасность — что обязательно проверить
Self-hosted runner — это машина, которая выполняет произвольный код из вашего репо. Любой человек с write-правами может запушить workflow, скачивающий бэкдор. Поэтому:
- Никогда не вешайте self-hosted runners на публичные репозитории. Pull request от форка выполнит что угодно — и вы разрешите это.
- Для приватных — включите «Require approval for first-time contributors» в Actions settings.
- Ограничьте права токена
GITHUB_TOKEN:permissions: contents: readв workflow, если запись не нужна. - Раннер крутится не от root. Отдельный пользователь, без sudo. Секреты — только через Settings → Secrets, не коммитьте.
- Сегрегация сети: production-раннеры не должны иметь доступа к staging и наоборот.
- Ephemeral runners: флаг
--ephemeralпри регистрации. Агент выполнит одну job и самоуничтожится. Самая безопасная схема.
Кейс: стриминговый сервис, 40+ сборок в день
Осенью 2025 к нам пришёл клиент — стриминг видео-контента, 11 разработчиков, монорепо на Go и React. CI был на shared runners, уходило 8 долларов за рабочий день, плюс 20-минутные очереди в рабочее время. Они потеряли ощущение скорости.
Мы поставили два физических раннера на серверах Dell Xeon Platinum 8280 в нашей стойке МТС (по 48 vCPU и 128 ГБ RAM каждый) и развернули поверх них ARC в отдельном Kubernetes-кластере. Подвезли 40G Mellanox аплинк, чтобы docker pull из внутреннего Harbor-регистра не упирался в полку. Плюс один Windows-раннер для сборки Electron-клиента.
Что получили: среднее время PR снизилось с 18 до 5 минут, очередь исчезла в принципе, GitHub-счёт упал с $480 до $12 (остались только secrets scanning + минуты на публичные репо). Работы заняли 4 дня, ценник проекта — 95 000 руб.
Настроим self-hosted runners и разгрузим бюджет
Спроектирую и подниму парк собственных раннеров GitHub Actions: физика или Kubernetes, Docker или VM-based, с автоскейлингом, метками и безопасной конфигурацией. Интеграция с внутренним Harbor/GitLab Registry, ARC в любом кластере. Обычно 2–5 дней работ.
Телефон: +7 903 729-62-41
Telegram: @ITfresh_Boss
Семёнов Евгений Сергеевич, директор АйТи Фреш
FAQ — частые вопросы по раннерам
- Можно ли один раннер подключать к нескольким репозиториям?
- Да. Регистрируйте раннер на уровне организации в Settings → Actions → Runners. Дальше — метки и Runner Groups для фильтрации репозиториев. Такой подход удобнее, чем плодить по отдельному раннеру для каждого проекта.
- Нужен ли белый IP-адрес для self-hosted runner?
- Не нужен. Раннер держит исходящее long-polling соединение к github.com через 443 порт. Входящих подключений нет, NAT и файрволл не мешают. Разрешите исходящий HTTPS на github.com и *.actions.githubusercontent.com — этого достаточно.
- Как обновляется раннер?
- GitHub сам обновляет агент при старте нового job, если доступна свежая версия. За прокси или с отрезанным интернетом скачивайте tarball руками и запускайте ./config.sh --replace. Если раннер под systemd — обновление применится после перезапуска сервиса.
- Не опасно ли монтировать docker.sock в раннер?
- Монтирование /var/run/docker.sock равносильно root-правам на хосте. Для приватных репозиториев с доверенными разработчиками — допустимо. Если есть сомнения: rootless Docker, Podman или DinD с отдельным daemon внутри контейнера раннера.
- Сколько параллельных job тянет один раннер?
- Один экземпляр агента обслуживает ровно одну job одновременно. Хотите параллелизм — запускайте N экземпляров в отдельных директориях с уникальными именами. Бюджет памяти — 250–500 МБ на агент плюс потребление самой сборки.