Возьмём одну оплаченную минуту pipeline и посмотрим, на что она реально тратится. Если разложить её на части, полезной работы там обычно меньше, чем кажется: часть времени уходит на ожидание свободной машины, часть на подготовку окружения, скачивание зависимостей и образов, и только потом начинается то, ради чего задание запускали, то есть компиляция, тесты, упаковка. Счёт приходит за всё сразу. Поэтому вопрос «окупится ли собственный runner» на самом деле звучит так: сколько из этих минут можно убрать, и во что обойдётся то, что мы взамен возьмём на себя.

Ответ не универсален. Для одних проектов собственный runner на VPS экономит и деньги, и нервы, для других превращается в ещё одну машину, которую нужно кормить обновлениями. Ниже способ посчитать это на своих числах, не доверяя ни красивым обещаниям, ни собственной интуиции.

Из чего состоит одна сборка

Для начала возьмите последние 20–30 успешных запусков основного workflow и для каждого выпишите четыре величины:

  • ожидание: от события (push, открытие pull request) до фактического старта первого шага;
  • подготовка: checkout, установка языковых инструментов, скачивание зависимостей, подтягивание базовых образов;
  • полезная работа: компиляция, тесты, линтеры, сборка артефакта;
  • хвост: загрузка артефактов, публикация образа, очистка.

Интерфейс GitHub показывает длительность заданий и шагов, так что дополнительных инструментов не требуется, хватит таблицы. Важно разделять именно эти части, потому что собственный runner влияет на них по-разному. Ожидание он сокращает, если у вас есть выделенная машина, которая не стоит в общей очереди. Подготовку он сокращает, если зависимости и слои образов лежат рядом и не скачиваются заново. Полезную работу он ускоряет, только если ваш VPS мощнее той машины, на которой задание шло раньше, а это надо проверять, а не предполагать. Хвост почти не меняется.

Типичная ловушка: смотреть только на общую длительность и радоваться, что сборка стала короче на две минуты. Если эти две минуты съела подготовка, а не компиляция, вы выиграли на кеше, а не на мощности, и тот же эффект можно получить без отдельного сервера. Об этом дальше.

Сколько сборок вы реально запускаете

Второй шаг скучнее, но именно он определяет результат: месячный объём. Считать надо не количество коммитов, а количество запусков заданий и их суммарные минуты. В них входят pull request, которые перезапускаются после каждого fixup-коммита, ночные прогоны по расписанию, матрицы (одно описание workflow порождает несколько заданий), ручные перезапуски упавших джобов. Команда из шести человек с матрицей на три версии языка и двумя операционными системами легко получает на один push не одну сборку, а шесть.

Выпишите для месяца:

  1. число запусков каждого workflow;
  2. среднее число заданий в запуске;
  3. среднюю длительность задания в минутах, как её видит биллинг;
  4. долю запусков, которые завершились неуспешно или были отменены.

Последний пункт часто недооценивают. Упавшая сборка стоит денег так же, как успешная, а перезапуск стоит ещё раз. Поэтому в конце разумно мерить не «цену минуты», а цену успешно завершённой сборки: все потраченные минуты, включая неудачные, делённые на число зелёных результатов. К этой метрике мы вернёмся в конце.

Точка безубыточности на переменных

Теперь сведём всё в одну формулу. Цены GitHub и VPS здесь намеренно не подставлены: берите актуальные из своего тарифа и счёта, а числа ниже условные и нужны только чтобы показать ход расчёта.

Обозначим:

  • M — минут в месяц, которые вы платите на облачных runner;
  • c — цена одной такой минуты по вашему тарифу;
  • V — месячная стоимость VPS под runner;
  • H — часы в месяц на сопровождение runner, r — стоимость часа человека, который этим занимается;
  • k — во сколько раз ваши задания идут на VPS быстрее или медленнее (k меньше 1 — быстрее).

Расходы на облачных минутах: M × c. Расходы на собственном runner: V + H × r, плюс платные облачные минуты, которые всё равно останутся (например, задания, которые вы не переносите). Минуты на VPS не тарифицируются GitHub отдельно, поэтому важна не их цена, а ёмкость машины: хватит ли её, чтобы выполнить M × k минут работы без очереди.

Точка безубыточности по деньгам наступает, когда M × c > V + H × r. Условный пример: пусть вы переносите 6000 минут в месяц, VPS обходится в 1 000 условных единиц, а сопровождение занимает 3 часа по 500. Тогда расходы на своей стороне 2 500, и перенос выгоден, если облачная минута стоит дороже 2 500 / 6000 ≈ 0,42 единицы. Стоит минута дешевле — выигрыша нет, даже если VPS быстрый. Вырастет объём вдвое при тех же часах поддержки — порог упадёт примерно до 0,25, потому что фиксированная часть размазывается по большему числу минут. Именно поэтому маленькие проекты редко выигрывают на деньгах, а активные команды выигрывают чаще.

Заметьте, что самая «мягкая» переменная в формуле — H. Люди склонны ставить туда ноль, потому что поддержка кажется бесплатной, пока не сломается. Честнее заложить хотя бы пару часов в месяц и посмотреть, переживает ли расчёт такую поправку.

Дешёвая минута не гарантирует быстрый выпуск

Теперь о том, что формула выше не видит. Предположим, минута на вашем VPS «стоит почти ноль». Выпуск от этого быстрее не станет автоматически. Сборка ускоряется, когда выполняются три условия: у задания есть свободная машина, на ней тёплый кеш и ей хватает ресурсов, чтобы параллельные задания не мешали друг другу.

Если на VPS стоит один runner, он берёт задания по одному. Четыре задания матрицы встанут в очередь друг за другом, и общее время от push до зелёной галочки может оказаться больше, чем при облачном запуске, где те же четыре задания идут одновременно. Тут деньги вы сэкономили, а время потеряли. Время команды обычно дороже минут, поэтому такой «выигрыш» легко превращается в убыток: четыре разработчика, ждущие лишние восемь минут на каждый pull request, быстро перекрывают экономию на тарифе.

Решений два, и оба стоят ресурсов. Можно запустить на одной машине несколько runner-процессов, но тогда они делят процессор, память и диск, и ёмкость нужно мерить замером, а не по спецификации тарифа. Либо взять машину побольше или несколько машин поменьше. Ресурсы тарифа VPS не равны обещанию ёмкости: 4 vCPU и 8 ГБ памяти не означают «четыре параллельные сборки». Всё зависит от того, сколько процессора и памяти ест ваша сборка в пике. Проще всего прогнать типичный workflow при одном, двух и трёх одновременных заданиях и посмотреть, как растёт длительность. Если при двух заданиях каждое замедляется вдвое, параллелизма вы по сути не получили.

У организаций с несколькими репозиториями есть дополнительный рычаг: по документации GitHub, runner можно назначить на уровне репозитория, организации или enterprise. Один общий пул на организацию лучше загружается, чем отдельная машина на каждый репозиторий, где половина простаивает, пока на другой копится очередь.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации Россия, США, Франция и Великобритания. Оплата картой РФ и по СБП.

АРЕНДОВАТЬ СЕРВЕР

Кеш: где на самом деле прячется экономия

Вернёмся к подготовке. Для сборки контейнеров самый заметный выигрыш даёт слоевой кеш. На облачном runner каждое задание стартует на чистой машине, поэтому кеш приходится выгружать и загружать по сети. Документация Docker описывает для Buildx несколько backend кеша: inline, registry и local, причём доступность зависит от драйвера и image store (Docker: Cache storage backends). Registry-кеш хранится отдельно от итогового образа, и его нужно явно импортировать и экспортировать.

На собственном runner появляется третий путь: локальный кеш на диске машины. Слои остаются между запусками, и следующая сборка не тянет их заново. Для проектов с тяжёлыми зависимостями и большими базовыми образами это нередко главный источник ускорения, а не мощность процессора. Но у этого есть цена, и стоит назвать её честно.

  • Диск растёт. Старые слои, образы и рабочие каталоги копятся, и без регулярной очистки NVMe заполнится в самый неудобный момент. Политику очистки придётся описать и проверить.
  • Кеш делят все задания на машине. Он ускоряет, но и связывает: ошибка или подмена в общем кеше затронет следующие сборки.
  • Чистота окружения больше не гарантирована. Документация GitHub прямо отмечает, что self-hosted runner не обязан быть новой чистой машиной для каждого задания (GitHub Actions: Self-hosted runners). Значит, «у меня собралось, потому что осталась старая зависимость» превращается из мема в реальный риск.

Отсюда практический вывод для расчёта: прежде чем покупать сервер, замерьте, сколько времени подготовки можно убрать одним лишь внешним registry-кешем в текущей облачной конфигурации. Если этого хватает, сопровождать машину не придётся. Если нет и подготовка остаётся тяжёлой, у собственного runner есть реальный аргумент.

Отдельная заметка про секреты. В документации Docker отмечено, что передача секретов через COPY или ARG может их раскрыть, а для сборочных секретов есть отдельный механизм. На общем runner с долгоживущим кешем это особенно важно: то, что попало в слой, может пережить задание и оказаться в кеше, который прочитает следующее.

Что входит в стоимость сопровождения

Теперь вернёмся к переменной H и разберём, из чего она состоит. По документации GitHub, ОС, оборудованием и программами собственного runner управляете вы. Само приложение runner по умолчанию обновляется автоматически, но это не означает обновление всей системы и зависимостей. Обновления ядра, пакетов, Docker, компиляторов и сертификатов остаются на вас.

Реальные статьи этого бюджета выглядят так:

  • регулярные обновления системы и перезагрузки, которые нужно согласовать со сборками;
  • очистка диска и контроль его заполнения;
  • мониторинг: жив ли runner, не завис ли он на задании, не застряла ли очередь;
  • воспроизводимость: если машина умрёт, насколько быстро вы поднимете такую же, а не «похожую»;
  • разбор странных падений, причина которых в состоянии машины, а не в коде.

Последний пункт самый коварный. На облачном runner падение почти всегда означает проблему в коде или в workflow. На своей машине появляется третья категория: «в коде всё хорошо, но на этой машине осталось что-то от прошлого запуска». Каждый такой случай стоит часы поиска, и именно их нужно закладывать в H. Если сборки станут нестабильнее, цена успешной сборки вырастет, даже когда счёт за минуты упал.

Для организации, которая уже ведёт серверы, это сопровождение почти бесплатно: машина добавляется к существующему парку. Для команды без админа каждый лишний час оторван от разработки. Решайте честно, кто будет этим заниматься, и вписывайте его время в формулу.

Практическую настройку runner на VPS и деплой через rsync разбирает материал CI/CD на VPS через GitHub Actions: self-hosted runner и rsync, а здесь мы остаёмся на уровне решения: стоит ли браться.

Какой VPS подойдёт под сборки

Сборки нагружают машину неравномерно: короткие пики процессора и диска, потом тишина. Для такого профиля важны быстрый диск и достаточно памяти, чтобы компилятор и контейнеры не уходили в swap. У VPS MAATRIX диски NVMe, процессоры AMD EPYC и root-доступ, а root здесь нужен практически всегда: устанавливать Docker, настраивать очистку и управлять службой runner без него не получится. В линейке есть тарифы от Ba (1 vCPU, 2 ГБ RAM, 40 ГБ NVMe) до Djed (16 vCPU, 32 ГБ, 320 ГБ), с промежуточными Uab, Heka и Maat. Это параметры тарифов, а не обещание, сколько параллельных сборок выдержит машина: точное число определяется замером вашего workflow.

Разумный порядок такой: взять скромный вариант, прогнать на нём реальную сборку при разной параллельности и только потом решать, нужен ли тариф побольше. Оплата за месяц позволяет поменять решение без долгих обязательств, а локации (Россия, США, Франция, Великобритания) дают возможность поставить runner поближе к вашим репозиториям, артефактам и серверам деплоя, если задержка между ними заметна.

Код из pull request и граница доверия

Теперь вопрос, который нельзя отложить на потом. На собственном runner выполняется код из ваших репозиториев, а в pull request этот код пишете не всегда вы. Если репозиторий публичный или принимает правки от внешних контрибьюторов, workflow может запустить чужие команды на вашей машине, рядом с вашим кешем и вашей сетью. Облачный одноразовый runner эту проблему смягчает самим устройством: машина уничтожается после задания. У постоянного self-hosted runner такой страховки нет по умолчанию.

Поэтому проведите границу заранее и сделайте её простой:

  • runner на вашем VPS обслуживает только доверенные репозитории и ветки, а сборки внешних pull request остаются на облачных машинах;
  • на машине нет ничего, что вы не готовы показать участнику проекта: ни ключей от продакшена, ни общих учётных записей;
  • deploy-секреты привязаны к тем заданиям, которые запускаются только из защищённых веток.

Контейнер задания не решает вопрос сам по себе. Документация Docker называет доступ к rootful Docker daemon существенной границей доверия: управляя им, можно смонтировать файловую систему хоста (Docker Engine security). Namespaces и cgroups участвуют в изоляции, но конфигурация контейнера и дополнительные возможности процесса влияют на неё не меньше. Из этого следует практичное правило: если workflow получает доступ к Docker socket или запускает контейнеры в privileged-режиме, считайте, что код задания фактически получил управление над хостом. Для доверенных веток с этим можно жить, для чужого кода нельзя.

Если нужна сборка недоверенного кода именно на своей инфраструктуре, потребуется более строгая схема: отдельная машина без доступа к остальной вашей сети и без долгоживущих секретов, пересоздаваемая после заданий. Это уже иная стоимость, и её надо вносить в ту же формулу, а не обнаруживать после инцидента.

Как проверить гипотезу и оставить путь назад

Чтобы не ставить на кон весь процесс, переносите частично. Выберите один тяжёлый workflow с понятной нагрузкой, направьте его на runner VPS через label, а остальные оставьте в облаке. Две-три недели собирайте те же четыре величины, что и в начале: ожидание, подготовка, полезная работа, хвост, плюс долю неуспешных запусков. Сравните с историей до переноса.

Откат при такой схеме тривиален: вернуть прежний label в workflow и остановить службу runner. Именно поэтому выгодно не удалять облачную конфигурацию сразу. Если результат убедителен, переносите следующий workflow. Если нет, вы потеряли пару недель и скромный месячный платёж, а не неделю разбирательств.

Если окажется, что цена выросла из-за нестабильности или очередей, это тоже результат: возможно, разумнее остаться на облачных минутах и сократить расходы кешем, отменой устаревших запусков и сокращением матриц.

Считать нужно успешную сборку

Возвращаясь к оплаченной минуте из начала: цена минуты ничего не говорит о ценности. Любая схема, облачная или своя, проверяется двумя числами. Первое — стоимость успешно завершённой сборки: все деньги на минуты, VPS и сопровождение за месяц, делённые на число зелёных запусков. Второе — время, которое команда ждёт зелёную галочку, умноженное на число людей, которые в этот момент ничего другого не делают.

Собственный runner имеет смысл, когда оба числа уходят вниз: объём достаточен, кеш реально ускоряет, очередь не растёт, а сопровождение укладывается в запланированные часы. Если падает только первое, а второе растёт, вы просто переложили расходы из счёта в рабочие часы. Если падает только второе и дорожает первое, возможно, это оправданная плата за скорость. Решение остаётся за вами, но теперь оно опирается на ваши замеры, а не на цену одной минуты.