Репозиторий: 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; строка остаётся в базе и видна в метриках и консоли.
sequenceDiagram participant C as Клиент S3 participant S as storage-server participant M as Метаданные (bolt / Postgres) participant D as Диспетчер participant R as Получатели (вебхук, NATS, Kafka) C->>S: PUT /bucket/key S->>S: запись байтов объекта S->>M: одна транзакция: строка объекта + строка outbox M-->>S: commit S-->>C: 200 OK loop раз в секунду D->>M: выбрать строки pending с наступившим сроком D->>R: отправить событие alt получатель подтвердил R-->>D: ok / ack D->>M: отметить delivered else ошибка или таймаут R-->>D: ошибка D->>M: attempts+1, следующая попытка через 1 с, 2 с, 4 с ... 5 мин end end Note over D,M: после 20 неудач получатель в строке помечается dead
Запись, попытка, доставка
Это три разных факта, и метрики у них разные.
-
Запись сохранена. Строка outbox закоммичена вместе с метаданными. Пока она
pending, её видно вdatasafe_events_outbox_pending. Это единственное, что гарантирует 200 на PUT. -
Попытка сделана. Диспетчер отправил событие. Неудачи считает
datasafe_events_failed_total, переход вdead—datasafe_events_dead_total. -
Доставка подтверждена. Получатель ответил 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, и база растёт, пока получатель не вернётся.
|
Переменная |
По умолчанию |
Смысл |
|---|---|---|
|
|
|
сколько хранить доставленные строки |
|
|
|
сколько хранить |
|
|
|
лимит ожидающих строк, |
|
|
|
неудач до |
Ни одна из переменных не обязательна.
Порядок по ключу
В первой версии диспетчера порядка не было. На локальном стенде объект создали, потом удалили, получатель в это время лежал, и после восстановления удаление пришло раньше создания: повтор для удаления наступил раньше. В индексе такой объект воскресает.
Теперь для каждого получателя диспетчер не отдаёт более позднюю строку с тем же тенантом, бакетом и ключом, пока ранняя ещё ожидает. Застрявший ключ блокирует только себя, получатели друг от друга не зависят. Если ранняя строка стала 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 |
не удался |
|
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 / WSLchmod +x install.sh && ./install.sh
# Windows.\install.ps1
Или вручную, с профилем по умолчанию:
cp .env.example .envdocker 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.0docker 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.0docker compose --profile postgres pulldocker 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
ссылка на оригинал статьи https://habr.com/ru/articles/1094034/