FUSE + Go: ковка собственной виртуальной файловой системы на блочном устройстве

от автора

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

Введение
Давно хотел понять, как сделать “файловую систему в файле” или на блочном устройстве, чтобы потом подключить её к любому Linux-серверу. Оказалось, что комбинация FUSE и Go — отличный вариант для быстрой прототипировки без костылей на C. В этой статье я расскажу о своём опыте, забавных факапах и главных открытиях на пути к рабочей системе.

Почему FUSE и Go — идеальный дуэт

FUSE (Filesystem in Userspace) позволяет запускать код файловой системы в пространстве пользователя, не лезя в ядро. Go же приносит удобную модель конкурентности, сборщик мусора и понятный синтаксис. Вместе они дают возможность писать надёжный код в сотни строк, а не в тысячи.

Подготовка окружения и зависимости

На Ubuntu/Debian всё просто:

sudo apt update sudo apt install golang-go libfuse-dev export GO111MODULE=on mkdir -p ~/go/src/fusefs && cd ~/go/src/fusefs go mod init github.com/you/fusefs go get bazil.org/fuse go get bazil.org/fuse/fs

Здесь bazil.org/fuse — наиболее “гуёвый” биндинг к libfuse.

Черновой набросок проекта

Создаём файл main.go с минимальным кодом:

// main.go package main  import (     "bazil.org/fuse"     "bazil.org/fuse/fs"     "context"     "log" )  func main() {     conn, err := fuse.Mount(         "/mnt/fusefs",         fuse.FSName("fusefs"),         fuse.Subtype("customfs"),         fuse.LocalVolume(),         fuse.VolumeName("GoFUSE"),     )     if err != nil {         log.Fatal(err)     }     defer conn.Close()      err = fs.Serve(conn, FS{})     if err != nil {         log.Fatal(err)     } }

Здесь FS{} — наш корневой узел, который реализует интерфейс fs.FS.

Реализация корневого узла и каталогов

Немного магии — делаем корневой каталог:

type FS struct{}  func (FS) Root() (fs.Node, error) {     return &Dir{         entries: map[string]fs.Node{             "hello.txt": &File{data: []byte("Привет, FUSE + Go!\n")},         },     }, nil }  type Dir struct {     entries map[string]fs.Node }  func (d *Dir) Attr(ctx context.Context, a *fuse.Attr) error {     a.Mode = os.ModeDir | 0o755     return nil }  func (d *Dir) Lookup(ctx context.Context, name string) (fs.Node, error) {     if node, ok := d.entries[name]; ok {         return node, nil     }     return nil, fuse.ENOENT }  func (d *Dir) ReadDirAll(ctx context.Context) ([]fuse.Dirent, error) {     var list []fuse.Dirent     for name := range d.entries {         list = append(list, fuse.Dirent{Name: name})     }     return list, nil }

Таким образом мы описали каталог с одной текстовой “заглушкой”.

Реализация файловых операций (чтение/запись)

Добавим в File методы чтения:

type File struct {     data []byte     mu   sync.Mutex }  func (f *File) Attr(ctx context.Context, a *fuse.Attr) error {     a.Mode = 0o644     a.Size = uint64(len(f.data))     return nil }  func (f *File) ReadAll(ctx context.Context) ([]byte, error) {     f.mu.Lock()     defer f.mu.Unlock()     return f.data, nil }

Для записи надо внедрить интерфейс fs.HandleWriter:

func (f *File) Write(ctx context.Context, req *fuse.WriteRequest, resp *fuse.WriteResponse) error {     f.mu.Lock()     defer f.mu.Unlock()     end := int(req.Offset) + len(req.Data)     if end > len(f.data) {         newData := make([]byte, end)         copy(newData, f.data)         f.data = newData     }     copy(f.data[req.Offset:], req.Data)     resp.Size = len(req.Data)     return nil }

Теперь после монтирования можно echo "test" > /mnt/fusefs/hello.txt и читать обратно.

Обработка метаданных и времён

Часто нужно указывать Atime, Mtime, Ctime. Добавим их:

func (f *File) Attr(ctx context.Context, a *fuse.Attr) error {     a.Mode = 0o644     a.Size = uint64(len(f.data))     a.Atime = time.Now()     a.Mtime = time.Now()     a.Ctime = time.Now()     return nil }

Но по-честному стоит хранить времена в структуре и обновлять их при записи.

Параллельный доступ и конкурентность

Go отлично управляет конкурентными запросами, но нужно не забывать про мьютексы:

type SafeFile struct {     data []byte     mu   sync.RWMutex }  // В ReadAll используем RLock, а в Write — Lock.

Без этого при стресс-тестах получим расслоение данных или паники.

Производительность: буферизация и кеш

FUSE по умолчанию делает много системных вызовов. Чтобы ускорить:

  • Реализовать блоки и кешировать их в памяти.

  • Использовать fuse.WritebackCache() при монтировании.

  • Оптимизировать ReadAll на большие файлы, отпочковывая req.Offset и req.Size.

Ломаем и чинить: типичные ошибки

  1. EBUSY при монтировании — не размонтировали старый инстанс.

  2. Проблемы с правами — проверяйте опции монтирования: fusermount -u /mnt/fusefs.

  3. Падения из‑за неправильного Attr — следите, чтобы поля структуры fuse.Attr были корректными.

Расширение: снапшоты и снапшот‑директории

Можно хранить снимки состояния:

type SnapshotDir struct {     parent *Dir     snap   []byte // сериализованный дамп }

Выгружать образ в файл и потом монтировать его как виртуальное устройство.

Резюме по опыту

Первые полдня я пытался переписать пример с C, потом забросил и сделал на Go ещё за 2 часа. Главное — не бояться экспериментов и не лезть сразу в оптимизацию без профилировщика.


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


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *