Перейти к содержимому
ПереводыDeepSeek

3FS: проектные заметки распределённой файловой системы DeepSeek

Полный перевод Design Notes файловой системы Fire-Flyer File System (3FS) и вводной части её README: как DeepSeek хранит обучающие данные, чекпойнты и KV-кэш инференса на тысячах SSD — метаданные в FoundationDB, цепная репликация CRAQ, восстановление после сбоев.

Оригинал: README.md, разделы до «Check out source code».

Fire-Flyer File System (3FS) — высокопроизводительная распределённая файловая система, созданная для нагрузок обучения и инференса ИИ. Она использует современные SSD и сети RDMA, чтобы дать общий слой хранения, который упрощает разработку распределённых приложений. Ключевые возможности и преимущества 3FS:

  • Производительность и удобство

    • Дезагрегированная архитектура. Объединяет пропускную способность тысяч SSD и сетевую пропускную способность сотен узлов хранения и позволяет приложениям обращаться к ресурсам хранения, не заботясь о том, где физически лежат данные.
    • Строгая согласованность. Реализует цепную репликацию с распределёнными запросами (Chain Replication with Apportioned Queries, CRAQ) для строгой согласованности, благодаря чему код приложений получается простым и понятным.
    • Файловые интерфейсы. Сервисы метаданных без состояния опираются на транзакционное хранилище «ключ-значение» (например, FoundationDB). Файловый интерфейс хорошо известен и используется повсюду. Учить новый API хранения не нужно.
  • Разнообразные нагрузки

    • Подготовка данных. Организует результаты конвейеров анализа данных в иерархические структуры каталогов и эффективно управляет большим объёмом промежуточных результатов.
    • Загрузчики данных. Снимают необходимость заранее подгружать или перемешивать датасеты: обучающие примеры доступны в случайном порядке со всех вычислительных узлов.
    • Чекпойнты. Поддерживает высокопроизводительное параллельное сохранение чекпойнтов при масштабном обучении.
    • KV-кэш для инференса. Экономичная альтернатива кэшированию в DRAM: высокая пропускная способность и значительно бо́льшая ёмкость.

1. Пиковая пропускная способность. На рисунке показана пропускная способность при нагрузочном тесте на чтение на большом кластере 3FS. Кластер состоит из 180 узлов хранения, в каждом 2 сетевые карты InfiniBand по 200 Гбит/с и шестнадцать NVMe SSD по 14 ТиБ. Для теста на чтение использовалось около 500+ клиентских узлов, у каждого одна сетевая карта InfiniBand на 200 Гбит/с. Итоговая суммарная скорость чтения достигла примерно 6,6 ТиБ/с — при фоновом трафике от задач обучения.

Чтобы измерить производительность 3FS, используйте наш движок fio для USRBIO.

2. GraySort. Мы оценили smallpond на тесте GraySort, который измеряет скорость сортировки больших датасетов. Наша реализация работает в два этапа: (1) разбиение данных на части перемешиванием по старшим битам ключей и (2) сортировка внутри каждой части. Оба этапа читают данные из 3FS и пишут их в 3FS.

Тестовый кластер: 25 узлов хранения (2 NUMA-домена на узел, 1 сервис хранения на NUMA-домен, 2 сетевые карты по 400 Гбит/с на узел) и 50 вычислительных узлов (2 NUMA-домена, 192 физических ядра, 2,2 ТиБ ОЗУ и 1 сетевая карта на 200 Гбит/с на узел). Сортировка 110,5 ТиБ данных по 8192 частям заняла 30 минут 14 секунд — средняя скорость 3,66 ТиБ/мин.

3. KV-кэш. KV-кэш — приём оптимизации инференса LLM. Он избегает повторных вычислений, сохраняя векторы ключей и значений предыдущих токенов в слоях декодера. Верхний рисунок показывает скорость чтения всех клиентов KV-кэша (по одной сетевой карте на 400 Гбит/с на узел), отмечены пиковые и средние значения; пик достигает 40 ГиБ/с. Нижний рисунок показывает IOPS операций удаления при сборке мусора (GC) за тот же период.

Оригинал: docs/design_notes.md.

В системе 3FS четыре компонента: менеджер кластера, сервис метаданных, сервис хранения и клиент. Все компоненты соединены RDMA-сетью (InfiniBand или RoCE).

Сервисы метаданных и хранения посылают менеджеру кластера сигналы «я жив» (heartbeats). Менеджер кластера обрабатывает изменения состава кластера и раздаёт конфигурацию кластера остальным сервисам и клиентам. Менеджеров кластера запускается несколько, и один из них выбирается главным (primary). Когда главный отказывает, главным становится другой менеджер. Конфигурация кластера обычно хранится в надёжном распределённом сервисе координации, например ZooKeeper или etcd. В нашей боевой среде мы используем то же хранилище «ключ-значение», что и для метаданных файлов, чтобы уменьшить число зависимостей.

Операции с метаданными файлов (например, открытие или создание файлов и каталогов) отправляются сервисам метаданных, которые реализуют семантику файловой системы. Сервисы метаданных не хранят состояния, поскольку метаданные файлов лежат в транзакционном хранилище «ключ-значение» (например, FoundationDB). Клиенты могут подключаться к любому сервису метаданных.

Каждый сервис хранения управляет несколькими локальными SSD и предоставляет интерфейс хранилища чанков (chunk store). Сервис хранения реализует цепную репликацию с распределёнными запросами (Chain Replication with Apportioned Queries, CRAQ), чтобы обеспечить строгую согласованность. Подход CRAQ «пишем во все, читаем из любой» помогает раскрыть пропускную способность SSD и RDMA-сети. Файл 3FS делится на чанки одинакового размера, которые реплицируются на несколько SSD.

Для приложений разработаны два клиента: FUSE-клиент и нативный клиент. Большинство приложений используют FUSE-клиент, порог входа у него низкий. Приложения, критичные к производительности, интегрируются с нативным клиентом.

flowchart LR
C["Клиент (FUSE или нативный)"] -->|"open, stat, create"| M["Сервисы метаданных (без состояния)"]
M --> F[("FoundationDB: inode и записи каталогов")]
C -->|"чтение и запись чанков по RDMA"| S["Сервисы хранения (SSD, CRAQ)"]
S -->|"heartbeat"| CM["Менеджер кластера (главный + резервные)"]
M -->|"heartbeat"| CM
CM -->|"таблицы цепочек, конфигурация"| C
CM -->|"таблицы цепочек"| S
Четыре компонента 3FS и их связи (схема переводчика по тексту)

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

  • Атомарные операции с каталогами. Объектное хранилище может имитировать иерархию каталогов с помощью косых черт (/) в ключах объектов. Но оно не поддерживает «из коробки» такие операции, как атомарное перемещение файлов и каталогов или рекурсивное удаление целых каталогов. А ведь в наших внутренних приложениях частый шаблон — создать временный каталог, записать в него файлы, а затем переместить каталог на окончательное место. При работе с большим числом мелких файлов рекурсивное удаление каталогов критически важно. Без него приложениям приходится обходить каждый каталог и удалять файлы по одному.

  • Символические и жёсткие ссылки. Наши приложения используют символические и жёсткие ссылки, чтобы создавать лёгкие снимки (snapshots) динамически обновляемых датасетов, в которые новые данные добавляются отдельными файлами.

  • Знакомый интерфейс. Файловый интерфейс хорошо известен и используется повсюду. Не нужно учить новый API хранения. Многие датасеты хранятся как файлы CSV/Parquet. Переделать загрузчики данных, работающие с файлами, под FUSE-клиент или нативный клиент 3FS несложно.

FUSE (Filesystem in Userspace, «файловая система в пространстве пользователя») упрощает разработку клиента файловой системы: модуль ядра FUSE перенаправляет операции ввода-вывода процессам в пространстве пользователя. Так создаётся иллюзия, что приложения обращаются к удалённой файловой системе как к локальной. Однако у производительности FUSE есть ограничения:

  • Накладные расходы на копирование памяти. Демон файловой системы в пространстве пользователя не имеет доступа к памяти приложения. Передача данных между пространствами ядра и пользователя расходует пропускную способность памяти и увеличивает сквозную задержку.

  • Примитивная поддержка многопоточности. Когда приложение отправляет запросы ввода-вывода, FUSE помещает их в общую многопоточную очередь, защищённую спин-блокировкой (spin lock). Затем демон файловой системы в пространстве пользователя забирает запросы из этой очереди и обрабатывает. Из-за борьбы за блокировку производительность ввода-вывода FUSE не растёт с числом потоков. Наши замеры показывают, что FUSE обрабатывает лишь около 400 тысяч чтений по 4 КиБ в секунду. Дальнейшее увеличение параллелизма не улучшает производительность, так как борьба за блокировку усиливается. Профилирование perf показывает, что спин-блокировка в пространстве ядра потребляет значительную долю процессорного времени.

Большинство приложений, например анализ данных, пишут в 3FS крупными блоками либо могут накапливать данные в памяти и сбрасывать их в 3FS, когда буфер записи заполнен. Однако FUSE в Linux 5.x не поддерживает одновременную запись в один и тот же файл1. Приложения обходят это ограничение, записывая параллельно в несколько файлов, и так добиваются максимальной суммарной пропускной способности.

У операций чтения картина сложнее. Некоторым задачам обучения нужен случайный доступ к примерам датасета, причём размер чтения одного примера — от нескольких килобайт до нескольких мегабайт. И примеры в файлах обычно не выровнены по границе 4 КиБ. Загрузчики данных специально спроектированы так, чтобы забирать примеры батчами. Но они плохо справляются с мелкими случайными чтениями из 3FS, смонтированной через FUSE. Пропускная способность SSD и RDMA-сети используется не полностью.

Реализация клиента файловой системы в виде модуля ядра VFS позволила бы избежать перечисленных проблем производительности. Но разработка модулей ядра значительно сложнее, чем системное программирование в пространстве пользователя. Ошибки трудно диагностировать, и они могут приводить к катастрофическим сбоям в боевой среде. Например, машины могут падать, не оставив в журнале ни одного сообщения для отладки. При обновлении модуля ядра нужно корректно остановить все процессы, использующие файловую систему, иначе потребуется перезагрузка машины.

По этим причинам мы решили реализовать нативный клиент внутри демона FUSE. Этот клиент предоставляет интерфейс с асинхронными операциями ввода-вывода без копирования (zero-copy). Операции с метаданными файлов по-прежнему обрабатывает демон FUSE (например, open/close/stat). Приложения вызывают open(), чтобы получить файловый дескриптор (fd), и регистрируют его через нативный API. После этого они могут выполнять операции ввода-вывода с файлом через нативный клиент. Такой подход сохраняет согласованность операций с метаданными с POSIX API, что упрощает перенос существующего кода.

Асинхронный API без копирования вдохновлён механизмом Linux io_uring. Ключевые структуры данных API:

  • Iov — большая область памяти для чтения и записи без копирования, общая для пользовательского процесса и нативного клиента. Регистрацией памяти для InfiniBand управляет клиент. В нативном API все прочитанные данные попадают в Iov, а все данные для записи нужно поместить в Iov до вызова API.

  • Ior — небольшой общий кольцевой буфер для связи между пользовательским процессом и нативным клиентом. Ior используется так же, как Linux io_uring: пользовательский процесс ставит в очередь запросы на чтение и запись, а нативный клиент забирает их из очереди и выполняет. Запросы выполняются батчами, размер которых задаёт параметр io_depth. Несколько батчей обрабатываются параллельно — и из разных колец, и из одного. Тем не менее многопоточным приложениям всё же рекомендуется использовать несколько колец, так как общее кольцо требует синхронизации, а она может снижать производительность.

Внутри нативного клиента запускается несколько потоков, которые забирают запросы ввода-вывода из Ior. Эти запросы собираются в батчи и отправляются сервисам хранения, что уменьшает накладные расходы на RPC от мелких запросов на чтение.

3FS делит данные файла на чанки одинакового размера и распределяет их полосами (striping) по нескольким цепочкам репликации (цепочки репликации и таблицы цепочек определены в разделе «Размещение данных»). Пользователи могут задавать таблицу цепочек, размер чанка и размер полосы (stripe size) для файлов на уровне каталога. Каждый чанк независимо хранится на нескольких сервисах хранения, а его идентификатор (chunk ID) получается склеиванием идентификатора inode файла и номера чанка.

При создании нового файла сервис метаданных по кругу (round-robin) выбирает из назначенной таблицы цепочек несколько идущих подряд цепочек репликации — столько, каков размер полосы. Затем генерируется случайное зерно (seed), чтобы перемешать выбранные цепочки. Такая стратегия распределения обеспечивает равномерное распределение данных по цепочкам и SSD.

Когда приложение открывает файл, клиент обращается к сервису метаданных за информацией о раскладке данных файла. После этого клиент может самостоятельно вычислять идентификаторы чанков и цепочки для операций с данными, и сервис метаданных почти не участвует в критическом пути.

Метаданные файлов в транзакционном хранилище «ключ-значение»

Заголовок раздела «Метаданные файлов в транзакционном хранилище «ключ-значение»»

3FS использует FoundationDB как распределённую систему хранения метаданных. FoundationDB предоставляет интерфейс хранилища «ключ-значение» и поддерживает транзакции с сериализуемой изоляцией снимков (Serializable Snapshot Isolation, SSI). 3FS хранит все метаданные в FoundationDB как пары «ключ-значение». Сервисы метаданных устроены без состояния, что сильно упрощает сопровождение: администраторы могут обновлять или перезапускать сервисы без перерывов в работе. Если запросы клиента завершаются ошибкой или по таймауту, клиент может автоматически переключиться на другой доступный сервис.

Метаданные файловой системы состоят в основном из двух базовых структур: inode и записей каталогов. В inode хранятся атрибуты файлов, каталогов и символических ссылок; каждый inode имеет глобально уникальный 64-битный идентификатор, который монотонно растёт. Ключ inode получается склеиванием префикса “INOD” и идентификатора inode, записанного в порядке байтов little-endian, чтобы inode распределялись по разным узлам FoundationDB. Значение inode зависит от его типа:

  • Все типы inode содержат базовые атрибуты: владельца, права доступа, время доступа, изменения содержимого и изменения атрибутов.

  • Дополнительные атрибуты inode файла: длина файла, размер чанка, выбранный диапазон в таблице цепочек, зерно перемешивания.

  • Дополнительные атрибуты inode каталога: идентификатор inode родительского каталога, настройки раскладки по умолчанию для подкаталогов и файлов (таблица цепочек, размер чанка, размер полосы). Идентификатор inode родителя нужен, чтобы обнаруживать циклы при перемещении каталогов. Перемещая dir_a/dir_b в dir_c/, нужно убедиться, что dir_c не является потомком dir_b; для этого проверяются все предки dir_c вверх по дереву.

  • Дополнительные атрибуты inode символической ссылки: строка с путём к цели.

Ключ записи каталога состоит из префикса “DENT”, идентификатора inode родителя и имени записи. Значение записи каталога хранит идентификатор inode цели и тип inode. Все записи одного каталога естественным образом образуют непрерывный диапазон ключей, поэтому содержимое каталога эффективно выводится запросом по диапазону.

Операции с метаданными опираются на транзакции FoundationDB:

  • Транзакции только для чтения — для запросов метаданных: fstat, lookup, listdir и т.д.

  • Транзакции чтения-записи — для изменения метаданных: create, link, unlink, rename и т.д.

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

В большинстве локальных файловых систем удаление открытого файла откладывается, пока не будут закрыты все связанные с ним файловые дескрипторы. Следовательно, нужно отслеживать все файловые дескрипторы файла. Задачи обучения при запуске открывают огромное число файлов. Хранение всех файловых дескрипторов создало бы большую нагрузку на сервис метаданных и FoundationDB. Поскольку задачи обучения от этой возможности не зависят, 3FS не отслеживает файловые дескрипторы, открытые только для чтения.

3FS ведёт файловую сессию для каждого файлового дескриптора (fd), открытого на запись, поскольку удаление файлов, открытых на запись, может оставить невозвращаемые «мусорные» чанки от одновременных записей. Если удаляется файл с активными сессиями записи, сервис метаданных откладывает удаление, пока не будут закрыты все его дескрипторы. Чтобы не оставались «висящие» сессии отключившихся клиентов, сервис метаданных 3FS периодически проверяет, живы ли клиенты, и очищает сессии отключившихся.

Длина файла хранится в inode. У файлов, которые активно обновляются, длина в inode может расходиться с фактической. Клиенты периодически (по умолчанию раз в 5 секунд) сообщают сервису метаданных максимальную позицию записи для каждого файла, открытого на запись. Если эта позиция больше длины в inode и нет одновременной операции усечения (truncate), она принимается как новая длина файла.

Поскольку возможны одновременные записи от нескольких клиентов, описанный способ обеспечивает для длины файла лишь согласованность в конечном счёте (eventual consistency). При обработке операций close/fsync сервис метаданных получает точную длину файла, запрашивая у сервиса хранения идентификатор и длину последнего чанка. Поскольку данные файла разложены полосами по нескольким цепочкам, эта операция заметно затратна.

Одновременные обновления длины одного файла несколькими сервисами метаданных могут вызывать конфликты транзакций и приводить к повторным вычислениям длины. Чтобы этого избежать, сервис метаданных распределяет задачи обновления длины файлов между сервисами метаданных по идентификаторам inode с помощью алгоритма рандеву-хеширования (rendezvous hashing).

В нашей боевой среде используется большой размер полосы: 200. У мелких файлов число цепочек, содержащих их чанки, намного меньше. Число потенциально используемых цепочек хранится в inode файла и служит подсказкой при обновлении длины. Начальное значение — 16, и оно удваивается каждый раз, когда чанки файла записываются на новые цепочки. Это позволяет не опрашивать все 200 цепочек при обновлении длины мелких файлов. Ту же оптимизацию можно применить и к удалению мелких файлов.

Цель проектирования системы хранения чанков — добиться максимально возможной пропускной способности даже при отказах носителей. Скорость чтения и записи 3FS должна расти линейно с числом SSD и с пропускной способностью сети в сечении (bisection bandwidth) между клиентами и сервисами хранения. Приложения обращаются к сервисам хранения, не заботясь о расположении данных.

Каждый чанк файла реплицируется по цепочке целей хранения (storage targets) с помощью цепной репликации с распределёнными запросами (CRAQ). В CRAQ запросы на запись отправляются головной цели и распространяются по цепочке. Запросы на чтение можно отправлять любой цели. Обычно трафик чтения равномерно распределяется между всеми целями цепочки для лучшей балансировки нагрузки. На каждом SSD создаётся несколько целей хранения, и эти цели входят в разные цепочки.

Допустим, есть 6 узлов: A, B, C, D, E, F. На каждом узле по 1 SSD. Создадим на каждом SSD 5 целей хранения: 1, 2, …, 5. Всего получится 30 целей: A1, A2, A3, …, F5. Если у каждого чанка 3 реплики, таблица цепочек строится так.

ЦепочкаВерсияЦель 1 (голова)Цель 2Цель 3 (хвост)
11A1B1C1
21D1E1F1
31A2B2C2
41D2E2F2
51A3B3C3
61D3E3F3
71A4B4C4
81D4E4F4
91A5B5C5
101D5E5F5

У каждой цепочки есть номер версии. Номер версии увеличивается, если цепочка меняется (например, цель хранения отключилась). Изменять таблицы цепочек может только главный менеджер кластера.

Можно построить несколько таблиц цепочек под разные требования к размещению данных. Например, можно создать две таблицы цепочек: одну для пакетных и офлайн-задач, другую для онлайн-сервисов. Эти две таблицы состоят из целей хранения на непересекающихся узлах и SSD.

Логически состояние каждой цепочки меняется независимо. Каждая цепочка может входить в несколько таблиц цепочек. Понятие таблицы цепочек введено, чтобы сервис метаданных мог выбрать таблицу для каждого файла и разложить чанки файла полосами по цепочкам этой таблицы.

Сбалансированный трафик при восстановлении

Заголовок раздела «Сбалансированный трафик при восстановлении»

Пусть трафик чтения равномерно распределён между всеми целями хранения в таблице цепочек выше. Когда A отказывает, её запросы на чтение перенаправляются на B и C. Под высокой нагрузкой пропускная способность чтения B и C сразу исчерпывается, и B, C становятся узким местом всей системы. Замена отказавшего SSD и синхронизация данных на новый SSD может занять несколько часов. Всё это время скорость чтения снижена.

Чтобы уменьшить влияние на производительность, можно сделать так, чтобы перенаправленный трафик делили между собой больше SSD. В следующей таблице цепочек A стоит в паре с каждым из остальных SSD. Когда A отказывает, каждый из остальных SSD получает 1/5 трафика чтения A.

ЦепочкаВерсияЦель 1 (голова)Цель 2Цель 3 (хвост)
11B1E1F1
21A1B2D1
31A2D2F2
41C1D3E2
51A3C2F3
61A4B3E3
71B4C3F4
81B5C4E4
91A5C5D4
101D5E5F5

Чтобы добиться максимальной скорости чтения во время восстановления, задачу балансировки нагрузки можно сформулировать как задачу о сбалансированной неполной блок-схеме (balanced incomplete block design). Оптимальное решение находится с помощью решателя целочисленного программирования.

CRAQ — протокол репликации «пишем во все, читаем из любой», оптимизированный для нагрузок с преобладанием чтения. Чтобы получить максимальную скорость чтения в системе хранения целиком на флеш-памяти, критически важно использовать пропускную способность чтения всех реплик.

Когда сервис хранения получает запрос на запись, он проходит следующие шаги:

  1. Сервис проверяет, совпадает ли версия цепочки в запросе с последней известной ему версией; если нет — отклоняет запрос. Запрос на запись может прийти от клиента или от предшественника в цепочке.

  2. Сервис выполняет операции RDMA Read, чтобы забрать данные для записи. Если клиент или предшественник отказал, операции RDMA Read могут завершиться по таймауту, и запись прерывается.

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

  4. Сервис читает в память зафиксированную версию чанка, применяет обновление и сохраняет обновлённый чанк как ожидающую версию (pending). Цель хранения может хранить две версии чанка: зафиксированную (committed) и ожидающую. У каждой версии есть монотонно растущий номер. Номера зафиксированной и ожидающей версий — v и u соответственно, и выполняется u = v + 1.

  5. Если сервис — хвост цепочки, зафиксированная версия атомарно заменяется ожидающей, и предшественнику отправляется подтверждение. Иначе запрос на запись пересылается преемнику. При обновлении зафиксированной версии текущая версия цепочки сохраняется как поле в метаданных чанка.

  6. Когда подтверждение приходит в сервис хранения, тот заменяет зафиксированную версию ожидающей и передаёт сообщение дальше своему предшественнику. После этого локальная блокировка чанка снимается.

Пусть в цепочке 3 цели: A, B, C. Запрос на запись только что вошёл в шаг 5 на A. A пересылает запрос преемнику B. Тут B мгновенно отказывает, и пересланный запрос теряется. Когда менеджер кластера обнаруживает отказ B, он помечает B как отключённую, переносит её в конец цепочки и рассылает обновлённую таблицу цепочек. Как только A получает последнюю таблицу, она пересылает запрос новому преемнику C. C может ещё не получить последнюю таблицу и отклонить запрос. Но A может продолжать пересылать запрос C. В конце концов C получает последнюю таблицу цепочек и принимает запрос.

Когда запрос на чтение приходит в сервис хранения:

  1. Если у сервиса есть только зафиксированная версия чанка, она и возвращается клиенту.

  2. В отличие от CRAQ, наша реализация не отправляет хвостовой цели запрос версии. Если есть и зафиксированная, и ожидающая версии, сервис отвечает специальным кодом статуса, чтобы уведомить клиента. Клиент может подождать короткое время и повторить запрос. Или клиент может отправить ослабленный (relaxed) запрос на чтение, чтобы получить ожидающую версию.

sequenceDiagram
participant K as Клиент
participant A as Голова A
participant B as B
participant C as Хвост C
K->>A: запись (версия цепочки)
A->>A: RDMA Read данных, блокировка, pending u = v + 1
A->>B: пересылка записи
B->>B: pending u = v + 1
B->>C: пересылка записи
C->>C: committed := pending
C-->>B: подтверждение
B-->>A: подтверждение (B фиксирует)
A-->>K: подтверждение (A фиксирует, снимает блокировку)
Запись по цепочке CRAQ в 3FS (схема переводчика по шагам 1–6)

Менеджер кластера обнаруживает отказы типа «остановка» (fail-stop) по сигналам «я жив». Менеджер объявляет сервис отказавшим, если не получает от него сигналов в течение настраиваемого интервала (например, T секунд). Сервис прекращает обрабатывать запросы и завершает работу, если не может связаться с менеджером кластера в течение T/2 секунд. Сигнал «я жив» можно рассматривать как запрос на продление аренды (lease), выданной менеджером.

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

В изменениях состава сервисов хранения менеджер кластера играет более важную роль. Он поддерживает глобальную картину таблиц цепочек и состояний целей хранения. У каждой цели хранения есть публичное и локальное состояние.

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

Публичное состояниеЧтениеЗаписьПояснение
servingдадасервис жив и обслуживает запросы клиентов
syncingнетдасервис жив, идёт восстановление данных
waitingнетнетсервис жив, восстановление данных ещё не началось
lastsrvнетнетсервис упал, и это была последняя обслуживающая цель
offlineнетнетсервис упал или отказал носитель

Локальное состояние известно только сервисам хранения и менеджеру кластера и хранится в памяти менеджера кластера. Если у цели хранения отказал носитель, соответствующий сервис сообщает в сигнале «я жив», что локальное состояние цели — offline. Если упал сервис хранения, все цели хранения, которыми он управляет, помечаются как offline.

Локальное состояниеПояснение
up-to-dateсервис жив и обслуживает запросы клиентов
onlineсервис жив, цель в состоянии syncing или waiting
offlineсервис упал или отказал носитель

Цель хранения может переходить из одного публичного состояния в другое в ответ на последнее локальное состояние. Локальное состояние играет роль события-триггера. Менеджер кластера периодически просматривает каждую цепочку и обновляет публичные состояния целей цепочки по таблице переходов.

  • Если цепочка обновлена, её версия увеличивается.

  • Если цель хранения помечена как offline, она переносится в конец цепочки.

  • Если сервис хранения обнаруживает, что публичное состояние какой-либо его локальной цели — lastsrv или offline, он немедленно завершает работу. Возможно, сервис отрезан от менеджера кластера из-за разделения сети (network partition).

  • Когда восстановление данных цели в состоянии syncing завершено, сервис хранения в следующих сигналах «я жив» менеджеру кластера сообщает локальное состояние цели up-to-date.

Локальное состояниеТекущее публичноеПубличное у предшественникаСледующее публичное
up-to-dateserving(любое)serving
syncing(любое)serving
waiting(любое)waiting
lastsrv(любое)serving
offline(любое)waiting
onlineserving(любое)serving
syncingservingsyncing
не servingwaiting
waitingservingsyncing
не servingwaiting
lastsrv(любое)serving
offline(любое)waiting
offlineservingнет предшественникаlastsrv
есть предшественникoffline
syncing(любое)offline
waiting(любое)offline
lastsrv(любое)lastsrv
offline(любое)offline

Когда сервис хранения завершает работу (например, процесс падает или перезапускается при обновлении) или отказывает носитель, все связанные с ним цели хранения помечаются менеджером кластера как offline и переносятся в конец цепочек. Когда сервис перезапускается, каждая его цель независимо входит в процесс восстановления. Весь процесс восстановления идёт параллельно с обычной работой и сводит перерывы к минимуму.

Когда запускается ранее отключённый сервис хранения:

  1. Сервис периодически забирает у менеджера кластера последние таблицы цепочек. Но он не посылает сигналов «я жив», пока все его цели хранения не окажутся помечены как offline в последних таблицах цепочек. Это гарантирует, что все его цели пройдут процесс восстановления данных.

  2. Запрос на запись, пришедший во время восстановления, — всегда запись с полной заменой чанка (full-chunk-replace). Локальная зафиксированная версия обновляется, а существующая ожидающая версия отбрасывается. Поскольку текущий сервис — хвост, предшественнику отправляется подтверждение. Полное состояние предшественника копируется на вернувшийся сервис непрерывным потоком записей с полной заменой чанков.

  3. Перед началом восстановления данных цели хранения предшественник отправляет вернувшемуся сервису запрос dump-chunkmeta. Сервис перебирает локальное хранилище метаданных чанков, собирает идентификаторы, версии цепочки и номера зафиксированной и ожидающей версий всех чанков цели и отправляет собранные метаданные предшественнику.

  4. Когда приходит сообщение sync-done, сервис знает, что цель хранения актуальна. Он сообщает в сигналах «я жив» менеджеру кластера, что локальное состояние цели — up-to-date.

Когда сервис хранения обнаруживает, что ранее отключённый преемник снова в сети:

  1. Сервис начинает пересылать преемнику обычные запросы на запись. Клиенты могут обновлять лишь часть чанка, но пересылаемые запросы должны содержать чанк целиком, то есть быть записями с полной заменой чанка.

  2. Сервис отправляет преемнику запрос dump-chunkmeta. Получив метаданные всех чанков цели преемника, он собирает метаданные чанков своей локальной цели. Затем сравнивает две копии метаданных и решает, какие чанки нужно передать.

  3. Выбранные чанки передаются преемнику запросами на запись с полной заменой чанка.

    • Сначала для каждого чанка берётся блокировка чанка.

    • Версия цепочки, номер зафиксированной версии и содержимое чанка читаются и передаются преемнику запросом с полной заменой чанка.

    • Блокировка чанка снимается.

  4. Когда все нужные чанки переданы, преемнику отправляется сообщение sync-done.

Правила, по которым решается, какие чанки передавать:

  • Если чанк есть только на локальной цели, его нужно передать.

  • Если чанк есть только на удалённой цели, его нужно удалить.

  • Если версия цепочки у локальной реплики чанка больше, чем у удалённой, чанк нужно передать.

  • Если версии цепочки у локальной и удалённой реплик совпадают, но номер локальной зафиксированной версии не равен номеру удалённой ожидающей версии, чанк нужно передать.

  • Иначе две реплики чанка либо одинаковы, либо прямо сейчас обновляются выполняющимися запросами на запись.

Чанки файлов хранятся в движке чанков (chunk engine). На каждом SSD постоянное хранилище движка чанков состоит из фиксированного числа файлов данных для хранения содержимого чанков и экземпляра RocksDB для метаданных чанков и другой системной информации. Кроме того, движок чанков держит в памяти кэш метаданных чанков, чтобы ускорить запросы. Для быстрого выделения новых чанков реализован распределитель чанков (chunk allocator). Интерфейс движка чанков обеспечивает потокобезопасный доступ через следующие операции:

  1. open/close — инициализирует движок: загружает метаданные из RocksDB и восстанавливает состояние распределителя чанков.

  2. get — получает метаданные чанка и дескриптор со счётчиком ссылок через кэш на хеш-таблице; допускает одновременный доступ со средней сложностью O(1).

  3. update — реализует семантику копирования при записи (copy-on-write, COW): перед изменением данных выделяются новые чанки. Старые чанки остаются доступными для чтения, пока не освобождены все дескрипторы.

  4. commit — фиксирует обновлённые метаданные чанка в RocksDB пакетами записей (write batches), чтобы обновление было атомарным; синхронно обновляет кэш метаданных чанков.

В конечном счёте данные чанков хранятся в физических блоках. Размеры физических блоков — от 64 КиБ до 64 МиБ с шагом в степень двойки, всего 11 разных размеров. Распределитель выделяет физические блоки, размер которых ближе всего к фактическому размеру чанка. Для каждого размера физического блока создаётся пул ресурсов, в каждом пуле 256 физических файлов. Состояние занятости физических блоков хранится в памяти в виде битовых карт. Когда физический блок освобождается, его флаг в битовой карте сбрасывается в 0. Место, занимаемое блоком, при этом сохраняется и в первую очередь используется при следующих выделениях. Когда свободных физических блоков не остаётся, вызывается fallocate(), чтобы выделить в физических файлах непрерывное большое пространство и создать 256 новых физических блоков, — такой подход помогает уменьшить фрагментацию диска.

При записи в чанк распределитель сначала выделяет новый физический блок. Затем система читает существующие данные чанка в буфер, применяет обновление и записывает обновлённый буфер в новый блок. Для дописывания в конец (append) реализован оптимизированный путь: данные добавляются прямо на месте в конец существующего блока. Из расположения нового блока и существующих метаданных чанка строится новая копия метаданных. Затем и новые метаданные чанка, и состояния нового и старого физических блоков атомарно обновляются в RocksDB.

ENRU
disaggregated architectureдезагрегированная архитектура (хранение отделено от вычислений)
cluster manager / metadata service / storage serviceменеджер кластера / сервис метаданных / сервис хранения
heartbeat, leaseсигнал «я жив», аренда
statelessбез состояния
transactional key-value storeтранзакционное хранилище «ключ-значение»
chunk, stripe sizeчанк (кусок файла), размер полосы
replication chain, chain tableцепочка репликации, таблица цепочек
storage target (head / tail)цель хранения (голова / хвост цепочки)
CRAQ (chain replication with apportioned queries)цепная репликация с распределёнными запросами
write-all-read-any«пишем во все, читаем из любой»
committed / pending versionзафиксированная / ожидающая версия
strong / eventual consistencyстрогая согласованность / согласованность в конечном счёте
zero-copy, ring bufferбез копирования, кольцевой буфер
full-chunk-replace writeзапись с полной заменой чанка
copy-on-write (COW)копирование при записи
balanced incomplete block designсбалансированная неполная блок-схема
network partitionразделение сети
  1. https://elixir.bootlin.com/linux/v5.4.284/source/fs/fuse/file.c#L1573 ↩