Репозиторий: github.com/DirektorBani/DataSafeS3 Лицензия: Apache-2.0 Релиз: DataSafeS3 CE v1.5.0, 12 октября 2026 Заметки к релизу: github.com/DirektorBani/DataSafeS3/releases/tag/v1.5.0

До 1.5.0 DataSafeS3 отправлял вебхук или сообщение в NATS из обработчика запроса, уже после коммита метаданных. Если процесс падал между коммитом и отправкой, событие пропадало: объект записан, клиент получил 200, а внешняя система об этом не узнала. Ошибки HTTP для уведомлений бакета старый код просто отбрасывал. Для аудита это значит, что отчёт «кто менял файлы в бакете с договорами» неполон, а антивирус не проверит объект, о котором не пришло события.

В 1.5.0 событие записывается в таблицу исходящих событий (outbox) в той же транзакции, что и метаданные объекта, а фоновый диспетчер доставляет его получателям: вебхуки, NATS и, по желанию, Kafka. Гарантия: доставка хотя бы один раз (at-least-once), дубликаты возможны. Исключения, при которых событие до получателя не дойдёт:

  • получатель в этой строке помечен dead: исчерпаны STORAGE_EVENTS_MAX_ATTEMPTS попыток (по умолчанию 20) или он ответил неповторяемым 4xx;

  • очередь переполнилась (STORAGE_EVENTS_MAX_PENDING, по умолчанию 100 000): самые старые ожидающие строки помечаются dropped, потеря учитывается в datasafe_events_dropped_total и в журнале действий (events_outbox_dropped);

  • в момент изменения не был настроен ни один получатель: строка не пишется вовсе;

  • операция не входит в одну транзакцию с событием. По коду это: запись байтов объекта (идёт до транзакции метаданных), мягкое удаление через корзину (копия в корзину и строка корзины до удаления оригинала), удаление записи multipart-загрузки после транзакции объекта, обновления видимости и команды после создания бакета, создание участника тенанта (событие вставляется после пользователя, членства и групп; ошибка вставки только логируется).

Вторая часть релиза: переключение на реплику Postgres скриптом, после которого остаётся пакет доказательств с проверками.

Это пятая статья серии (v1.0.0, v1.0.1–1.0.2, v1.1.0, v1.3.0); версии 1.4.0 и 1.4.1 я отдельно не описывал.

Отдельным требованием было не утяжелить установку на одном сервере: большинство пользователей запускают один контейнер на bolt, без Postgres и без брокера.

Таблица исходящих событий

В bolt это два новых бакета, event_outbox и event_outbox_due, они создаются при открытии файла базы, в том числе файла от 1.4.x. В Postgres это таблица event_outbox, миграция 020. Бинарь 1.4.x их не замечает, так что откат на предыдущую версию возможен.

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

Диспетчер — фоновый цикл внутри storage-server. Раз в секунду он выбирает строки, у которых подошло время попытки, и отправляет их. Успех помечает получателя в строке как delivered. Неудача увеличивает счётчик попыток и откладывает следующую по экспоненте: 1 с, 2 с, 4 с и так далее до 5 минут, со случайным разбросом ±20%. После STORAGE_EVENTS_MAX_ATTEMPTS неудач получатель в строке становится dead; строка остаётся в базе и видна в метриках и консоли.

Запись, попытка, доставка

Это три разных факта, и метрики у них разные.

  1. Запись сохранена. Строка outbox закоммичена вместе с метаданными. Пока она pending, её видно в datasafe_events_outbox_pending. Это единственное, что гарантирует 200 на PUT.

  2. Попытка сделана. Диспетчер отправил событие. Неудачи считает datasafe_events_failed_total, переход в dead — datasafe_events_dead_total.

  3. Доставка подтверждена. Получатель ответил 2xx или брокер подтвердил приём; это datasafe_events_delivered_total, задержка — datasafe_events_delivery_lag_seconds. Подтверждение NATS или Kafka означает, что брокер сохранил сообщение, а не что потребитель его обработал.

Между вторым и третьим пунктом возникают дубликаты: если отправка прошла, а отметку о доставке записать не удалось, следующая попытка отправит событие ещё раз. Потребитель должен отбрасывать дубликаты по event_id.

Переполнение и полнота аудита

Если получатель лежит долго, очередь растёт. При превышении STORAGE_EVENTS_MAX_PENDING самые старые ожидающие строки помечаются dropped, а запись объекта не отклоняется. Для аудита это означает, что журнал во внешней системе становится неполным, причём пропадает самое старое. Обнаружить это можно так:

  • datasafe_events_dropped_total больше нуля — события уже потеряны; в журнале действий есть записи events_outbox_dropped;

  • рост datasafe_events_dead_total — у конкретных строк получатель сдался;

  • datasafe_events_outbox_pending растёт — до потерь ещё есть время.

На все три случая есть правила в deploy/docker/prometheus/alerts.yml. Если запись без события для вас хуже отказа в записи, лимит отключается значением 0, и база растёт, пока получатель не вернётся.

Переменная

По умолчанию

Смысл

STORAGE_EVENTS_RETENTION

24h

сколько хранить доставленные строки

STORAGE_EVENTS_DEAD_RETENTION

168h

сколько хранить dead и dropped

STORAGE_EVENTS_MAX_PENDING

100000

лимит ожидающих строк, 0 отключает

STORAGE_EVENTS_MAX_ATTEMPTS

20

неудач до dead

Ни одна из переменных не обязательна.

Порядок по ключу

В первой версии диспетчера порядка не было. На локальном стенде объект создали, потом удалили, получатель в это время лежал, и после восстановления удаление пришло раньше создания: повтор для удаления наступил раньше. В индексе такой объект воскресает.

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

Получатели

Пустые заглушки Kafka и RabbitMQ удалены, остался один интерфейс events.EventSink.

Вебхуки настраиваются в консоли. Ошибки соединения, таймауты, 5xx, 408 и 429 повторяются с учётом Retry-After (не больше 5 минут). Остальные 4xx и 3xx сразу помечают получателя dead в этой строке: на 401 или 404 повторять бессмысленно. Текст статуса попадает в last_error.

NATS включается через STORAGE_NATS_URL. Если есть JetStream-поток, покрывающий STORAGE_NATS_SUBJECT.<тип события>, публикация ждёт подтверждения JetStream. Без JetStream подтверждается только приём сервером NATS.

Kafka в отдельном процессе

Kafka в DataSafeS3 не связывает узлы кластера: они координируются через Postgres. Это внешняя лента событий. Потребитель может перечитать её с нужного места, несколько групп (SIEM, антивирус, индексатор) читают её независимо. Ключ сообщения — тенант/бакет/ключ_объекта, поэтому события одного объекта попадают в один раздел. Без тенанта ключ начинается с /: /preview-demo/report.pdf. Ключ объекта сам может содержать /, так что точные бакет и ключ берите из тела.

Включается одной переменной STORAGE_KAFKA_BROKERS. Тема по умолчанию datasafe.events (STORAGE_KAFKA_TOPIC), acks=all, идемпотентный продюсер, TLS и SASL (PLAIN только поверх TLS, SCRAM-SHA-256/512). Тело — в формате уведомлений S3: массив Records с eventVersion 2.2, eventSource datasafe:s3, eventName вида s3:ObjectCreated:Put, s3:ObjectRemoved:Delete или s3:ObjectCreated:CompleteMultipartUpload, бакетом, ключом, размером, ETag и версией. Рядом блок datasafe с tenant_id, event_id и schema_version (сейчас 1). Тела вебхуков и NATS остались прежними.

Клиент — franz-go, чистый Go без CGO. Первая версия встраивала его в основной бинарь, и проверка одного узла в CI (о ней ниже) упала: память в простое и время старта вышли за допуск относительно 1.4.1, хотя Kafka не была настроена и клиент не создавался.

Допуск я ослаблять не стал. Клиент переехал в дочерний процесс datasafe-kafka-bridge, который storage-server запускает только при непустом STORAGE_KAFKA_BROKERS. Основной бинарь собирается с тегом datasafe_sinkexec и franz-go не содержит. Размеры из опубликованных образов: /storage-server 32,96 МБ в 1.4.1 и 33,31 МБ в 1.5.0, файл моста 14,6 МБ; сжатый образ для скачивания вырос с 15,8 до 21,5 МБ. Появился ещё один процесс со своими падениями; их считает datasafe_events_bridge_restarts_total. Отдельной границей безопасности мост не является, он наследует окружение сервера.

С Kafka нашлись ещё две ошибки. GET /api/v1/events/delivery при недоступном брокере висел больше шести минут, потому что синхронно опрашивал Kafka; теперь статус отдаётся из кэша, а проверка идёт в фоне с таймаутом 2 секунды. И мост убивался родителем, если отправка не укладывалась в срок; теперь он отвечает в пределах своего срока, а перезапуск считается только при реальном выходе процесса (это вошло в PR #10).

Один узел

Задание CI single-node-gate запускается на пул-реквесты в main и поднимает только storage-server так, как его поднимает профиль установщика core: bolt, STORAGE_DEV=false, без NATS, Kafka и Postgres. Сценарий: /healthz, вход администратора и первичная настройка, PUT/GET/список через AWS CLI, журнал действий, инвентаризация, пакет доказательств.

Проверка падает при попытках подключения к брокеру или исходящем TCP, при непустой очереди событий без получателей и при росте ресурсов. Память в простое и время до готовности — медиана пяти чередующихся запусков нового образа и 1.4.1 на одной машине CI; допуск +10% памяти и +1 с старта. На загруженных машинах GitHub Actions она иногда падает без реальной причины, и я её перезапускаю, а допуск не расширяю.

На моём компьютере (Windows, Docker Desktop, docker stats через 25 с после старта, три запуска на версию) контейнер на bolt без интеграций занимает 6,4–6,9 МиБ и в 1.4.1, и в 1.5.0.

./install.sh --yes --profiles core прогоняет тот же сценарий. В install.ps1 нашлась старая ошибка: в Windows PowerShell 5.1 при $ErrorActionPreference = 'Stop' и перенаправлении вывода прогресс docker compose в stderr считался фатальным, и скрипт выходил с кодом 1 при уже поднятом стеке. Теперь вызовы docker идут через обёртку. В CI скрипт для Windows только разбирается синтаксически.

Консоль 404 в чистой копии

Эта ошибка нашлась в день релиза при проверке кандидата «как новый пользователь»: git archive в пустой каталог, установщик, и консоль на :8080 отвечает 404. Сервер работал, установщик писал «Console edge healthy», потому что проверял только /healthz. В 1.4.1 было то же самое: Caddy раздавал консоль из каталога web/console/dist на хосте, в чистой копии его нет, и Docker молча создавал пустую папку. Образ консоли установщик прописывал в .env, но compose его не использовал. В коммите 0be28bc (вошёл в PR #12) одноразовый контейнер при старте копирует консоль из образа в том, а установщики ждут от GET / ответа 200 со страницей приложения. Для локальной сборки консоли есть наложение deploy/compose/docker-compose.local-console.yml.

Как увидеть доставку

В настройках консоли появилась страница «Доставка событий»: состояние каждого получателя (подключён, деградировал, отключён), доставленные и неудачные отправки, последняя ошибка, размер очереди. Кнопка «Отправить тестовое событие» (только для администратора) проходит весь путь через outbox, ею можно проверить и вебхук, и права в Kafka. До первой успешной проверки получатель показывается деградировавшим с ошибкой waiting for first successful connection. Версия и коммит сервера видны внизу бокового меню и в /healthz.

В дашборде Grafana есть ряд «Event delivery»: очередь, доставки, ошибки и dead по получателям, потери из-за лимита, задержка p50/p95/p99, перезапуски моста. В alerts.yml четыре правила: очередь больше 1000 или растёт 10 минут, получатель деградировал дольше 5 минут, растёт dead, растут потери. В журнал действий пишутся events_sink_degraded и events_sink_recovered; отдельные повторы туда не попадают.

Переключение Postgres с отчётом

Скрипт scripts/ha/promote.sh (и promote.ps1) делает три шага. Сначала ограждает старый основной сервер: ALTER SYSTEM SET default_transaction_read_only=on и перечитывание конфигурации, сессии обрываются. storage-server на старом узле, получив ошибку read-only при продлении аренды, сразу снимает с себя роль лидера, и запись получает 503. Затем pg_promote на реплике. Затем проверка, что новый основной вышел из восстановления и принимает запись. --dry-run печатает план. Повторный запуск безопасен.

Код выхода

Значение

0

переключение выполнено или уже было выполнено

1

неверные аргументы

2

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

3

ограждение не удалось, старый основной сервер всё ещё принимает запись

4

не удался pg_promote

5

проверка после переключения: новый основной сервер в восстановлении или не принимает запись

От чего защищает ограждение. Оно рассчитано на старого лидера, который жив и подключён к старому основному серверу: его транзакции становятся read-only, и он перестаёт писать и отправлять события. Оно не защищает от суперпользователя, который может снять default_transaction_read_only в своей сессии, и от любого процесса, который пишет в старый сервер мимо storage-server или подключается к нему после того, как кто-то вернул настройку. Если старый сервер недоступен скрипту, ограждение не выполнится (код 3). Это не полная защита от раздвоения: такой гарантии без отключения питания или сети старого узла скрипт не даёт. Новый основной сервер Postgres CE сам не выбирает, старый в реплику не пересобирается. Для Patroni есть scripts/cluster/promote.sh с теми же кодами.

После переключения scripts/collect-dr-evidence.sh собирает каталог с summary.md и evidence.json и zip: время переключения, отставание реплики до и после, результат повторного запуска, /healthz узлов, запись и чтение объекта на новом лидере, очередь событий, работа диспетчера на новом лидере, потери объектов, дубликаты. В evidence.json есть verdicts, по одному PASS/FAIL на проверку.

Весь сценарий: bash scripts/ha/run-promote-drill.sh, его же выполняет задание CI ha-promote-drill. Поднимаются два Postgres, два узла storage-server и тестовый вебхук, во время переключения идёт запись объектов.

Старый лидер и пять дубликатов

Первые прогоны учений показали, что после ограждения старый лидер отправлял вебхуку одно и то же событие на каждом шаге диспетчера, раз в секунду. Отправка проходила, получатель отвечал 200, а отметку о доставке в базу, уже read-only, записать не удавалось. Строка оставалась pending, и цикл повторялся, пока не отваливалось продление аренды. На одном ключе набралось 5 дубликатов.

Формально это укладывается в at-least-once, но для аудита выглядит как сбой. Исправление вошло в PR #10: на ошибке read-only (SQLSTATE 25006) или ответе хранилища «не лидер» диспетчер сразу останавливает цикл и снимает аренду, этот процесс строку больше не публикует. Строку, которая ушла, но не была отмечена, новый лидер может доставить ещё раз. Пакет доказательств считает это: одна лишняя доставка на ключ, бывший в полёте, допустима, вторая делает пакет FAIL.

Мои прогоны на Windows-компьютере с Docker Desktop (Git Bash, стандартный сценарий run-promote-drill.sh), четыре раза: два на коммите PR #11 3df318a, один на eed3e99, один на опубликованном образе v1.5.0. Переключение заняло 7–9 секунд, потерянных объектов нет (10–11 ключей в каждом прогоне), в каждом прогоне одна лишняя доставка на одном ключе.

Кластер из трёх узлов

Отдельно я проверял переключение лидера между узлами storage-server. Пишет только лидер, то есть узел с арендой в Postgres; остальные обслуживают чтение и на запись отвечают 503 not cluster leader. Масштабирования записи кластер не даёт.

Условия: тот же Windows-компьютер с Docker Desktop, compose-файл с тремя узлами, одним Postgres, общим томом для объектов, NATS JetStream и приёмником вебхуков. Все три узла стартуют одновременно на пустом Postgres. Нагрузку даёт скрипт на Node.js (fetch), который пишет через REST API администратора (PUT /api/v1/buckets/{bucket}/objects/{key}), всегда в текущего лидера. Вебхук создаётся после старта всех узлов. Ниже прогон на кандидате в релиз 7f566f5 с арендой 10 с; в скобках — два прогона на 60d8d55 (аренда 10 с и по умолчанию), где они расходятся.

  • Новый бакет виден на ведомых узлах через 30–43 мс (22–33 мс).

  • 50 объектов, записанных через лидера, читаются с обоих ведомых, ETag совпадают.

  • 500 PUT по 1 КБ, 16 параллельных запросов: 286 PUT/с, p50 51 мс, p95 79 мс, p99 90 мс, ошибок 0 (280–283 PUT/с, p95 79–82 мс).

  • 200 последовательных загрузок, на 60-й лидер убивается docker kill, ручного перезапуска нет; клиент при ошибке ждёт 250 мс и ищет лидера через /healthz. Новый лидер через 7,8 с, запись недоступна 7,4 с, 28 неудачных попыток, все 200 загрузок в итоге успешны. С арендой по умолчанию — 28,4 с.

  • Вебхук после переключения: 181 из 181 уникального ключа без дубликатов, 20 перезаписей одного ключа пришли в порядке записи; после нагрузки 500 событий из 500, не больше одного на ключ.

  • 20 параллельных PUT в один ключ: все 200, итоговое состояние согласовано.

  • Старый лидер после перезапуска возвращается ведомым и роль не забирает. За три одновременных старта на 60d8d55 — ноль падений и один администратор.

Время переключения близко к сроку аренды: чем она короче, тем быстрее переключение, но тем чаще продления и выше риск ложного переключения при задержках до Postgres. Узел, однажды потерявший аренду, сам её больше не берёт.

Остальные ошибки, найденные на этом стенде исправлены в PR #11 (коммиты ветки eed3e99 и 60d8d55):

  • ведомые пытались взять аренду только при старте, автоматического переключения не было (eed3e99);

  • интервал продления мог превышать треть срока аренды (eed3e99);

  • параллельные PUT в один ключ возвращали 400 из-за общего .tmp и INSERT; теперь побеждает последний записавший (eed3e99);

  • узлы одновременно накатывали миграции и падали на pg_extension_name_index; миграции под advisory lock (eed3e99);

  • POST /api/v1/gateway/connections возвращал secret_key в ответе (eed3e99);

  • два из трёх узлов падали с user already exists при создании администратора по умолчанию; теперь под advisory lock и идемпотентно (60d8d55);

  • после переключения вебхуки не доставлялись с sink not registered; узел перечитывает получателей при получении роли и примерно каждые 2 секунды (60d8d55).

Gateway: расписание синхронизации

У каждого правила Gateway (синхронизация с внешним S3) своё расписание: «По умолчанию (полный обход раз в час)», «Выключено (только события)», 15 минут, 1 час, 6 часов, 24 часа. На карточке правила видны имя, время последнего запуска и последняя ошибка; в Postgres это миграция 021.

Порты лабораторных стендов разведены и больше не занимают :9000. Стенд учений по умолчанию использует 19100, 19101, 15442, 15443 и 18080, они переопределяются переменными HA_DRILL_*.

Как попробовать

# Linux / macOS / WSL
chmod +x install.sh && ./install.sh
# Windows
.\install.ps1

Или вручную, с профилем по умолчанию:

cp .env.example .env
docker compose -p datasafe --profile postgres -f docker-compose.yml -f docker-compose.local-data.yml up -d

Консоль на http://localhost:8080, вход admin / admin с обязательной сменой пароля, S3 API на http://localhost:9000. Образы в GHCR, только linux/amd64:

docker pull ghcr.io/direktorbani/datasafe-storage-server:v1.5.0
docker pull ghcr.io/direktorbani/datasafe-console:v1.5.0

Для проверки событий создайте вебхук и нажмите «Отправить тестовое событие». Kafka поднимается профилем установщика kafka или наложением deploy/compose/docker-compose.kafka.yml.

Обновление с 1.4.1

Сервер и консоль берите одного тега, собирать образы не нужно:

git pull
# в .env:
# DATASAFE_SERVER_IMAGE=ghcr.io/direktorbani/datasafe-storage-server:v1.5.0
# DATASAFE_CONSOLE_IMAGE=ghcr.io/direktorbani/datasafe-console:v1.5.0
docker compose --profile postgres pull
docker compose --profile postgres up -d

На Postgres применяются миграции 020 (event_outbox) и 021 (расписание Gateway), на bolt создаются бакеты event_outbox и event_outbox_due. Откат на 1.4.x возможен. После обновления GET /healthz возвращает "version":"1.5.0" и commit, GET / на порту консоли — страницу приложения. Если пользовались стендами erasure или site A, удалите старые .env.erasure и .env.site-a.

Я проверял этот путь на чистых стендах 1.4.1 с Postgres и с bolt: десять объектов и их ETag на месте, вебхук из 1.4.1 сразу получает события, консоль открывается. Подробности в docs/operations-guide/ru/upgrade.md.

Ограничения

  • Доставка at-least-once, дубликаты отбрасывайте по event_id.

  • Получатель, включённый позже, старых событий не получит; события за период без получателей не воспроизводятся.

  • Атомарность «изменение плюс событие» относится к метаданным; пути из нескольких транзакций перечислены во введении и в event-outbox.md.

  • dead по ранней строке оставляет дыру по ключу; переполнение теряет самые старые события (datasafe_events_dropped_total).

  • Подтверждение NATS и Kafka — приём брокером, не обработка потребителем.

  • Переключение Postgres выполняется по команде оператора; ограждение не защищает от суперпользователя и от записи мимо storage-server.

  • Кластер масштабирует чтение, не запись; переключение лидера занимает примерно срок аренды.

  • Только linux/amd64.

  • Счётчики на странице консоли сбрасываются при перезапуске процесса, накопленные значения есть в Prometheus.

  • Проверка одного узла иногда ложно падает на загруженной машине CI; install.ps1 в CI проверяется только синтаксически.

Ссылки

Обновление: docs/operations-guide/ru/upgrade.md Таблица исходящих событий: docs/operations-guide/ru/event-outbox.md Kafka: docs/ru/integrations/kafka.md Аварийное восстановление: docs/operations-guide/ru/disaster-recovery.md Проверка одного узла: docs/operations-guide/ru/single-node-gate.md

Уязвимости — через SECURITY.md, ошибки — в GitHub Issues.

Если вы отправляете события хранилища в SIEM, напишите, как обходитесь с дубликатами и дырами: дедуплицируете у потребителя или требуете большего от источника.

Автор: Илья Трачук, разработчик DataSafeS3