Rust Advent Calendar 2019 10日目の記事です。

本記事では、拙作の FUSE ライブラリである polyfuse を紹介します。

FUSE の概略

FUSE (Filesystem in Userspace) とは、ユーザ空間でファイルシステムを実装するための仕組みです。 本来ならカーネルモジュールとして実装する必要があるファイルシステムを(ユーザ空間で動作する)実行ファイルとして動作させることで、ファイルシステムを簡単に作成・配布することが出来るようになります。 同様の仕組みとして、NetBSD の puffs や、 WSL が Windows 側に rootfs を公開するためのプロトコルとして採用したことでも知られる 9P などがあります。 FUSE を用いたファイルシステムの実装として、SSH 経由でネットワーク越しにディレクトリをマウントする SSHFS などが知られています。 FUSE については下の記事が参考になるかもしれません。

FUSE のアーキテクチャは下図のようになります。 FUSE はカーネル空間で動作し VFS との橋渡しを行う FUSE カーネルモジュール(図中右下にある緑色の四角)と、ユーザ空間で動作し実質的な処理を行う FUSE ファイルシステムデーモン(図中右上の点線で囲まれた箇所)とが連携することでファイルシステムとしての動作を実現します。 FUSE カーネルモジュールとの接続の確立は、モジュールロード時に登録されるキャラクタデバイス /dev/fuse をオープンし mount システムコールを介してパラメータを指定することで行います(実際には、mount を呼び出す権限を持たない非特権ユーザに対応するために後述する回避策が取られます)。

FUSE structure
FUSE structure.svg @Sven (Licensed under CC BY-SA 3.0)

プロセス(図中左上の四角)がシステムコールを呼び出す、カーネルがページキャッシュやディレクトリエントリキャッシュを更新するなどの理由で VFS に要求が発行されると、FUSE カーネルモジュールは FUSE ファイルシステムに対するリクエストを作成し、それをキューの末尾に追加します。 FUSE ファイルシステムデーモンは、キューに蓄えられたリクエストを先ほどオープンした /dev/fuseread/readv することで逐次取り出し、それらに対する処理を実行します。 リクエストの処理結果は /dev/fuse に対し write/writev を呼び出すことでカーネルへと返され、その後の処理をFUSE カーネルモジュールが継続して行い、ファイルシステムとしての動作を完了します。

キャッシュやバックグラウンドでの動作などがが絡んでくるので、実際の挙動はもう少し複雑なものになります。 下の資料に FUSE カーネルモジュールの内部構造についての説明があるので、FUSE の内部実装に興味のある人は目を通すと良いかもしれません。

Rust の FUSE 事情

Rust で FUSE によるファイルシステム実装を行う場合、次に挙げる選択肢が上げられます。

FUSE プロトコルを自力で実装する

原理的には、FUSE カーネルモジュールとの接続を確立し、その通信路に対する read/write さえ出来れば FUSE ファイルシステムの実装は可能です。 実装するファイルシステムの規模にもよりますが、移植性云々を無視するのであればカーネルとの通信部をフルスクラッチで実装してしまうのも一つの手だと思います(ほんとに?)。 FUSE のプロトコルに関する詳細は、(断片的ですが)以下の解説記事が参考になるかもしれません…

libfuse を FFI 経由で呼び出す

プロトコル部分の実装は車輪の再開発以外の何物でもないため、通常は、FUSE を扱うためのライブラリを併用し、ファイルシステムとして動作するために必要な処理のみを実装します。 libfuse は C 言語による FUSE ライブラリの参照実装であり、多くの言語の FUSE ライブラリは libfuse を FFI 経由で呼び出すラッパーとして FUSE の実装を実現しています。 Rust で libfuse をバインディングした FUSE ライブラリは(簡単に検索した限り)存在しないため、下の記事の例のように bindgen を併用するなどして直接 C の関数を呼び出す必要があります(以前にRust 向けの libfuse バインディングを作ろうとした時期があったのですが、非同期処理への対応が難しく旨味がなさそうだったので開発を断念しました)。

fuse-rs を使用する

fuse-rs(旧 rust-fuse)は以前から存在していた Rust の FUSE ライブラリです。 libfuse を単純にバインディングするのではなく FUSE カーネルモジュールとの通信部を Rust で再実装することで、FFI によるオーバヘッドが生じない設計になっています(ただし、カーネルとの接続の確立に libfuse の API を使用しているためリンクが必要)。 libfuse における lowlevel API 相当の機能が実装されています。 fuse-rs については公式リポジトリや下記の記事を併せてご参照ください。

polyfuse

polyfuse は、fuse-rs と同様に libfuse を介さずに Rust の機能をフルで活用した FUSE によるファイルシステムを実装するためのライブラリです。 後発のライブラリということもあり、fuse-rs との差別化を図るため次のような点に重視して開発を進めています。

  • async/.await への完全対応
  • リクエスト処理の多重化
  • ファイルシステムからカーネルへの通知をサポート
  • libfuse へのリンクが不要

Hello, polyfuse

手始めに、何もしないファイルシステムを作り、動作確認を行います。 まずはじめに、ファイルシステムのパッケージを作成します。

$ cargo new --bin hello

作成したパッケージに依存関係を追加していきます。 ファイルシステムを動かすためには、使用する非同期ランタイムに対応したサーバの実装を用いる必要があります。 現時点では tokio サポートのみを提供していますが、将来的には async-std への対応を追加したいと考えています(tokio::io::PollEvented 相当の API がないため現時点では対応できない)。

[dependencies]
polyfuse = "0.2"
polyfuse-tokio = "0.1"

anyhow = "1"
tokio = { version = "0.2", features = [ "full" ] }
tracing = "0.1"
tracing-subscriber = "0.1"

rust-fuse における最小構成のコードに相当するサンプルの実装は次のようになります。 ファイルシステム本体の実装するトレイト Filesystemasync-trait を用いて定義されているので本来であれば実装側にも #[async_trait] が必要になりますが、今回はデフォルト実装を用いるため不要です。 ログ出力には tracing を用いているため、env_logger ではなくこちらを使用します。

use polyfuse::Filesystem;

struct EmptyFS;

impl<T> Filesystem<T> for EmptyFS {}

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    tracing_subscriber::fmt::init();

    let mountpoint = std::env::args_os()
        .nth(1)
        .map(std::path::PathBuf::from)
        .ok_or_else(|| anyhow::anyhow!("missing mountpoint"))?;
    anyhow::ensure!(
        mountpoint.is_dir(),
        "the mountpoint must be a directory",
    );

    polyfuse_tokio::mount(EmptyFS, &mountpoint, &[]).await?;
    Ok(())
}

適当な場所に空のディレクトリを作成し、その場所をマウントポイントにしてファイルシステムを起動します。例えば、/tmp/emptyfs をマウントポイントにする場合は次のように実行します。

$ mkdir /tmp/emptyfs
$ cargo run /tmp/emptyfs

ファイルシステムを実行するためには fuse カーネルモジュールをロードする必要があります。 有効化されていない場合は、次のようにモジュールをあらかじめロードしておいてください。 正常にロードされていれば、/dev/fuse というキャラクタデバイスが追加されているはずです。 また、コンテナ内でテストする場合はホスト側の /dev/fuse がコンテナ側にマウントされている必要があります。

$ sudo modprobe fuse

ファイルシステムの動作には fusermount という setuid ビットが付与された実行ファイルが必要になります。 この実行ファイルは適切な権限(Linux では CAP_SYS_ADMIN ケーパビリティ)を持たない非特権ユーザが mount システムコールを呼び出すことなくファイルシステムのマウントを行うために用いられ、通常はディストリビューションが提供する fuse パッケージに同梱されています。 例えば Arch Linux の場合、次のようにしてパッケージをインストールすることができます(fuse3 ではない点に注意)。

$ sudo pacman -S fuse2 # Arch Linux

別途シェルを開き、先ほど指定したマウントポイントに対して適当な操作をしてみます。 現時点ではファイルシステムとして必要な機能が何も実装されていないので、次のように ENOSYS に相当するエラーメッセージが表示されるはずです。

$ stat /tmp/emptyfs
stat: cannot stat '/tmp/emptyfs': Function not implemented

$ ls /tmp/emptyfs
ls: cannot access '/tmp/emptyfs': Function not implemented

プロセスに Ctrl+C を送信するか fusermount -u <mountpoint> を実行することでファイルシステムを終了することが出来ます。

このままだと味気ないので、ルートディレクトリ直下にファイルが一つある読み込み専用なファイルシステムを作ってみます。 まず、Filesystem の実装を次のように修正し、カーネルから送られてくるリクエストを処理できるようにします(libcfutures などは適宜 Cargo.toml に追記してください)。

use futures::io::AsyncWrite;
use polyfuse::{Context, FileSystem, Operation};
use std::io;

struct Hello;

#[polyfuse::async_trait]
impl<T> Filesystem<T> for Hello {
    async fn call<W: ?Sized>(&self, cx: &mut Context<'_, W>, op: Operation<'_, T>) -> io::Result<()>
    where
        W: AsyncWrite + Unpin + Send,
        T: Send + 'async_trait,
    {
        match op {
            _ => cx.reply_err(libc::ENOSYS).await,
        }
    }
}

ルートディレクトリに対する statreaddir の処理を記述します。 readdir に対する応答は本来であれば ... を含める必要がありますが、ここでは省略しています。

+use polyfuse::reply::ReplyAttr;

+const ROOT_INO: u64 = 1;

#[polyfuse::async_trait]
impl<T> Filesystem<T> for Hello {
    async fn call<W: ?Sized>(&self, cx: &mut Context<'_, W>, op: Operation<'_, T>) -> io::Result<()>
    where
        W: AsyncWrite + Unpin + Send,
        T: Send + 'async_trait,
    {
        match op {
+           Operation::Getattr(op) => match op.ino() {
+               ROOT_INO => op.reply(cx, ReplyAttr::new(root_attr())).await,
+               _ => cx.reply_err(libc::ENOENT).await,
+           },
+
+           Operation::Readdir(op) => match op.ino() {
+               ROOT_INO => op.reply(cx, &[]).await,
+               _ => cx.reply_err(libc::ENOENT).await,
+           },
+
            _ => cx.reply_err(libc::ENOSYS).await,
        }
    }
}
fn root_attr() -> FileAttr {
    let mut attr = FileAttr::default();
    attr.set_ino(ROOT_INO);
    attr.set_nlink(2);
    attr.set_mode(libc::S_IFDIR | 0o555);
    attr.set_uid(unsafe { libc::getuid() });
    attr.set_gid(unsafe { libc::getgid() });
    attr
}

次のように statls の出力結果が得られる事を確認します。 この時点では readdir の実装が適切ではないため、ls で表示される項目はありません。

$ stat /tmp/hello
  File: /tmp/hello
  Size: 0               Blocks: 0          IO Block: 4096   directory
Device: 42h/66d Inode: 1           Links: 2
Access: (0555/dr-xr-xr-x)  Uid: ( 1000/  archie)   Gid: ( 1000/  archie)
Access: 1970-01-01 09:00:00.000000000 +0900
Modify: 1970-01-01 09:00:00.000000000 +0900
Change: 1970-01-01 09:00:00.000000000 +0900
 Birth: -

$ ls -al /tmp/hello
total 0

次に、ルートディレクトリ直下にファイルを追加し、その内容を読み込めるようにします。 今回は、次のように hello.txt という名前のファイルがルート直下にあると想定します。

/tmp/hello
  └── hello.txt

必要なインポートと定数定義を行います。

use polyfuse::reply::ReplyEntry;
use std::os::unix::ffi::OsStrExt;

const HELLO_FILENAME: &str = "hello.txt";
const HELLO_CONTENT: &str = "Hello, world!\n";
const HELLO_INO: u64 = 2;

まず、Readdir の実装を次のように書き換えます。 先ほどは ROOT_INO に対して空のデータを送信していましたが、今回は op.offset()op.size() の指定する範囲に応じて適切なデータを送信します。 オフセット値の扱いはファイルの追加・削除が絡んでくるとややこしくなるのでここでは説明を省略します(今回は3つのエントリに連続して番号を割り当てています)。

            Operation::Readdir(op) => match op.ino() {
+               ROOT_INO => {
+                   let entries = make_entries();
+                   let mut total_len = 0;
+                   let entries: Vec<&[u8]> = entries
+                       .iter()
+                       .map(|entry| entry.as_ref())
+                       .skip(op.offset() as usize)
+                       .take_while(|entry| {
+                           total_len += entry.len();
+                           total_len <= op.size() as usize
+                       })
+                       .collect();
+                   op.reply_vectored(cx, &entries[..]).await
+               }
+               HELLO_INO => cx.reply_err(libc::ENOTDIR).await,
                _ => cx.reply_err(libc::ENOENT).await,
            },
use polyfuse::DirEntry;

fn make_entries() -> Vec<DirEntry> {
    vec![
        DirEntry::dir(".", ROOT_INO, 1),
        DirEntry::dir("..", ROOT_INO, 2),
        DirEntry::file(HELLO_FILENAME, HELLO_INO, 3),
    ]
}

hello.txt に対して属性を取得出来るように Getattr のコールバックを修正します。 また、Readdir によって返されたエントリの存在を確認するために Lookup リクエストが発行されるのでこれに対するコールバックも実装します。


#[polyfuse::async_trait]
impl<T> Filesystem<T> for Hello {
    async fn call<W: ?Sized>(&self, cx: &mut Context<'_, W>, op: Operation<'_, T>) -> io::Result<()>
    where
        W: AsyncWrite + Unpin + Send,
        T: Send + 'async_trait,
    {
        match op {
            Operation::Getattr(op) => match op.ino() {
                ROOT_INO => op.reply(cx, ReplyAttr::new(root_attr())).await,
+               HELLO_INO => op.reply(cx, ReplyAttr::new(hello_attr())).await,
                _ => cx.reply_err(libc::ENOENT).await,
            },
+
+           Operation::Lookup(op) => match op.parent() {
+               ROOT_INO => {
+                   if op.name().as_bytes() == HELLO_FILENAME.as_bytes() {
+                       op.reply(cx, ReplyEntry::new(hello_attr())).await
+                   } else {
+                       cx.reply_err(libc::ENOENT).await
+                   }
+               }
+               HELLO_INO => cx.reply_err(libc::ENOTDIR).await,
+               _ => cx.reply_err(libc::ENOENT).await,
+           },
+
fn hello_attr() -> FileAttr {
    let mut attr = FileAttr::default();
    attr.set_ino(HELLO_INO);
    attr.set_nlink(1);
    attr.set_size(HELLO_CONTENT.len() as u64);
    attr.set_mode(libc::S_IFREG | 0o444);
    attr.set_uid(unsafe { libc::getuid() });
    attr.set_gid(unsafe { libc::getgid() });
    attr
}

最後に、hello.txt の内容を読み込むために Read の実装を追加します。

+           Operation::Read(op) => match op.ino() {
+               ROOT_INO => cx.reply_err(libc::EISDIR).await,
+               HELLO_INO => {
+                   let offset = op.offset() as usize;
+                   if offset <= HELLO_CONTENT.len() {
+                       let size = op.size() as usize;
+                       let content = &HELLO_CONTENT.as_bytes()[offset..];
+                       let content = &content[..std::cmp::min(content.len(), size)];
+                       op.reply(cx, content).await
+                   } else {
+                       op.reply(cx, &[]).await
+                   }
+               }
+               _ => cx.reply_err(libc::ENOENT).await,
+           },

ファイルシステムを実行し、期待通りの出力結果が得られていることを確認します。

$ ls /tmp/hello
total 0
dr-xr-xr-x  2 archie archie   0 Jan  1  1970 .
drwxrwxrwt 10 root   root   260 Dec  9 16:57 ..
-r--r--r--  1 archie archie  14 Jan  1  1970 hello.txt

$ stat /tmp/hello/hello.txt
  File: /tmp/hello/hello.txt
  Size: 14              Blocks: 0          IO Block: 4096   regular file
Device: 42h/66d Inode: 2           Links: 1
Access: (0444/-r--r--r--)  Uid: ( 1000/  archie)   Gid: ( 1000/  archie)
Access: 1970-01-01 09:00:00.000000000 +0900
Modify: 1970-01-01 09:00:00.000000000 +0900
Change: 1970-01-01 09:00:00.000000000 +0900
 Birth: -

$ cat /tmp/hello/hello.txt
Hello, world!

完成形は次のようになります。

use futures::io::AsyncWrite;
use polyfuse::reply::{ReplyAttr, ReplyEntry};
use polyfuse::{Context, DirEntry, FileAttr, Filesystem, Operation};
use std::io;
use std::os::unix::ffi::OsStrExt;

const ROOT_INO: u64 = 1;

const HELLO_FILENAME: &str = "hello.txt";
const HELLO_CONTENT: &str = "Hello, world!\n";
const HELLO_INO: u64 = 2;

fn root_attr() -> FileAttr {
    let mut attr = FileAttr::default();
    attr.set_ino(ROOT_INO);
    attr.set_nlink(2);
    attr.set_mode(libc::S_IFDIR | 0o555);
    attr.set_uid(unsafe { libc::getuid() });
    attr.set_gid(unsafe { libc::getgid() });
    attr
}

fn hello_attr() -> FileAttr {
    let mut attr = FileAttr::default();
    attr.set_ino(HELLO_INO);
    attr.set_nlink(1);
    attr.set_size(HELLO_CONTENT.len() as u64);
    attr.set_mode(libc::S_IFREG | 0o444);
    attr.set_uid(unsafe { libc::getuid() });
    attr.set_gid(unsafe { libc::getgid() });
    attr
}

fn make_entries() -> Vec<DirEntry> {
    vec![
        DirEntry::dir(".", ROOT_INO, 1),
        DirEntry::dir("..", ROOT_INO, 2),
        DirEntry::file(HELLO_FILENAME, HELLO_INO, 3),
    ]
}

struct Hello;

#[polyfuse::async_trait]
impl<T> Filesystem<T> for Hello {
    async fn call<W: ?Sized>(&self, cx: &mut Context<'_, W>, op: Operation<'_, T>) -> io::Result<()>
    where
        W: AsyncWrite + Unpin + Send,
        T: Send + 'async_trait,
    {
        match op {
            Operation::Lookup(op) => match op.parent() {
                ROOT_INO => {
                    if op.name().as_bytes() == HELLO_FILENAME.as_bytes() {
                        op.reply(cx, ReplyEntry::new(hello_attr())).await
                    } else {
                        cx.reply_err(libc::ENOENT).await
                    }
                }
                HELLO_INO => cx.reply_err(libc::ENOTDIR).await,
                _ => cx.reply_err(libc::ENOENT).await,
            },

            Operation::Getattr(op) => match op.ino() {
                ROOT_INO => op.reply(cx, ReplyAttr::new(root_attr())).await,
                HELLO_INO => op.reply(cx, ReplyAttr::new(hello_attr())).await,
                _ => cx.reply_err(libc::ENOENT).await,
            },

            Operation::Readdir(op) => match op.ino() {
                ROOT_INO => {
                    let entries = make_entries();
                    let mut total_len = 0;
                    let entries: Vec<&[u8]> = entries
                        .iter()
                        .map(|entry| entry.as_ref())
                        .skip(op.offset() as usize)
                        .take_while(|entry| {
                            total_len += entry.len();
                            total_len <= op.size() as usize
                        })
                        .collect();
                    op.reply_vectored(cx, &entries[..]).await
                }
                HELLO_INO => cx.reply_err(libc::ENOTDIR).await,
                _ => cx.reply_err(libc::ENOENT).await,
            },

            Operation::Read(op) => match op.ino() {
                ROOT_INO => cx.reply_err(libc::EISDIR).await,
                HELLO_INO => {
                    let offset = op.offset() as usize;
                    if offset <= HELLO_CONTENT.len() {
                        let size = op.size() as usize;
                        let content = &HELLO_CONTENT.as_bytes()[offset..];
                        let content = &content[..std::cmp::min(content.len(), size)];
                        op.reply(cx, content).await
                    } else {
                        op.reply(cx, &[]).await
                    }
                }
                _ => cx.reply_err(libc::ENOENT).await,
            },

            _ => cx.reply_err(libc::ENOSYS).await,
        }
    }
}

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    tracing_subscriber::fmt::init();

    let mountpoint = std::env::args_os()
        .nth(1)
        .map(std::path::PathBuf::from)
        .ok_or_else(|| anyhow::anyhow!("missing mountpoint"))?;
    anyhow::ensure!(mountpoint.is_dir(), "the mountpoint must be a directory",);

    polyfuse_tokio::mount(Hello, &mountpoint, &[]).await?;
    Ok(())
}

リポジトリの examples/ ディレクトリにサンプルのファイルシステムをいくつか公開しているので、そちらも併せて参照してください(数は少ないですが…)。

今後の課題

本ライブラリの開発は数か月前に始めたものであり、async/.await の安定化に併せてリリースするために急いだ事もあっていくつかの機能に制限を設けています。

FreeBSD/macOS 対応

FUSE 自体は FreeBSD や macOS でも利用することが出来ますが、使用するプロトコルのバージョンの関係上、現在のバージョンではそれらのサポートを(意図的に)除外しています。 特に macOS は、筆者が Apple の開発機を所持していないため現時点で行う予定はありません(ほんとはやりたいと思ってるんですよ。でも自分が使えない環境のサポートってモチベーションが(ry)。 協力者求む…

バッファコピーの効率化

FUSE はユーザ空間でファイルシステムが動作するため、カーネル側からのデータ転送時にバッファコピーの追加コストが生じるという欠点があります。 カーネル空間とユーザ空間の間のコピーを抑えるための手段として splice(2) などが知られており、libfuse ではこのシステムコールを用いて不要なコピーが生じないようにしています。 polyfuse では現時点ではこの問題に対する対処をしていませんが、将来的にはサポートしたいと考えています(fuse-rs との差別化を目指すならばぜひ着手したいとは思っている)。

inode ベースの高レベル API の実装

現時点では fuse-rs と同様に libfuse の lowlevel API 相当の機能しか実装できていませんが、これはファイルシステムを作成するにあたって非常に使いづらいものになっています。 go-fuse などは高レベルの API を提供しているみたいなので、それを参考により使いやすい API を考えていくことが今後の課題になります。

おわりに

駆け足ですが、FUSE ライブラリである polyfuse を紹介しました。

数日前から、実用的なファイルシステムを書くための実験として gist-fs の実装をはじめました。 今後はこいつをちまちま更新しつつ、より使いやすいライブラリにしていこうと思っています。