Self-hosted runner для GitHub Actions на Kubernetes: как настроить с нуля
GitHub-hosted runners в стандартной подписке — это 2 vCPU, 7 ГБ RAM и 14 ГБ диска. Этого хватает на маленькие репозитории, но как только у тебя крупный монорепо с тестами, докер-сборками и e2e — начинается боль. То раннер кончился по диску посреди сборки, то время ожидания свободного слота больше, чем сама сборка. Я переехал на self-hosted в k8s в трёх компаниях и расскажу, как делать это правильно в 2026.
Версия — k8s 1.30, ARC (Actions Runner Controller) 0.10, GitHub Enterprise Cloud / GitHub.com.
Зачем вообще self-hosted
Конкретные причины, по которым стоит съезжать с GitHub-hosted:
- Нужны жирные машины — 16 vCPU, 64 ГБ RAM для линковки больших проектов или интеграционных тестов с БД.
- Нужен GPU для ML CI.
- Сборки требуют доступа в private VPC (тестовая БД, internal API).
- Хочется кешировать большие docker-образы и npm/cargo/go-mod без выкачивания каждый раз.
- Объём минут больше, чем платишь за GitHub-hosted.
Раньше self-hosted ставили на VMки, что плохо: один runner = одна VM, нагрузка скачет, ресурсы простаивают. На k8s раннеры запускаются эфемерно: пришёл job — поднялся pod, отработал — pod удалился. Это эластично и дёшево.
Какой контроллер выбрать
В 2026 фактически два варианта:
actions-runner-controller (ARC) от GitHub
Официальный контроллер, активно развивается, поддержка от GitHub. Использует архитектуру с двумя CRD: AutoscalingRunnerSet и RunnerSet. Всё через scale set — это новый API GitHub.
summerwind/actions-runner-controller (legacy)
Старый комьюнити-проект, который GitHub форкнул и продолжает. На него ещё много туториалов, но он уже legacy. Новые проекты — только официальный ARC.
Я ставлю ARC. Дальше в статье — про него.
Подготовка
1. Создаём GitHub App
Раньше можно было использовать PAT (personal access token), но ARC уже не рекомендует. Идёшь в Settings → Developer settings → GitHub Apps → New GitHub App:
- Name:
self-hosted-runners-arc - Homepage URL: что угодно валидное.
- Webhook: отключаешь.
- Repository permissions: Actions (Read and write), Administration (Read and write), Metadata (Read-only).
- Organization permissions (если на уровне org): Self-hosted runners (Read and write).
После создания: запоминаешь App ID, генерируешь private key (.pem-файл скачается). Затем ставишь приложение на нужную организацию или конкретные репозитории.
2. Создаём Installation
В Install App кликаешь, выбираешь репозитории, и в URL увидишь installations/<number> — это Installation ID. Запомни.
3. Кладём секреты в k8s
kubectl create namespace arc-systems
kubectl create namespace arc-runners
kubectl create secret generic github-app-secret \
--namespace=arc-runners \
--from-literal=github_app_id=<APP_ID> \
--from-literal=github_app_installation_id=<INSTALLATION_ID> \
--from-file=github_app_private_key=./private-key.pemprivate-key.pem — тот файл, который GitHub дал при создании App.
Установка ARC
Через Helm-чарт официальный, два релиза — controller и scale set.
helm install arc \
--namespace arc-systems \
oci://ghcr.io/actions/actions-runner-controller-charts/gha-runner-scale-set-controller \
--version 0.10.1Проверяем, что controller поднялся:
kubectl get pods -n arc-systemsДолжен быть arc-gha-rs-controller-xxx в Running.
Создаём scale set
Scale set — это группа раннеров с общими настройками. Можно сделать несколько — разных размеров, для разных команд.
helm install arc-runner-set \
--namespace arc-runners \
--set githubConfigUrl="https://github.com/my-org" \
--set githubConfigSecret=github-app-secret \
--set minRunners=2 \
--set maxRunners=20 \
--set 'containerMode.type=kubernetes' \
oci://ghcr.io/actions/actions-runner-controller-charts/gha-runner-scale-set \
--version 0.10.1githubConfigUrl — на org или на конкретный репо. minRunners=2 держит warm-pool из 2 готовых раннеров, чтобы не было cold start. maxRunners=20 — потолок.
Используем в workflow
name: build
on: [push]
jobs:
build:
runs-on: arc-runner-set
steps:
- uses: actions/checkout@v4
- run: make testИмя arc-runner-set — это название Helm-релиза. Просто пишешь его в runs-on.
Тонкости и грабли
Docker внутри раннера
Если у тебя в job-ах docker build — нужен docker socket или dind. ARC поддерживает оба режима.
# values.yaml для chart
template:
spec:
containers:
- name: runner
image: ghcr.io/actions/actions-runner:latest
command: ["/home/runner/run.sh"]
- name: dind
image: docker:dind
args: ["dockerd", "--host=unix:///var/run/docker.sock"]
securityContext:
privileged: true
volumeMounts:
- name: dind-sock
mountPath: /var/run
volumes:
- name: dind-sock
emptyDir: {}privileged: true — да, без этого dind не работает. На прод-кластере с другими нагрузками это страшновато. Я вырезаю dind в отдельный namespace и отдельный пул нод, помеченный taint-ом, чтобы туда не поехали приложения.
Альтернатива — kaniko или buildkit без privileged. Это правильнее, но требует переписывания pipeline под их CLI.
Кеширование
actions/cache хранит кеш в GitHub-managed storage. На GitHub-hosted это бесплатно, на self-hosted — тоже работает, но медленно: каждое скачивание идёт через интернет.
Локальный кеш — через actions/cache с альтернативным backend. Например, actions/cache@v4 с настройкой S3-backend или MinIO в кластере. Быстрее в десятки раз.
- uses: actions/cache@v4
with:
path: ~/.cargo
key: cargo-${{ hashFiles('Cargo.lock') }}Для больших монорепо я ставлю tespkg/actions-cache или everpcpc/actions-cache-s3 с MinIO в самом кластере — кеш качается за секунды.
Container mode kubernetes vs dind
В values есть containerMode.type=kubernetes или type=dind.
Kubernetes mode — это когда сам job запускается как отдельный pod рядом с раннером. Чище, безопаснее, но нужен hook-extension в раннере и поддержка от Actions.
Dind — старая схема, всё в одном pod-е. Проще, но privileged.
Для большинства случаев dind проще, для compliance-ориентированных — kubernetes mode.
Мониторинг
ARC отдаёт Prometheus-метрики. Что я смотрю:
arc_runners_count— сколько runners сейчас активно.arc_runners_pending— сколько jobs ждут раннера. Если постоянно >0, увеличивайminRunners.- CPU/memory usage самих runner-pods через kubelet metrics.
Алёрты в Prometheus:
- alert: ARCRunnersStuck
expr: arc_runners_pending > 5
for: 10m
annotations:
summary: "5+ jobs ждут runner больше 10 минут"Стоимость и насколько окупается
Считал по своему e-commerce-кейсу: средне 8000 jobs в месяц, средний job — 5 минут. На GitHub-hosted это ~80K минут, что выходит в $640/месяц на business-тарифе.
На self-hosted у меня 5 spot-нод m6a.2xlarge в AWS, постоянно 2 ноды и спайки до 5 — стоит около $200/месяц. Плюс время инженера на поддержку — пару часов в месяц.
Окупается, если у тебя минут больше пары тысяч в месяц. Меньше — оставайся на GitHub-hosted, не плоди технический долг.
Что запомнить
Self-hosted в k8s — это эластично и дёшево, если объём оправдывает. ARC от GitHub — текущий стандарт, не легаси-форки. GitHub App вместо PAT. Warm-pool через minRunners. Кеш — локальный S3, не GitHub-managed. Dind — privileged, выноси на отдельные ноды.
Куда копать: официальная документация ARC и репо actions/actions-runner-controller. Если нужны GPU — есть отдельный гайд по runtime-class и device plugins, я как-нибудь напишу.