# 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/`. Этот URL вписывается в Gitea webhook. Аутентификация — HMAC-SHA256 в заголовке `X-Gitea-Signature`, проверяется внутри функции. Поток событий: 1. Push в Gitea → System Webhook на URL функции. 2. Функция валидирует HMAC, читает state ВМ, действует по стейт-машине (см. ниже). 3. ВМ стартует, docker поднимает контейнер `act_runner`, тот подключается к Gitea и забирает джобу. 4. На ВМ работают probe (раз в 30 сек собирает телеметрию) и decide (раз в 1 мин принимает решение). 5. После 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:`), `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.`, не `.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 сек): ```bash # pseudocode busy_count=$(docker ps -q | grep -v | 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 мин): 1. Читает хвост `samples.log`. 2. Находит `last_busy_at` — timestamp последней `busy`-строки. 3. Находит `last_sample_at` — timestamp последней любой строки. 4. Логика: - `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: ```python 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, ручное расследование. ## План внедрения 1. Создать в YC: 2 SA, ВМ, дисковый ресурс. Через `inv runner-bootstrap` или вручную через консоль (выбираем по желанию на этапе реализации). 2. Прогнать `inv runner-pl` на свежесозданной ВМ. С временно уменьшенным `IDLE_THRESHOLD` (2 мин вместо 10) — чтобы тестировать гашение быстро. 3. Зарегистрировать раннер в Gitea руками: получить registration token, положить в Vault, повторить `inv runner-pl`. 4. Проверить, что раннер появился в Gitea UI и забирает тестовую джобу. 5. Проверить idle-watch: дать ВМ постоять, убедиться, что гасится. 6. Создать функцию `runner-starter` через `inv runner-deploy-function`. Проверить ручным `yc serverless function invoke`. 7. Прописать System Webhook в Gitea на URL функции, секрет совпадает с Vault. 8. Тестовый push → end-to-end проверка. 9. Поднять `IDLE_THRESHOLD` обратно до 10 мин. 10. Настроить алерт Cloud Monitoring на uptime > 24 ч. 11. Неделя наблюдения: лог функции, samples.log, uptime ВМ, счёт. ## Открытые вопросы - **Канал нотификаций** для алерта Cloud Monitoring (Telegram, ntfy, email) — выбрать на этапе настройки. - **Тип executor** в act_runner — docker (по умолчанию) или host. Ходим через docker, host-executor пока не обсуждается. - **Webhook на pull request** — нужно или только push? По умолчанию только push. Расширим, если возникнет PR-flow. - **Перенос ВМ в отдельный фолдер** — когда в общем появится вторая ВМ. Пока не критично.