Пишем файловую сиcтему на Go и FUSE

28 minute read

Перевод статьи "Writing file systems in Go with FUSE" от Tommi Virtanen.

Мотивация

Некоторое время назад мне понадобилось улучшить используемое хранилище. Основная причина - это необходимость удобной синхронизации. Кроме того, было и другие причины. Мне была необходима файловая система, которая сочетает в себе лучшее из трех стихий: локальных файлов, сетевой файловой системы и синхронизации файлов. Все это реализовалось в виде проекта Bazil, названный в честь базиллион байтов.

Для создания Bazil мне был необходим инструмент для простого написания файловых систем на Go. Таким образом, на свет появился пакет bazil.org/fuse.

Сегодня, в качестве примера, мы напишем Go приложение, которое позволит использовать zip архивы как файловую систему:

$ unzip -v archive.zip
Archive:  archive.zip
 Length   Method    Size  Cmpr    Date    Time   CRC-32   Name
--------  ------  ------- ---- ---------- ----- --------  ----
       0  Stored        0   0% 2014-12-11 04:03 00000000  buried/
       0  Stored        0   0% 2014-12-11 04:03 00000000  buried/deep/
       5  Stored        5   0% 2014-12-11 04:03 2efcceec  buried/deep/loot
      13  Stored       13   0% 2014-12-11 04:03 f4247453  greeting
--------          -------  ---                            -------
      18               18   0%                            4 files
$ zipfs archive.zip mnt &
$ tree mnt
mnt
├── buried
│   └── deep
│       └── loot
└── greeting

2 directories, 2 files
$ cat mnt/greeting
hello, world

FUSE

FUSE (Filesystem In Userpace) это файловая система ядра Linux, которая работает поверх запросов к файловым дескрипторам в пространстве пользователя. Исторически, все это обрабатывается с помощью сишной библиотеки с таким же именем. Но FUSE - это всего лишь протокол. Сейчас его поддержка реализована в OS X, FreeBSD и OpenBSD.

bazil.org/fuse это реализация протокола на чистом Go.

Структура файловой системы Unix

Файловая система в никсах состоит из inodes (“index nodes”). Это файлы, каталоги, и т.д. Каталоги содержат записи (dirent от directory entries) которые указывают на вложенные ноды. Запись в каталоге идентифицируется по имени и содержит очень мало метаинформации. Через ноды можно добраться до дополнительной метаинформации(включая контроль доступа) и до содержимого самого файла.

Открытые файлы идентифицируются с помощью файловых дескрипторов, который являются безопасными ссылками на объекты ядра (handles или ручки).

Go API

Наша библиотека FUSE разделена на две части. Низкоуровневая часть bazil.org/fuse. И более высокоуровневая часть bazil.org/fuse/fs в которой есть методы для отслеживания времени жизни объектов, их состояний.

Каждая файловая система имеет корневую запись. В интерфейсе fs.FS есть метод Root, который возвращает fs.Node.

Для доступа к файлу (просмотр метаданных, открытие, и т.д.) ядро будет запрашивать его по имени, отправляя fuse.LookupRequest к FUSE серверу. Этот запрос обрабатывается методом Lookup родительской fs.Node. Метод возвращает fs.Node, результат кешируется в ядре и пересчитываются ссылки. Удаление кеша инициирует ForgetRequest и, когда количество ссылок становится равным нулю, вызывается Forget.

Файлы переименовываются с помощью Rename, удаляются с помощью Remove и так далее.

Обработчики навешиваются, для примера, в момент открытия файла. Открытия любого существующего файла инициирует OpenRequest который обрабатывается с помощью Open. Все методы, которые создают новые обработчики, возвращают Handle. Обработчики закрываются комбинацией Flush и Release.

Если Open не определен, то как Handle используется fs.Node. Это, как правило, работает нормально, но только для неизменяемых файлов.

Для чтение из Handle используется Read, для записи - Write. И кроме всего этого есть дополнительные данные, аналогичные io.ReaderAt и io.WriterAt. Обратите внимание, что информация о размере файла изменяется только через Setattr и не зависит от вызовов Write. Нужно следить, чтобы Attr возвращал корректный Size.

Листинг в каталоге реализуется благодаря чтению дескриптора каталога, который, на самом деле, тоже файл. После прочтения каталога, вместо содержимого файла, возвращается список(directory entries). Метод ReadDir реализует чуть более высокоуровневое API и возвращает слайс.

Написание файловой системы требует неплохого понимания структур данных ядра и принципов изменения их состояний, на абстрактном уровне. Но Go позволяется обойтись без лишних усложнений. Так что давайте погрузимся в код.

Zipfs

Для примера напишем простую файловую систему, которая позволит просматривать содержимое zip архива.

Исходники готовой программы доступны тут: https://github.com/bazillion/zipfs

Каркас

Начнем с каркаса программы, которая парсит аргументы командной строки:

package main

import (
    "archive/zip"
    "flag"
    "fmt"
    "io"
    "log"
    "os"
    "path/filepath"
    "strings"

    "bazil.org/fuse"
    "bazil.org/fuse/fs"
)

// Мы должны учитывать, что zip файл также содержит записи для каталогов.

var progName = filepath.Base(os.Args[0])

func usage() {
    fmt.Fprintf(os.Stderr, "Usage of %s:\n", progName)
    fmt.Fprintf(os.Stderr, "  %s ZIP MOUNTPOINT\n", progName)
    flag.PrintDefaults()
}

func main() {
    log.SetFlags(0)
    log.SetPrefix(progName + ": ")

    flag.Usage = usage
    flag.Parse()

    if flag.NArg() != 2 {
        usage()
        os.Exit(2)
    }
    path := flag.Arg(0)
    mountpoint := flag.Arg(1)
    if err := mount(path, mountpoint); err != nil {
        log.Fatal(err)
    }
}

Монтирование это несколько громоздкое действо, еще и потому что монтирование в Linux отличается от монтирования в OSXFUSE. И это может быть причиной некоторых ошибок.

func mount(path, mountpoint string) error {
    archive, err := zip.OpenReader(path)
    if err != nil {
        return err
    }
    defer archive.Close()

    c, err := fuse.Mount(mountpoint)
    if err != nil {
        return err
    }
    defer c.Close()

    filesys := &FS{
        archive: &archive.Reader,
    }
    if err := fs.Serve(c, filesys); err != nil {
        return err
    }

    // проверяем ошибки при монтировании
    <-c.Ready
    if err := c.MountError; err != nil {
        return err
    }

    return nil
}

Файловая система

Фактически, эта файловая система работает через указатель на zip архив:

type FS struct {
    archive *zip.Reader
}

И для начала нам нужно указать Root метод:

var _ fs.FS = (*FS)(nil)

func (f *FS) Root() (fs.Node, fuse.Error) {
    n := &Dir{
        archive: f.archive,
    }
    return n, nil
}

Каталоги

Zip файлы содержат списки файлов. Кроме того, типичные zip-архивы содержать записи каталогов, которые заканчиваются на слеш. Мы вернемся к этому чуть позже.

Определим наш тип Dir и реализуем обязательный метод Attr. Будем использовать *zip.File для работы с метаданными каталога.

type Dir struct {
    archive *zip.Reader
    // для корневого каталога, который не имеет ни
    // каких зипов, это будет nil
    file *zip.File
}

var _ fs.Node = (*Dir)(nil)

func zipAttr(f *zip.File) fuse.Attr {
    return fuse.Attr{
        Size:   f.UncompressedSize64,
        Mode:   f.Mode(),
        Mtime:  f.ModTime(),
        Ctime:  f.ModTime(),
        Crtime: f.ModTime(),
    }
}

func (d *Dir) Attr() fuse.Attr {
    if d.file == nil {
        // корневой каталог
        return fuse.Attr{Mode: os.ModeDir | 0755}
    }
    return zipAttr(d.file)
}

Просмотр записей в каталоге

Чтобы наша файловая система могла делать что то полезное, нам нужно реализовать поиск файла по имени. Мы просто будем перебирать все записи в zip архиве, сравнивая путь:

var _ = fs.NodeRequestLookuper(&Dir{})

func (d *Dir) Lookup(req *fuse.LookupRequest, 
                resp *fuse.LookupResponse, 
                intr fs.Intr) (fs.Node, fuse.Error) {

    path := req.Name
    if d.file != nil {
        path = d.file.Name + path
    }
    for _, f := range d.archive.File {
        switch {
        case f.Name == path:
            child := &File{
                file: f,
            }
            return child, nil
        case f.Name[:len(f.Name)-1] == path && f.Name[len(f.Name)-1] == '/':
            child := &Dir{
                archive: d.archive,
                file:    f,
            }
            return child, nil
        }
    }
    return nil, fuse.ENOENT
}

Файлы

Наш метод Lookup возвращает типы File, если запись не заканчивается на слеш. Определим тип File, который использует туже вспомогательную функцию zipAttr:

type File struct {
    file *zip.File
}

var _ fs.Node = (*File)(nil)

func (f *File) Attr() fuse.Attr {
    return zipAttr(f.file)
}

Но файлы не нужны, если их нельзя открыть:

var _ = fs.NodeOpener(&File{})

func (f *File) Open(req *fuse.OpenRequest, 
                resp *fuse.OpenResponse,
                intr fs.Intr) (fs.Handle, fuse.Error) {

    r, err := f.file.Open()
    if err != nil {
        return nil, err
    }
    // индивидуальный записи в архиве
    resp.Flags |= fuse.OpenNonSeekable
    return &FileHandle{r: r}, nil
}

Обработчики

type FileHandle struct {
    r io.ReadCloser
}

var _ fs.Handle = (*FileHandle)(nil)

Мы реализуем "открытый файл" с помощью обработчиков. В нашем случае это просто обертка на archive/zip, но в других случаях это может быть *os.File, сетевое соединение и многое другое. И нужно не забывать закрывать их:

var _ fs.HandleReleaser = (*FileHandle)(nil)

func (fh *FileHandle) Release(req *fuse.ReleaseRequest, intr fs.Intr) fuse.Error {
    return fh.r.Close()
}

Давайте напишем реальный обработчик операции Read:

var _ = fs.HandleReader(&FileHandle{})

func (fh *FileHandle) Read(req *fuse.ReadRequest, 
                resp *fuse.ReadResponse, intr fs.Intr) fuse.Error {

    // В такой реализации мы не учитываем Offset для определения, где
    // закончилось прошлое чтение. Для этого нам пришлось бы трекать эту
    // операции. Ядро сделает все за нас основываясь
    // на флаге fuse.OpenNonSeekable.
    buf := make([]byte, req.Size)
    n, err := fh.r.Read(buf)
    resp.Data = buf[:n]
    return err
}

Чтение директорий

В таком виде мы имеем доступ к нашим файлам через cat и аналогичный способы. Но для этого нам нужно знать их имена. Давайте добавим поддержку ReadDir:

var _ = fs.HandleReadDirer(&Dir{})

func (d *Dir) ReadDir(intr fs.Intr) ([]fuse.Dirent, fuse.Error) {
    prefix := ""
    if d.file != nil {
        prefix = d.file.Name
    }

    var res []fuse.Dirent
    for _, f := range d.archive.File {
        if !strings.HasPrefix(f.Name, prefix) {
            continue
        }
        name := f.Name[len(prefix):]
        if name == "" {
            // сама директория, не вложения
            continue
        }
        if strings.ContainsRune(name[:len(name)-1], '/') {
            // есть слеш в середине -> находится во вложенном каталоге
            continue
        }
        var de fuse.Dirent
        if name[len(name)-1] == '/' {
            // каталог
            name = name[:len(name)-1]
            de.Type = fuse.DT_Dir
        }
        de.Name = name
        res = append(res, de)
    }
    return res, nil
}

Тестируем zipfs

Подготавливаем zip файл:

$ mkdir -p data/buried/deep
$ echo hello, world >data/greeting
$ echo gold >data/buried/deep/loot
$ ( cd data && zip -r -q ../archive.zip . )

Монтируем архив:

$ mkdir mnt
$ zipfs archive.zip mnt &

Просматриваем записи в каталоге:

$ ls -ld mnt/greeting
-rw-r--r-- 1 root root 13 Dec 11  2014 mnt/greeting
$ ls -ld mnt/buried
drwxr-xr-x 1 root root 0 Dec 11  2014 mnt/buried

Читаем содержимое файла:

$ cat mnt/greeting
hello, world
$ cat mnt/buried/deep/loot
gold

Чтение каталога ("total 0" это не правильно, но пока не значительно):

$ ls -l mnt
total 0
drwxr-xr-x 1 root root  0 Dec 11  2014 buried
-rw-r--r-- 1 root root 13 Dec 11  2014 greeting
$ ls -l mnt/buried
total 0
drwxr-xr-x 1 root root 0 Dec 11  2014 deep

Размонтирование (для OS X используйте umount mnt):

$ fusermount -u mnt

B на этом все. Если хотите продолжения и больше примеров, посмотрите https://github.com/bazillion/bolt-mount (скринкаст в дополнение к коду) и другие проекты использующие fuse.

Почитать

  • Bazil распределенная файловая система. Позволяет шарить файлы на всех ваших компьютерах, включая облачные сервисы.
  • FUSE модуль для ядер UNIX-подобных операционных систем. Свободное программное обеспечение с открытым исходным кодом.
  • Сишная библиотека FUSE, реализующая поддержку протокола.
  • bazil.org/fuse Go библиотека для написания файловых систем. Смотрите godoc для fuse и fuse/fs
  • OSXFUSE это реализация ядра FUSE для OS X.
  • bolt-mount это более обширный пример файловой системы, включая операции записи. Также, можете посмотреть скринкаст.
  • Написание файловых систем на Go чуть более подробное описание FUSE.
  • Вопросы по FUSE можете задавать в bazil-dev Google Group или на IRC канале #go-nuts на irc.freenode.net.