Files
pet-project-server/docs/drafts/gitea-runner-on-demand.md
2026-05-22 19:13:05 +03:00

16 KiB
Raw Permalink Blame History

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, проверяется внутри функции.

Поток событий:

  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.ymlact_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 мин):

  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:

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.
  • Перенос ВМ в отдельный фолдер — когда в общем появится вторая ВМ. Пока не критично.