diff --git a/docs/drafts/alerts.md b/docs/drafts/alerts.md new file mode 100644 index 0000000..1d77fdc --- /dev/null +++ b/docs/drafts/alerts.md @@ -0,0 +1,82 @@ +# Алерты на проблемные контейнеры + +## Контекст + +Случай с wakapi: при старте упали миграции, контейнер встал в restart-loop и +несколько дней крутился по кругу — никто не узнал. Из этого две проблемы: + +1. Контейнеры могут бесконечно перезапускаться при ошибке. +2. Нет алертов о таких ситуациях. + +## Что есть и что использовать + +- **Netdata** — Docker-collector через cgroups + Docker API: состояние, + restart count, healthcheck status. Алерты в `health.d/*.conf`, нотификации + через `health_alarm_notify.conf` (Telegram/Discord/email/ntfy). +- **Dozzle** — только для просмотра логов после факта, нормальных алертов нет. +- **Caddy** — мог бы участвовать в healthcheck снаружи, но это отдельный слой. + +## План — три слоя + +### 1. Healthchecks в compose (фундамент) + +Без них Docker считает контейнер «running», пока процесс жив, — wakapi с +падающими миграциями этому условию удовлетворял. Добавить в каждый +`docker-compose.yml.j2`: + +```yaml +healthcheck: + test: ["CMD", "wget", "-q", "-O-", "http://localhost:PORT/health"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 60s # окно на миграции — failed-проверки до истечения не считаются +``` + +`start_period` — ключевая штука для случая wakapi: даём миграциям отработать, +до его истечения healthcheck не убивает контейнер. + +### 2. Алерты через Netdata (главное) + +Два разных сигнала: + +- **Restart loop** — алерт на `docker.container_state` или счётчик + перезапусков (растёт > N за M минут). Это и есть «контейнер крутится по + кругу». +- **Unhealthy** — после healthcheck выше алерт на + `docker.container_health_status != healthy` дольше M минут. + +Канал нотификаций: один, проще всего Telegram-бот. Настройка в +`health_alarm_notify.conf`. + +### 3. Restart policy — что менять (или не менять) + +Скорее **оставить `unless-stopped`**. Альтернативы и их минусы: + +- `on-failure:5` — Docker сам остановит после 5 попыток. Минус: после ребута + сервера сервис не поднимется (только `always`/`unless-stopped` встают на + старте докера). Серьёзный регресс для домашнего сервера. +- Внешний sidecar, слушающий `docker events` и останавливающий контейнер + после N рестартов в окне — лишняя сложность ради того, что уже сделает + алерт. + +Лучше: алерт пришёл → решаем вручную, остановить или чинить. + +## Опционально (вне netdata) + +- **Uptime Kuma** — внешний HTTP-чек по публичным URL. Ловит случаи, когда + контейнер «здоров», но прокся/DNS/Caddy сломаны. Свои нотификации, дашборд. + Не дублирует netdata, проверяет с другой стороны. + +## Шаги при реализации + +1. Добавить healthcheck + start_period в compose-шаблоны (начать с wakapi, + потом по списку). +2. Проверить, что netdata собирает Docker-метрики (collector включён). +3. Настроить один канал нотификаций (Telegram/ntfy/email — выбрать). +4. Написать пару алертов: restart-loop и unhealthy. + +## Открытые вопросы + +- Какой канал нотификаций использовать. +- Добавлять ли Uptime Kuma сразу или потом. diff --git a/docs/drafts/gitea-runner-on-demand.md b/docs/drafts/gitea-runner-on-demand.md new file mode 100644 index 0000000..ed14d58 --- /dev/null +++ b/docs/drafts/gitea-runner-on-demand.md @@ -0,0 +1,285 @@ +# 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. +- **Перенос ВМ в отдельный фолдер** — когда в общем появится вторая + ВМ. Пока не критично.