Написать файловую систему для Linux - это звучит как задача для узкого круга специалистов, которые не боятся отладчика ядра и знают наизусть структуру inode. Долгое время так и было: любая файловая система существовала исключительно в пространстве ядра, и цена ошибки там - паника системы, а не просто исключение в пользовательском коде. Именно это положение дел изменил FUSE - Filesystem in Userspace - механизм, позволяющий писать полноценные файловые системы на Python, C++, Go, Rust и практически любом другом языке, оставаясь при этом обычным пользовательским процессом.
Цена этой свободы - производительность. Каждая файловая операция проходит сквозь несколько уровней переключений контекста, чего не происходит в традиционных ядерных файловых системах. Но для огромного класса задач - монтирование облачных хранилищ, шифрованные файловые системы, доступ к архивам как к директориям, сетевые файловые системы - эта плата полностью оправдана гибкостью.
Три компонента FUSE и их взаимодействие
FUSE состоит из трёх компонентов: модуля ядра fuse.ko, пользовательской библиотеки libfuse и утилиты монтирования fusermount. Понять, как они работают вместе - значит понять всю архитектуру FUSE от первого системного вызова до возврата данных приложению.
Модуль ядра fuse.ko - это мост между VFS и пользовательским пространством. Когда приложение обращается к файлу на FUSE-файловой системе, VFS передаёт операцию в fuse.ko, а тот упаковывает её в запрос и отправляет через специальное символьное устройство /dev/fuse демону файловой системы в пользовательском пространстве. Ядро общается с libfuse именно через символьное устройство /dev/fuse.
Библиотека libfuse - это клей, который соединяет ядерный модуль с кодом файловой системы. Она предоставляет функции для монтирования и размонтирования файловой системы, чтения запросов от ядра и отправки ответов обратно. Разработчик файловой системы не работает с /dev/fuse напрямую - он пишет функции-обработчики и регистрирует их в структуре fuse_operations, а всю низкоуровневую работу с протоколом берёт на себя libfuse.
Утилита fusermount решает задачу привилегий. Одна из важнейших особенностей FUSE - возможность монтирования без привилегий суперпользователя. Именно fusermount установлена с битом setuid root, что позволяет обычному пользователю монтировать FUSE-файловую систему, не получая полного root-доступа. Это принципиально отличает FUSE от традиционных файловых систем, монтирование которых требует прав администратора.
Проверить, что модуль ядра загружен:
lsmod | grep fuse
# fuse 172032 3
Число в третьей колонке - количество активных FUSE-монтирований. Три активных монтирования - вполне типичная картина на рабочей станции с одним облачным диском, EncFS и SSHFS.
Жизнь одного системного вызова внутри FUSE
Чтобы почувствовать реальную стоимость FUSE-операции, стоит пройти вслед за системным вызовом read() от момента, когда приложение его выполнило, до момента получения данных. Этот маршрут раскрывает и гибкость механизма, и его узкие места.
Приложение вызывает read(fd, buf, count). Ядро получает системный вызов и через VFS определяет, что файловый дескриптор относится к FUSE-файловой системе. Ядро перенаправляет I/O-запрос зарегистрированному обработчику, а затем возвращает ответ обработчика пользователю. Модуль fuse.ko формирует структуру запроса fuse_read_in и помещает её в очередь. Вызывающий процесс засыпает в ожидании ответа.
Демон FUSE-файловой системы читает запрос из /dev/fuse через libfuse. Libfuse десериализует его и вызывает зарегистрированный колбэк read из структуры fuse_operations. Этот колбэк - уже обычный код на Python, C++ или любом другом языке - выполняет реальную работу: ходит в облако, читает локальный файл, генерирует данные на лету. Результат возвращается через libfuse обратно в /dev/fuse, ядро получает ответ, будит засыпавший процесс и передаёт ему данные.
Итог: одна операция read() потребовала минимум двух переключений контекста ядро-пользователь (запрос и ответ) плюс время выполнения самого колбэка. Для сравнения - в ext4 тот же read() при кэш-попадании обойдётся копированием из page cache без единого переключения контекста. Это и есть фундаментальная цена FPIO-архитектуры.
Посмотреть активные FUSE-соединения и количество ожидающих запросов в каждом из них:
ls /sys/fs/fuse/connections/
# Каждая директория - одно смонтированное FUSE-устройство
# Количество запросов, ожидающих обработки демоном
cat /sys/fs/fuse/connections/*/waiting
Если значение waiting растёт и не уменьшается - демон файловой системы завис или не успевает обрабатывать запросы. Именно это поле помогает быстро диагностировать "залипшую" FUSE-файловую систему, которая отказывается отвечать на операции.
Структура fuse_operations - весь интерфейс файловой системы в одной структуре
Структура fuse_operations - это набор функций обратного вызова, куда поступают запросы: либо для перенаправления на удалённый сервер, как в случае sshfs, либо для работы с локальной файловой системой. По сути это точный аналог file_operations и inode_operations из ядерного VFS, только вынесенный в пользовательское пространство.
Минимальная реализация файловой системы на C с libfuse выглядит так:
#include <fuse.h>
#include <string.h>
#include <errno.h>
// Возвращает атрибуты файла - аналог stat()
static int myfs_getattr(const char *path, struct stat *stbuf,
struct fuse_file_info *fi)
{
memset(stbuf, 0, sizeof(struct stat));
if (strcmp(path, "/") == 0) {
stbuf->st_mode = S_IFDIR | 0755;
stbuf->st_nlink = 2;
} else if (strcmp(path, "/hello.txt") == 0) {
stbuf->st_mode = S_IFREG | 0444;
stbuf->st_nlink = 1;
stbuf->st_size = 13;
} else {
return -ENOENT;
}
return 0;
}
// Читает содержимое директории - аналог readdir()
static int myfs_readdir(const char *path, void *buf,
fuse_fill_dir_t filler, off_t offset,
struct fuse_file_info *fi,
enum fuse_readdir_flags flags)
{
if (strcmp(path, "/") != 0)
return -ENOENT;
filler(buf, ".", NULL, 0, 0);
filler(buf, "..", NULL, 0, 0);
filler(buf, "hello.txt", NULL, 0, 0);
return 0;
}
// Читает содержимое файла
static int myfs_read(const char *path, char *buf, size_t size,
off_t offset, struct fuse_file_info *fi)
{
const char *content = "Hello, FUSE!\n";
size_t len = strlen(content);
if (strcmp(path, "/hello.txt") != 0)
return -ENOENT;
if (offset >= (off_t)len) return 0;
if (offset + size > len) size = len - offset;
memcpy(buf, content + offset, size);
return size;
}
// Регистрация колбэков
static const struct fuse_operations myfs_ops = {
.getattr = myfs_getattr,
.readdir = myfs_readdir,
.read = myfs_read,
};
int main(int argc, char *argv[])
{
return fuse_main(argc, argv, &myfs_ops, NULL);
}
Компиляция и монтирование этой файловой системы:
gcc -Wall myfs.c $(pkg-config fuse3 --cflags --libs) -o myfs
mkdir /tmp/myfuse
./myfs /tmp/myfuse
ls /tmp/myfuse
# hello.txt
cat /tmp/myfuse/hello.txt
# Hello, FUSE!
Всего три функции - и файловая система существует. Команды ls и cat работают с ней через стандартные системные вызовы, ничего не зная о том, что данные генерируются на лету в пользовательском коде. Любая другая программа, не написанная с учётом FUSE, тоже будет работать прозрачно - в этом и состоит ценность интеграции через VFS.
Как работает sshfs и монтирование облачных хранилищ
sshfs - один из самых показательных примеров того, что становится возможным с FUSE. Он монтирует удалённую директорию по SSH так, что обычные команды - cp, grep, vim - работают с удалёнными файлами как с локальными. Никакого специального клиента, никаких особых протоколов - только SFTP поверх SSH и FUSE как мост в файловую систему.
Установка и монтирование:
apt install sshfs -y
# Создать точку монтирования
mkdir ~/remote-server
# Монтировать удалённую директорию
sshfs Адрес электронной почты защищен от спам-ботов. Для просмотра адреса в браузере должен быть включен Javascript. :/var/data ~/remote-server
# Работать с файлами как с локальными
ls ~/remote-server
grep -r "error" ~/remote-server/logs/
# Размонтировать
fusermount3 -u ~/remote-server
Каждая файловая операция в этой конфигурации транслируется в SFTP-команду: ls вызывает SFTP readdir, cat - SFTP read, cp - последовательность SFTP open/read/close. Сетевая задержка накладывается на уже имеющиеся переключения контекста FUSE - именно поэтому производительность sshfs заметно уступает локальным файловым системам, но для большинства задач это совершенно неважно.
Для монтирования Google Drive через FUSE используется rclone - утилита, поддерживающая десятки облачных провайдеров через единый FUSE-интерфейс:
apt install rclone -y
# Настроить подключение к Google Drive
rclone config
# Следовать интерактивному мастеру, выбрать Google Drive
# Создать точку монтирования и смонтировать
mkdir ~/gdrive
rclone mount gdrive: ~/gdrive --daemon \
--vfs-cache-mode writes \
--dir-cache-time 10m
Параметр --vfs-cache-mode writes включает кэширование записи - файл сначала накапливается в локальном кэше, а потом загружается в облако целиком. Без этого параметра каждый write() транслировался бы в HTTP-запрос к Google Drive, что сделало бы запись файлов неработоспособно медленной.
Два режима API - высокоуровневый и низкоуровневый
libfuse предлагает два API: высокоуровневый синхронный и низкоуровневый асинхронный. В обоих случаях входящие запросы от ядра передаются основной программе через колбэки. При использовании высокоуровневого API колбэки могут работать с именами файлов и путями вместо inode, а обработка запроса завершается, когда функция колбэка возвращает результат.
Высокоуровневый API - это то, что использовалось в примере выше. Он удобен: пути уже разрешены, inode не нужны, функции вызываются синхронно. Цена удобства - невозможность асинхронной обработки нескольких запросов одновременно. Каждый запрос обрабатывается до конца, прежде чем начнётся следующий.
Низкоуровневый API работает с inode напрямую и поддерживает асинхронную обработку. Структура fuse_lowlevel_ops аналогична struct inode_operations в ядерных файловых системах. Это правильный выбор для высоконагруженных файловых систем, где несколько операций должны выполняться параллельно - например, для сетевых файловых систем с пулом потоков под каждый запрос.
Разница в поведении хорошо видна при монтировании с флагом отладки:
# Запустить FUSE-файловую систему в режиме переднего плана с отладкой
./myfs /tmp/myfuse -f -d
# В другом терминале выполнить операцию
ls /tmp/myfuse
В режиме -d libfuse выводит каждый запрос от ядра и каждый ответ обратно - это исчерпывающий способ понять, какие именно системные вызовы превращаются в какие FUSE-операции и сколько времени занимает каждая.
Кэширование в FUSE и опции монтирования
Без кэширования каждый stat(), каждый read() вызывал бы колбэк в пользовательском пространстве. Для облачных файловых систем это означало бы HTTP-запрос на каждую операцию - совершенно неприемлемо. FUSE решает это через page cache ядра и атрибутный кэш.
По умолчанию ядро кэширует данные файлов через стандартный page cache и атрибуты файлов на короткое время. Опция монтирования kernel_cache полностью отключает инвалидацию кэша при открытии файла - данные берутся из page cache без обращения к демону. Это подходит только для файловых систем, где данные никогда не меняются извне через другой путь доступа.
Опция direct_io отключает page cache полностью - каждое чтение идёт напрямую в колбэк. Это нужно там, где файловая система сама управляет когерентностью данных или где данные нельзя кэшировать по природе их источника.
Смонтировать с явными параметрами кэширования:
# Монтирование sshfs с настройкой кэша атрибутов и кэша директорий
sshfs user@host:/data ~/remote \
-o attr_timeout=60 \
-o entry_timeout=60 \
-o kernel_cache \
-o reconnect
# Проверить параметры монтирования
cat /proc/mounts | grep fuse
Параметр reconnect в sshfs особенно важен для долгоживущих монтирований: при разрыве SSH-соединения демон автоматически переподключается, не требуя ручного размонтирования и повторного монтирования.
Производительность под микроскопом - измерение реальных издержек
Разговор о "медленно, но гибко" становится предметным, когда цифры конкретны. Измерить накладные расходы FUSE относительно нативной файловой системы помогает fio - гибкий инструмент нагрузочного тестирования I/O:
# Тест последовательного чтения на нативной ext4
fio --name=read-test --rw=read --bs=1M --size=1G \
--numjobs=1 --ioengine=sync \
--filename=/tmp/testfile --output-format=terse
# Тот же тест на FUSE-файловой системе (passthrough - простейший случай)
fio --name=read-test --rw=read --bs=1M --size=1G \
--numjobs=1 --ioengine=sync \
--filename=/tmp/myfuse/testfile --output-format=terse
На типичном сервере разница в последовательном чтении между passthrough FUSE и нативной файловой системой составляет 15-30% даже при простейшей реализации без какой-либо логики в колбэке - только накладные расходы на переключение контекста. При операциях с мелкими блоками (4 КБ) разрыв увеличивается до двух-трёх раз: переключения контекста становятся доминирующей стоимостью по сравнению с ничтожным объёмом передаваемых данных.
Именно поэтому FUSE не используют там, где нужна максимальная производительность I/O - для баз данных на локальных NVMe или высоконагруженных файловых серверов. Но для задач, где сетевая задержка или логика трансформации данных в любом случае доминирует над ценой переключения контекста, FUSE - разумный выбор.
FUSE перевернул представление о том, кто может писать файловые системы. То, что раньше требовало глубокого знания ядра и бесстрашия перед printk, теперь доступно любому разработчику со знанием libfuse и пониманием модели колбэков. Google Drive в виде директории в файловой системе, прозрачное шифрование через EncFS, доступ к S3-бакету как к локальной папке - всё это работает именно потому, что кто-то написал несколько функций в структуре fuse_operations и позволил VFS сделать всё остальное.