В этой статье подробно разбирается создание пользовательской файловой системы с помощью 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 часа. Главное — не бояться экспериментов и не лезть сразу в оптимизацию без профилировщика.

Комментарии (1)


  1. bBars
    04.08.2025 01:48

    Когда я об этом задумывался, меня остановило только отсутствие хорошей идеи, чего бы такого взять необычного в качестве blkdev. Потому что найденный мною пример был как раз просто областью оперативы, и дальше энтузиазм угас. S3fs к тому моменту уже давно запилили, всё более старое есть и подавно.

    В качестве мануала заметка норм, но уровень бы я всё-таки сбавил со Сложного хотя бы на Средний: нет тут таких уж прям сложностей всё-таки