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