balkhaev.com Блог · AI-агенты и автоматизации

Коммерческие автоматизациичасть 8 из 8

Эксплуатация: мониторинг, бэкапы, секреты и стоимость

Последняя часть цикла. Внешний мониторинг, который замечает, что система умерла целиком, ротация логов, зашифрованные бэкапы у другого провайдера с проверкой восстановления, обращение с секретами, учёт расходов на Claude и обновления, включая переход на Postgres 18.

обновлено 13 мин чтенияautomationoperationsbackupsmonitoringpostgres

Коммерческие автоматизациичасть 8 из 8
  1. 01Концепция: какие автоматизации окупаются
  2. 02Фундамент: сервер, база и деплой по пушу
  3. 03Сердце системы: события, очередь и повторы
  4. 04Заменяем продажника: заявка, квалификация и менеджер за минуту
  5. 05Заменяем бухгалтера: оплаты, доступы и напоминания о долгах
  6. 06Заменяем поддержку: разбор входящих и черновики ответов
  7. 07Заменяем аналитика: утренняя сводка и тревоги
  8. 08Эксплуатация: мониторинг, бэкапы, секреты и стоимость

Это восьмая, последняя часть цикла про коммерческие автоматизации. В прошлой части система научилась сама замечать проблемы в бизнесе. Теперь научим её замечать проблемы в себе самой и позаботимся о том, что будет, когда сервер однажды сгорит. В статье только ключевые фрагменты кода, полный код серии с тестами лежит в репозитории.

Что может пойти не так

Что случилось Как узнаем Раздел
сервер или приложение упали внешняя проверка /health шаг 1
воркер завис или умер, очередь стоит пинги через очередь перестали приходить в Healthchecks шаг 1
диск заполнился логами не заполнится: логи ограничены шаг 2
база потеряна или испорчена восстанавливаемся из ночного бэкапа шаги 3–4
утёк ключ меняем по инструкции, ключи с минимальными правами шаг 5
расходы на модель выросли строка в сводке и тревога о лимите шаг 6
тихая поломка в бизнесе тревоги из прошлой части —
Сервер нет пинга не отвечает пинг проверяет pg_dump, age пинг после загрузки Healthchecksждёт пингов Uptime-сервисGET /health раз в минуту Хранилище другого провайдеразашифрованные дампы Telegram, почта Расписание jobs Воркерheartbeat раз в 5 минут app: /health Postgres cron: backup.sh в 03:30

Шаг 1. Внешний мониторинг

Тревоги из прошлой части выполняет сам воркер. Если он умер, их некому отправить. Система не может надёжно сообщить о собственной смерти, поэтому нужен наблюдатель снаружи. Нужны две проверки, и каждая ловит свои проблемы.

Пульс через очередь. Задача heartbeat раз в 5 минут дёргает адрес в Healthchecks.io. Это сервис по принципу «мёртвой руки»: он не проверяет нас сам, а ждёт пингов, и если пинга нет дольше заданного времени, присылает тревогу в Telegram или на почту. Важно, что пинг идёт не из отдельного cron, а обычной задачей: её ставит расписание, кладёт в очередь и выполняет воркер. Пока пинги приходят, работает вся цепочка, а если сломалось любое звено, пинги прекратятся.

src/handlers.tsts
// Каждые 5 минут через всю цепочку: расписание, очередь, воркер.
// Если пинги прекратятся, Healthchecks поднимет тревогу снаружи.
heartbeat: async () => {
  if (config.heartbeatUrl) {
    const response = await fetch(config.heartbeatUrl, { signal: AbortSignal.timeout(10_000) });
    await check(response, "heartbeat");
  }
},

В расписании heartbeat теперь раз в 5 минут вместо часа. В Healthchecks создайте проверку с периодом 5 минут и запасом 10, подключите интеграцию с Telegram и положите адрес пинга в HEARTBEAT_URL.

Проверка снаружи. Пульс не заметит, что упал Caddy или кончился сертификат: воркер при этом жив и пингует. Поэтому второй сервис раз в минуту открывает https://auto.example.com/health. Маршрут из второй части делает select 1 к базе, поэтому проверка заодно подтверждает, что приложение видит Postgres. Подойдёт любой uptime-сервис: UptimeRobot, Better Stack или свой Uptime Kuma, но тогда на другом сервере, иначе он умрёт вместе с наблюдаемым.

Шаг 2. Логи

По умолчанию Docker хранит логи контейнеров без ограничений. Через полгода болтливый воркер займёт ими весь диск, и упадёт база. Ограничиваем все сервисы одним якорем: не больше пяти файлов по 10 МБ на контейнер. Якорь объявляется в начале compose.yml и подключается в общем x-app и в сервисах db и caddy:

compose.ymlyaml
# По умолчанию Docker хранит логи бесконечно; ограничиваем, чтобы не забить диск
x-logging: &logging
  driver: json-file
  options:
    max-size: 10m
    max-file: "5"

# и в каждом сервисе, включая db и caddy:
#   logging: *logging

Для системы такого размера стек сбора логов не нужен. Хватает docker compose logs -f --since 1h worker и grep. Ошибки задач и так видны в таблице jobs в поле last_error, а окончательные провалы приходят в чат тревог.

Обратите внимание на том базы: начиная с Postgres 18 он подключается к /var/lib/postgresql, а не к /var/lib/postgresql/data, как было в образах до 17-й версии. Как перейти на новую мажорную версию с работающей базы, рассказано в шаге 7.

Шаг 3. Бэкапы

Docker-том на том же сервере это не бэкап. Сгорит диск, удалят сервер по ошибке в панели хостинга, уйдёт в блокировку аккаунт, и том пропадёт вместе с базой. Настоящий бэкап отвечает трём условиям:

  1. Лежит в другом месте. Объектное хранилище, совместимое с S3, у другого провайдера и лучше в другой стране: Backblaze B2, Cloudflare R2, Yandex Object Storage, Selectel. Для небольшой базы это гигабайты, а значит копейки.
  2. Зашифрован. Дампы шифруются через age открытым ключом. Закрытый ключ на сервере не лежит, он хранится в менеджере паролей. Хранилище и любой, кто получит к нему доступ, видят только шифрованные файлы.
  3. Его нельзя стереть с сервера. Ключ доступа к хранилищу может записывать, но не может удалять. Старые копии удаляет правило жизненного цикла на стороне хранилища, например через 30 дней. Если сервер взломают, злоумышленник не сможет уничтожить бэкапы. Если хранилище поддерживает версионирование или блокировку объектов, включите и их.

Ставим на сервер age и rclone: apt install age rclone. Сам backup.sh лежит в репозитории, и деплой из второй части копирует его на сервер вместе с compose.yml: в шаге scp добавился ещё один файл. Скрипт запускается из cron каждую ночь в 03:30. Полная версия в репозитории, а суть в шести строках:

# backup.sh, главное
set -euo pipefail # упавший pg_dump должен ронять скрипт, а не загружать пустой файл
trap 'curl -fsS -m 10 --retry 3 "$BACKUP_PING_URL/fail" > /dev/null || true' ERR

docker compose exec -T db pg_dump -U app -Fc app | age -r "$BACKUP_RECIPIENT" > "$file"
[ "$(stat -c %s "$file")" -ge 1024 ] # дамп живой базы не бывает меньше 1 КБ
rclone copyto "$file" "backup:$BACKUP_BUCKET/automations/$name"
curl -fsS -m 10 --retry 3 "$BACKUP_PING_URL" > /dev/null

set -o pipefail здесь важнее всего остального. Без него в цепочке pg_dump | age код возврата берётся у последней команды. Если pg_dump упадёт, age честно зашифрует пустоту, и скрипт с успехом загрузит пустой файл. С pipefail скрипт остановится, пингнёт Healthchecks адресом /fail, а проверка размера отсечёт и более экзотические случаи.

Настройки лежат рядом, в отдельном файле backup.env с правами 600: открытый ключ age, имя бакета, доступ к хранилищу для rclone (через переменные RCLONE_CONFIG_BACKUP_*, без файла настроек) и адрес пинга Healthchecks. Шаблон лежит в репозитории как backup.env.example. Для бэкапов заведите в Healthchecks отдельную проверку с периодом сутки. Если ночной бэкап не прошёл или cron вообще не запустился, утром придёт тревога.

Шаг 4. Проверка восстановления

Бэкап, который никто ни разу не восстанавливал, это надежда, а не бэкап. Дамп может оказаться пустым, ключ может потеряться, формат может не подойти к новой версии Postgres. Узнавать об этом в день аварии поздно. Поэтому раз в месяц на своём компьютере скачиваем свежий файл из хранилища и запускаем restore-check.sh с путём к файлу и к закрытому ключу age. Скрипт поднимает временный Postgres в Docker, расшифровывает дамп, восстанавливает его через pg_restore и показывает число строк и дату самой свежей записи по заявкам, заказам, обращениям и событиям.

Если числа похожи на правду, а самая свежая запись вчерашняя, бэкапы работают. Заодно вы потренировались в восстановлении, и в день аварии будете делать это не впервые.

Шаг 5. Секреты

Секретов в системе уже полтора десятка: токен бота, ключи ЮKassa, почты, Claude, хранилища, секреты вебхуков. Правила простые.

  • Секреты живут только в .env на сервере с правами 600. В git его нет, в образ он не попадает (он в .dockerignore со второй части).

  • Список секретов живёт в репозитории в виде .env.example без значений. Новый человек или новый сервер сразу видят, что нужно заполнить, а required() в config.ts не даст запуститься без обязательного. В нашем .env.example около двадцати строк, сгруппированных по частям цикла: база, вебхуки, Telegram, заявки, оплаты, почта, Claude, эксплуатация.

  • В GitHub только то, что нужно для деплоя: SSH-ключ в секретах Actions и адрес сервера в переменных. Образ публикуется встроенным GITHUB_TOKEN, и ни один ключ приложения GitHub не видит.

  • У каждого ключа минимальные права. Вебхук Bitrix24 только с правами на CRM, ключ хранилища без права удаления, для Claude отдельный workspace в Console со своим лимитом расходов. Утечка одного ключа не открывает всё остальное.

  • Ротация. Выпустить новый ключ у провайдера, поменять в .env, выполнить docker compose up -d, отозвать старый ключ. Именно up -d: эта команда пересоздаст контейнеры с новым окружением, а docker compose restart перезапустит их со старым.

  • Секреты не попадают в логи и тексты для модели. Мы нигде не логируем заголовки и тела запросов целиком, а в запросы к Claude уходят только данные заявок и обращений.

Шаг 6. Сколько это стоит

Инфраструктура. Для всего, что мы построили, хватает одной небольшой VPS: 2 vCPU и 2–4 ГБ памяти. Ориентир на момент написания: несколько сотен рублей или 5–10 евро в месяц. Хранилище для бэкапов стоит копейки, у Healthchecks и uptime-сервисов есть бесплатные тарифы, у почтовых сервисов тоже бесплатные объёмы для небольшой нагрузки. Проверьте актуальные условия, они меняются.

Claude. Здесь расходы зависят от нагрузки, и их нужно не угадывать, а измерять. Каждый запрос теперь записывается в таблицу llm_usage: задача, модель, входные токены, прочитанные из кэша и записанные в кэш, выходные токены и стоимость в долларах. Запись делает askJson сразу после ответа модели, а стоимость считает по usage из ответа:

// Доллары за миллион токенов. Цены меняются: сверяйтесь с сайтом Anthropic
const PRICE = { input: 4, output: 20, cacheRead: 0.2, cacheWrite: 5 };

/** Сколько стоил ответ, по числу токенов из `usage`. */
export function cost(usage: Anthropic.Beta.BetaUsage) {
  return (
    (usage.input_tokens * PRICE.input +
      (usage.cache_read_input_tokens ?? 0) * PRICE.cacheRead +
      (usage.cache_creation_input_tokens ?? 0) * PRICE.cacheWrite +
      usage.output_tokens * PRICE.output) /
    1_000_000
  );
}

Цены в коде указаны для Claude Opus 5.5 на момент написания: 4 доллара за миллион входных токенов, 20 за миллион выходных, 0.20 за чтение из кэша. При смене модели или цен поправьте PRICE. Если сработал запасной вариант модели, в model попадёт её имя, а стоимость будет посчитана по ценам основной, так что это оценка, а не счёт.

Утренняя сводка получила строку «Claude: 34 запроса на 0.41 доллара», а в проверки добавилась тревога, если за день потрачено больше LLM_DAILY_LIMIT_USD:

// src/alerts.ts, в runChecks
{
  name: "llm.spend",
  firing: spend.today > config.llmDailyLimit,
  text: `Claude сегодня обошёлся в $${spend.today.toFixed(2)}, это больше лимита $${config.llmDailyLimit}`,
},

Тревога сообщает, но ничего не останавливает. Жёсткий предел задаётся в Claude Console лимитом расходов на workspace: даже если в коде заведётся цикл, больше лимита не спишут.

Грубая оценка по нашим запросам. Разбор заявки это несколько сотен входных токенов и пара сотен выходных, меньше цента. Черновик ответа поддержки длиннее, и модель над ним дольше думает, это около двух центов, причём база знаний читается из кэша в 20 раз дешевле обычного входа. Утренняя сводка стоит пару центов в день. При 100 заявках и 30 обращениях в день получается порядка 30–40 долларов в месяц. Точную цифру покажет llm_usage через неделю работы.

Если цифра кажется большой, вот рычаги по порядку:

  • Модель под задачу. Сортировку заявок по полям прекрасно выполняет Claude Haiku 5.5, и стоит она в десятки раз меньше Opus: 0.10 и 0.50 доллара за миллион токенов. Меняется строка model, но учтите, что серверного запасного варианта у Haiku нет, поэтому параметры betas и fallbacks для неё нужно убрать. Черновики клиентам лучше оставить на Opus.
  • Кэш промпта для всего, что повторяется (часть про поддержку).
  • effort не выше нужного: у нас low для классификации и medium для черновиков.

Шаг 7. Обновления

  • Система. На сервере включите unattended-upgrades, чтобы обновления безопасности ставились сами.
  • Образы. caddy:2 и postgres:18 указаны по мажорной версии, поэтому раз в месяц docker compose pull && docker compose up -d подтягивает исправления.
  • Зависимости приложения. Dependabot или Renovate в GitHub раз в неделю присылает PR с обновлениями bun.lock и версий actions. CI с тестами из прошлых частей показывает, можно ли мержить.

Новая мажорная версия Postgres. Она выходит раз в год, и её нельзя поставить, просто поменяв тег: файлы данных несовместимы между версиями. Пример ниже для перехода с 17 на 18, если вы начинали со старого образа. В 18-й версии к тому же поменялся путь данных, поэтому том подключается к /var/lib/postgresql. Через год так же переходят на 19. Самый простой путь для небольшой базы это дамп и восстановление, теми же инструментами, что и бэкап:

cd /opt/automations
docker compose stop app worker                     # больше никто не пишет в базу
docker compose exec -T db pg_dump -U app -Fc app > /root/before-18.dump
docker compose down                                # старый том pgdata остаётся на месте
# в compose.yml: postgres:18, том pgdata_18:/var/lib/postgresql, в volumes: pgdata_18
docker compose up -d db
docker compose exec -T db pg_restore -U app -d app --no-owner < /root/before-18.dump
docker compose up -d

Старый том pgdata не трогаем неделю: если что-то пойдёт не так, вернуть postgres:17 можно за минуту. Перед переходом убедитесь, что свежий ночной бэкап на месте.

Шаг 8. Тесты

Новые тесты проверяют расчёт стоимости с учётом кэша, запись каждого запроса к модели, однократную тревогу о лимите и то, что пульс пингует адрес и громко падает, если адрес недоступен. Падение здесь правильное поведение: очередь повторит задачу, а Healthchecks, не дождавшись пинга, поднимет тревогу. Например, для 1000 входных токенов, 10 000 из кэша и 500 выходных стоимость должна выйти ровно 0.016 доллара.

Новых обязательных переменных нет: без HEARTBEAT_URL пульс просто ничего не делает, а у лимита расходов есть значение по умолчанию.

Скрипты бэкапа проверены на локальном Postgres в Docker, с заглушками вместо rclone и Healthchecks. Успешный прогон загружает файл и пингует проверку. Восстановление через restore-check.sh показывает строки. Если остановить базу, скрипт падает на pg_dump, ничего не загружает и пингует /fail.

Чек-лист эксплуатации

Как часто Что делать
каждый день прочитать утреннюю сводку, разобраться с тревогами
раз в неделю смержить PR с обновлениями зависимостей, если CI зелёный
раз в месяц restore-check.sh на свежем бэкапе, docker compose pull && up -d
раз в квартал пересмотреть ключи и права, отозвать неиспользуемые, сверить расходы на Claude с пользой
раз в год перейти на новую мажорную версию Postgres

Итоги цикла

За восемь частей мы прошли путь от идеи до системы, которая работает на одном сервере и заменяет большую часть рутины небольшой компании:

  • Фундамент: Bun, TypeScript и Postgres на одной VPS, Docker Compose, Caddy с автоматическим HTTPS и деплой по пушу через GitHub Actions.
  • Ядро: приём вебхуков с проверкой подписи и защитой от дублей, очередь задач на Postgres с повторами и расписанием.
  • Продажи: заявка с сайта у менеджера через секунды, разбор Claude, CRM и напоминания.
  • Оплаты: счета, ссылка на оплату, которая не протухает, доступ ровно один раз, сверка и напоминания должникам.
  • Поддержка: входящие в одном чате, черновики по базе знаний, отправляет человек.
  • Аналитика: утренняя сводка, где цифры считает SQL, и тревоги о тихих поломках.
  • Эксплуатация: внешний мониторинг, бэкапы, которые проверены восстановлением, секреты и учёт расходов.

Это около 2200 строк кода и SQL и ещё 700 строк тестов, без единого фреймворка, и каждую строку можно прочитать и понять. Через всю систему проходят несколько идей: событие сначала сохраняется и только потом обрабатывается, у каждого действия есть ключ идемпотентности, гонки решает один условный update, модель помогает человеку, но решает и отправляет человек, а о проблемах система сообщает сама, один раз и по делу.

Весь код с тестами лежит в репозитории, а все части цикла собраны на странице серии.