Место FUSE в файловом стеке Linux
Ключевой момент: FUSE не подменяет системные вызовы на уровне приложения и не требует от программ никакого специального API. Программа, которая вызывает open("/mnt/cloud/photo.jpg") и затем read(fd, buf, 4096), не знает, куда ведёт этот путь — к ext4 на локальном диске или к демону, общающемуся по SSH.
Разберём путь одного чтения по шагам.
- Программа делает обычный системный вызов. Никакого FUSE-специфичного интерфейса она не использует — те же
open(2),read(2),stat(2), что и для любой другой файловой системы. - VFS получает вызов и по пути определяет, к какому mount он относится. Это тот самый общий слой ядра, который заставляет ext4, XFS, NFS, tmpfs и десятки других реализаций выглядеть для приложений одинаково.
- Если операция требует участия userspace, FUSE-клиент в ядре формирует запрос и помещает его в очередь соединения. Запрос становится доступен через специальное символьное устройство
/dev/fuse. - FUSE-демон читает запрос из
/dev/fuse— например, «прочитай такой-то файл с такого-то смещения». Ядру при этом неважно, откуда демон возьмёт байты: прочитает локальный файл, сходит по SSH, запросит объект из S3 или сгенерирует содержимое на лету. - Демон записывает ответ обратно в
/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. Разница начинается внутри ядра.
Порядок такой:
- VFS разбирает путь. По таблице mount определяется, какая файловая система обслуживает
/mnt/cloud. Для приложения это обычный каталог; для ядра — точка, за которой стоит FUSE. - FUSE-клиент в ядре готовит запрос, если ответа нет в кэше. Запрос описывает операцию («прочитай файл с такого-то смещения») и кладётся в очередь соединения.
- Запрос становится доступен через
/dev/fuse— специальное символьное устройство. Это и есть тот самый канал между ядром и userspace. - Демон читает запрос из
/dev/fuseи решает, откуда взять байты: локальный файл, SSH-сервер, объект из S3, расшифрованный блок, сгенерированные на лету данные — ядру это безразлично. - Демон пишет ответ обратно в
/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%, а часть сценариев оставалась близка к прежней скорости.