Написал утилиту, которая считает мои облачные счета: немного кода, граблей и советов

Обсудить

Этот текст написан в Сообществе, в нем сохранены авторский стиль и орфография

Аватар автора

Антон Черкасов

Страница автора

Каждый квартал у меня один и тот же ритуал. Приходит счёт, не совсем такой, какой ожидается, я открываю пять вкладок с личными кабинетами разных провайдеров и полвечера выясняю, откуда лишнее. Обычно что-нибудь да нахожу, иногда диск от проекта, который закрылся ещё весной, иногда снапшот, который забыл удалить.

Этим летом я решил, что раз ношу гордое имя "разработчик", самое время поработать немного на самого себя. И вместо того чтобы в очередной раз кликать по личным кабинетам, сесть и написать утилиту, которая делала бы это за меня. Рассказываю, что получилось.

Коротко, что я сделал

Небольшой self-hosted-сервис на Go, который раз в час обходит все мои облака, сводит в одно место, сколько я где трачу, прикидывает счёт к концу месяца и пишет мне в личку, если находит ресурс, который тикает деньги, но ни в одном моём деплое не значится. Назвал costwatch (фантазия у меня на нуле, извините).

Что внутри

Идея была простая, как пробка: свести всё к одному числу и одному алерту. В начале всё ожидаемо уперлось в то, что провайдеры между собой не похожи вообще ничем. Поэтому в основе один интерфейс, за которым прячется каждый из них:

// Дальше коду всё равно, VPSка это, S3-бакет или арендованная GPU-машина.
type Provider interface {
// Сколько накапало с начала месяца, разбито по ресурсам.
MonthToDate(ctx context.Context) ([]Charge, error)
// Что провайдер реально считает существующим — то, за что он выставит счёт.
Inventory(ctx context.Context) ([]Resource, error)
}

Дальше — один Go-бинарь, один контейнер через Docker Compose (поднимать кубер ради одного бинаря немножечко лень), провайдеры описаны в yaml, ключи зашифрованы sops с age и в таком виде лежат прямо в гите:

# providers.yaml
providers:
- name: prov-name
kind: vps
region: ru-1
token: ENC[AES256_GCM,data:…] # sops+age
- name: prov-name-s3
kind: s3
endpoint: s3.prov-name…
access_key: ENC[AES256_GCM,data:…]

Метрики я не стал городить у себя, отдал в уже поднятые VictoriaMetrics и Grafana, алерты гоню через тот же Alertmanager, что и так крутится. Сам costwatch живёт на VPS в Selectel, а бэкапы его базы уезжают в S3 к Cloud4U. На выходе в Grafana просто три семейства метрик:

costwatch_mtd_rub{provider="prov-name"} 8412.5
costwatch_forecast_rub{provider="prov-name"} 14900
costwatch_orphan_total{provider="prov-name"} 1

А теперь грабли, ради которых, собственно, и весь текст.

Грабля первая: у половины провайдеров нет нормального биллинг-API

Изначально я думал, что MonthToDate — это сходить в API и получить число. Ага, конечно. У кого-то API, у кого-то CSV раз в месяц, а у кого-то (привет тем, у кого и под Terraform API нет) ты парсишь личный кабинет как в 2010-м. В итоге у половины адаптеров MonthToDate собирается вручную — берёшь Inventory, умножаешь на прайс-лист, вбитый руками в тот же yaml:

func (p *dumbProvider) MonthToDate(ctx context.Context) ([]Charge, error) {
inv, err:= p.Inventory(ctx)
if err!= nil {
return nil, err
}
// Никакого биллинг-API у провайдера нет, считаем сами:
// ресурс × прайс × прошедшая доля месяца.
return estimate(inv, p.priceList, fractionOfMonthElapsed()), nil
}

Отвратительно и некрасиво, но работает в пределах коридора, о котором ниже.

Грабля вторая: egress узнаёшь постфактум

Предсказать egress в реальном времени невозможно, потому что сам провайдер сообщает тебе цифру задним числом. Тут я чуть не бросил всё: главная фича, ради которой затевалось, оказалась принципиально неточной.

"Спасло" то, что я опустил планку. Перестал считать счёт до рубля и начал показывать коридор:

prov-name МТD 8 412 ₽ прогноз 13 900–16 200 ₽ (egress: ±2 700)

Цель сместилась с точного предсказания на условное, и в целом, мне ровно это и было нужно.

Грабля третья: сироту найти сложнее, чем кажется

Ради чего всё затевалось — ловить те самые висящие ресурсы. Логика простая: сравни то, за что провайдер выставляет счёт, с тем, что описано в твоём Terraform. У меня инфра в OpenTofu, так что источник правды достаётся одной командой:

# всё, что реально описано в моей инфре
tofu show -json | jq -r '.. |.resources? // empty |.[].values.id'

Дальше вычитаешь это множество из Inventory() провайдера, и всё, чего в стейте нет, кандидат в сироты. Звучит железобетонно, но не всё живёт в Tofu: есть машины на Ansible и всякие ручные снапшоты. Поэтому первое время costwatch верещал на вполне легальные ресурсы. Пришлось переучить, теперь помечает и то, что не может объяснить сам. В личку падает вот такое:

[costwatch] prov-name — ресурс не из деплоя
floating-ip 5.188.xxx.xxx
~120 ₽/мес, в tofu state отсутствует, живёт 23 дня
→ удалить или объяснить

Заодно поменялась и задумка. Сначала я хотел красивый веб-дашборд и даже сделал его (спасибо нейросети и Copilot за CSS, который я ненавижу всей душой). А потом вспомнил про Grafana, куда и отдал все цифры.

Что в итоге

Работает. И, что важнее для этого журнала, проект окупился деньгами, а не только чувством собственной важности.

В первую же неделю нашёл снапшот от мёртвого проекта и зарезервированный IP, о которых я забыл начисто. Через месяц поймал ресурс, который Tofu считал удалённым, а облако продолжало биллить, ровно тот класс проблемы, из-за которого всё и затевалось. А ещё costwatch теперь присматривает за GPU-инстансами, про аренду которых я тут недавно писал (статья не получила популярности, так что повторно рекомендую к ознакомлению), и не может молчать, когда дорогая карта сутками стоит с утилизацией в шесть процентов. Если сложить всё вместе, за первый месяц он нашёл проблем на несколько тысяч рублей. Вечера, влитые в разработку, отбились почти сразу, и это радует.

Про репозиторий и планы

Репозиторий пока не выкладываю, код местами держится на скотче и матерном слове, показывать стыдно. Как причешу адаптеры и выкину захардкоженные прайс-листы в конфиг, заведу публичный, скорее всего зеркалом на GitFlic, а не только на GitHub.

Дальше хочу нормальный модуль под GPU-траты, раз уж влез в эту тему по уши, и алерт на аномалии, не просто что "счёт вырос", а "вырос на треть, надо разобраться".

И про монетизацию. Чтобы считать твои счета, сервису нужны API-ключи от всех облаков — а отдавать биллинговые ключи третьей стороне не станет ни один разумный человек, и я первый. Так что никакого SaaS: только self-hosted.

В целом первое число месяца я всё ещё не люблю. Но теперь хотя бы знаю заранее, за что именно мне будет больно, а для фрилансера это уже почти счастье.

сервисыприложения
Сообщество