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

от автора

Вы наверняка сталкивались с этим эффектом: монтируете sshfs, rclone mount или gocryptfs — и после этого файловый менеджер, редактор, архиватор и бэкапилка работают с появившимся каталогом так, будто перед ними самая обычная файловая система. Хотя байты могут жить на сервере, в S3, внутри шифрованного каталога или вообще рождаться на лету.

Как программа из userspace умудряется встать между open() и данными так, чтобы остальная система ничего особенного не заметила? Сегодня разберёмся. Заваривайте чай покрепче (ядро любит крепкий), устраивайтесь поудобнее — поговорим про FUSE.

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

Зачем вообще было огород городить

Обычные Linux-файловые системы вроде ext4, XFS и btrfs работают в ядре. Но причина не совсем в том, что «только ядро умеет трогать диск». Процесс с достаточными правами вполне может открыть блочное устройство и читать его напрямую.

Главное другое.

Главное — не доступ к блочному устройству, а единый слой, через который приложения вообще видят файлы.

В Linux эта задача была решена задолго до появления FUSE. Между программой и конкретной файловой системой находится VFS — Virtual File System: общий слой ядра, который позволяет ext4, XFS, NFS, tmpfs и десяткам других файловых систем выглядеть для приложений одинаково.

Программа говорит open(«/home/user/file»), а VFS разбирается, какой mount находится под этим путём и какой конкретной файловой системе передать операцию.

И вот спустя годы разработчики FUSE посмотрели на уже существующую архитектуру и задались интересным вопросом: а обязательно ли реализация, которой VFS передаёт эти операции, должна целиком жить в ядре?

А теперь представьте, что вы придумали супер-файловую систему. Например, хотите показывать почтовый ящик как каталог: письмо — файл, папка IMAP — директория, вложения — ещё файлы. Или хотите сделать файловый интерфейс к архивам, облаку, базе данных, удалённому серверу — да хоть к API кофеварки.

Путь первый — модуль ядра.

А это уже kernel-space: C, никаких привычных libc-шных удобств, аккуратная работа с памятью и блокировками, внутренние API ядра, которые для внешних модулей не обязаны вечно оставаться стабильными, и возможность превратить обычную ошибку в kernel oops или panic.

Знаете, как называется человек, который добровольно так делает?

Правильно: разработчик файловых систем ядра.

Их мало, и теперь вы возможно понимаете почему.

Но хотелось другого: чтобы файловую логику можно было написать обычной программой в userspace, отлаживать как обычную программу и при этом подключить к VFS так, чтобы остальная система видела нормальный mount.

Вот для этого и появился FUSE.

Однажды в Будапеште

История FUSE связана с венгерским разработчиком Миклошем Середи (Miklos Szeredi) и проектом AVFS — Virtual File System, который позволял обращаться к архивам и другим необычным источникам данных как к каталогам.

AVFS сначала экспериментировал с трюками вроде LD_PRELOAD, затем использовал интерфейс Coda. Но у обоих подходов были ограничения. В какой-то момент Середи решил, что нужен отдельный универсальный мост между VFS ядра и файловой системой в пользовательском процессе.

Так появился FUSE — Filesystem in Userspace.

Публичное объявление FUSE датируется 12 ноября 2001 года. Стабильный FUSE 1.0 вышел 20 февраля 2003-го. А 27 октября 2005 года FUSE вошёл в основное ядро Linux 2.6.14.

Это важная последовательность: FUSE не возник внезапно в момент попадания в mainline. К тому времени проект уже несколько лет жил отдельно, развивался и доказывал, что идея вообще работает. Ну знаете, как та история как когда bcachefs выпиздили из mainline, но только наоборот.

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

То есть не «вытащить файловые системы из ядра вообще», а провести новую границу ответственности.

С этого момента экзотическую файловую систему стало возможно разрабатывать без собственного kernel-модуля. А обычный пользователь при подходящей конфигурации системы мог монтировать FUSE-файловые системы без полноценного root-доступа.

И вот тут начинается самое интересное.

Как это устроено

Упростим путь одного чтения файла.

Допустим, программа делает:

open("/mnt/cloud/photo.jpg", ...)
read(fd, buf, 4096)

Если /mnt/cloud — FUSE-mount, схема примерно такая:

Происходит следующее.

  1. Программа вызывает обычный системный вызов. Никакого специального FUSE API она не знает.

  2. VFS определяет, к какому mount относится путь. Часть работы ядро вообще может выполнить без обращения к демону — например, если нужные данные или метаданные уже есть в кэше.

  3. Если для операции нужен userspace, FUSE-клиент в ядре формирует запрос. Он помещается в очередь соединения FUSE и становится доступен через специальное символьное устройство /dev/fuse.

  4. FUSE-демон читает запрос из /dev/fuse. Например: «прочитай такой-то файл с такого-то смещения». Дальше ядру всё равно, откуда демон возьмёт результат. Он может прочитать локальный файл, сходить по SSH, запросить объект из S3, расшифровать блок или сгенерировать содержимое на лету.

  5. Демон записывает ответ обратно в /dev/fuse. Ядро принимает его, обновляет своё состояние и завершает системный вызов приложения.

Получается почти МФЦ файловых систем.

Приложение приносит заявление ядру. Ядро понимает, что вопрос не по его ведомству, выдаёт талончик FUSE-демону, тот бегает по нужным инстанциям и возвращает официальный ответ. А заявитель на выходе получает обычные байты и вообще может не знать, что половина учреждения сидела в userspace.

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

Именно поэтому точнее говорить не «FUSE пересылает в userspace каждый файловый вызов», а «FUSE пересылает туда те операции, для которых ядру нужен ответ файлового сервера».

А если демон упадёт?

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

Если ошибка происходит в файловой системе внутри ядра, радиус поражения потенциально велик: повреждение памяти, oops, deadlock, panic — спектр развлечений широкий.

Если падает FUSE-демон, ядро обычно продолжает жить.

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

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

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

А как пользователь вообще делает mount без root?

Сам системный вызов mount(2) — привилегированная операция. Поэтому в классической схеме libfuse есть небольшой помощник fusermount, а в современных версиях — fusermount3.

Обычно он устанавливается setuid-root и выполняет строго ограниченную привилегированную часть операции: проверяет условия, открывает нужное FUSE-соединение и помогает создать mount от имени пользователя. Сама файловая система после этого работает обычным процессом с обычными пользовательскими правами.

Отсюда же ещё одна деталь безопасности.

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

-o allow_other

А чтобы не-root пользователь вообще имел право запросить allow_other, администратор должен разрешить это в /etc/fuse.conf:

user_allow_otherТо есть user_allow_other сам по себе никому mount не «расшаривает». Он только разрешает обычным пользователям применять опцию allow_other.

Маленькая деталь, но именно на таких деталях обычно и живёт безопасность многопользовательской системы.

Чем это закончилось

А дальше случилось то, что обычно случается с хорошей абстракцией: люди начали запихивать в неё всё подряд.

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

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

Есть EncFS и gocryptfs, которые показывают расшифрованное представление данных поверх зашифрованного каталога.

Есть s3fs и rclone mount, превращающие объектные и облачные хранилища в дерево файлов — иногда с теми семантическими компромиссами, которые неизбежны, когда API вида «положить объект по ключу» заставляют притворяться POSIX-файловой системой.

Есть GlusterFS. У CephFS есть и kernel-клиент, и userspace-вариант ceph-fuse, так что говорить «CephFS работает через 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-аудиостек.

То есть кто-то посмотрел на FUSE и сказал:

— А файловыми системами ограничиваться обязательно?

Конечно нет.

Android — тоже FUSE, но история там хитрее

С Android легко написать эффектную фразу и почти гарантированно соврать.

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

В Android 12 появился ещё и Android-специфичный FUSE passthrough для некоторых веток ядер 5.4/5.10.

И это отдельная причина не писать, что «FUSE passthrough появился в Linux 5.15». В Android похожая технология жила в своих kernel-ветках раньше, чем её вариант попал в mainline Linux.

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

И даже виртуалки

В конце 2010-х появился virtiofs — файловая система для быстрого совместного доступа виртуальной машины к дереву каталогов на хосте. Поддержка virtiofs есть в mainline начиная с Linux 5.4 (2019).

Тут особенно интересно, что virtiofs использует протокол FUSE, но классическая схема меняется: вместо обычного обмена guest-демона с ядром через /dev/fuse запросы идут между гостем и хостом через virtio/virtqueues.

То есть FUSE к этому моменту оказался уже не только удобным способом написать userspace-файловуху. Его протокол стал строительным блоком для других архитектур.

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

А что со скоростью?

А вот здесь наступает момент, когда за удобную абстракцию приходит счёт.

Если операция не обслужилась из кэша, классический FUSE-путь требует передать запрос из ядра в userspace, разбудить демон, обработать запрос и передать ответ обратно. Добавьте переключения контекста, копирование/маппинг данных, очереди, планировщик — и на большом количестве мелких операций накладные расходы становятся заметны.

Отсюда многолетняя репутация FUSE как «удобно, но медленно».

За последние годы в ядре появилось как минимум два важных ответа на эту проблему. И они решают разные задачи.

Passthrough: иногда демон можно вообще убрать с data path

В Linux 6.9, вышедшем в мае 2024 года, в mainline появился FUSE passthrough для обычного файлового I/O.

Идея такая: иногда userspace-демон нужен, чтобы решить, какому реальному файлу соответствует FUSE-файл, проверить политику или подготовить отображение. Но после этого нет особого смысла гонять через демон каждый read() и write(), если данные в итоге всё равно лежат в обычном backing file на нижележащей файловой системе.

Демон регистрирует backing file и при открытии говорит ядру примерно: «вот этому FUSE-файлу соответствует вот этот нижний файл». После этого поддерживаемые операции чтения/записи и mmap ядро может направлять непосредственно в backing filesystem.

Это очень мощная оптимизация, но не волшебная кнопка «ускорить любой FUSE».

Если ваши данные на самом деле синтезируются демоном, лежат в S3 или прилетают с другого конца SSH-соединения, реального локального backing file может просто не быть. Пропускать демон тогда некуда.

Кроме того, нынешняя upstream-реализация накладывает ограничения на создание passthrough-отображений: это не механизм, который произвольный непривилегированный FUSE-сервер автоматически получает бесплатно.

Так что правильнее думать о passthrough как об ускоренном маршруте для определённого класса FUSE-файловых систем.

io_uring: демон остаётся, но общаться с ним можно дешевле

В Linux 6.14, вышедшем 24 марта 2025 года, появилась другая оптимизация: FUSE over io_uring. Основную серию патчей для нового транспорта вёл Бернд Шуберт (Bernd Schubert).

Здесь никто не пытается убрать userspace-демон из схемы. Вместо этого меняется транспорт между ядром и демоном.

Классический /dev/fuse-интерфейс исторически означает много отдельных операций чтения и записи запросов. io_uring позволяет организовать очереди эффективнее: обрабатывать несколько запросов с меньшим syscall overhead, совмещать возврат ответа на старый запрос с получением нового и лучше сохранять CPU/NUMA locality.

Условно:

Classic:kernel -> request -> daemonkernel <- reply <- daemonkernel -> request -> daemonkernel <- reply <- daemonio_uring:kernel <==== queues / batches / commit+fetch ====> daemon

На микробенчмарках ранних patch series результаты местами были очень впечатляющими: отдельные сценарии показывали ускорения в два-три раза, а некоторые direct-I/O тесты — ещё больше. Но разброс между workload’ами огромный: другие тесты были близки к прежней скорости, а в одном из более поздних наборов измерений выигрыш составлял около 25%.

Поэтому фраза «в Linux 6.14 FUSE стал в несколько раз быстрее» была бы отличным заголовком и плохим техническим утверждением.

Корректнее так: FUSE-over-io_uring заметно снижает накладные расходы связи kernel -userspace и на подходящих нагрузках способен дать большой выигрыш, но величина ускорения сильно зависит от конкретной нагрузки.

И есть ещё одна важная деталь: поддержка io_uring-пути развивалась постепенно, и не все типы FUSE-запросов обязаны проходить через него — часть служебного обмена по-прежнему может использовать /dev/fuse.

Вот это различие стоит запомнить:

  • passthrough пытается не ходить к демону там, где данные можно отдать напрямую из backing file;

  • io_uring оставляет демон в цепочке, но делает сам обмен с ним дешевле.

Две оптимизации, две разные причины тормозов.

Так что же в FUSE было такого важного?

FUSE не доказал, что «файловые системы не нужны в ядре», и в целом не преследовал такую цель.

В ядре по-прежнему остаются VFS, mount namespace, кэши, проверки доступа, FUSE-клиент и куча другой работы. Из kernel-space вынесли то, что особенно удобно выносить: специфическую логику конкретной файловой системы.

И в этом, пожалуй, главное достижение FUSE.

Без него все перечисленные вещи не были бы физически невозможны — существовали и существуют kernel-модули, сетевые файловые протоколы, Coda, NFS, GVfs/KIO и другие способы решать похожие задачи.

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

Хотите файловую систему поверх SSH — пожалуйста.

Поверх шифрованного каталога — пожалуйста.

Поверх S3 — с оговорками, но пожалуйста.

Хотите протокол FUSE между виртуалкой и хостом — и до этого дошли.

А началось всё с вопроса: обязательно ли вся специфическая логика файловой системы должна жить в ядре?

Оказалось — нет.

И ядро с этим вполне ужилось: оставило себе контроль над границей, а userspace выдало окошко для приёма заявлений — почти как в МФЦ.

ссылка на оригинал статьи https://habr.com/ru/articles/1080806/