Последние месяцы я довольно активно разрабатываю собственный AI SaaS‑проект. Причём активность в случае AI‑разработки имеет немного другой смысл, чем раньше. Когда значительную часть работы выполняют AI‑агенты, скорость появления изменений резко возрастает. Агент создал изменения, открыл PR, другой агент или workflow проверил результат, если что‑то не прошло, то новая итерация.
И довольно быстро я обнаружил неожиданное ограничение этой модели разработки. Если честно я ожидал и боялся упереться в токены LLM. Были небольшие опасения в производительности моего ноутбука. И даже сложность оркестрации не стала блокером.
Я упёрся в GitHub Actions квоту.

Как AI‑разработка начала съедать CI
В обычном проекте человек сделал несколько изменений, отправил pull request и подождал CI. В AI‑assisted разработке стоимость запуска CI почти не ощущается на уровне одной операции. Агентов не особенно волнует, что очередной запуск pipeline стоит денег.
Изменение.
Commit.
Push.
CI.
Review.
Исправление.
Снова commit.
Снова CI.
Добавляем несколько параллельно развивающихся веток, автоматические проверки, validation workflows, и количество запусков начинает расти намного быстрее, чем при моей обычной ручной разработке. «Масла в огонь» подлило моё неутолимое желание к автономности работы агентов — чем ближе я был к цели тем ближе был блокер.
В какой‑то момент GitHub сообщил, что включённая квота Actions заканчивается.
Первой реакцией было удивление. Стоит упоминуть что во всех компаниях я работал в GitLab и внешних системах CI/CD системах, типа JetBrains TeamCity. Даже мысли не было что кто‑то тарифицирует минуты.
На free плане на месяц бесплатно предоставляется 2000 минут.
Первое, самое очевидное решение: увеличить бюджет. Месяц заканчивался (оставалось 7 дней). Я добавил лимит расходов (10$) и продолжил работать. В тот же день я приостановил все активные задачи на проектах и приступил к оптимизации использования GitHub Actions — оптимизировать было много чего, так как ранее не оглядывался на ограничения. В общем за оставшуюся неделю я почти израсходовал бюджет, но перешел в новый месяц без остановок.
Но наблюдая как за первые 5 дней я прошел отметку 1300 из 2000 минут, я понял что решать проблему надо иначе. Я задал себе довольно простой вопрос:
Почему я вообще покупаю вычислительные минуты у GitHub, если могу купить вычислительную машину целиком?

Так начался небольшой инфраструктурный эксперимент, который в итоге закончился переносом основной части CI на собственный GitHub Actions runner.
Немного экономики
Для private repositories GitHub предоставляет определённое количество GitHub Actions минут в зависимости от плана. После исчерпания квоты начинается поминутная тарификация. https://docs.github.com/en/billing/concepts/product‑billing/github‑actions#free‑use‑of‑github‑actions

На момент написания статьи стандартный GitHub‑hosted Linux runner стоит:
$0.006 за минуту.
Для сравнения, небольшой Hetzner Cloud сервер можно держать постоянно примерно за 6 евро в месяц. Например, после изменения цен летом 2026 года:
CX23 — €5.49/месяц без IPv4 и VAT;
CAX11 — €5.99/месяц без IPv4 и VAT.
Разумеется, сравнение не полностью честное. GitHub‑hosted runner является managed infrastructure.
GitHub:
создаёт чистое окружение;
обслуживает инфраструктуру;
обновляет образ;
уничтожает VM после job;
занимается availability;
обеспечивает удобную интеграцию.
Self‑hosted runner означает: всё это теперь ваша проблема. Однако для моего сценария появилась интересная экономика.
При $0.006/min:
1000 дополнительных минут ≈ $6.
3000 минут ≈ $18.
5000 минут ≈ $30.
10 000 минут ≈ $60.
А небольшой сервер имеет практически фиксированную месячную стоимость. Точка экономической целесообразности появляется довольно быстро, особенно если CI активно используется AI‑агентами.
Архитектура осталась очень простой:
GitHub Actions
↓
self‑hosted runner
↓
мой Hetzner VM
С точки зрения workflow меняется практически только место исполнения. Например, раньше:
runs-on: ubuntu-latest
После migration:
runs-on: [self-hosted, linux, x64, playbook-ci]
Конечно я не строил альтернативу GitHub Actions, а просто заменил арендованную поминутно вычислительную мощность собственной.
Почему я не стал сразу подключать production CI
Сам GitHub позволяет зарегистрировать self‑hosted runner довольно быстро, но «runner появился Online» и «на него можно безопасно перенести CI», это совершенно разные утверждения. Поэтому миграцию я разделил на два этапа. Сначала сделал локальный Docker pilot. Только после его проверки перенёс runner на отдельную VM.
Шаг 1. Отдельная машина
Я сознательно не стал устанавливать runner:
на рабочий компьютер;
на production server;
на машину с другими credentials;
на сервер с пользовательскими данными.
Для runner была создана отдельная VM. Минимальная базовая конфигурация:
Linux;
Docker;
Git;
outbound HTTPS;
несколько гигабайт RAM;
достаточно SSD под checkout и временные файлы.
Self‑hosted runner в моём случае repository‑level, то есть он предназначен для одного конкретного repository, что уменьшает blast radius.
Шаг 2. Docker вместо установки runner прямо на host
Runner можно поставить непосредственно на Linux. Я выбрал Docker. Причина не в моде на контейнеры. Мне хотелось явно зафиксировать security boundary.
Контейнер runner:
работает не от root;
не использует privileged mode;
получает
no-new-privileges;лишён Linux capabilities;
не получает Docker socket;
не использует host network;
не получает repository host mount;
не публикует ports.
В локальном pilot runtime user имел отдельный UID/GID. Главный принцип:
CI job не должен автоматически получать власть над host только потому, что я решил сэкономить на GitHub Actions.
Особенно это важно, если значительная часть изменений генерируется AI.
Шаг 3. Регистрация runner
В GitHub:
Repository → Settings → Actions → Runners → New self-hosted runner
GitHub выдаёт инструкции и короткоживущий registration token. Я не сохраняю его в .env. На машине значения передаются через environment:
export RUNNER_URL=https://github.com/OWNER/REPOSITORY export RUNNER_TOKEN='<short-lived-token>' export RUNNER_NAME=my-ci-runner
После этого запускается подготовленный Compose:
cd infrastructure/github-actions-runner docker compose build --pull docker compose up -d
В моей реализации registration token передаётся контейнеру как Docker Compose secret, то есть вместо хранения токена в обычных environment variables контейнера он доступен runner во временном secret mount. После регистрации он больше не нужен job.
Шаг 4. Проверяем, что мы случайно не дали runner слишком много прав
После запуска:
docker compose ps
Затем проверяем container boundary:
docker inspect <runner-container> \ --format '{{json .HostConfig.Privileged}} {{json .HostConfig.Binds}} {{json .HostConfig.PortBindings}} {{json .HostConfig.CapDrop}} {{json .Config.User}}'
Я ожидаю увидеть:
Privileged = false;отсутствие Docker socket;
отсутствие host binds;
отсутствие published ports;
dropped capabilities;
отдельного non‑root user.
Проверяю это после запуска, потому что security configuration в YAML и фактическая runtime configuration — не одно и то же доказательство.
Шаг 5. Labels
Runner получил стандартные labels: self‑hosted, linux, x64. И дополнительный repository‑specific label: playbook‑ci. Workflow маршрутизируется только на полный набор:
runs-on: [self-hosted, linux, x64, playbook-ci]
Это лучше, чем просто:
runs-on: self-hosted
Когда ранеров станет больше, слишком общий selector способен отправить job совсем не туда, куда вы ожидали.
Первая проблема: persistent runner не есть GitHub‑hosted runner
GitHub‑hosted runner обычно даёт свежую VM для job в отличии от Persistent self‑hosted runner. И это меняет модель исполнения. Представьте:
Job A оставил файл.
Job B запустился через десять минут.
Если B случайно использовал этот файл, вы получили зависимость от предыдущего запуска.
CI зелёный, но результат невоспроизводим.
Для AI‑разработки это особенно неприятно: число запусков большое, а последовательность изменений менее предсказуема. Поэтому пришлось сделать cleanup частью контракта runner. Перед/после execution очищается repository state:
git reset --hard git clean -ffdx
Также удаляются временные validation artifacts. Cleanup после workflow выполняется даже при failure.
Например:
- name: Cleanup if: always() run: | git reset --hard git clean -ffdx
Self‑hosted runner без продуманного cleanup может быть дешевле, но одновременно он способен сделать CI менее надёжным.
Вторая проблема: «Online» ничего не доказывает
После регистрации runner красиво появился в GitHub. Зелёная точка (Online), но я не стал сразу менять все:
runs-on: ubuntu-latest
на:
runs-on: [self-hosted, linux, x64, playbook-ci]
Сначала появился отдельный synthetic smoke workflow. Я проверил:
checkout конкретного commit SHA;
запуск validation;
повторный запуск на том же SHA;
cleanup;
restart runner;
повторный запуск после restart.
Перезапуск контейнера:
docker compose restart runner
После него тот же smoke должен снова пройти. Для проверки свойства: runner работает не потому, что я один раз вручную довёл его до правильного состояния, а он способен восстановиться.
Третья проблема: одинаковый код должен дать одинаковый результат
Самая важная проверка перед migration была простой:
один exact commit SHA должен успешно выполняться и на GitHub‑hosted, и на self‑hosted runner.
То есть:
exact SHA │ ├── ubuntu-latest → success │ └── self-hosted → success
Это сильно лучше, чем: «На новом runner вроде всё работает». Мы сравниваем одинаковый input. Если результаты расходятся, проблема находится в execution environment, а не в коде между двумя разными commits. Только после этой проверки я начал переносить реальные workflows.
Четвёртая проблема: rollback нужно проверить до того, как он понадобится
Обычно rollback выглядит так: «Если что‑то сломается, то вернём обратно». Это не rollback plan, а надежда. Я оставил возможность вернуть workflow на:
runs-on: ubuntu-latest
и отдельно проверил hosted execution. Получилась довольно простая схема:
GitHub Actions | +---- self-hosted runner ← основной путь | +---- ubuntu-latest ← проверенный fallback
При проблеме с VM я не чиню инфраструктуру под давлением сломанного CI, а возвращаю execution placement на GitHub‑hosted runner.
Пятая проблема: cancellation
Happy path проверить легко, однако гораздо интереснее:
Что произойдёт с persistent runner, если job оборвать посередине?
Я отдельно проверял cancellation/timeout и последующий запуск того же workflow. Runner после cancellation не должен навсегда оставаться: Busy или Offline. И workspace не должен содержать состояние отменённого job. После преднамеренного cancellation следующий запуск на том же SHA должен успешно завершиться, иначе экономия нескольких долларов превращается в весьма сомнительную сделку.
Шаг 6. Переносим production workflows
Только после smoke, restart recovery, equivalence и rollback я изменил execution placement реальных jobs.
runs-on: ubuntu-latest → runs-on: [self-hosted, linux, x64, playbook-ci]
При этом я сознательно не менял одновременно:
workflow triggers;
permissions;
concurrency;
timeout;
validation commands;
required check names;
branch protection.
Это очень полезный принцип для инфраструктурных миграций:
Если меняете execution environment, то постарайтесь не менять одновременно semantics самого workflow.
Иначе при failure вы не будете понимать, что именно сломалось.
Что в итоге получилось
Сейчас модель выглядит так. GitHub по‑прежнему:
принимает push;
запускает workflow;
ставит jobs в очередь;
показывает результаты;
хранит CI evidence;
управляет lifecycle workflow.
Но вычисления выполняет моя VM. То есть я сохранил удобство GitHub Actions, но перестал использовать GitHub‑hosted Linux capacity для основной части подходящих jobs. Со стороны GitHub использование self‑hosted runner не тарифицируется поминутно. Я оплачиваю инфраструктуру самостоятельно.
А действительно ли это дешевле?
Зависит от нагрузки. Допустим, GitHub‑hosted Linux стоит $0.006/min. Тогда дополнительные:
1 000 min → $6 3 000 min → $18 5 000 min → $30 10 000 min → $60
Теперь сравним это с VM порядка €5–10 в месяц. Грубая break‑even point получается уже в районе первых одной‑двух тысяч платных Linux minutes в месяц.
Self‑hosted runner требует:
первоначальной настройки;
patching OS;
мониторинга диска;
обновлений;
диагностики;
security;
backup/restore strategy там, где она нужна;
cleanup;
incident handling.
Поэтому для проекта, который использует 100 дополнительных CI minutes в месяц, заниматься этим ради экономии бессмысленно, но AI меняет экономику.
Почему AI‑assisted development меняет расчёт
Раньше ограничителем CI throughput часто был человек. Человек пишет код относительно медленно, поэтому количество meaningful commits, PR и validation cycles естественным образом ограничено. AI‑agent может генерировать итерации существенно быстрее и тогда инфраструктурные расходы начинают зависеть уже не столько от количества разработчиков, сколько от:
agents × iterations × validations × retries
Получается довольно интересный эффект. Мы обсуждаем стоимость LLM tokens, выбираем более дешёвые модели, оптимизируем prompts, а рядом может находиться менее заметная статья расходов:
машинное подтверждение результатов машинной работы.
Чем автономнее становится development loop, тем больше вычислений требуется не только для генерации, но и для проверки.
Когда я бы НЕ использовал self‑hosted runner
Несмотря на результат эксперимента, переносить всё на self‑hosted я бы не советовал. GitHub‑hosted runners прекрасны, когда:
CI запускается редко;
included minutes хватает;
не хочется обслуживать инфраструктуру;
требуется clean VM для каждого job;
выполняется untrusted code;
нужна большая эластичная concurrency;
стоимость инженерного обслуживания выше стоимости minutes.
скорость прохождения CI не критична
Особенно осторожно нужно относиться к публичным repositories, так как. Self‑hosted runner это ваша машина, выполняющая код workflow. Если вы разрешили непроверенному pull request получить execution на ней, последствия могут оказаться значительно дороже GitHub Actions bill.
Когда self‑hosted начинает иметь смысл
Для меня решение стало рациональным, когда одновременно появились три условия.
Первое: высокая частота CI. AI‑assisted development генерировал достаточно много validation cycles.
Второе: предсказуемая нагрузка. Мне не требовались десятки параллельных runners. Один persistent runner с concurrency = 1 закрывал текущий workload.
Третье: инфраструктура стала частью эксперимента.
Мне всё равно требовалось понимать execution boundary, reproducibility, recovery и evidence. Поэтому настройка runner была не только способом уменьшить bill, но и полезной частью архитектуры проекта.
Несколько рекомендаций после этого эксперимента
Если решите повторить:
Не начинайте с production workflow. Сначала отдельный smoke.
Не устанавливайте runner на машину с ценными credentials. CI выполняет код. Относитесь к нему соответствующим образом.
Не монтируйте
/var/run/docker.sockпросто потому, что так проще. Это практически передача runner контроля над Docker host.Не храните registration token в repository или
.env. Он должен быть short‑lived operational secret.Используйте специфичные labels.
runs-on: [self-hosted, linux, x64, your-project-ci]Проверяйте cleanup. Persistent environment создаёт новый класс скрытого mutable state.
Проверяйте exact‑SHA equivalence. Одинаковый commit на hosted и self‑hosted — очень полезный migration test.
Проверьте restart. Не считайте систему рабочей, пока она не пережила перезапуск.
Проверьте cancellation. Happy path всегда недостаточное доказательство.
Сначала проверьте rollback, потом делайте cutover.
Вывод
Началось всё с довольно банальной проблемы. Я слишком активно использовал GitHub Actions и упёрся в quota. Первое решение было финансовым: добавить бюджет. Второе уже инфраструктурным: купить собственную execution capacity; но в процессе появился более интересный вывод.
Когда AI начинает активно участвовать в разработке, стоимость software engineering постепенно перемещается. Мы платим не только за людей, LLM tokens. Мы начинаем платить за инфраструктуру, которая позволяет машинам проверять работу других машин и если development loop выглядит примерно так:
AI → code → CI → review → AI → fix → CI → ...
то стоимость CI перестаёт быть мелкой технической деталью. Она становится частью unit economics AI‑assisted development, а значит, рано или поздно вопрос будет не только:
Какую модель использовать?
Но и:
На чьём железе все эти агенты будут доказывать, что написанный ими код действительно работает?

