
В этой статье подробно разбирается создание пользовательской файловой системы с помощью 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
.
Ломаем и чинить: типичные ошибки
EBUSY при монтировании — не размонтировали старый инстанс.
Проблемы с правами — проверяйте опции монтирования:
fusermount -u /mnt/fusefs
.Падения из‑за неправильного
Attr
— следите, чтобы поля структурыfuse.Attr
были корректными.
Расширение: снапшоты и снапшот‑директории
Можно хранить снимки состояния:
type SnapshotDir struct {
parent *Dir
snap []byte // сериализованный дамп
}
Выгружать образ в файл и потом монтировать его как виртуальное устройство.
Резюме по опыту
Первые полдня я пытался переписать пример с C, потом забросил и сделал на Go ещё за 2 часа. Главное — не бояться экспериментов и не лезть сразу в оптимизацию без профилировщика.
bBars
Когда я об этом задумывался, меня остановило только отсутствие хорошей идеи, чего бы такого взять необычного в качестве blkdev. Потому что найденный мною пример был как раз просто областью оперативы, и дальше энтузиазм угас. S3fs к тому моменту уже давно запилили, всё более старое есть и подавно.
В качестве мануала заметка норм, но уровень бы я всё-таки сбавил со Сложного хотя бы на Средний: нет тут таких уж прям сложностей всё-таки