16 KiB
Gitea runner on-demand в Yandex Cloud
Контекст
В YC планируется развернуть self-hosted раннер для Gitea Actions. Сборки — несколько раз в неделю, в среднем ~10 в неделю по ~5 минут. ВМ 24/7 даёт утилизацию в районе 1%, остальное оплачивается впустую.
Цель — раннер активен только во время сборки и небольшого окна простоя после, без ручных команд от разработчика.
Архитектура
push → Gitea ──webhook──► Cloud Function ──Compute API──► ВМ (раннер)
(HMAC validate, │
start logic) ▼
act_runner (docker)
probe + decide
│
└─REST self-stop
Без API Gateway: функция публикуется напрямую через свой HTTPS-эндпоинт
https://functions.yandexcloud.net/<id>. Этот URL вписывается в Gitea
webhook. Аутентификация — HMAC-SHA256 в заголовке X-Gitea-Signature,
проверяется внутри функции.
Поток событий:
- Push в Gitea → System Webhook на URL функции.
- Функция валидирует HMAC, читает state ВМ, действует по стейт-машине (см. ниже).
- ВМ стартует, docker поднимает контейнер
act_runner, тот подключается к Gitea и забирает джобу. - На ВМ работают probe (раз в 30 сек собирает телеметрию) и decide (раз в 1 мин принимает решение).
- После idle-окна decide дёргает Compute REST API на gas самой себя.
Cloud-side
Ресурсы в YC
- Один фолдер на старте — общий с Gitea-сервером. Принятый риск: SA самогашения формально может остановить любую ВМ в фолдере. Перенос в отдельный фолдер — миграция на потом.
- Два сервисных аккаунта:
runner-self-stop(привязан к ВМ):compute.instances.stop,compute.instances.get.runner-starter-fn(привязан к функции):compute.instances.start,compute.instances.get.
- Cloud Function
runner-starter, runtime Python 3.11, 256 MB, timeout 30 сек. Публичный HTTPS-эндпоинт включён. - Алерт Cloud Monitoring:
compute.instance.status = RUNNINGдольше 24 ч подряд → нотификация (канал — на этапе внедрения).
Bootstrap-скрипты
scripts/
├── runner-starter/ # код Cloud Function
│ ├── handler.py # webhook → start, стейт-машина
│ └── requirements.txt
├── runner_bootstrap.py # one-time: создать SA, ВМ, функцию, алерт
└── runner_deploy_function.py # обновить версию функции (yc CLI)
Скрипты на Python поверх yc CLI (через subprocess). Идемпотентность —
проверкой существования ресурсов перед созданием. Terraform не вводим:
ресурсов мало, оверкилл.
Стейт-машина функции
| State ВМ | Действие |
|---|---|
RUNNING, STARTING, RESTARTING |
200, ничего не делаем |
STOPPED |
instances:start → 200 |
STOPPING |
poll до STOPPED (до 25 сек), затем start → 200 |
PROVISIONING, UPDATING |
503 (временное состояние, retry клиентом) |
ERROR, CRASHED |
500 + лог ошибки (нужен человек) |
DELETING, DELETED |
500 + лог ошибки (что-то очень не так) |
Host-side
ВМ
- 2 vCPU (100%), 4 GB RAM, 25 GB network-hdd.
- Ubuntu 22.04 LTS.
- Без публичного IP при возможности (все исходящие к Gitea — через NAT или внутренний адрес).
- Привязан SA
runner-self-stop. - Регистрация в Gitea Actions делается один раз при первой настройке.
Registration token берётся в Site Admin → Actions → Runners, кладётся
в Vault. Плейбук проверяет наличие
.runnerфайла на ВМ; если есть — пропускает регистрацию.
Плейбук playbook-gitea-runner.yml
Стандартная структура проекта:
roles/owner— пользовательgitea-runner(uid/gid выделить отдельные, в группеdocker).files/gitea-runner/:docker-compose.template.yml—act_runnerв docker (gitea/act_runner:<pinned>),restart: unless-stopped, mount docker socket для запуска job-контейнеров.act-runner-config.template.yaml— конфиг раннера.runner-probe.template.py+runner-probe.template.service+runner-probe.template.timer.runner-decide.template.py+runner-decide.template.service+runner-decide.template.timer.samples-logrotate.template.conf— ротацияsamples.log.
Расширения шаблонов — .template.<ext>, не .j2 (соглашение проекта).
Раннер в docker
act_runner стартует через docker compose up -d под пользователем
gitea-runner. Поскольку restart: unless-stopped, дополнительный
systemd-юнит для самого раннера не нужен — после Start ВМ docker
поднимет контейнер автоматически.
Идентификатор контейнера фиксированный (gitea_runner_app), чтобы probe
мог исключать его из счёта.
Probe и decide
Два независимых юнита, телеметрия — append-only лог.
runner-probe (timer раз в 30 сек):
# pseudocode
busy_count=$(docker ps -q | grep -v <runner_container_id> | wc -l)
state=$([ "$busy_count" -gt 0 ] && echo busy || echo idle)
echo "$(date -u +%FT%TZ) $state containers=$busy_count" \
>> /var/lib/runner-idle/samples.log
В реальной реализации — Python, фильтр по docker SDK или по результату
docker ps --format '{{.Names}}'.
runner-decide (timer раз в 1 мин):
- Читает хвост
samples.log. - Находит
last_busy_at— timestamp последнейbusy-строки. - Находит
last_sample_at— timestamp последней любой строки. - Логика:
now - last_sample_at > STALE_THRESHOLD(5 мин) → probe сломан, не гасим, логируем error. Алерт CM поймает по uptime.now - last_busy_at > IDLE_THRESHOLD(10 мин) →instances:stopчерез REST.- Иначе → ничего.
Параметры (IDLE_THRESHOLD, STALE_THRESHOLD) — переменные в шаблоне,
тюнятся по эксплуатации.
Самогашение через REST
Без yc CLI. Decide-скрипт получает IAM-токен из metadata-сервиса и
дёргает Compute REST:
TOKEN_URL = "http://169.254.169.254/computeMetadata/v1/instance/" \
"service-accounts/default/token"
ID_URL = "http://169.254.169.254/computeMetadata/v1/instance/id"
HEADERS = {"Metadata-Flavor": "Google"}
token = requests.get(TOKEN_URL, headers=HEADERS).json()["access_token"]
instance_id = requests.get(ID_URL, headers=HEADERS).text
requests.post(
f"https://compute.api.cloud.yandex.net/compute/v1/instances/{instance_id}:stop",
headers={"Authorization": f"Bearer {token}"},
)
Никаких файлов с SA-key, никаких зависимостей сверх python3 + requests.
Страховки от зависшей ВМ
Главный failure mode — probe или decide молча сломались, ВМ работает 24/7.
Слой 1 — soft idle-stop в decide. Нормальная работа.
Слой 2 — probe-staleness в decide. Если samples.log не обновляется
дольше STALE_THRESHOLD — логируем error, не гасим (мог идти
длинный билд). Полагаемся на слой 3.
Слой 3 — внешний алерт через Cloud Monitoring на uptime ВМ > 24 ч. Не дёргает остановку, только нотификация. Порог высокий, чтобы дни активной отладки не триггерили его. Если фактически висит сутки — это сигнал смотреть руками.
Hard-cap по uptime внутри decide не делаем: ломает кейс «активно тестирую несколько часов подряд», когда busy-сэмплы есть и логика идёт правильно.
Секреты (Vault, vars/secrets.yml)
| Ключ | Назначение |
|---|---|
gitea_runner_registration_token |
одноразовый токен для act_runner register |
gitea_webhook_secret |
общий с функцией HMAC-секрет для webhook |
yc_runner_folder_id |
в каком фолдере живёт ВМ |
yc_runner_instance_id |
ID ВМ (заполняется после bootstrap) |
yc_runner_function_url |
URL функции для webhook (заполняется после bootstrap) |
invoke-таски
| Таск | Что делает |
|---|---|
inv runner-bootstrap |
one-time: создаёт SA, ВМ, функцию, алерт. Идемпотентен. |
inv runner-deploy-function |
заливает новую версию runner-starter. |
inv runner-pl |
up → ansible-playbook playbook-gitea-runner.yml → down. С try/finally. |
inv runner-up / down |
ручной старт/стоп ВМ для дебага. |
inv runner-status |
state ВМ + хвост samples.log (через ssh). |
inv runner-ssh |
ssh на ВМ, поднимает её при необходимости. |
runner-pl — основной таск, единственный «штатный» путь обновления
конфига ВМ. Если плейбук падает посередине, finally всё равно гасит ВМ
(idle-watch её и так бы погасил, но явное лучше).
Стоимость
Базовая ставка YC (USD, после повышения 1 мая 2026): vCPU 100% = $0.010164/ч, RAM = $0.002705/ГБ·ч, network-hdd = $0.0000356/ГБ·ч.
Профиль: 10,75 ч активной ВМ в месяц.
| Конфиг (2 vCPU 100%, 4 GB RAM, 25 GB HDD) | $/мес |
|---|---|
| Compute (vCPU + RAM) при 10,75 ч | ~0.33 |
| Disk (HDD, 24/7) | ~0.64 |
| Cloud Function, Monitoring | 0.00 |
| Итого | ~1.0 |
Сравнение: эта же ВМ в режиме 24/7 ≈ $23/мес. Экономия — порядка 95%.
Дальнейшая оптимизация — диск (15 GB вместо 25, ещё ~$0.25/мес). Делать не сейчас.
Принятые риски
- Общий фолдер с другими ВМ. SA
runner-self-stopтеоретически может погасить и Gitea-сервер, если тот переедет в YC рядом. Митигация при появлении такой ВМ — перенос в отдельный фолдер. - Холодный старт ~60 сек. Дизайн заявляет 40, реальность ближе к 60 (Ubuntu boot + docker pull + act_runner connect). Документируем как «нормальная задержка первой джобы».
- Регистрационный токен утерян. При пересоздании диска ВМ нужен новый токен из Gitea UI. Документируем процесс. Раз в годы — терпимо.
- Probe сломан, ВМ висит. Поймает алерт CM, ручное расследование.
План внедрения
- Создать в YC: 2 SA, ВМ, дисковый ресурс. Через
inv runner-bootstrapили вручную через консоль (выбираем по желанию на этапе реализации). - Прогнать
inv runner-plна свежесозданной ВМ. С временно уменьшеннымIDLE_THRESHOLD(2 мин вместо 10) — чтобы тестировать гашение быстро. - Зарегистрировать раннер в Gitea руками: получить registration token,
положить в Vault, повторить
inv runner-pl. - Проверить, что раннер появился в Gitea UI и забирает тестовую джобу.
- Проверить idle-watch: дать ВМ постоять, убедиться, что гасится.
- Создать функцию
runner-starterчерезinv runner-deploy-function. Проверить ручнымyc serverless function invoke. - Прописать System Webhook в Gitea на URL функции, секрет совпадает с Vault.
- Тестовый push → end-to-end проверка.
- Поднять
IDLE_THRESHOLDобратно до 10 мин. - Настроить алерт Cloud Monitoring на uptime > 24 ч.
- Неделя наблюдения: лог функции, samples.log, uptime ВМ, счёт.
Открытые вопросы
- Канал нотификаций для алерта Cloud Monitoring (Telegram, ntfy, email) — выбрать на этапе настройки.
- Тип executor в act_runner — docker (по умолчанию) или host. Ходим через docker, host-executor пока не обсуждается.
- Webhook на pull request — нужно или только push? По умолчанию только push. Расширим, если возникнет PR-flow.
- Перенос ВМ в отдельный фолдер — когда в общем появится вторая ВМ. Пока не критично.