Назад к блогу

FUSE изнутри: как пользовательские программы становятся файловыми системами

FUSE изнутри: как пользовательские программы становятся файловыми системами

Статья разбирает, как устроен FUSE — механизм, позволяющий обычной программе притвориться файловой системой для всей операционной системы. На примере одного чтения файла показано, как запрос проходит от системного вызова через ядро к демону в пользовательском пространстве, и почему файловый менеджер или редактор видят такое дерево как самый обычный диск. Это полезно всем, кто хочет понять границы между ядром и userspace и почему абстракция, годами жившая вне mainline, в итоге стала стандартом.

Место FUSE в файловом стеке Linux

Ключевой момент: FUSE не подменяет системные вызовы на уровне приложения и не требует от программ никакого специального API. Программа, которая вызывает open("/mnt/cloud/photo.jpg") и затем read(fd, buf, 4096), не знает, куда ведёт этот путь — к ext4 на локальном диске или к демону, общающемуся по SSH.

Разберём путь одного чтения по шагам.

  1. Программа делает обычный системный вызов. Никакого FUSE-специфичного интерфейса она не использует — те же open(2), read(2), stat(2), что и для любой другой файловой системы.
  2. VFS получает вызов и по пути определяет, к какому mount он относится. Это тот самый общий слой ядра, который заставляет ext4, XFS, NFS, tmpfs и десятки других реализаций выглядеть для приложений одинаково.
  3. Если операция требует участия userspace, FUSE-клиент в ядре формирует запрос и помещает его в очередь соединения. Запрос становится доступен через специальное символьное устройство /dev/fuse.
  4. FUSE-демон читает запрос из /dev/fuse — например, «прочитай такой-то файл с такого-то смещения». Ядру при этом неважно, откуда демон возьмёт байты: прочитает локальный файл, сходит по SSH, запросит объект из S3 или сгенерирует содержимое на лету.
  5. Демон записывает ответ обратно в /dev/fuse. Ядро принимает его, обновляет своё состояние и завершает системный вызов — приложение получает обычные байты.

Здесь важно не утрировать схему. Не каждый read() обязательно превращается в отдельный поход через /dev/fuse: page cache, attribute cache и собственные механизмы FUSE умеют обслуживать часть обращений без нового round trip. Точнее говорить так — FUSE пересылает в userspace те операции, для которых ядру нужен ответ файлового сервера.

Практическое следствие такой архитектуры: смонтированное дерево попадает в общий файловый namespace. Поэтому файловый менеджер, редактор, архиватор и бэкапилка видят его как самую обычную файловую систему, хотя байты могут жить где угодно. Стоит оговориться: не всякая «сетевая папка» в проводнике — это FUSE. GNOME GVfs, KDE KIO и MTP показывают похожую картину на уровне приложений, но работают иначе. FUSE подключается именно к файловому стеку ядра, поэтому его mount видят обычные программы через привычные системные вызовы, а не только файловые менеджеры с поддержкой конкретного протокола (подробный разбор).

От AVFS к ядру 2.6.14: краткая история проекта

Проект AVFS Миклоша Середи (Miklos Szeredi) начинался с грубых приёмов. На раннем этапе для перехвата файловых операций использовался LD_PRELOAD — подмена библиотечных вызовов прямо в адресном пространстве процесса. Приём работал, но оставался хрупким: он действовал только на динамически слинкованные программы и не мог обмануть статически собранные бинарники или вызовы, уходящие в ядро напрямую.

Следующей попыткой стал интерфейс Coda — сетевой файловой системы, у которой уже был свой мост между ядром и демоном в userspace. Идея была ближе к цели, но Coda тянула за собой багаж собственного протокола и не давала универсального способа подключать произвольную логику. Обе попытки упирались в одно: не хватало отдельного, ни к чему не привязанного канала между VFS и пользовательским процессом.

Ответом стало решение о разделении ответственности: универсальную часть оставить в ядре, специфическую логику — в обычной программе. Публично о FUSE объявили 12 ноября 2001 года. До стабильного релиза 1.0 прошло больше года — он вышел 20 февраля 2003-го, что дало проекту время доказать работоспособность на реальных задачах.

В mainline FUSE попал 27 октября 2005 года, в ядро Linux 2.6.14 — через четыре года после анонса. Порядок этих дат и есть главное наблюдение: абстракция сначала годами жила вне дерева ядра, набирала пользователей и лишь потом получила статус встроенной подсистемы. Такой путь типичен для идей, которые сначала кажутся рискованными, а потом становятся обыденностью.

Анатомия запроса: /dev/fuse и обмен с демоном

Возьмём конкретный вызов: open("/mnt/cloud/photo.jpg", ...), следом read(fd, buf, 4096). Программа не знает ни слова про FUSE — она пользуется теми же open(2), read(2), stat(2), что и с ext4. Разница начинается внутри ядра.

Порядок такой:

  1. VFS разбирает путь. По таблице mount определяется, какая файловая система обслуживает /mnt/cloud. Для приложения это обычный каталог; для ядра — точка, за которой стоит FUSE.
  2. FUSE-клиент в ядре готовит запрос, если ответа нет в кэше. Запрос описывает операцию («прочитай файл с такого-то смещения») и кладётся в очередь соединения.
  3. Запрос становится доступен через /dev/fuse — специальное символьное устройство. Это и есть тот самый канал между ядром и userspace.
  4. Демон читает запрос из /dev/fuse и решает, откуда взять байты: локальный файл, SSH-сервер, объект из S3, расшифрованный блок, сгенерированные на лету данные — ядру это безразлично.
  5. Демон пишет ответ обратно в /dev/fuse. Ядро принимает его, обновляет состояние и завершает системный вызов, который всё это время ждал приложение.

Метафора, которая здесь хорошо работает: МФЦ. Заявитель приносит бумаги в окошко, ядро понимает, что вопрос не по его части, выдаёт талончик демону, тот обходит нужные инстанции и возвращает готовый ответ. Заявитель получает байты и не подозревает, что половина учреждения сидела в userspace. Только не растягивайте аналогию: не каждый read() превращается в отдельный поход через /dev/fuse.

Именно поэтому корректная формулировка — не «FUSE гоняет в userspace каждый файловый вызов», а «FUSE гоняет туда операции, для которых ядру нужен ответ файлового сервера». Часть обращений гасят кэши: page cache отдаёт уже прочитанные страницы, attribute cache — метаданные вроде прав и размеров, а собственные механизмы FUSE срезают повторные round trip. Если файл только что прочитан и не менялся, второй read() может вообще не дойти до демона — данные придут из памяти.

Практический вывод для тех, кто пишет свой демон: чем больше операций вы сумеете закрыть кэшем, тем меньше запросов реально пройдёт по маршруту «ядро → /dev/fuse → userspace → /dev/fuse → ядро». Каждый такой круг — переключение контекста и работа планировщика, а не бесплатная абстракция.

Почему падение демона не роняет систему

Ошибка в файловой системе, работающей в ядре, и падение FUSE-демона — это два разных сценария по масштабу последствий. В первом случае спектр неприятностей широк: повреждение памяти, oops, взаимная блокировка, kernel panic. Во втором ядро, как правило, продолжает работать.

Но не спешите радоваться. Сам mount после падения демона здоровее не становится. Процессы, обращающиеся к нему, начинают получать ошибки, а часть незавершённых запросов может зависнуть вплоть до разрыва или принудительного abort соединения.

Почему запросы зависают? Вспомните механику: ядро положило запрос в очередь и ждёт ответа от userspace. Если демон исчез, отвечать некому. Пока соединение не будет разорвано или не сработает abort, запрос остаётся в подвешенном состоянии, а вызвавший его системный вызов — заблокированным.

Отсюда ключевой вывод: FUSE не превращает плохой код в безопасный. Он изолирует значительную часть файловой логики от kernel-space. Ошибка чаще ломает конкретную файловую систему и её клиентов, а не всю ОС. Это заметно приятнее, чем отлаживать свежий kernel-модуль на машине, где вы параллельно пишете статью о преимуществах userspace.

Привилегии и fusermount: как монтирует обычный пользователь

Системный вызов mount(2) требует привилегий. Тогда как обычный пользователь умудряется подключать sshfs или rclone mount, не имея root-прав? Ответ — небольшой setuid-помощник. В классической libfuse это fusermount, в современных версиях — fusermount3. Бинарник ставится setuid-root и берёт на себя ровно ту часть операции, где нужны привилегии: проверяет условия, открывает нужный ресурс. Всю остальную работу продолжает делать непривилегированный демон в userspace.

Такой помощник специально ограничен по задачам. Он не превращается в универсальную «кнопку стать root» — иначе любой setuid-бинарь стал бы дырой. Расширять права самого FUSE-демона не нужно: разделение «тонкая привилегированная обёртка + основная логика без прав» и есть смысл конструкции.

По умолчанию смонтированное дерево видит только тот, кто его создал. Соседи по машине в него не заглянут — и это разумно, иначе каждый пользовательский mount становится общей папкой. Чтобы открыть доступ другим, файловую систему монтируют с опцией:

-o allow_other

Но тут вступает второй предохранитель. Опция allow_other повышает риск: через пользовательский mount другие учётные записи получают доступ к данным. Поэтому не-root пользователь может применить allow_other только с явного разрешения администратора. Оно задаётся строкой в конфигурации /etc/fuse.conf:

user_allow_other

Важная деталь: сама строка user_allow_other никого ни с кем не «расшаривает». Она лишь снимает запрет для обычных пользователей на использование опции allow_other. Что именно монтировать и кому открывать доступ, решает каждый вызов mount отдельно.

Итоговая цепочка получается такой: setuid-помощник даёт непривилегированному процессу ограниченное право на mount, а /etc/fuse.conf решает, разрешено ли этому процессу делать mount видимым для остальных. Права демона при этом не расширяются ни на шаг.

От SSHFS до virtiofs: где FUSE реально используют

SSHFS вышел в 2004 году и сделал простую вещь: взял SFTP и показал удалённый сервер как локальный каталог. Приложению не нужен отдельный сетевой протокол — оно читает файлы через обычные вызовы.

Дальше в ту же абстракцию начали складывать всё подряд.

NTFS-3G в июле 2006-го появился как beta, а 21 февраля 2007 года получил стабильный релиз 1.0. Для тех, кто держал dual boot, это была почти бытовая магия: полноценная запись на NTFS без kernel-модуля. Сегодня в Linux есть in-kernel драйвер ntfs3, так что NTFS-3G интересен прежде всего как исторический пример.

EncFS и gocryptfs показывают расшифрованное дерево поверх зашифрованного каталога. s3fs и rclone mount превращают объектные хранилища в файловое дерево — с неизбежными семантическими компромиссами, когда API «положить объект по ключу» заставляют притворяться POSIX-файловой системой. GlusterFS тоже здесь. А вот про CephFS так говорить нельзя: у неё есть и kernel-клиент, и userspace-вариант ceph-fuse, то есть FUSE — лишь один из способов подключения.

В 2009 году появился CUSE — Character device in Userspace, вошедший в Linux 2.6.31. Ту же идею применили к символьным устройствам: kernel-side прокладка, логика устройства — в userspace. Ранний OSS Proxy через CUSE создавал привычные /dev/dsp, /dev/adsp и /dev/mixer, а звук пересылал в userspace-аудиостек.

С Android легко написать эффектную фразу и почти гарантированно соврать. История shared storage там менялась несколько раз. В старых версиях работал FUSE-слой, помогавший реализовать модель доступа к общему хранилищу. В Android 8 ради производительности появился SDCardFS — реализация в ядре. Затем всё снова поменялось: в Android 11 SDCardFS для современных устройств вывели из игры, а эмуляция shared storage вернулась к обновлённому FUSE-подходу вместе с MediaProvider и scoped storage. В Android 12 добавился Android-специфичный FUSE passthrough для части веток ядер 5.4/5.10.

Если же вы просто открыли телефон по MTP в Nautilus или Dolphin — это может быть GVfs/KIO, а не FUSE-mount. Внешне похоже, этаж абстракции другой.

И даже виртуалки: в конце 2010-х появился virtiofs — файловая система для быстрого доступа виртуальной машины к дереву каталогов на хосте. В mainline поддержка есть начиная с Linux 5.4 (2019). virtiofs использует протокол FUSE, но классическая схема меняется: вместо обмена guest-демона с ядром через /dev/fuse запросы идут между гостем и хостом через virtio/virtqueues. FUSE к этому моменту оказался уже не только способом написать userspace-файловуху — его протокол стал строительным блоком для других архитектур.

Неплохо для идеи, выросшей из желания нормально лазить по архивам.

passthrough и io_uring: два пути борьбы с накладными расходами

У FUSE репутация «медленно», но причины у этой репутации разные — и лечатся они по-разному. Linux 6.9 принёс passthrough для обычного файлового I/O, а 6.14 добавил транспорт over io_uring. Разница принципиальная, и её стоит держать в голове при выборе.

Passthrough убирает демон с пути данных. Идея в том, что демон нужен, чтобы решить, какому реальному файлу соответствует FUSE-файл, проверить политику, подготовить отображение. Но если данные в итоге лежат в обычном backing file на нижележащей ФС, гонять через демон каждый read() и write() незачем. Демон регистрирует backing file и сообщает ядру: этому FUSE-файлу соответствует вот этот нижний файл. После этого поддерживаемые операции чтения, записи и mmap ядро направляет прямо в backing filesystem.

Ограничение простое и жёсткое: нужен реальный локальный backing file. Если содержимое синтезируется на лету, приходит из S3 или с другого конца SSH-соединения — пропускать демон некуда, оптимизация неприменима. К тому же текущая upstream-реализация не позволяет произвольному непривилегированному FUSE-процессу создавать такие отображения.

io_uring, наоборот, демон из схемы не выносит. Меняется транспорт между ядром и демоном. Классический /dev/fuse означает множество отдельных чтений и записей на каждый запрос; io_uring позволяет обрабатывать несколько запросов с меньшим числом syscall, совмещать возврат ответа на старый запрос с получением нового и лучше сохранять CPU/NUMA locality. Серию патчей вёл Бернд Шуберт (Bernd Schubert).

Цифры здесь требуют осторожности: на ранних микробенчмарках отдельные сценарии давали ускорение в два-три раза, часть direct-I/O тестов — ещё больше, но другие нагрузки оставались близки к прежней скорости, а в одном из поздних наборов измерений выигрыш составил около 25%. Фраза «FUSE стал в несколько раз быстрее» звучит как заголовок и плохо работает как техническое утверждение. Поддержка io_uring-пути развивалась постепенно, и часть служебного обмена по-прежнему идёт через /dev/fuse.

Сопоставление получается такое: passthrough работает с данными, убирая лишнее звено из цепочки, но требует backing file; io_uring работает со связью, оставляя демон на месте, и потому применим шире — в том числе там, где данные приходят по сети. Это два разных узких места, и выбирать между ними стоит по тому, где именно у вас теряется время.

Что остаётся в ядре и что выносится в userspace

Граница ответственности FUSE проходит не там, где «файловая система целиком», а там, где специфическая логика конкретной ФС. VFS, mount namespace, кэши, проверки доступа и FUSE-клиент остаются в ядре — в userspace уезжает только то, что удобно отлаживать как обычную программу. Падение демона ломает конкретный mount и его клиентов, но не роняет ОС; часть незавершённых запросов может подвиснуть до разрыва соединения. Это и есть реальный выигрыш: не безопасность «плохого кода», а изоляция и предсказуемый радиус поражения.

Второй вывод — про природу накладных расходов. Passthrough и io_uring-транспорт лечат разные болезни. Passthrough вообще выводит демон из data path, но только если у файла есть настоящий локальный backing file; для S3, SSH или синтеза на лету пропускать демон некуда. Транспорт over io_uring, наоборот, демон оставляет, но удешевляет сам обмен запросами и ответами. Путать эти вещи — значит ждать ускорения там, где его не будет по определению.

Практический совет: задайте один вопрос до выбора — «где физически лежат байты?». Если это обычные локальные файлы, отдайте приоритет passthrough: демон отработает открытие и mapping, а дальше ядро пойдёт в backing filesystem напрямую. Если данные приходят снаружи или генерируются, смотрите в сторону io_uring-транспорта, но не закладывайте в план фиксированный множитель ускорения. На отдельных нагрузках выигрыш был в разы, в других — около 25%, а часть сценариев оставалась близка к прежней скорости.

Похожее