С конца 2025 года известная многим, кто работает с object storage, система Minio начала издавать тревожные сигналы: разработчики полностью перестали принимать новые правки, прекратили обновлять Docker образ, убрали веб-интерфейс из опенсорсной версии, а с 13 февраля проект на GitHub полностью заморожен и стал архивом. Можно провести связь с покупкой Broadcom компании VMware, которой и принадлежит Minio, и последующим выжиманием денег из клиентов.
Но мы не будем строить теории, есть вопрос интереснее: кто убережёт наши объекты, если не Minio? Давайте попробуем узнать на примере Garage и SeaweedFS. Мы будем бросать эти системы об стену, и смотреть, что получится на выходе. Так работает chaos testing. Наверное.
Почему именно гараж и водоросли
Есть Ceph, есть CubeFS, и много других прекрасных систем, скажете вы, и будете правы. Но тут надо сказать, что статья появилась как результат внутреннего тестирования в компании.
У нас в "Монитор Софт" имелись следующие главные критерии отбора:
Поддержка кластеризации и репликации.
Наличие синхронной записи
Надёжность, т.е. способность переживать высокие сетевые задержки, потери пакетов, или отказ региона/зоны.
Относительная лёгкость в обслуживании, чтобы “как у minio”.
Опционально - наличие хорошего Helm чарта, а лучше оператора под Kubernetes. Мы в компании обожаем различные операторы и "примочки" для кубера.
Так вот, какие кандидаты есть?
RustFS на момент выхода статьи всё ещё тестирует распределённый режим работы. Не проходит по первому пункту, нам всё-таки нужно готовое к проду решение.
CubeFS требует больших ресурсов для минимального HA-кластера: три master node, три meta node, и 1+ data node, а также object gateway. Кроме того, у них нет готовых сборок вроде Docker образов, официальная документация предлагает собирать из исходников. Это наводит на мысли о молодости проекта, хотя на самом деле репозиторий существует с 2019 года. Также злые языки с Reddit жалуются на небезопасность проекта из-за активного вайбкодинга:

Ceph и его S3 Gateway не совсем подходит под статью. Я хотел изучить легковесные решения последнего поколения. Ceph, кроме того, взрослый проект, о котором немало докладов с опытом починок кластера, постов про оптимизацию производительности, и многое другое. И я не знаю, что нового добавить к опыту коллег по индустрии.
Versity S3 Gateway. Нет кластеризации, отбрасываем.
В конечном счёте из кандидатов остаются Garage и SeaweedFS. Эти инструменты уже не раз обозревались на Хабре. К примеру, есть обзорная статья по функционалу, и ещё другая. Или даже с инструкциями по сетапу кластера и небольшими бенчмарками. Однако никто не проводил публичные исследования надёжности. Как у каждого из них ведёт себя кластер при отказе узла, как обрабатывается обрыв загрузки, и многое другое. Статья делалась на основе внутренней задачи, при этом отчёты и код тестов изначально задумывались для публичного чтения.
Да, тесты на Go автоматизированы, и с их кодом и инструкциями можно будет ознакомиться по ссылке в конце статьи.
Garage
Распределённое хранилище объектов. Написано на Rust, развивается французской компанией Deuxfleur с милым ASCII сайтом. Разработка ведётся с 2020 года, система разрабатывается и по сей день со стабильной активностью.
Заявленный функционал системы:
Основная и важная для нас фича - отказоустойчивость и надёжность при нестабильной сети. Система делает свои чексуммы объектов и проверяет целостность данных (кроме метаданных).
Поддерживается кластеризация и геораспределённость, система сама делает ребалансировку при добавлении/изменении узлов.
Синхронная запись есть, согласно replication factor. Асинхронная запись (с подтверждением после записи на половину узлов и тд) поддерживается через реконфигурацию, с потерей гарантии консистентности read-after-write.
Удобство администрирования. Система сама в фоне делает ребалансировку при изменении узлов или отказе дисков.
Для Kubernetes есть Helm чарт.
В наши требования вписывается почти идеально, для полного счастья не хватает лишь Kubernetes оператора.
Особенности, которые могут вылиться в проблемы:
Изменение фактора репликации проблематична: потребуется рестарт всего кластера и переинициализация cluster layout. Ещё нет гарантий, что процесс полностью надёжный, более того, он официально не поддерживается разработчиками.
Вообще, различные операции, связанные с администрированием, требуют рестарта всех узлов, так как задаются через конфиги на каждой из нод, а не через кластерный API.
Проверка чек-сумм данных происходит с интервалом в месяц. Частоту проверки не перенастроить, но, по крайней мере, можно вручную запускать через
garage repair blocks.
На помент написания статьи последняя версия 2.2.0, при этом в блоге последний анонс посвящён 2.0.0 и датируется июнем 2025 года. К счастью, в Forgejo разработчиков имеется release notes по каждой версии. Тесты проведём на 2.1.0, но если захотите воспроизвести результаты на последних версиях -- без проблем.
Подготовка кластера Garage
Конфигурация Docker Compose для кластера из трёх узлов:
compose.yml
services: garaged-1: container_name: garaged-1 hostname: garaged-1 image: ${GARAGE_IMAGE} volumes: - ./garaged-1.toml:/etc/garage.toml:ro - garaged1-data:/storage ports: - "127.0.0.1:3901:3900" garaged-2: container_name: garaged-2 hostname: garaged-2 image: ${GARAGE_IMAGE} volumes: - ./garaged-2.toml:/etc/garage.toml:ro - garaged2-data:/storage ports: - "127.0.0.1:3902:3900" garaged-3: container_name: garaged-3 hostname: garaged-3 image: ${GARAGE_IMAGE} volumes: - ./garaged-3.toml:/etc/garage.toml:ro - garaged3-data:/storage ports: - "127.0.0.1:3903:3900" volumes: garaged1-data: garaged2-data: garaged3-data: networks: default: name: objstor external: true
Здесь можно встретить то, что сеть objstor указана как внешняя. Это связано с тем, что она делится с сетью для сбора метрик. Если вам не потребуется мониторинг - убирайте весь блок networks.
А это - конфигурационный файл от garaged-1, остальные отличаются только `rpc_public_addr`:
garaged-1.toml
metadata_dir = "/storage/metadata" data_dir = "/storage/data" db_engine = "lmdb" replication_factor = 3 rpc_bind_addr = "[::]:3901" rpc_public_addr = "garaged-1:3901" # общий между всеми узлами кластера, создавать через # openssl rand -hex 32 rpc_secret = "..." [s3_api] s3_region = "garage" api_bind_addr = "[::]:3900" root_domain = ".s3.garage.localhost" [s3_web] bind_addr = "[::]:3902" root_domain = ".web.garage.localhost" index = "index.html" [k2v_api] api_bind_addr = "[::]:3904" [admin] api_bind_addr = "[::]:3903" # токен для admin API, генерируется # openssl rand -base64 32 admin_token = "..." # отдельный токен для снятия метрик metrics_token = "..."
После запуска такого кластера при запуске в контейнере garaged-1 команды garage status будет такой вывод:
$ docker compose exec -it garaged-1 /garage status ==== HEALTHY NODES ==== ID Hostname Address Tags Zone Capacity DataAvail Version b18a006c47144eed garaged-1 172.18.0.4:3901 NO ROLE ASSIGNED v2.1.0
В нём только один узел. Почему? Сервис запущен, но кластер ещё не собран воедино и не размечен на зоны.
Исправляем это. Сначала получаем ID соседних узлов, потом подключаем первый узел к соседям. Пример для настройки связи со вторым узлом:
$ docker compose exec -it garaged-2 /garage node id 379173d4dc34f954ed993ec8233dba9f3bb62e0395e1aedf39b4c002a9f9ef5e@garaged-2:3901 To instruct a node to connect to this node, run the following command on that node: garage [-c <config file path>] node connect 379173d4dc34f954ed993ec8233dba9f3bb62e0395e1aedf39b4c002a9f9ef5e@garaged-2:3901 $ docker compose exec -it garaged-1 /garage node connect 379173d4dc34f954ed993ec8233dba9f3bb62e0395e1aedf39b4c002a9f9ef5e@garaged-2:3901 Success.
Затем создаём “разметку” кластера:
Перечень команд
$ docker compose exec -it garaged-1 /garage status ==== HEALTHY NODES ==== ID Hostname Address Tags Zone Capacity DataAvail Version 379173d4dc34f954 garaged-2 172.18.0.6:3901 NO ROLE ASSIGNED v2.1.0 4073ab03040fcc36 garaged-3 172.18.0.5:3901 NO ROLE ASSIGNED v2.1.0 b18a006c47144eed garaged-1 172.18.0.4:3901 NO ROLE ASSIGNED v2.1.0 $ docker compose exec -it garaged-1 /garage layout assign -z dc1 -c 2GB b18a006c47144eed Role changes are staged but not yet committed. Use `garage layout show` to view staged role changes, and `garage layout apply` to enact staged changes. # делаем для оставшихся двух нод и их ID... $ docker compose exec -it garaged-1 /garage layout show ==== CURRENT CLUSTER LAYOUT ==== No nodes currently have a role in the cluster. See `garage status` to view available nodes. Current cluster layout version: 0 ==== STAGED ROLE CHANGES ==== ID Tags Zone Capacity 379173d4dc34f954 [] dc2 2.0 GB 4073ab03040fcc36 [] dc3 2.0 GB b18a006c47144eed [] dc1 2.0 GB ==== NEW CLUSTER LAYOUT AFTER APPLYING CHANGES ==== ID Tags Zone Capacity Usable capacity 379173d4dc34f954 [] dc2 2.0 GB 2.0 GB (100.0%) 4073ab03040fcc36 [] dc3 2.0 GB 2.0 GB (100.0%) b18a006c47144eed [] dc1 2.0 GB 2.0 GB (100.0%) Zone redundancy: maximum ==== COMPUTATION OF A NEW PARTITION ASSIGNATION ==== Partitions are replicated 3 times on at least 3 distinct zones. Optimal partition size: 7.8 MB Usable capacity / total cluster capacity: 6.0 GB / 6.0 GB (100.0 %) Effective capacity (replication factor 3): 2.0 GB dc2 Tags Partitions Capacity Usable capacity 379173d4dc34f954 [] 256 (256 new) 2.0 GB 2.0 GB (100.0%) TOTAL 256 (256 unique) 2.0 GB 2.0 GB (100.0%) dc3 Tags Partitions Capacity Usable capacity 4073ab03040fcc36 [] 256 (256 new) 2.0 GB 2.0 GB (100.0%) TOTAL 256 (256 unique) 2.0 GB 2.0 GB (100.0%) dc1 Tags Partitions Capacity Usable capacity b18a006c47144eed [] 256 (256 new) 2.0 GB 2.0 GB (100.0%) TOTAL 256 (256 unique) 2.0 GB 2.0 GB (100.0%) $ docker compose exec -it garaged-1 /garage layout apply --version 1
Готово. Теперь у нас есть рабочий кластер S3. На два гигабайта. Шутку “игровой компьютер, два ядра, два гига” перефразируете сами. Но для большинства наших тестов этого хватит. Понадобится загружать больше - это будет подставляться в шаблоне для тестов.
Мониторинг
У Garage есть внутренние метрики. Во время эксплуатации или тестов можно ориентироваться на следующие метрики:
api_s3_request_counter api_s3_request_duration_sum table_size{table_name="object"} # достаточно много метрик про внутренние коммуникации между узлами rpc_request_counter # очередь задач повторной синхронизации блоков block_resync_queue_length
Полный список можно узнать в документации. Активные пользователи Prometheus заметят, что у метрик нет префикса garage. Допустим, у node exporter все метрики зовутсяnode_<…>. Почему у Garage нет префиксов - загадка.
Если захотите мониторить сервер с кластером Garage, то в репозитории в директории garage/o11y есть дополнительный Compose файл:
compose.yml
name: o11y services: fluentbit: image: fluent/fluent-bit:4.0.10 command: "-c /fluent-bit/etc/fluent-bit.yaml" volumes: - ./fluent-bit.yaml:/fluent-bit/etc/fluent-bit.yaml:ro - /proc:/proc restart: unless-stopped cadvisor: image: gcr.io/cadvisor/cadvisor:v0.52.1 volumes: - /:/rootfs:ro - /var/run:/var/run:ro - /sys:/sys:ro - /var/lib/docker/:/var/lib/docker:ro - /dev:/dev:ro devices: - /dev/kmsg:/dev/kmsg restart: unless-stopped networks: default: name: objstor
Там же лежит пример конфигурации Fluent Bit. Полностью приводить его тут не буду, если что, изучите сами.
Тестирование Garage
Что, кроме производительности, можно протестировать в объектном хранилище?
Работу при отказе одного из регионов. Будут ли ошибки у клиентов?
Как система переживёт прерывание multipart загрузки? Будет ли запущена очистка по прошествии времени?
Сколько inode будет уходить на каждый объект? А на каждый кусок multipart загрузки? Другими словами, какая будет заполненность файловой системы?
Какие накладные расходы будут на каждый объект? Загрузили 1 КБ, сколько будет на диске?
Как быстро узел Garage догонит соседние зоны после восстановления питания?
Что будет, если в сеть внедрить задержки? Терять пакеты между отдельными узлами? Переупорядочивать пакеты?
Важно отметить, что тестов много, а для чистоты результатов желательно иметь новый кластер. Собственно, поэтому все тесты автоматизированы, как и пересоздание кластера. Это позволило нам достичь хорошей воспроизводимости. Надёжным тестам - надёжные результаты.
Все тесты проводились в Timeweb Cloud, на инстансе SSD-50 (2x2.8 ГГц, 4 ГБ RAM, 50 SSD, зона SPB-3). ОС Ubuntu 24.04, Docker 29.3.0 с logging driver выставленным в local (вместо json-file). Планировалось тестировать в Cloud ru, однако оба выделенных для российской виртуалки IP-адреса были уже заблокированы стражами галактики из рнк.
Тест 1. Отказ региона
Этот тест достаточно простой по задумке: стартуем кластер по рецепту выше, загружаем через первый узел 1000 файлов по 4 Кб, и в процессе загрузки на небольшое время останавливаем через SIGKILL один из соседних узлов.
Сценарий в Go-тестах называется TestStopZone. Вот как выглядит таймлайн теста:
garageutil_test.go:35: 07:01:30.364 - bucket 'monitorsoft' created, uploading 1000 files garageutil_test.go:35: 07:02:00.937 - killed one of the nodes garageutil_test.go:92: Error: Received unexpected error: put object: operation error S3: PutObject, https response error StatusCode: 0, RequestID: , HostID: , canceled, context deadline exceeded garageutil_test.go:35: 07:02:16.505 - added back node garageutil_test.go:35: 07:02:16.505 - finished upload
В момент, когда один из узлов был остановлен, загрузки у клиента начали зависать. Это связано с тем, что мы выставили фактор репликации в 3, а в Garage по умолчанию есть настройка consistency_mode = "consistent". Подробно про эту настройку здесь в документации. Попробуем поменять значение на ”degraded”:
garageutil_test.go:35: 07:10:27.846 - bucket 'monitorsoft' created, uploading 1000 files garageutil_test.go:35: 07:10:58.482 - killed one of the nodes garageutil_test.go:35: 07:11:13.933 - added back node garageutil_test.go:35: 07:13:15.153 - finished upload
Тест пройден.
В логах первого узла момент отказа сопровождался ошибками:
2026-03-17T07:10:58.062046Z ERROR garage_net::error: Error: ClientConn send_loop: IO error: Broken pipe (os error 32) 2026-03-17T07:10:58.062547Z ERROR garage_net::error: Error: ClientConn send true to stop_recv_loop: Watch send error 2026-03-17T07:10:58.062672Z INFO garage_net::netapp: Connection to 0ffd76f9cf0ec93f closed 2026-03-17T07:10:58.062685Z INFO garage_net::peering: Connection to 0ffd76f9cf0ec93f was closed 2026-03-17T07:10:58.904825Z INFO garage_net::peering: Retrying connection to 0ffd76f9cf0ec93f at 172.18.0.6:3901 (1) 2026-03-17T07:11:05.870469Z WARN garage_table::sync: (key) Sync error with 0ffd76f9cf0ec93f: Network error: Not connected: 0ffd76f9cf0ec93f 2026-03-17T07:11:05.870574Z WARN garage_table::sync: (bucket_alias) Sync error with 0ffd76f9cf0ec93f: Network error: Not connected: 0ffd76f9cf0ec93f 2026-03-17T07:11:05.870777Z WARN garage_table::sync: (bucket_v2) Sync error with 0ffd76f9cf0ec93f: Network error: Not connected: 0ffd76f9cf0ec93f 2026-03-17T07:11:15.871859Z WARN garage_table::sync: (bucket_alias) Sync error with 0ffd76f9cf0ec93f: Network error: Not connected: 0ffd76f9cf0ec93f 2026-03-17T07:11:15.872030Z WARN garage_table::sync: (key) Sync error with 0ffd76f9cf0ec93f: Network error: Not connected: 0ffd76f9cf0ec93f 2026-03-17T07:11:15.872195Z WARN garage_table::sync: (bucket_v2) Sync error with 0ffd76f9cf0ec93f: Network error: Not connected: 0ffd76f9cf0ec93f
В выводе статуса узел корректно отображался как failed
===== HEALTHY NODES ==== ID Hostname Address Tags Zone Capacity DataAvail Version c81eefcec649b504 garaged-3 172.18.0.4:3901 \[\] dc3 2.0 GB 47.2 GB (92.7%) v2.1.0 d496784680f4ca88 garaged-1 172.18.0.5:3901 \[\] dc1 2.0 GB 47.2 GB (92.7%) v2.1.0 ==== FAILED NODES ==== ID Hostname Tags Zone Capacity Last seen 0ffd76f9cf0ec93f garaged-2 \[\] dc2 2.0 GB 13 seconds ago
После запуска контейнера и прошествии пяти-шести секунд узел вернулся в кластер как здоровый.
Логи из первого узла
2026-03-17T07:11:18.446567Z INFO garage_net::netapp: Connected to 172.18.0.6:3901, negotiating handshake... 2026-03-17T07:11:18.488781Z INFO garage_net::netapp: Connection established to 0ffd76f9cf0ec93f 2026-03-17T07:11:18.488882Z INFO garage_net::peering: Successfully connected to 0ffd76f9cf0ec93f at 172.18.0.6:3901 2026-03-17T07:11:18.489281Z INFO garage_net::peering: Successfully connected to 0ffd76f9cf0ec93f at 172.18.0.6:3901 2026-03-17T07:11:18.648227Z INFO garage_net::netapp: Incoming connection from \[::ffff:172.18.0.5\]:38970, negotiating handshake... 2026-03-17T07:11:18.649793Z INFO garage_net::netapp: Accepted connection from ad39fc6f170c99b6 at \[::ffff:172.18.0.5\]:38970 2026-03-17T07:11:18.691407Z INFO garage_api_admin::api_server: Proxied admin API request: GetClusterStatus 2026-03-17T07:11:18.692165Z INFO garage_api_admin::api_server: Proxied admin API request: GetClusterLayout 2026-03-17T07:11:18.693216Z INFO garage_net::netapp: Connection from ad39fc6f170c99b6 closed
Стоит отметить, что дозагрузка отсутствующих данных произошла не сразу, а через примерно 9 минут. Это отмечено аннотациями в графике Object count:

Потребление системных ресурсов незначительное. В среднем за время тестов потребление памяти каждым узлом не выходило за рамки 12 Мб.

Чтение объектов практически не использовало диск, в отличии от записи.
Таким образом, тест проходит при понижении режима консистентности до degraded. Что, в принципе, логично. Нужна гарантированная репликация на 3 зоны? Терпите, пока все зоны не будут доступны.
Тест 2. Обрыв загрузки multipart
В S3 API есть понятие multipart upload, которое по идее похоже на Chunked Transfer Encoding в HTTP/1.1. Создаётся запрос на загрузку объекта по частям, по ID запроса загружаются части, в конце отправляется завершающий запрос.
Вопрос: как поведёт себя Garage, если часть объектов загрузится, а завершающего API-вызова не будет, например, из-за обрыва питания? Согласно спецификации - никак не поведёт, если только не включена Lifecycle Policy. Так что тест заключается в следующем:
Включается Lifecycle Policy с указанием времени жизни (TTL) незавершённых загрузок;
Проверяем, что правило создалось;
Загружаем немного частей multipart объекта;
Перезапускаем узел интереса ради;
Выжидаем сутки (минимальный срок TTL);
И смотрим, пропал объект загрузки или нет.
Сценарий в репозитории называется TestMultipartBreak. Автоматизировано всё, кроме ожидания суток. Лог выполнения:
Rule ID: <nil> # ID правила, похоже, nil допустим в Garage expiration: <nil> # дополнительные настройки правила очистки incomplete multipart ttl: 1 status: Enabled garageutil_test.go:35: 14:24:08.099 - killing one of nodes garageutil_test.go:35: 14:24:39.818 - added back node garageutil_test.go:35: 14:24:39.819 - test finished
При выполнении в контейнере команды /garage bucket info monitorsoft видно следующее:
==== BUCKET INFORMATION ==== Bucket: e538b9d8b048ea06ff97b99ef438bf46d0ea6405e0805cb982d14399d962299f Created: 2026-03-17 14:23:07.576 +00:00 Size: 0 B (0 B) Objects: 0 Unfinished uploads: 1 multipart uploads 1 including regular uploads Size of unfinished multipart uploads: 45.0 MiB (47.2 MB)
По прошествии суток файл был удалён.
Также можно вручную запускать очистку старых объектов с TTL меньше суток. Например, спустя 10 минут:
$ docker exec -it garaged-1 /garage bucket cleanup-incomplete-uploads monitorsoft --older-than 10m e538b9d8b048ea06: 1 uploads deleted $ docker exec -it garaged-1 /garage bucket info monitorsoft ==== BUCKET INFORMATION ==== Bucket: e538b9d8b048ea06ff97b99ef438bf46d0ea6405e0805cb982d14399d962299f Created: 2026-03-17 14:23:07.576 +00:00 Size: 0 B (0 B) Objects: 0
Здесь всё понятно, тест засчитан.
Тест 3. Число файлов на диске
На определённых файловых системах в Linux, таких, как популярная ext4, есть понятие inode, то есть index node. Это отдельные от блоков данных структуры, которые содержат метаданные файла, будь то права доступа или время изменения. По умолчанию в ext4 выделяется по inode на каждые 16 Кб диска. Пример для текущей виртуалки:
$ df -hi / # статистика по inode Filesystem Inodes IUsed IFree IUse% Mounted on /dev/sda1 6.2M 196K 6.0M 4% / $ df -h / # общая статистика Filesystem Size Used Avail Use% Mounted on /dev/sda1 48G 3.9G 44G 9% /
То есть для диска в 50 Гб выделено 6.2 млн индексных нод. И если у вас средний размер объекта планируется больше 16 Кб, то проблем не возникнет.
Некоторые хранилища решают проблему объединением объектов в т.н. тома, в том числе для экономии inode. Так, например, делает Ceph. Проверка Garage будет следующая:
Подсчитывается количество файлов на Docker-томе одного из узлов Garage (отдельно считая данные и метаданные);
Загружается десять тысяч файлов по 4 Кб;
Повторно подсчитывается число файлов на томе данных.
Запускаем тест TestInodesCount:
garageutil_test.go:35: file count: 1 (metadata: 9), uploading 10k files garageutil_test.go:35: file count after upload 10k files: 10001 (metadata: 9) --- PASS: TestInodesCount (89.49s)
На каждый объект выделяется по одному файлу на диске. Так что если на кластере Garage ожидается множество крохотных (<16KiB) файлов, то стоит подумать в сторону других систем, будь то XFS или относительно современной Btrfs.
Примечание. При подсчёте размера директории было обнаружено, что вместо ожидаемых 39 Мб (4096 байт * 10000 объектов) на диске использовалось 115 Мб под данные и ещё 25 Мб метаданных. Почему так? Ответ заключается в размере блока и сжатии. Garage по умолчанию дополнительно сжимает объекты алгоритмом Zstandard (zstd), а если объект состоит из рандомных байт, то сжатие наоборот, немного “раздувает” файл на диске. Он начинает весить не 4096 байт, а, например, 4109 байт. Соответственно, он уже не влезает в один блок файловой системы (4096), и после выделения дополнительного блока он стал весить 8 Кб. Учитывайте вероятные накладные расходы при дизайне кластера.
Примечание 2. При загрузке большого числа объектов (24 тысячи, например) потребление памяти почти не менялось, составляя в среднем 3-4 Мб.
Тест 4. Число файлов при multipart
Ответвление предыдущего теста. Сколько файлов уйдёт на multipart загрузку? Будут ли объекты объединены в итоге в один? Проверим это, загрузив 1000 частей по 128 Кб.
Запустим TestMultipartInodes:
garageutil_test.go:35: file count: 1 (metadata: 9), uploading multipart garageutil_test.go:35: file count: 1001 (metadata: 9), completing multipart garageutil_test.go:35: file count: 1001 (metadata: 9), multipart completed garageutil_test.go:35: multipart finished, waiting for minute to finish background processes garageutil_test.go:35: file count: 1001 (metadata: 9), test done --- PASS: TestMultipartInodes (122.36s)
Даже после выжидания минуты объекты не были объединены в один.
Тест 5. Накладные расходы на файлы среднего размера
До этого момента в хранилище загружались объекты по 4 Кб. Будет ли overhead при загрузке 12 тысяч файлов по, скажем, 256 Кб? Такой размер выбран, поскольку примерно соответствует размер обычной картинки JPEG в интернете. Тест TestFileSizeOverhead:
garageutil_test.go:35: uploading many files garageutil_test.go:35: disk usage after uploading 12000 files: 3.0G /data/data 30.1M /data/metadata --- PASS: TestFileSizeOverhead (235.92s)
12000 умножаем на 256 Кб, получается как раз 3 Гб. Выходит, на файлах среднего размера волк не так уж страшен.
Промежуточный тест 6. Скорость загрузки
Начинаются сетевые тесты. Данный сценарий требуется как референсное значение пропускной способности кластера.
Просто загрузим в 8 потоков суммарно 2 Гб. Тест TestUploadSpeed:
garageutil_test.go:35: 19:44:28.314 - uploading 2048 MiB in parallel garageutil_test.go:35: 19:46:49.425 - upload finished, speed 14.51 MiB/s
На моём инстансе вышло 14.5 Мб/с. Задержка при этом в среднем была 110-122 мс:

Тест 7. Добавление задержек.
Тест-кейс тоже загружает 2 ГБ в параллельных потоках, но ближе к середине теста на втором узле добавляется сетевая задержка в 200 мс на исходящий трафик. Для этого понадобятся утилиты tc (Traffic Control), Python-надстройка tcset (пакет tcconfig), а также Linux-команда для работы с namespace nsenter. Запуск этих утилит в тестах автоматизирован, важно просто их наличие в ОС. Лог теста TestNetworkDelay:
garageutil_test.go:35: 19:57:21.867 - uploading 2048 MiB in parallel garageutil_test.go:35: current speed 14.95 MiB/s garageutil_test.go:35: injecting delay=100ms garageutil_test.go:35: upload finished, speed 14.93 MiB/s garageutil_test.go:35: file count: 8193 (metadata: 9) garageutil_test.go:35: file count after removing delay: 8193 (metadata: 9) --- PASS: TestNetworkDelay (166.98s)
Скорость практически не снизилась. Это может быть связано с включенной ранее настройкой consistency_mode=”degraded”, которая позволяет одной из зон отставать или вообще быть недоступной. Отключаем настройку в шаблоне конфигов, перезапускаем тест:
garageutil_test.go:35: 08:41:37.687 - uploading 2048 MiB in parallel garageutil_test.go:35: current speed 15.60 MiB/s garageutil_test.go:35: injecting delay=100ms garageutil_test.go:35: upload finished, speed 13.53 MiB/s garageutil_test.go:35: file count: 8193 (metadata: 9) garageutil_test.go:35: file count after removing delay: 8193 (metadata: 9)
Скорость снизилась на пару мегабайт. После увеличения задержки до 200 мс ничего не поменялось. Добавим такую же задержку и на третий узел тоже:
garageutil_test.go:35: 08:50:35.723 - uploading 2048 MiB in parallel garageutil_test.go:35: current speed 14.15 MiB/s garageutil_test.go:35: injecting delay=200ms ^Csignal: interrupt FAIL objstor-testcases/garage 615.065s
Дальше 10 минут можно не ждать. На графике виден для сравнения предыдущий запуск, где только второй узел с задержками.

Выходит, исходящая задержка уже на двух зонах из трёх создаёт большие проблемы. Кроме того, сильно выросла средняя задержка клиентских запросов (со 112 мс до более 2 секунд!)

Дальше перенастраивать задержки смысла нет.
Примечание: в тесте с 200мс на одном узле потребление памяти неожиданно выросло до 204 Мб:

Тест 8. Потеря пакетов
Тест-кейс похож по сути на предыдущие, но на этот раз на втором узле теряется 2% пакетов.
Повлияло ли это на производительность? Лог теста TestPacketLoss с включением полной консистентности:
garageutil_test.go:35: 10:26:17.610 - uploading 2048 MiB in parallel garageutil_test.go:35: current speed 15.65 MiB/s garageutil_test.go:35: injecting loss=2% garageutil_test.go:35: upload finished, speed 13.99 MiB/s garageutil_test.go:35: file count: 8193 (metadata: 9) garageutil_test.go:35: file count after removing rule: 8193 (metadata: 9) --- PASS: TestPacketLoss (169.90s)
Влияние есть, небольшое. Сделаем потерю исходящих пакетов и на третьем узле:
garageutil_test.go:35: 10:33:34.081 - uploading 2048 MiB in parallel garageutil_test.go:35: current speed 14.35 MiB/s garageutil_test.go:35: injecting loss=2% garageutil_test.go:35: upload finished, speed 10.11 MiB/s garageutil_test.go:35: file count: 8193 (metadata: 9) garageutil_test.go:35: file count after removing rule: 8193 (metadata: 9) --- PASS: TestPacketLoss (228.80s)
Сравнение метрик между прогонами:


Тест 9. Переупорядочивание пакетов
Теперь очередь за, каламбур, переменами в очереди пакетов (reorder).
Внедрим переупорядочивание пакетов на втором узле в 2% в рамках окна 100мс. Лог теста TestPacketReorder:
garageutil_test.go:35: 11:09:17.322 - uploading 2048 MiB in parallel garageutil_test.go:35: current speed 15.40 MiB/s garageutil_test.go:35: injecting reorder=2% garageutil_test.go:35: upload finished, speed 14.51 MiB/s garageutil_test.go:35: file count: 8193 (metadata: 9) garageutil_test.go:35: file count after removing rule: 8193 (metadata: 9) --- PASS: TestPacketReorder (165.27s)
Ничего не поменялось, график задержек держался в рамках референсных значений.
Повторяем уже на 2 и 3 узлах:
garageutil_test.go:35: 11:17:28.570 - uploading 2048 MiB in parallel garageutil_test.go:35: current speed 15.95 MiB/s garageutil_test.go:35: injecting reorder=2% ^Csignal: interrupt FAIL objstor-testcases/garage 197.778s
Тест отменился вручную, так как уже по графикам было видно ухудшение картины:



Как минимум, средняя задержка выросла до 1 секунды.
Тест 10. Скорость восстановления репликации
Этот тест проверяет гипотезу: если на время один из узлов был недоступен по питанию, как быстро на него дореплицируются данные?
Шаги:
Включаем
consistency_mode = “degraded”;Начинаем загружать 512 Мб частями по 1 Мб в кластер;
Спустя две секунды останавливаем второй узел;
Дожидаемся конца загрузки объекта;
Включаем обратно узел
Дожидаемся, пока число файлов во втором узле будет не меньше 512.
Если спустя 10 минут узел не догнал соседей, тест провален.
В процессе всего теста, соответственно, мониторим число файлов в фоне.
Результат теста TestReplication:
garageutil_test.go:35: 11:34:03.085 - uploading 512 MiB in a broken cluster garageutil_test.go:35: file count: 6 (metadata: 9) garageutil_test.go:35: file count: 16 (metadata: 9) garageutil_test.go:35: file count: 23 (metadata: 9) <...> garageutil_test.go:35: file count: 23 (metadata: 9) garageutil_test.go:35: 11:34:52.355 - node added back garageutil_test.go:35: file count: 23 (metadata: 9) <...> <таймаут>
Спустя десять минут узел так и не восстановился. Почему? Как выяснилось, Garage запускает перепроверку данных лишь с интервалом в месяц. Почему нельзя делать даже проверку соответствия числа объектов после аварийного завершения - загадка.
К счастью, можно вручную запустить garage repair -a --yes tables; /garage repair -a --yes blocks на любом из узлов, и это починит внутренние таблицы, а также блоки данных.
Попробуем:
Лог теста
garageutil_test.go:35: 11:44:00.452 - uploading 512 MiB in a broken cluster
garageutil_test.go:35: file count: 9 (metadata: 9)
garageutil_test.go:35: file count: 22 (metadata: 9)
garageutil_test.go:35: file count: 26 (metadata: 9)
<...>
garageutil_test.go:35: 11:44:47.549 - node added back
garageutil_test.go:35: file count: 26 (metadata: 9)
<...>
garageutil_test.go:35: 2026-03-18T11:45:02.762407Z
ba2f4b9e4e26ef5e Repair launched
24921374b376fef6 Repair launched
a70baf5e745daa1c Repair launched
garageutil_test.go:35: file count: 26 (metadata: 9)
garageutil_test.go:35: file count: 26 (metadata: 9)
garageutil_test.go:35: 2026-03-18T11:45:04.075557Z
24921374b376fef6 Repair launched
a70baf5e745daa1c Repair launched
ba2f4b9e4e26ef5e Repair launched
garageutil_test.go:35: file count: 26 (metadata: 9)
garageutil_test.go:35: file count: 31 (metadata: 9)
garageutil_test.go:35: file count: 38 (metadata: 9)
garageutil_test.go:35: file count: 44 (metadata: 9)
<...>
garageutil_test.go:35: file count: 372 (metadata: 9)
garageutil_test.go:35: file count: 372 (metadata: 9)
garageutil_test.go:35: file count: 372 (metadata: 9)
<...>
<таймаут>
По какой-то причине восстановление остановилось на полпути. При этом оно снова запустилось при ручном выполнении команд из консоли.
Тест провален, так как даже ручной процесс ремонта ненадёжен. На этом с гаражом всё, переходим к следующему инструменту.
SeaweedFS
Система хранения файлов. Важно отметить, что файлов, а не объектов. У SeaweedFS есть S3 Gateway, который нам и нужен, просто он построен поверх компонента под названием Filer.
Проект написан на Go, хостится на GitHub (там же и вики), и развивается с 2012 года, по большей части одним человеком (Крис Лу). Наибольшая активность появилась лишь недавно:

Функционал системы:
Основные преимущества - скорость работы и эффективность хранения.
Скорость поиска файла заявлена как O(1), накладные расходы на каждый файл составляют ровно 40 байт.
Архитектура разнесена на компоненты: Master , Volume node и Filer.
В этом SeaweedFS похож на Ceph и GlusterFS.
Master - контроллер кластера и координирует положение разделов (volume) и других компонентов.
Volume node - хранит тома/разделы с данными.
Filer - файловый API поверх volume, также имеет опцию работы как S3 API.
Можно строить разные топологии, кроме репликации по региону настраивается также уровень репликации между ЦОД и стойками.
Управление классами хранения (тёплое/холодное хранилище).
Изменение топологии не влечёт за собой ребалансировки.
Поддерживается шифрование разделов.
Доступно проксирование на внешние объектные хранилища с поддержкой кэширования чтения.
FUSE монтирование Filer-коллекций (поверх которых работает S3). Также имеется WebDAV.
Есть оператор для Kubernetes.
Особенности:
Файлы (и объекты) объединяются в большие “тома” размером до 30 Гб.
Зная это, можно уже не проводить тесты на inode.
Реплицируются не отдельные файлы, а тома целиком.
Для распределённой работы Filer требуется внешнее хранилище метаданных. Например, PostgreSQL или Redis.
Автоматическая ребалансировка при отказе стоек или зон не делается by design. Об этом подробнее описано в одном из тестов.
Тесты будут проводиться на SeaweedFS версии 4.07 с хранилищем метаданных PostgreSQL, часть тестов будет на MariaDB.
Мониторинг
Экспорт метрик включается одним консольным флагом -metricsPort=9090. Список метрик здесь, там ж�� есть ссылка на готовый дашборд для Grafana.
Подготовка кластера SeaweedFS
Настройка кластера SeaweedFS из трёх зон заключается в таком Docker Compose файле:
compose.yml
name: seaweed services: postgres: image: postgres:18-alpine restart: unless-stopped environment: POSTGRES_PASSWORD: postgres volumes: - postgres-data:/data seaweed-1: image: ${WEED_IMAGE} restart: unless-stopped container_name: seaweed-1 command: >- server -s3 -dir=/data -ip.bind=0.0.0.0 -metricsPort=9090 -volume.max=0 -master.volumeSizeLimitMB=256 -master.defaultReplication=100 -master.peers=seaweed-2:9333,seaweed-3:9333 -dataCenter dc1 volumes: - seaweed1-data:/data - ./filer.toml:/etc/seaweedfs/filer.toml ports: - 127.0.0.1:8334:8333 depends_on: - postgres seaweed-2: image: ${WEED_IMAGE} restart: unless-stopped container_name: seaweed-2 command: >- server -s3 -dir=/data -ip.bind=0.0.0.0 -metricsPort=9090 -volume.max=0 -master.volumeSizeLimitMB=256 -master.defaultReplication=100 -master.peers=seaweed-1:9333,seaweed-3:9333 -dataCenter dc2 volumes: - seaweed2-data:/data - ./filer.toml:/etc/seaweedfs/filer.toml ports: - 127.0.0.1:8335:8333 depends_on: - postgres seaweed-3: image: ${WEED_IMAGE} restart: unless-stopped container_name: seaweed-3 command: >- server -s3 -dir=/data -ip.bind=0.0.0.0 -metricsPort=9090 -volume.max=0 -master.volumeSizeLimitMB=256 -master.defaultReplication=100 -master.peers=seaweed-1:9333,seaweed-2:9333 -dataCenter dc3 volumes: - seaweed3-data:/data - ./filer.toml:/etc/seaweedfs/filer.toml ports: - 127.0.0.1:8336:8333 depends_on: - postgres volumes: seaweed1-data: seaweed2-data: seaweed3-data: postgres-data: networks: default: name: objstor external: true
Опять же, если не требуется мониторинг, убирайте блок networks.
Тут defaultReplication=100 означает, что нужны дополнительные (т.е. число 1 значит +1 к исходному тому) реплики томов в другом регионе, но сверх того копии в соседних ЦОД или других серверных стойках создавать не надо. Другими словами, фактор репликации в 2. Подробнее про настройку и возможные значения можно почитать здесь.
Параметр volume.max=0 отключает жёсткое ограничение на максимальное число разделов, таким образом оно будет автоматически высчитываться из свободного пространства на файловой системе. Это сделано для удобства тестирования. Для того же удобства используется параметр master.volumeSizeLimitMB=256. По умолчанию тома создаются с размером в 2 Гб, что для многих тестов оверкилл. В production вы можете настраивать этот параметр как душе удобно, но с лимитом в 30 Гб. Хотя есть дополнительная версия сервиса, с большим оверхедом на файл, но зато максимальный размер одного тома будет 8 ТБ. Такая версия имеет суффикс <тег>_large_disk. Например, образ chrislusf/seaweedfs:4.07_large_disk.
Для целей тестирования подойдёт следующий .env файл:
WEED_IMAGE=chrislusf/seaweedfs:4.07 AWS_ACCESS_KEY_ID=development AWS_SECRET_ACCESS_KEY=seaweedfs
После docker compose up -d и прошествии примерно минуты (чтобы все компоненты успешно инициализировались) можно зайти в оболочку seaweed, где доступна, например, команда cluster.status:
weed shell
$ docker exec -it seaweed-1 weed shell > cluster.status cluster: id: topo status: unlocked nodes: 3 topology: 3 DCs, 3 disks on 3 racks volumes: total: 0 volumes, 1 collection max size: 268 MB regular: 0/513 volumes on 0 replicas, 0 writable (0%), 0 read-only (0%) EC: 0 EC volumes on 0 shards (0 shards/volume) storage: total: 0 B regular volumes: 0 B EC volumes: 0 B raw: 0 B on volume replicas, 0 B on EC shards > volume.list -v 1 Topology volumeSizeLimit:256 MB hdd(volume:12/489 active:12 free:477 remote:0) DataCenter dc1 hdd(volume:4/163 active:4 free:159 remote:0) DataCenter dc1 {Size:448 FileCount:1 DeletedFileCount:0 DeletedBytes:0} DataCenter dc2 hdd(volume:5/164 active:5 free:159 remote:0) DataCenter dc2 {Size:456 FileCount:1 DeletedFileCount:0 DeletedBytes:0} DataCenter dc3 hdd(volume:3/162 active:3 free:159 remote:0) DataCenter dc3 {Size:24 FileCount:0 DeletedFileCount:0 DeletedBytes:0} total size:928 file_count:2
Тестирование
Тестов будет немного. К примеру, мы не будем выяснять про inode, так как уже известна архитектура с volume-ми.
Какие намечены сценарии?
Отказ зоны;
Обработка незагруженного файла;
Восстановление репликации;
Три сетевых теста (задержки, потеря и переупорядочивание пакетов);
Тест 1. Отказ зоны
Всё то же самое. Загружаем 1000 файлов по 8 Кб, в процессе загрузок останавливаем на 15 секунд соседний узел.
Лог сценария TestStopZone:
util_test.go:35: 14:14:22.909 - bucket created, uploading 1000 files util_test.go:35: 14:14:43.541 - killed node 2 util_test.go:35: 14:14:59.459 - added back node seaweed_test.go:48: Error Trace: /root/objstor-testing/testsdk/seaweed/seaweed_test.go:48 /root/.local/share/mise/installs/go/1.25.8/src/sync/waitgroup.go:239 /root/.local/share/mise/installs/go/1.25.8/src/runtime/asm_amd64.s:1693 Error: Received unexpected error: put object: operation error S3: PutObject, https response error StatusCode: 500, RequestID: 1773843322450744460, HostID: , api error InternalError: We encountered an internal error, please try again. Test: TestStopZone util_test.go:35: 14:15:22.452 - finished upload --- FAIL: TestStopZone (132.41s)
Первый блин комом. После изучения логов выяснилось, что в этот момент потерялась связность между master узлами и нарушился Raft кворум.
Повторим:
util_test.go:35: 20:16:09.668 - bucket created, uploading 1000 files util_test.go:35: 20:16:30.239 - killed node 2 util_test.go:35: 20:16:46.096 - added back node util_test.go:35: 20:17:35.297 - finished upload --- PASS: TestStopZone (158.47s)
Кластер быстро обнаружил потерю узла:
weed shell
$ echo "cluster.status; volume.list -v 3" | docker exec -i seaweed-1 weed shell > cluster: id: topo status: unlocked nodes: 2 topology: 3 DCs, 2 disks on 3 racks volumes: total: 12 volumes, 2 collections max size: 268 MB regular: 12/311 volumes on 17 replicas, 17 writable (100%), 0 read-only (0%) EC: 0 EC volumes on 0 shards (0 shards/volume) storage: total: 2.0 MB regular volumes: 2.0 MB EC volumes: 0 B raw: 3.0 MB on volume replicas, 0 B on EC shards Topology volumeSizeLimit:256 MB hdd(volume:17/311 active:17 free:294 remote:0) DataCenter dc1 hdd(volume:10/157 active:10 free:147 remote:0) Rack DefaultRack hdd(volume:10/157 active:10 free:147 remote:0) DataNode 172.18.0.6:8080 hdd(volume:10/157 active:10 free:147 remote:0) DataNode 172.18.0.6:8080 {Size:1880928 FileCount:229 DeletedFileCount:0 DeletedBytes:0} Rack DefaultRack {Size:1880928 FileCount:229 DeletedFileCount:0 DeletedBytes:0} DataCenter dc1 {Size:1880928 FileCount:229 DeletedFileCount:0 DeletedBytes:0} DataCenter dc3 hdd(volume:7/154 active:7 free:147 remote:0) Rack DefaultRack hdd(volume:7/154 active:7 free:147 remote:0) DataNode 172.18.0.7:8080 hdd(volume:7/154 active:7 free:147 remote:0) DataNode 172.18.0.7:8080 {Size:1139304 FileCount:139 DeletedFileCount:0 DeletedBytes:0} Rack DefaultRack {Size:1139304 FileCount:139 DeletedFileCount:0 DeletedBytes:0} DataCenter dc3 {Size:1139304 FileCount:139 DeletedFileCount:0 DeletedBytes:0} total size:3020232 file_count:368
Топология кластера не поменялась, так как сервер существовал в кластере, просто не был доступен. Такие временные неполадки SeaweedFS считает частью жизни кластера и не обрабатывает как аварии, чтобы не плодить на каждый чих полные ребалансировки. Ребалансировка задумана как ручная процедура.
После восстановления кластер даже не пометил реплики на упавшем узле как read-only:
cluster: id: topo status: unlocked nodes: 3 topology: 3 DCs, 3 disks on 3 racks volumes: total: 12 volumes, 2 collections max size: 268 MB regular: 12/465 volumes on 24 replicas, 24 writable (100%), 0 read-only (0%) EC: 0 EC volumes on 0 shards (0 shards/volume) storage: total: 8.4 MB regular volumes: 8.4 MB EC volumes: 0 B raw: 17 MB on volume replicas, 0 B on EC shards
Поверим на слово.
Примечание. При выставлении репликации в 200 (т.е. 1+2 дополнительных копии) и одном упавшем регионе сервер уже не может давать гарантий записи. В таком случае, все S3 запросы PutObject зависают на 10 секунд, затем кластер отвечает https response error StatusCode: 500, RequestID: 1768979547062334468, HostID: , api error InternalError: We encountered an internal error, please try again.
Тест 2. Обрыв загрузки multipart
По аналогии с тестом Garage, создаём Lifecycle policy, проверяем её наличие, загружаем multipart объект, но не до конца.
get bucket policy: operation error S3: GetBucketLifecycleConfiguration, https response error StatusCode: 404, RequestID: 1773865928859847404, HostID: , api error NoSuchLifecycleConfiguration: The lifecycle configuration does not exist util_test.go:35: 20:33:08.861 - killing node with uploading util_test.go:35: 20:33:40.010 - added back node util_test.go:35: 20:33:40.010 - test finished --- PASS: TestMultipartBreak (164.29s) PASS ok objstor-testcases/seaweed 164.306s
Тест создал Lifecycle policy, но не смог её получить. В wiki или issues на GitHub нет упоминаний о поддержке S3 Lifecycle API или о проблемах с ним, можно сделать вывод, что её и не должно быть. А кластер имеет незавершённую загрузку:
> ls -la buckets/monitorsoft/.uploads drwxrwxrwx 0 seaweed seaweed 0 /buckets/monitorsoft/.uploads/df26805343ab746a905b4c4484b5e4936d998f6d_cb032d14bbe044f297da8765674fd0ea > du buckets/monitorsoft/.uploads block: 359 logical size: 47054848 /buckets/monitorsoft/.uploads
Как тогда очистить такие загрузки, не трогая ещё продолжающиеся? К счастью, имеется ручная команда s3.clean.uploads:
> help s3.clean.uploads s3.clean.uploads # clean up stale multipart uploads Example: s3.clean.uploads -timeAgo 1.5h > s3.clean.uploads -timeAgo 2m purge http://172.18.0.6:8888/buckets/monitorsoft/.uploads/df26805343ab746a905b4c4484b5e4936d998f6d_cb032d14bbe044f297da8765674fd0ea?recursive=true&ignoreRecursiveError=true > s3.bucket.list monitorsoft size:47083616 chunk:0
Размер бакета практически не изменился. Ковальски, варианты?
Тут надо рассказать поподробнее про Volume в SeaweedFS. Тома в “водорослях” это append-only структуры, при удалении файлов место на томе продолжает заниматься, просто удаляется упоминание о файле в БД. При заполнении какого-либо тома больше, чем на 70%, включается vacuum. Его можно так же запустить принудительно, только требуется взять “блокировку” на изменения в кластере:
> lock > volume.vacuum -garbageThreshold=0.01 <проходит время> > unlock
Подробности, как всегда, можно изучить в вики проекта.
Примечание к производительности: во время тестов потребление памяти не превышало 128 МБ:

Тест 3. Сетевые задержки
Так же внедряем 100 мс на втором узле.
Запускается TestNetworkDelay… тайм-аут. Судя по графикам, всё упиралось в PostgreSQL, поскольку он занимал все ядра на сервере. У меня не было задачи его тюнить, увеличивать shared buffers и так далее, поэтому я просто перевёл Filer на MariaDB:
filer.toml
[leveldb2] enabled = false [postgres2] enabled = false [mysql2] enabled = true createTable = """ CREATE TABLE IF NOT EXISTS `%s` ( `dirhash` BIGINT NOT NULL, `name` VARCHAR(766) NOT NULL, `directory` TEXT NOT NULL, `meta` LONGBLOB, PRIMARY KEY (`dirhash`, `name`) ) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; """ hostname = "mariadb" port = 3306 username = "seaweedfs" password = "seaweedfs" database = "seaweedfs" connection_max_idle = 10 connection_max_open = 50 connection_max_lifetime_seconds = 300 interpolateParams = false enableUpsert = true upsertQuery = """INSERT INTO %s (dirhash, name, directory, meta) VALUES (?,?,?,?) ON DUPLICATE KEY UPDATE meta=VALUE(meta)"""
Соответственно, надо поправить Compose конфигурацию, заменив постгрес на MariaDB:
services: mariadb: image: mariadb:12 restart: unless-stopped environment: MARIADB_ROOT_PASSWORD: mariadb MARIADB_USER: seaweedfs MARIADB_PASSWORD: seaweedfs MARIADB_DATABASE: seaweedfs volumes: - mariadb-data:/var/lib/mysql # <...> volumes: mariadb-data: # <...>
Перезапускаем тест.
Лог выполнения
util_test.go:35: 21:32:27.977 - uploading 2048 MiB in parallel util_test.go:35: current speed 31.15 MiB/s util_test.go:35: 21:32:37.979 - injecting delay=100ms util_test.go:35: 21:35:18.127 - upload finished util_test.go:35: speed 10.84 MiB/s util_test.go:35: cluster status list: > Topology volumeSizeLimit:256 MB hdd(volume:36/432 active:24 free:396 remote:0) DataCenter dc1 hdd(volume:18/150 active:12 free:132 remote:0) Rack DefaultRack hdd(volume:18/150 active:12 free:132 remote:0) DataNode 172.18.0.7:8080 hdd(volume:18/150 active:12 free:132 remote:0) Disk hdd(volume:18/150 active:12 free:132 remote:0) id:0 Disk hdd {Size:2150696008 FileCount:8196 DeletedFileCount:0 DeletedBytes:0} DataNode 172.18.0.7:8080 {Size:2150696008 FileCount:8196 DeletedFileCount:0 DeletedBytes:0} Rack DefaultRack {Size:2150696008 FileCount:8196 DeletedFileCount:0 DeletedBytes:0} DataCenter dc1 {Size:2150696008 FileCount:8196 DeletedFileCount:0 DeletedBytes:0} DataCenter dc2 hdd(volume:8/140 active:5 free:132 remote:0) Rack DefaultRack hdd(volume:8/140 active:5 free:132 remote:0) DataNode 172.18.0.6:8080 hdd(volume:8/140 active:5 free:132 remote:0) Disk hdd(volume:8/140 active:5 free:132 remote:0) id:0 Disk hdd {Size:941431680 FileCount:3587 DeletedFileCount:0 DeletedBytes:0} DataNode 172.18.0.6:8080 {Size:941431680 FileCount:3587 DeletedFileCount:0 DeletedBytes:0} Rack DefaultRack {Size:941431680 FileCount:3587 DeletedFileCount:0 DeletedBytes:0} DataCenter dc2 {Size:941431680 FileCount:3587 DeletedFileCount:0 DeletedBytes:0} DataCenter dc3 hdd(volume:10/142 active:7 free:132 remote:0) Rack DefaultRack hdd(volume:10/142 active:7 free:132 remote:0) DataNode 172.18.0.5:8080 hdd(volume:10/142 active:7 free:132 remote:0) Disk hdd(volume:10/142 active:7 free:132 remote:0) id:0 Disk hdd {Size:1208739944 FileCount:4607 DeletedFileCount:0 DeletedBytes:0} DataNode 172.18.0.5:8080 {Size:1208739944 FileCount:4607 DeletedFileCount:0 DeletedBytes:0} Rack DefaultRack {Size:1208739944 FileCount:4607 DeletedFileCount:0 DeletedBytes:0} DataCenter dc3 {Size:1208739944 FileCount:4607 DeletedFileCount:0 DeletedBytes:0} total size:4300867632 file_count:16390 util_test.go:35: cluster status after removing delay: > Topology volumeSizeLimit:256 MB hdd(volume:36/432 active:24 free:396 remote:0) DataCenter dc1 hdd(volume:18/150 active:12 free:132 remote:0) Rack DefaultRack hdd(volume:18/150 active:12 free:132 remote:0) DataNode 172.18.0.7:8080 hdd(volume:18/150 active:12 free:132 remote:0) Disk hdd(volume:18/150 active:12 free:132 remote:0) id:0 Disk hdd {Size:2150696008 FileCount:8196 DeletedFileCount:0 DeletedBytes:0} DataNode 172.18.0.7:8080 {Size:2150696008 FileCount:8196 DeletedFileCount:0 DeletedBytes:0} Rack DefaultRack {Size:2150696008 FileCount:8196 DeletedFileCount:0 DeletedBytes:0} DataCenter dc1 {Size:2150696008 FileCount:8196 DeletedFileCount:0 DeletedBytes:0} DataCenter dc2 hdd(volume:8/140 active:5 free:132 remote:0) Rack DefaultRack hdd(volume:8/140 active:5 free:132 remote:0) DataNode 172.18.0.6:8080 hdd(volume:8/140 active:5 free:132 remote:0) Disk hdd(volume:8/140 active:5 free:132 remote:0) id:0 Disk hdd {Size:941431680 FileCount:3587 DeletedFileCount:0 DeletedBytes:0} DataNode 172.18.0.6:8080 {Size:941431680 FileCount:3587 DeletedFileCount:0 DeletedBytes:0} Rack DefaultRack {Size:941431680 FileCount:3587 DeletedFileCount:0 DeletedBytes:0} DataCenter dc2 {Size:941431680 FileCount:3587 DeletedFileCount:0 DeletedBytes:0} DataCenter dc3 hdd(volume:10/142 active:7 free:132 remote:0) Rack DefaultRack hdd(volume:10/142 active:7 free:132 remote:0) DataNode 172.18.0.5:8080 hdd(volume:10/142 active:7 free:132 remote:0) Disk hdd(volume:10/142 active:7 free:132 remote:0) id:0 Disk hdd {Size:1208739944 FileCount:4607 DeletedFileCount:0 DeletedBytes:0} DataNode 172.18.0.5:8080 {Size:1208739944 FileCount:4607 DeletedFileCount:0 DeletedBytes:0} Rack DefaultRack {Size:1208739944 FileCount:4607 DeletedFileCount:0 DeletedBytes:0} DataCenter dc3 {Size:1208739944 FileCount:4607 DeletedFileCount:0 DeletedBytes:0} total size:4300867632 file_count:16390 \--- PASS: TestNetworkDelay (246.61s) PASS ok objstor-testcases/seaweed 246.623s
Скорость упала с 31 Мб/с до менее 11 Мб/с. SeaweedFS более чувствительный к задержкам, но не раскалывается, и то хорошо.
Тест 4. Потеря пакетов
Запускаем тест TestPacketLoss на потерю 2% исходящих пакетов на втором узле:
util_test.go:35: 21:46:33.454 - uploading 2048 MiB in parallel util_test.go:35: current speed 28.29 MiB/s util_test.go:35: 21:46:53.457 - injecting loss=2% util_test.go:35: 21:47:47.541 - upload finished util_test.go:35: speed 27.74 MiB/s util_test.go:35: cluster status list: <...> total size:4185383272 file_count:15944 util_test.go:35: cluster status after removing loss: total size:4297601448 file_count:16372
В случае потери пакетов один из узлов начинает отставать по синхронизации, что практически не влияет на скорость клиента. Спустя секунду узел уже в целом догнал соседние volume node.
Тест 5. Переупорядочивание пакетов
Сценарий TestPacketReorder на переупорядочивание 2% пакетов в рамках 100 миллисекунд:
util_test.go:35: 21:54:29.461 - uploading 2048 MiB in parallel util_test.go:35: current speed 29.96 MiB/s util_test.go:35: 21:54:49.462 - injecting reorder=2% util_test.go:35: 21:57:04.356 - upload finished util_test.go:35: speed 10.66 MiB/s util_test.go:35: cluster status list: total size:4297000688 file_count:16373 util_test.go:35: cluster status in 3s after removing reorder: total size:4301982336 file_count:16392
Так как переупорядочивание происходит в рамках окна, то ядру требуется задержать пакеты на 100 мс, чтобы затем перемешать их. И здесь видна картина синхронной репликации.
Тест 6. Скорость восстановления после отказа ДЦ
Сценарий TestReplication начинает загружать multipart файл, затем останавливает соседнюю ноду, после чего включает её и вручную запускает процесс проверки файлов. В процессе всего теста подсчитывается число файлов на узлах.
Лог теста
util_test.go:35: 22:05:03.103 - uploading file util_test.go:35: 22:05:03.103 - cluster stats: util_test.go:25: DataCenter dc1 hdd(volume:6/168 active:6 free:162 remote:0) util_test.go:25: DataCenter dc2 hdd(volume:5/168 active:5 free:163 remote:0) util_test.go:25: DataCenter dc3 hdd(volume:1/168 active:1 free:167 remote:0) util_test.go:25: total size:0 file_count:0 util_test.go:35: 22:05:04.103 - cluster stats: util_test.go:25: DataCenter dc1 hdd(volume:7/168 active:7 free:161 remote:0) util_test.go:25: DataCenter dc2 hdd(volume:11/168 active:11 free:157 remote:0) util_test.go:25: DataCenter dc3 hdd(volume:6/168 active:6 free:162 remote:0) util_test.go:25: total size:0 file_count:0 util_test.go:35: 22:05:05.104 - cluster stats: util_test.go:35: 22:05:05.104 - killing node 2 util_test.go:25: DataCenter dc1 hdd(volume:7/151 active:7 free:144 remote:0) util_test.go:25: DataCenter dc1 {Size:78644696 FileCount:16 DeletedFileCount:0 DeletedBytes:0} util_test.go:25: DataCenter dc3 hdd(volume:6/168 active:6 free:162 remote:0) util_test.go:25: total size:78644696 file_count:16 util_test.go:35: 22:05:06.104 - cluster stats: seaweed_test.go:274: Error Trace: /root/objstor-testing/testsdk/seaweed/seaweed_test.go:274 Error: Received unexpected error: upload part: operation error S3: UploadPart, https response error StatusCode: 0, RequestID: , HostID: , canceled, context deadline exceeded Test: TestReplication --- FAIL: TestReplication (77.70s) FAIL FAIL objstor-testcases/seaweed 77.714s FAIL
Неожиданный фейл. Перезапускаем:
Лог теста
util_test.go:35: 22:07:32.423 - uploading file <...> util_test.go:35: 22:08:02.424 - cluster stats: util_test.go:25: DataCenter dc1 hdd(volume:10/152 active:8 free:142 remote:0) util_test.go:25: DataCenter dc1 {Size:1247818024 FileCount:239 DeletedFileCount:0 DeletedBytes:0} util_test.go:25: DataCenter dc3 hdd(volume:7/148 active:5 free:141 remote:0) util_test.go:25: DataCenter dc3 {Size:1279275568 FileCount:245 DeletedFileCount:0 DeletedBytes:0} util_test.go:25: total size:2527093592 file_count:484 util_test.go:35: 22:08:03.124 - file uploaded, node added back <...> util_test.go:35: 22:08:07.424 - cluster stats: util_test.go:25: DataCenter dc1 hdd(volume:10/151 active:7 free:141 remote:0) util_test.go:25: DataCenter dc1 {Size:1342190728 FileCount:257 DeletedFileCount:0 DeletedBytes:0} util_test.go:25: DataCenter dc2 hdd(volume:7/148 active:7 free:141 remote:0) util_test.go:25: DataCenter dc2 {Size:47186408 FileCount:9 DeletedFileCount:0 DeletedBytes:0} util_test.go:25: DataCenter dc3 hdd(volume:7/148 active:4 free:141 remote:0) util_test.go:25: DataCenter dc3 {Size:1295004352 FileCount:248 DeletedFileCount:0 DeletedBytes:0} util_test.go:25: total size:2684381488 file_count:514 util_test.go:35: 22:08:08.125 - running check disk... util_test.go:35: 22:08:08.424 - cluster stats: util_test.go:35: 22:08:08.466 - check disk done, stats: util_test.go:25: DataCenter dc1 hdd(volume:10/151 active:7 free:141 remote:0) util_test.go:25: DataCenter dc1 {Size:1342190728 FileCount:257 DeletedFileCount:0 DeletedBytes:0} util_test.go:25: DataCenter dc2 hdd(volume:7/148 active:7 free:141 remote:0) util_test.go:25: DataCenter dc2 {Size:47186408 FileCount:9 DeletedFileCount:0 DeletedBytes:0} util_test.go:25: DataCenter dc3 hdd(volume:7/148 active:4 free:141 remote:0) util_test.go:25: DataCenter dc3 {Size:1295004352 FileCount:248 DeletedFileCount:0 DeletedBytes:0} util_test.go:25: total size:2684381488 file_count:514 --- PASS: TestReplication (110.16s) PASS ok objstor-testcases/seaweed 110.168s
Здесь всё просто. Мы только что убедились, что при отказе второго узла файлы начали реплицироваться между первым и третьим. Ребалансировка не требуется, поскольку на каждом из узлов примерно одинаковое число volume. Shell-команда volume.check.disk -apply даже не потребовалась, по факту.
Итоги
По итогу мы выбрали “водоросли”. Рядом с SeaweedFS гараж выглядит хоть и недурным хранилищем, но всё же на юном этапе развития. Перечислим “фи” и плюсы:
SeaweedFS: Дизайн системы с многоуровневой топологией производит впечатление. “Но” совсем немного:
Нет Lifecycle policy. В целом, есть ручная команда, а также можно настроить TTL отдельных путей на уровне Filer через shell-команды. Но почему нет нативного параметра для S3 для меня остаётся загадкой.
Периодически во время тестов ловились рандомные ошибки, и узлы теряли друг друга на короткое время. Я не могу свалить это на сетевые проблемы, так как это локальные интерфейсы в рамках одного Linux инстанса.
Не очень хорошо переживает сетевые задержки.
Из хорошего: активное развитие и поддержка в GitHub, например, мною был обнаружен баг в операторе для Kubernetes, и спустя полдня после заведения Issue разработчик выпустил тег с исправлением. У проекта хорошая документация и её интересно читать, там описывается функционал, который нечасто встретишь (вроде erasure coding или server side encryption).
Garage: Система неплохая… в целом. Не сказать, чтобы отличная:
Во-первых, смущает, что на лэндинге сайта заявлена надёжность при неполадках, при этом был ощутимый рост задержек клиентских запросов при симуляции сетевых неполадок.
Во-вторых. Почему даже ручное восстановление не всегда завершается до конца? Это больше всего напрягает.
В-третьих, при многих системных операциях или перенастройках требуется перезапускать узлы или кластер, а при изменении фактора репликации - рекомендуется в принципе бэкапить данные и пересоздавать кластер целиком.
Из мелочей: куски multipart объектов не сливаются в один файл, а также неграмотность в метриках (отсутствие префикса).
Из хорошего: система неплохо кластеризуется, также можно настроить Lifecycle policy. И у проекта хорошая документация.
До качества, нужного для production, Garage не подходит. Не спасает даже то, что он написан на надёжном Rust.
Воспроизведение результатов
Описанные сценарии описаны в виде кода на GitHub. Вы можете перенастроить Compose под себя, только для тестов на inode в Garage желательно оставлять имена volume прежними. Ещё можете внедрить задержки или потери пакетов на всех трёх узлах. Или провести тесты на последних версиях хранилищ. В общем, простора для фантазии полно.
Замечания можете оставлять в комментариях к статье - так я буду больше замотивирован ответить.
Игорь Овсянников, Монитор Софт
