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

286 lines
16 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.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 сек):
```bash
# 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:
```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.
- **Перенос ВМ в отдельный фолдер** — когда в общем появится вторая
ВМ. Пока не критично.