А затем однажды ночью:
No space left on deviceПосле этого последствия могут быть намного интереснее самого сообщения.
PostgreSQL не может нормально записать данные.
Приложение перестаёт принимать загрузки.
Логи больше не записываются — именно тогда, когда они особенно нужны.
Очередь фоновых задач начинает падать.
Новый deployment невозможно распаковать.
Даже создание резервной копии может окончательно добить оставшееся пространство.
При разработке Ritm — одного из проектов WebRuta — мы постепенно пришли к выводу, что свободное место нельзя рассматривать как бесконечный ресурс, который администратор иногда проверяет командой df -h.
У диска должен быть собственный бюджет.
А у каждого типа постоянно растущих данных — понятный жизненный цикл.
В результате в production появились сразу несколько независимых механизмов: rolling backup, предварительная проверка свободного места перед pg_dump, ограниченная ротация логов, 14-дневное окно служебных данных, очистка старых релизов, lifecycle объектного хранилища, удаление временных файлов, мониторинг диска и наконец disk guard, который способен отказать в операции до того, как файловая система заполнится физически.
Разберём, почему одного logrotate оказалось недостаточно и как мы в итоге построили защиту от медленного заполнения VPS.
Сначала мы посмотрели не на размер диска, а на всё, что умеет бесконечно расти
Если спросить:
Что занимает диск веб-приложения?
первый ответ обычно:
База данных.
Но в работающем production это далеко не единственный источник роста.
У Ritm потенциально увеличиваются:
PostgreSQL
резервные копии
Nginx access/error logs
логи API
логи worker
логи maintenance
пользовательские медиа
временные файлы обработки голоса
объекты S3/MinIO
старые версии объектов
release-каталоги
npm cache во время deployment
фоновые jobs
request metrics
audit events
служебные токеныУ каждого источника своя скорость роста.
И, что важнее, разный смысл данных.
Дневник пользователя нельзя удалить просто потому, что ему больше 14 дней.
Лог HTTP-запросов месячной давности обычно можно.
Последняя проверенная резервная копия необходима.
Двадцать предыдущих локальных копий той же базы — уже вопрос архитектуры хранения.
Активный release необходим.
Release восьмимесячной давности, который никто не собирается использовать для rollback, — нет.
Поэтому первая идея, от которой мы отказались, звучала так:
Иногда будем чистить диск.
Вместо этого появилась другая:
у каждого класса данных должна быть собственная retention policy.
Самая очевидная ловушка — ежедневные резервные копии
Допустим, сегодня база занимает 3 ГБ.
Каждую ночь запускается:
pg_dumpи создаётся файл:
backup-2026-09-01.pgdump
backup-2026-09-02.pgdump
backup-2026-09-03.pgdump
...Если ничего больше не делать, объём приблизительно растёт вместе с количеством сохранённых копий.
Через месяц на диске уже может лежать десятки гигабайт backup-данных.
Самое неприятное — всё работает именно так, как было запрограммировано.
Никакой ошибки нет.
Backup успешно создаётся каждый день.
Просто хорошая функция постепенно уничтожает production.
В Ritm локальный backup сделали rolling
Мы разделили две задачи:
- быстро восстановить текущую production-систему;
- иметь независимую disaster-recovery историю вне VPS.
На самом VPS основная база резервируется в один rolling-файл:
/opt/ritm/backups/
ritm-latest.pgdump.aesgcm
ritm-latest.pgdump.aesgcm.jsonТо есть локально нам не нужны:
ritm-2026-09-01...
ritm-2026-09-02...
ritm-2026-09-03...
...После успешного создания новой копии предыдущая заменяется.
Исторические резервные копии, если они нужны для disaster recovery, должны жить в независимом offsite-хранилище, а не постепенно занимать диск production VPS.
Это оказалось важным архитектурным разделением:
VPS
→ быстрое восстановление последнего состояния
Offsite storage
→ история для disaster recoveryОдин механизм не обязан решать обе задачи.
Но просто перезаписывать старый backup опасно
Самый простой rolling backup мог бы выглядеть так:
удалить старый backup
↓
создать новыйЭто плохая последовательность.
Представим, что старый файл уже удалён, после чего pg_dump падает.
Теперь:
старой копии нет
новой копии нетПоэтому в Ritm используется противоположный подход.
Сначала создаётся временная новая копия:
.ritm-next-...Она шифруется.
Для неё вычисляется SHA-256.
После этого backup реально расшифровывается и проверяется.
И только когда новый файл доказал, что он не пустой и криптографически читается, он атомарно занимает место:
ritm-latest.pgdump.aesgcmСтарый working backup до этого момента остаётся нетронутым.
Получается:
Старый GOOD backup
+
создаём NEW temp
↓
шифрование
↓
проверка
↓
NEW действительно исправен?
↙ ↘
нет да
↓ ↓
удалить temp заменить GOODЭто защищает данные.
Но создаёт другую проблему.
Безопасный rolling backup временно требует место под две копии
Во время backup на диске одновременно находятся:
предыдущая исправная копия
+
новая временная копияПоэтому нельзя начинать pg_dump, основываясь только на условии:
Свободно ещё 500 МБ.
Если ожидаемый backup займёт несколько гигабайт, файловая система заполнится в середине операции.
Тогда мы не просто не получим новый backup.
Можно создать проблемы остальным процессам сервера.
Поэтому перед запуском pg_dump Ritm сначала оценивает будущий объём.
Мы проверяем headroom ещё до начала pg_dump
В production backup-скрипт спрашивает PostgreSQL:
SELECT pg_database_size(current_database());Далее размер используется для приблизительной оценки следующего dump с запасом.
Упрощённо идея выглядит так:
estimatedBackup =
databaseSize × 1.2Плюс должен сохраниться отдельный свободный резерв файловой системы.
По умолчанию в release-политике Ritm:
DISK_MIN_FREE_BYTES = 5 GiBПоэтому backup начинается только если выполняется примерно такое условие:
freeBytes >=
safetyReserve
+
estimatedNextBackupЕсли условие не выполняется, скрипт прекращает работу до запуска pg_dump.
Причём предыдущий исправный rolling backup остаётся на месте.
Это принципиально лучше сценария:
запустили backup
↓
диск закончился
↓
начали разбираться, что теперь поврежденоПочему резерв в 5 ГБ — не «потерянное место»
Когда VPS имеет относительно небольшой SSD, возникает соблазн использовать его почти полностью.
Например:
диск: 60 ГБ
данные: 58 ГБ
свободно: 2 ГБФормально место ещё есть.
Практически сервер находится очень близко к аварии.
Новый release потребует временного пространства.
PostgreSQL может потребовать место для операций.
Backup создаёт временный файл.
Лог неожиданно может вырасти.
Обработка файла создаёт temp-данные.
Поэтому последние гигабайты нельзя считать обычной доступной ёмкостью приложения.
Мы рассматриваем их как аварийный operational reserve.
Это похоже на RAM.
Если сервер имеет 4 ГБ оперативной памяти, хорошая архитектура не планирует постоянную нагрузку ровно в 3,99 ГБ.
С диском логика такая же.
Следующая проблема — deployment сам способен заполнить диск
Представим release весом несколько сотен мегабайт.
Deployment создаёт новый каталог:
/opt/ritm/releases/3.0.2-...Старый release при этом ещё должен оставаться доступным для rollback.
Затем выполняется:
npm ciПоявляется node_modules.
npm создаёт cache.
Параллельно остаются старые release-каталоги.
Через десять обновлений /opt/ritm/releases превращается в маленький музей истории приложения.
Это удобно до момента, когда музей занимает десятки гигабайт.
Мы сохраняем только current и previous
После успешного deployment Ritm оставляет:
current
previousТо есть:
current
→ активный release
previous
→ один гарантированный rollbackОстальные release-директории удаляются автоматически.
Вместо:
release-001
release-002
release-003
release-004
release-005
release-006
...остаётся только то, что реально необходимо operational-процессу.
В случае ошибки до переключения release неудачный каталог тоже должен быть удалён, чтобы серия неуспешных deployments не начала постепенно занимать диск.
Deployment имеет собственный reserve
Перед копированием нового release скрипт отдельно проверяет свободное место.
Используется общий disk reserve плюс дополнительный запас под установку.
В release присутствует:
DEPLOY_EXTRA_HEADROOM_BYTESс типичным значением порядка 1 ГБ.
Логика:
free space
>=
disk reserve
+
deployment headroomЕсли места недостаточно, deployment не начинается.
Это лучше, чем узнать о нехватке места на середине npm ci.
Даже npm cache мы не оставляем жить бесконечно
Во время installation npm нужен cache.
Но production VPS не обязан превращаться в постоянное хранилище пакетов npm.
В deployment Ritm cache создаётся непосредственно внутри временного release:
$DEST/.npm-cacheПосле установки:
rm -rf "$DEST/.npm-cache"То есть cache выполняет свою задачу в рамках одной операции и исчезает.
Это небольшая оптимизация.
Но production-диск часто заканчивается не из-за одной большой ошибки, а из-за десятка мелких каталогов, про которые все постепенно забыли.
Логи — второй классический источник медленного роста
Логирование кажется безобидным.
Строка:
GET /api/health 200 4msзанимает совсем немного.
Но теперь умножим её на:
запросы
×
24 часа
×
месяцы работыПлюс есть:
Nginx access.log
Nginx error.log
API stdout
API stderr
worker logs
maintenance logs
monitor logsЕсли хотя бы один процесс начинает писать ошибку в цикле, скорость роста меняется на порядки.
Мы не используем один общий предел для всех логов
У Ritm есть отдельные Nginx-файлы:
/var/log/nginx/ritm-access.log
/var/log/nginx/ritm-error.logДля них настроена ежедневная ротация.
Хранится до 14 rotation-периодов и не более 14 дней.
Кроме времени есть ограничение по размеру:
maxsize 50MТо есть огромный access log не обязан ждать следующей ночи только потому, что ротация называется daily.
Для application logs действует более строгий ранний предел:
/var/log/ritm/*.log
maxsize 20MТоже с 14-дневным окном.
Старые файлы сжимаются.
Так retention ограничен одновременно:
по времени
+
по количеству ротаций
+
по размеру активного файлаПочему для долгоживущих Node.js-процессов понадобился copytruncate
Здесь есть небольшая production-деталь, о которой легко забыть.
API и worker работают долго.
systemd направляет stdout/stderr процессов в файлы:
/var/log/ritm/api-3.log
/var/log/ritm/api-4.log
/var/log/ritm/worker.logЕсли просто переименовать файл лога, работающий процесс может продолжать держать старый file descriptor.
Получается странная ситуация:
на диске большой старый файл;
в каталоге вы его вроде бы уже "ротировали";
процесс всё ещё пишет именно туда.Освободить место не получается так, как ожидалось.
Поэтому для application logs в конфигурации используется:
copytruncateФайл копируется в ротацию, после чего активный файл обнуляется на месте.
Процесс продолжает писать в тот же inode/path без обязательного restart.
Для Nginx используется другой штатный механизм: после rotation отправляется USR1, чтобы Nginx переоткрыл log files.
Это хороший пример того, почему одной команды:
logrotateнедостаточно.
Нужно понимать, кто держит файл открытым после ротации.
Мы ограничили и данные, которые растут внутри PostgreSQL
Даже если backup и logs идеально ограничены, сама база содержит множество служебных таблиц.
Например:
jobs
request_metrics
audit_events
auth_tokens
push subscriptionsЕсли хранить их навсегда, размер БД будет постоянно расти.
Но опять же нельзя написать:
DELETE WHERE older_than_14_daysпо всей базе.
Потому что дневники, workspace и активные пользовательские медиа — это пользовательские данные.
У них совсем другая retention policy.
В Ritm 14-дневное окно относится именно к операционным метаданным.
Что чистится через 14 дней
Daily maintenance удаляет устаревшие:
использованные/просроченные auth tokens;
отключённые push subscriptions;
завершённые фоновые jobs;
request metrics;
audit events.Для production window установлен жёсткий upper bound:
14 днейДаже если кто-то ошибочно попытается выставить больше через environment, maintenance ограничивает значение через:
Math.min(14, ...)То есть retention — не рекомендация из документации.
Это инвариант кода.
Пользовательские записи при этом не удаляются
Это принципиальное разделение.
Maintenance прямо исходит из правила:
User workspaces
and active media
are NOT retention-prunedИначе можно было бы получить абсурдную систему:
Чтобы сервер не заполнялся, автоматически удаляем дневник пользователя старше двух недель.
Мы не считаем retention заменой capacity planning.
Retention нужен для данных, срок жизни которых действительно ограничен их назначением.
Пользовательский контент должен управляться отдельными правилами, quota и бизнес-логикой.
DELETE в PostgreSQL не означает, что файл БД сразу уменьшился
Это тоже важный момент.
Допустим, maintenance удалил миллион старых строк.
Не стоит ожидать:
до:
10 GB
после DELETE:
4 GBPostgreSQL обычно сохраняет пространство файлов таблиц и затем повторно использует его для новых данных.
Поэтому после retention выполняется:
VACUUM (ANALYZE)Мы используем его не как магическую команду:
Верни все гигабайты операционной системе.
А чтобы освобождённые страницы нормально возвращались во внутренний пул повторного использования, а planner получал обновлённую статистику.
Таким образом rolling window перестаёт приводить к постоянному линейному росту таблиц от churn.
Удалённые медиа оказались отдельной задачей
Представим пользовательский файл в объектном хранилище.
В PostgreSQL существует metadata.
Пользователь удаляет файл.
В идеальном мире:
DELETE metadata
+
DELETE S3 objectНо PostgreSQL и S3 не находятся в общей ACID-транзакции.
Может произойти так:
метаданные изменены
↓
S3 временно недоступен
↓
объект физически осталсяЕсли забыть об этом, появляются orphan objects.
Их никто не видит в интерфейсе.
Но диск или bucket постепенно растёт.
Физическое удаление объектов должно быть доведено до конца
В Ritm удалённые media metadata некоторое время сохраняются, а maintenance повторно пытается физически удалить соответствующий объект.
Есть также durable jobs типа:
delete_objectЕсли обычная cleanup-операция упала, задача не забывается.
Причём failed delete_object jobs — особый случай.
Обычные завершённые/failed jobs можно удалить после retention window.
А failed cleanup job нельзя просто удалить по возрасту, пока объект всё ещё существует.
Иначе вместе с job мы потеряем единственное напоминание о мусоре в хранилище.
Поэтому логика обратная:
попробовать удалить объект ещё раз
↓
получилось?
↙ ↘
да нет
↓ ↓
удалить job оставить jobЭто небольшая деталь, но именно из таких деталей получается storage hygiene.
Ошибка при upload тоже может оставить мусор
Есть ещё один неприятный сценарий.
Пользователь загружает файл.
Приложение успевает:
1. записать object в S3а затем падает:
2. запись metadata в PostgreSQLТеперь появился object, про который база ничего не знает.
В upload-логике Ritm при таком сбое сначала пытается удалить загруженный object сразу.
Если и удаление не удалось, создаётся durable cleanup job:
reason:
orphan_media_uploadТо же применяется к загрузке административных изображений.
Получается compensating action для операции, которую нельзя завернуть в одну транзакцию PostgreSQL.
И снова цель не только консистентность.
Orphan objects — это ещё и неконтролируемый расход хранилища.
В S3 тоже может существовать невидимый рост
Object Storage создаёт ложное ощущение:
Мы удалили файл — значит места он больше не занимает.
Не обязательно.
Если включено versioning, удалённые или заменённые объекты могут продолжать существовать как старые versions.
Пользователь их не видит.
Основное приложение их не видит.
Но storage provider продолжает хранить байты.
Если MinIO расположен на том же VPS, проблема особенно очевидна: диск всё равно уменьшается.
Поэтому versioning тоже получил lifecycle
В production init Ritm versioning по умолчанию переводится в:
Suspendedа lifecycle удаляет noncurrent versions примерно через сутки.
Кроме того, незавершённые multipart uploads не должны жить вечно.
Для них тоже установлен срок очистки:
1 деньExpired delete markers также удаляются.
Это ещё один важный урок:
квота должна учитывать не только объекты, которые приложение способно перечислить обычным запросом.
Иногда пространство занимают данные, скрытые механизмами самого storage.
Временные файлы обработки голоса тоже имеют срок жизни
В Ritm есть локальная speech-to-text обработка.
Аудио может временно преобразовываться через FFmpeg/whisper pipeline.
Любой такой механизм потенциально создаёт temp-файлы.
В идеале каждый файл удаляется сразу после завершения обработки.
Но процессы падают.
VPS перезагружается.
Worker может быть остановлен.
Поэтому maintenance дополнительно проверяет transcription temp directory и удаляет файлы старше суток.
Получается два уровня:
нормальный путь
→ удалить temp сразу
аварийный путь
→ maintenance подчистит stale tempНа cleanup критичных временных ресурсов лучше не полагаться только на finally.
То же произошло с временными файлами backup
Во время безопасного backup появляются:
.ritm-next-...
.ritm-old-...Нормальная операция удаляет их сама.
Но если сервер выключился ровно посередине процесса, временный файл способен остаться.
Поэтому следующий backup до проверки свободного места удаляет stale temp-файлы предыдущих незавершённых запусков.
Почему именно до проверки?
Потому что гигантский забытый .ritm-next может быть самой причиной того, почему новый backup считает, что места больше нет.
Самый важный механизм появился на уровне самого API
Все предыдущие решения уменьшают вероятность заполнения диска.
Но они не дают стопроцентной гарантии.
Например, пользователь может загрузить большой объём новых данных быстрее, чем administrator отреагирует на alert.
Поэтому появился DISK_GUARD.
Его задача:
не допустить использования последнего резерва диска приложением.
Мы не ждём, когда Linux скажет ENOSPC
Перед операцией, которая способна увеличить storage, сервер получает состояние файловой системы через statfs.
Далее проверяется:
freeBytes - projectedBytes >= reserveBytesГде projectedBytes — приблизительная стоимость конкретной операции.
Например, для cloud sync учитывается размер будущего workspace плюс запас.
Для media upload — сам файл плюс дополнительный operational headroom.
Для регистрации с первым workspace — размер стартовых данных.
Если после операции safety reserve оказался бы нарушен, API отвечает:
HTTP/1.1 507 Insufficient Storageс кодом:
DISK_RESERVE_REACHEDТо есть приложение отказывается до записи.
Почему HTTP 507 здесь лучше, чем падение базы
Представим два сценария.
Без disk guard
осталось 50 МБ
↓
пользователь загружает файл
↓
место заканчивается
↓
S3/MinIO/FS падает
↓
PostgreSQL тоже пытается писать
↓
логи начинают ошибаться
↓
несколько подсистем одновременно переходят в аварийное состояниеС disk guard
свободное место подходит к резерву
↓
операция роста блокируется
↓
HTTP 507
↓
уже существующие данные продолжают обслуживаться
↓
администратор получает время освободить/расширить storageВторой сценарий намного менее драматичен.
Пользователь получает ошибку одной операции.
Не весь сервер получает ошибку файловой системы.
Это принцип, знакомый по другим ресурсам
Для CPU мы используем rate limiting.
Для RAM — memory limits.
Для соединений — connection pools.
Для upload — file size limits.
А диск почему-то часто оставляют в режиме:
Ну когда закончится, тогда и узнаем.
Мы решили рассматривать его так же:
Disk space
=
bounded production resourceС заранее зарезервированной зоной, в которую приложение не имеет права заходить в обычном режиме.
У пользователя при этом тоже есть quota
Disk guard отвечает за здоровье всей системы.
Но отдельный пользователь тоже не должен иметь возможность занять всё хранилище.
Поэтому media upload дополнительно проверяет пользовательскую quota.
В текущей конфигурации проекта предусмотрены разные лимиты для разных планов.
Таким образом есть два независимых ограничения:
User quota
→ защищает fair use между пользователями
Disk reserve
→ защищает весь серверНаличие первого не отменяет второе.
Даже если никто не превышает персональную quota, суммарный объём всех аккаунтов всё равно может приблизиться к пределу инфраструктуры.
За резервом нужно не только следить при записи, но и наблюдать постоянно
Disk guard действует в момент операции.
Но администратор должен узнать о проблеме раньше, чем пользователи начнут получать 507.
Для этого production monitor Ritm запускается примерно каждые пять минут.
Среди прочего он вычисляет:
freeBytes
totalBytes
freePct
reserveBytesЕсли свободное пространство оказывается ниже safety reserve, проверка становится failed.
При настроенном внешнем alert webhook уведомление можно отправить за пределы самого VPS.
Это важная деталь.
Если мониторинг и уведомление существуют только внутри умирающего сервера, в момент серьёзной аварии они могут умереть вместе с ним.
Мы мониторим и возраст backup
Свободное место — только половина проблемы.
Можно построить прекрасную систему, которая ничего не переполняет, но при этом backup перестал запускаться неделю назад.
Поэтому monitor также проверяет:
существует ли
ritm-latest.pgdump.aesgcmи насколько он старый.
В текущей production policy максимальный допустимый возраст по умолчанию составляет около 30 часов.
Если daily backup перестал обновляться, monitoring сообщает об этом отдельно.
То есть storage hygiene не должна достигаться ценой:
Мы больше не делаем backup, зато диск теперь свободен.
Backup и backup verification — отдельные операции
В production они разнесены даже по времени.
Например:
03:20
создание encrypted backup
04:10
отдельная verification
04:40
retention maintenanceУ каждой операции свой systemd timer.
Время немного рандомизируется, чтобы регулярные тяжёлые задачи не были жёстко привязаны к одной секунде.
Такой порядок помогает не смешивать сразу несколько storage-intensive операций.
Почему maintenance идёт после backup
Это не абсолютное правило для любого проекта, но здесь порядок достаточно логичен.
Сначала мы создаём свежую копию БД.
После этого выполняем очистку устаревших operational records.
Получается дополнительная страховка:
создать свежий backup
↓
проверить его
↓
чистить устаревшие данныеДаже несмотря на то что retention касается служебных данных, такой порядок проще объяснять и эксплуатировать.
Что с offsite backup
Хранить только одну копию на том же VPS недостаточно для настоящего disaster recovery.
Если исчезнет весь сервер, исчезнет и:
production database
+
local backupПоэтому проект поддерживает отдельный offsite S3-compatible target.
Причём код запрещает считать offsite-хранилищем тот же самый bucket/endpoint, что используется основным storage.
Для remote database backups уже можно иметь историю за несколько дней.
Но она имеет собственный retention.
По умолчанию это небольшое окно, а конфигурация ограничена разумным диапазоном.
То есть даже disaster-recovery bucket не должен превращаться в вечное кладбище backup-файлов.
Мы старались не решать проблему одним cron-скриптом удаления
Можно было создать что-то вроде:
find / -type f -mtime +14 -deleteи объявить задачу решённой.
Это опасный подход.
Файловая система не знает, что является:
пользовательским документом;
working backup;
временным файлом;
активным release;
rollback release;
логом;
служебной метрикой.Retention должен находиться ближе к домену данных.
Например:
PostgreSQL maintenance понимает статус job.
Backup-script понимает, какой файл является последней проверенной копией.
Deployment понимает current и previous.
Object Store понимает versions.
Logrotate понимает открытые log files.
Так очистка становится семантической, а не:
Всё старше двух недель считается мусором.
Удалять слишком агрессивно тоже опасно
После решения проблемы роста легко уйти в другую крайность.
Например:
оставляем только сегодняшний лог;
никогда не храним rollback;
удаляем backup сразу после нового;Диск действительно будет свободным.
Но при первом инциденте выяснится:
логов уже нет;
rollback уже нет;
предыдущего backup уже нет.Поэтому хороший retention — это баланс двух рисков:
накопить слишком много
и
удалить слишком рано.
В Ritm некоторые значения выбраны достаточно консервативно:
логи → до 14 дней
operational metadata → до 14 дней
release → current + previous
local DB backup → один проверенный rolling
remote DR history → ограниченное окноЭто не универсальные числа для любого бизнеса.
Но сам принцип универсален.
Что должно произойти, если очистка не удалась
Это ещё один вопрос, который легко пропустить.
Представим:
maintenance не смог удалить objectПлохой вариант:
catch {}и забыть.
Через год таких объектов может быть тысячи.
Для важных cleanup-операций состояние должно оставаться обнаруживаемым.
Отсюда durable delete_object jobs.
Отсюда monitor failed jobs.
Отсюда logs самой maintenance.
Мы старались построить систему так, чтобы failure очистки не превращался в невидимую утечку диска.
Почему мы не считаем «диск заполнен на 70%» достаточным мониторингом
Процент сам по себе бывает обманчив.
70% от диска 20 ГБ и 70% от 2 ТБ — совсем разные operational situations.
Кроме того, backup может требовать известный абсолютный объём.
Поэтому в коде основной guard работает не от процента, а от:
absolute free bytesТо есть:
должно остаться минимум N байтMonitor дополнительно вычисляет процент просто для удобства диагностики.
Это оказалось практичнее.
Отдельно нужно думать о будущем росте базы
Disk guard спасает от аварии сегодня.
Он не заменяет capacity planning.
Если база растёт на:
2 ГБ в месяца свободно:
10 ГБответ уже понятен.
Сервер нужно расширять или переносить данные ещё до того, как guard начнёт блокировать операции.
Защита от переполнения — это последняя линия обороны.
Не стратегия масштабирования.
Что в итоге контролирует рост диска в Ritm
После всех изменений система получилась примерно такой:
VPS DISK
│
┌───────────────┼────────────────┐
│ │ │
↓ ↓ ↓
PostgreSQL Logs Releases
│ │ │
14-day ops logrotate current + previous
retention size + age │
│ │ old releases
VACUUM compress delete
│
├─────────── Backup
│ │
│ rolling-single
│ │
│ verify before replace
│ │
│ headroom before pg_dump
│
├────────── Object storage
│ │
│ lifecycle/version
│ cleanup
│
└────────── Temp data
│
stale cleanup
─────────────────
DISK GUARD
─────────────────
│
free - projected >= reserve
│
иначе HTTP 507А поверх этого работает monitoring:
каждые ~5 минут
↓
свободный диск
backup age
failed jobs
dependencies
API errors
HTTPS
TLSНи один механизм не является идеальным сам по себе.
Именно комбинация делает поведение предсказуемым.
Самый важный тест — искусственно «закончить» диск
Механизм защиты трудно считать готовым только потому, что в коде есть:
if (freeSpace < reserve) ...В архиве Ritm для disk guard есть отдельный regression test.
Он запускает сервер с заведомо невозможным требованием к свободному месту:
DISK_MIN_FREE_BYTES =
Number.MAX_SAFE_INTEGERТо есть guard гарантированно считает диск находящимся ниже резерва.
После этого тест проверяет административный overview:
disk.enabled = true
disk.ok = falseА затем пытается выполнить storage-growing operation — регистрацию с workspace.
Ожидаемый результат:
507и:
DISK_RESERVE_REACHEDТаким образом проверяется не наличие if в исходнике, а реальное поведение API.
Retention policy тоже является частью regression suite
Отдельный тест проверяет наличие production-инвариантов:
rolling backup;
14-day operational window;
disk guard;
bounded releases;
bounded logs;
release-local npm cache;
S3 version lifecycle;
cleanup orphan objects.Почему мы считаем это важным?
Потому что через полгода новый deployment может случайно вернуть:
храним все release навсегдаили кто-то изменит logging и забудет добавить новый файл в rotation.
Если storage policy существует только в документации, она постепенно расходится с реальностью.
Если она ещё и проверяется тестами, регрессия становится заметнее до production.
Какие ошибки мы теперь считаем особенно опасными
После этого опыта у нас появилось несколько вопросов, которые мы задаём для любых VPS-проектов.
Не только:
Сколько места занимает приложение сегодня?
А:
Что в нём растёт каждый день?
Не только:
Backup создаётся?
А:
Сколько одновременно backup-копий может оказаться на локальном диске?
Не только:
Логи ротируются?
А:
Действительно ли долгоживущий процесс перестал держать старый большой файл открытым?
Не только:
Объект удалён в базе?
А:
Физически ли исчез файл из storage?
Не только:
Deployment завершился?
А:
Сколько предыдущих release после него осталось?
И наконец:
Что приложение сделает до того, как свободное место станет равно нулю?
Если ответ на последний вопрос:
Упадёт,
значит защита ещё не закончена.
Почему ручная чистка — плохая production-политика
Можно раз в месяц выполнять:
du -sh /*находить большой каталог и удалять лишнее.
Для домашнего сервера это иногда нормально.
Для продукта, которым пользуются люди, слишком многое зависит от человеческой памяти.
Администратор может забыть.
Уехать.
Не заметить быстрый рост.
Удалить не тот файл.
Поэтому мы стараемся исходить из принципа:
если данные имеют предсказуемый срок жизни, система должна уметь обслуживать этот срок самостоятельно.
Человек должен получать alert о необычном состоянии.
Не выполнять обязательную еженедельную уборку, без которой production рано или поздно остановится.
Что бы мы закладывали сразу в новый проект
Теперь при проектировании VPS-развёртывания мы бы с самого начала разделили данные на несколько классов.
Постоянные пользовательские данные
Удаляются только по бизнес-правилам.
Operational metadata
Имеют ограниченное retention window.
Логи
Имеют time + size rotation.
Backup
Имеет отдельную local и offsite стратегию.
Releases
Имеют bounded history для rollback.
Temporary data
Имеют cleanup после операции плюс stale cleanup.
Object storage
Имеет lifecycle для versions, multipart uploads и orphan cleanup.
И над всем этим:
disk reserve
+
monitoringТак намного проще, чем спустя полгода пытаться понять, откуда на сервере появились 47 ГБ неизвестных файлов.
Главный вывод
Проблема переполнения диска редко решается увеличением VPS с 60 до 100 ГБ.
Если в архитектуре существует бесконечное накопление:
backup;
logs;
releases;
versions;
temp files;
metadata;дополнительный диск лишь переносит дату аварии.
В Ritm мы пришли к другой модели.
Для каждой категории данных мы определили:
почему она существует;
сколько должна жить;
кто её удаляет;
что произойдёт, если удаление не удалось.А для всего сервера оставили абсолютный safety reserve.
В результате приложение не должно доходить до момента:
free space = 0в обычной работе вообще.
Backup откажется стартовать раньше.
Deployment откажется стартовать раньше.
API прекратит storage-growing operations раньше.
Monitoring должен предупредить ещё раньше.
Именно такая последовательность нам кажется правильной:
MONITORING
↓
предупредить
RETENTION
↓
не накапливать лишнее
PREFLIGHT
↓
не запускать тяжёлую операцию без headroom
DISK GUARD
↓
не использовать аварийный резерв
FILESYSTEM
↓
никогда не должен становиться
первым компонентом, сообщившим о проблемеДля нас это и стало главным уроком этого кейса:
свободное место на production VPS — не остаток после работы приложения. Это ресурс, которым приложение обязано управлять так же осознанно, как памятью, соединениями и нагрузкой на CPU.