Blog

2025.12.12

Engineering

社内オブジェクトストレージ向け FUSE デーモンの Rust による実装

Kohei Sugihara

Engineer

本ブログ記事は、2025 年 PFN 夏期インターンで勤務された岡村悠真さんによる寄稿です。

背景

PFN では社内の計算基盤向けに独自のオブジェクトストレージの開発を進めています [1]。このオブジェクトストレージはまだ非標準の独自の API しか用意されていません。しかし、それでは既存のアプリケーションのコード変更が必要になり、利便性に問題があります。そこで、オブジェクトストレージをファイルシステムとしてマウントし、POSIX API でアクセスできるようにしたいです。オブジェクトストレージをファイルシステムとしてマウントすることは AWS S3 など他のオブジェクトストレージでも一般的に行われており、その際に FUSE という技術がよく使用されます [2, 3]。

PFN のオブジェクトストレージにも FUSE インターフェースの PoC 実装が作成されていました。この PoC は C 言語で基本的な機能のみが実装されていました。例えば以下のような制約がありました。

  • ルート直下のファイル操作のみに対応している
  • 限定的な API サポート
  • ファイルの部分的な読み込みは未実装
  • 書き込みのバッファリングを行わない

今回のインターンシップでは、社内オブジェクトストレージ用の FUSE インターフェースを充実させ様々なユースケースで使用できるようにすることを目標に作業しました。特に、具体的な目標として rsync を動作させることを目標にしました。Rsync を選んだのは、オブジェクトストレージへファイルを再帰的にコピーしたいという需要があると考えたからです。また、2 週間というインターン期間と rsync 対応に必要な作業の規模感がマッチしていると考えたからです。

Filesystem in Userspace (FUSE)

FUSE はユーザ空間でファイルシステムを実装するためのカーネルインターフェースです。通常、ファイルシステムはカーネル空間で実装されますが、カーネル空間の実装は苦労が多いです。例えば、カーネル空間で動作するコードにバグがあるとシステム全体が不安定になったりクラッシュしたりする可能性があります。そのため、ユーザ空間で実装したいという需要があります。

FUSE では、カーネル内の FUSE ドライバというコンポーネントとユーザ空間の FUSE デーモンと呼ばれるプロセスが通信することによってファイルシステムを実現します。FUSE を使ってファイルシステムを実装する場合、FUSE デーモンを実装することになります。FUSE によって実装されたファイルシステムは他のファイルシステムと同様にユーザが指定した場所にマウントされます。通常は、ユーザが FUSE デーモンを起動するときにファイルシステムがマウントされ、終了するときにアンマウントされることになります。

FUSE ドライバと FUSE デーモンの通信はデバイスファイル /dev/fuse を読み書きすることによって行われます。より抽象的には FUSE はクライアント・サーバモデルのプロトコルです。FUSE ドライバがクライアント、FUSE デーモンがサーバとなって、/dev/fuse 経由でバイナリ形式のメッセージをやり取りすることによって通信します。メッセージの例として以下のようなものがあります。

  • FUSE_GETATTR – ファイルの属性を取得する。stat(2) に対応する。
  • FUSE_OPEN – ファイルを開く。open(2) に対応する。

例えば FUSE によって管理されているファイルを stat(2) する際の流れは以下のようになります。まず、ユーザ空間のアプリケーションが stat(2) を発行します。カーネル内のシステムコールハンドラが VFS を呼び出します。ここまではどのファイルシステムでも共通の流れです。その後、FUSE の場合、VFS は FUSE ドライバに処理を移譲します。FUSE ドライバはユーザ空間の FUSE デーモンに FUSE_GETATTR メッセージを送信します。FUSE デーモンはファイルの属性を返信します。そして FUSE ドライバから VFS、VFS から元のアプリケーションにファイルの属性が伝えられます。このようにして stat(2) が処理されます。

/dev/fuse を直接読み書きしたりバイナリ形式のメッセージをデコードしたりする処理は決まりきっているため、libfuse というユーザ空間のライブラリがよく使用されます。

High-level API vs. low-level API

libfuse は FUSE を 2 段階に抽象化していて、high-level API と low-level API の 2 セットの API を提供しています。High-level API は抽象度が高く簡単に実装できますが、細かい制御や最適化が難しいです。一方 low-level API は抽象度が低く実装することが多いですが、反対に細かい制御や最適化をしやすいです。High-level API ではファイルパスをもとにファイルシステムを実装するのに対して、low-level API では inode ベースで実装するなどの違いがあります。

社内オブジェクトストレージでは、ファイルとディレクトリに UUID が振られています。ファイルとディレクトリの親子関係を表現するために、ファイル、ディレクトリは親の UUID を持ちます。そのため、ファイルやディレクトリの情報を取得したり、新規作成したりする API は親の UUID と子の名前の組を受け取るようになっています。

libfuse の high-level API では、ファイルパスをもとにしたインターフェースを使用します。これは、階層構造を親の UUID で管理するデータ構造の場合にディレクトリのトラバーサルが必要になり、API 呼び出しのラウンドトリップが増加してしまいます。stat(2) を例に説明します。以下は stat(2) に対応する処理を行う関数の大まかな実装です。この関数は libfuse からファイルパスを受け取り、ファイルの情報を返します。ファイルシステムのルートディレクトリと、その直下のファイルにのみ対応しています。

static int getattr(const char *path, struct stat *stbuf, struct fuse_file_info *fi)
{
    memset(stbuf, 0, sizeof(struct stat));


    if (strcmp(path, "/") == 0) {
        stbuf->st_mode = S_IFDIR | 0755;
        return 0;
    }


    const char *name = path + 1;


    object_storage_file_info info;
    object_storage_get_file_info_from_parent_uuid_and_child_name(root_uuid, name, &info);
    if (strcmp((char *)info.name, name) != 0) {
        return -ENOENT;
    }


    stbuf->st_mode = info.st_mode;
    stbuf->st_size = info.st_size;
    return 0;
}

仮にこの関数を任意の深さの階層に対応させる場合、非常に面倒であることがわかるかと思います。ファイルパスを分解し、オブジェクトストレージの API を繰り返し呼び出す必要があるためです。
ここで libfuse の low-level API に目を向けると、stat(2) に対応する関数である lookup は以下のようなシグネチャを要求されています。Low-level API は inode ベースのインターフェースであるため、ファイルパスではなく inode が渡されます。fuse_ino_tuint64_t です。

void(*lookup)(fuse_req_t req, fuse_ino_t parent, const char *name)

lookup は親の inode parent と子の名前 name を渡されることになっており、正にオブジェクトストレージの API を呼び出す際に必要な情報が揃っています。そのため、ルート直下以外のファイルにも対応するためには low-level API のほうが都合がよいと考えられます。

実装方針

上述の通り、high-level API よりも low-level API のほうが社内オブジェクトストレージの API と相性が良さそうであるため、low-level API を使用して実装することにしました。相性の良さに加えて、low-level API のほうがレイヤが低く、ロマンを感じました。

社内オブジェクトストレージは Rust で開発されており、C 実装の PoC では FFI を使ってオブジェクトストレージにアクセスしていました。また、PoC はシンプルな実装でしたが、今後機能を拡充していくことを考えると、複雑なコードを書かなければならないことが予想されました。FFI 用のコードを用意するのは Rust と C の間のデータ型を取り回すためのデータ構造や処理を準備しなければならないため煩雑であるという点や、そのために C で複雑なコードを書くのは大変であるという点から、FUSE 実装もRust を使用して実装することにしました。

さらに、あわよくば libfuse の依存を外し、pure Rust で実装したいと考えました。libfuse に依存する場合、実行環境に libfuse が必要になってしまいますが、pure Rust で実装すればそのようなことがないからです。他にも、pure Rust はロマンなのでロマンを追求したいという思いがありました。

クレートの選定

実装にあたっては FUSE インターフェースを扱うためのクレートを使用します。このようなクレートは 2 種類に大別されます。libfuse のバインディングを提供するものと、libfuse に依存せずに /dev/fuse を直接読み書きするものです。それぞれの代表として libfuse-sys と fuser があります。

libfuse-sys

libfuse-sys は libfuse のバインディングを提供する API です。high-level API と low-level API の両方をサポートしています。利点としてはバインディングであるため、既存の C 実装を移植しやすいという点があります。しかし、今回は既存のコードを大幅に書き直す予定であったことや、FFI と unsafe を避けたかったことから別の選択肢を探しました。

pub struct fuse_lowlevel_ops {
    …
    lookup: Option<unsafe extern "C" fn(req: fuse_req_t, parent: fuse_ino_t, name: *const c_char)>,
    …
}

コード: libfuse-sys のインターフェースの例

fuser (FUSE-Rust、fuse-rs)

fuser は Rust で実装された libfuse の代替品です。libfuse 非依存であり、/dev/fuse を直接読み書きする形で実装されています。ユーザが unsafe コードを書く必要が無い点や、low-level API 相当の Rust らしいインターフェースを提供している点が優れています。

fuser クレートを使用して FUSE デーモンを実装するためには、fuser::FileSystem トレイトを実装することになります。それぞれのメソッドが FUSE のメッセージに対応していて、FUSE ドライバからメッセージを受け取った際に呼び出されることになります。

pub trait Filesystem {
    …
    fn lookup(
        &mut self,
        _req: &Request<'_>,
        parent: u64,
        name: &OsStr,
        reply: ReplyEntry,
    );
    …
}

コード: fuser のインターフェースの例

今回は fuser を採用しました。FFI を減らし、また、既存のコードを大幅に書き換える予定であるという今回の状況にマッチしたためです。

Rust による FUSE デーモンの実装

FUSE を実装する目的は、POSIX API によるアクセスを可能にすることにより既存の様々なアプリケーションからオブジェクトストレージを活用できるようにすることでした。今回は特にアプリケーションとして rsync を想定して作業を進めました。Rsync を選んだ理由としては、冒頭に述べた理由に加えて技術的には様々なシステムコールを呼び出しそうであるという点があります。

fuser クレートの採用

C 言語で実装された PoC に代わって、Rust と fuser クレートを使用して FUSE デーモンを実装しました。PoC が libfuse の high-level API で実装されていたのに対して、本実装は libfuse 非依存で low-level API 相当のインターフェースを実装しています。

Inode の発行と管理

FUSE では inode の発行は FUSE デーモンが行います。libfuse の high-level API では inode の管理は隠蔽されていますが、今回は low-level API 相当のインターフェースを用いて FUSE デーモンを実装するため、自分で inode を管理する必要があります。

Inode はファイルシステム内でユニークである必要があります。FUSE ドライバは inode によってファイルを指定するので、FUSE デーモンは inode とファイルの実体の対応関係を把握しておく必要があります。特別な inode として 1 があります。これは必ずファイルシステムのルートを表すことになっています。

オブジェクトストレージの FUSE を実装するに当たって、Inode の管理方針は 2 つ考えられます。1 つ目はオブジェクトの UUID をそのまま inode として使う方法です。この場合実装はシンプルになりますが、今回は採用しませんでした。オブジェクトの UUID が 128bit であるのに対して inode は 64bit であるためです。

2 つ目は、inode をオンデマンドに発行する方法です。今回はこちらを採用しました。FUSE デーモン側で独自の ID を採番して inode とします。具体的には、ファイルの存在を初めて FUSE ドライバに知らせる際に、FUSE デーモン内部で inode を発行します。例えば新しいオブジェクトを作成した際や、ディレクトリのリスティングによってファイルの存在を初めて知らせる際に inode を発行します。Inode とオブジェクトの対応関係はファイルシステムがマウントされてからアンマウントされるまでの間だけ維持されればいいので、永続的に記録する必要はありません。

一時的に対応関係を管理する方法は libfuse の high-level API でも採用されています。libfuse の high-level API は、ユーザが inode の代わりにファイルパスを利用して FUSE デーモンを実装できるように、inode とファイルパスの対応関係をオンメモリで管理しています。

再帰的なパスの取り扱い

既存の PoC では実装を簡略化するためにルート直下のファイルのみを取り扱えるようにしていました。今回実装した FUSE デーモンでは、ディレクトリ内のファイルなど任意の深さの階層を扱えるようになりました。

具体例として、/a/b というファイルに対する stat(2) の処理の流れを説明します。ファイルシステムがマウントされた直後、FUSE デーモンはルートディレクトリの inode 1 だけを知っているため、まず inode 1 を使って lookup を呼びます。

オブジェクトストレージには以下のようなインターフェースが存在します。FUSE デーモンはこのインターフェースを使用して lookup を処理します。

get_info(親の UUID, 子の名前) -> 子のメタデータ

ルートディレクトリは唯一の存在であるため、実は FUSE デーモンは最初からその UUID を知っています。FUSE デーモンはルートディレクトリの UUID と “a” という名前を使ってオブジェクトストレージに /a の情報を問い合わせます。オブジェクトストレージから /a の UUID とその他の情報が得られるので、/a に対応する新しい inode を発行し、その他の情報とともに FUSE ドライバへ返します。

FUSE ドライバは /a の inode を知ることができたので、それを使って再び lookup を呼び出します。

FUSE デーモンはすでに /a の UUID を知っているため、その UUID と “b” という名前を使ってオブジェクトストレージに /a/b の情報を問い合わせます。同様にオブジェクトストレージから /a/b の UUID とその他の情報が得られるので、/a/b に対応する新しい inode を発行し、その他の情報とともに FUSE ドライバへ返します。

このような流れで /a/b に対する stat(2) が処理されます。

一連の流れの前は FUSE ドライバはルートディレクトリの inode 1 しか知らなかったのに対して、処理の後は /a/a/b の inode を知っている状態になります。FUSE デーモンも最初は inode 1 とルートディレクトリの UUID の対応関係しか知らなかったのに対して、処理の後は /a/a/b の inode と UUID のマッピングを持っている状態になります。そのため、再び /a/a/b に関する処理を行う際も FUSE ドライバの知っている inode と不整合を起こすことなく処理ができます。

ファイル、ディレクトリの作成・削除

ファイル、ディレクトリを作成・削除できるようにしました。オブジェクトストレージに対してオブジェクト、ディレクトリの作成・削除を指示するようなコードを書きました。具体的には、以下のインターフェースを実装しました。

  • unlink – ファイルの削除
  • rmdir – ディレクトリの削除

ファイル、ディレクトリの削除に成功すると、その時点で対応する inode は無効になります。FUSE ドライバはそのことをちゃんと把握していて、無効になった inode を FUSE_FORGET というメッセージで通知してきます。FUSE デーモンは FUSE_FORGET を受け取ると inode と UUID の対応関係を削除するような実装にしました。

ファイルの部分的な読み込み

現在のオブジェクトストレージの実装では、ファイルはブロックと呼ばれる単位に分割されて保存されます。ファイルの任意の領域を読み込むためには、オフセットとブロックの対応関係を計算する必要があります。FUSE ドライバの実装では、オフセットとサイズを指定してファイルの内容を部分的に読み込めるようにすることを試みました。部分的な読み込みが可能になると、例えば巨大な zip ファイルの一部だけを解凍する、ファイルの先頭部分だけを読んでファイルの種類を判別するなどのタスクが高速化されます。

ファイル書き込みのバッファリング

ファイル書き込み (オブジェクトのアップロード) でバッファリングを行うようにしました。PoC 実装ではバッファリングを行っておらず、ユーザ空間アプリケーションが write(2) を呼び出すたびにオブジェクトストレージ側の書き込み命令を発行して通信を行っていました。そのため、write(2) が細かく大量に呼ばれるケースでは書き込みが非常に遅くなっていました。一定サイズのメモリバッファの実装により、オブジェクトストレージとの通信回数を大幅に減らし、書き込みを高速化することができました。

評価

作った FUSE デーモンを評価するために、実際に使われそうなさまざまなコマンドを使ってファイルの読み書きを試しました。テスト用の Kubernetes クラスタ上で動作しているオブジェクトストレージを、実装した FUSE デーモンを用いてマウントして実験しました。

head

head コマンドはファイルの先頭 10 行を読み込みます。ファイルの部分読み込みを実装したので、巨大なファイルに対して head コマンドを実行した場合においても必要な部分だけダウンロードすることにより高速に実行できるようになりました。head の他に tail や file などのコマンドについても高速化できました。

git clone

再帰的なパスの取り扱いなどを実装したことにより、ディレクトリツリーを作成できるようになりました。これにより、git clone を動作させることができるようになりました。

ただしワークアラウンドが必要です。Git はリポジトリのクローン中に .git 内のファイルをリネームするのですが、社内オブジェクトストレージではまだリネームをサポートしておらず、FUSE でもリネームを実装しませんでした。そのため、--separate-git-dir オプションにより、.git を FUSE 外の通常のファイルシステム上に作る必要があります。

Rsync

ディレクトリツリーを作成できるようになったことにより、rsync による再帰的なコピーをすることができるようになりました。FUSE の外から中、中から外、中から中のコピーができます。ファイルの存在チェックに使用される readdirlookup がルート直下以外の処理にも対応したことにより、すでに存在するファイルはコピーをスキップするなどの動作も期待通りに行われていることが確認できました。

ただしこちらもワークアラウンドが必要です。リネームを実装できていないため、リネームが行われないように --inplace オプションをつける必要があります。

まとめ

既存のアプリケーションから POSIX API を用いて社内オブジェクトストレージにアクセスできるようにするために、FUSE デーモンを実装しました。実装には Rust を使用しました。既存の PoC を置き換える形で機能を充実させ、rsync や Git などのアプリケーションが条件付きで動作するようになりました。Rsync を動作させることは当初の目標であり、無事達成することができました。

今後の課題

今後の課題としてはファイルの所有者、パーミッションなど各種メタデータの扱いがあります。オブジェクトストレージ自体が持っている認証・認可の仕組みをうまく反映できるようにしたいと考えています。

また、限られた時間で実装したため、テストが存在しません。今後はテストを書きたいと思います。

感想

このインターンシップを通して FUSE に詳しくなることができました。特に、low-level API 相当のインターフェースの実装を進めていき、最終的に rsync などの大きなアプリケーションを動作させることができたので達成感があります。Rsync を動作させることは当初の目標だったので動かすことができて非常に満足しています。

日々の作業ではメンターの杉原さん、上西さん、Storage Team の皆さんにお世話になりました。コードレビューなどを通じてよりよいコードの書き方などを学ぶことができました。この場をお借りしてお礼申し上げます。

メンターより

エンジニアの杉原です。岡村さんには夏期インターンで、Rust によるオブジェクトストレージ向けの FUSE 実装に取り組んでいただきました。FUSE を用いる利点としては、幅広い既存のアプリケーションをコード変更なくサポートできる点が挙げられます。特に社内のオブジェクトストレージではデータセットやモデルの学習時のチェックポイントをアーカイブする用途が多く、これらがローカルファイルシステムと同様の使用感でアクセスできること、手元で標準的な Linux コマンドや既存のツールチェインが動作することは大きなメリットです。岡村さんには、このようなユースケースや実装背景を踏まえて、ターゲットとなるアプリケーションを選定してインターン期間中で必要なインタフェースを実装いただきました。また Rust の利用にあたっては、メンテナンス性やコードの保守性を考慮してクレートを選定いただき、最終的には FFI や libfuse に依存しない実装を作成できました。

PFN ではこのようなデータ管理・転送に関連したストレージやファイルシステムやオペレーティングシステムの要素技術、およびこれらの技術スタックを実装する基盤ソフトウェアの開発についても積極的に行っております。ご興味のある方がいらっしゃいましたら、来年度のインターンにぜひご期待ください。また、新卒採用・中途採用は通年で行っておりますので、募集要項のページをご覧ください。

参考文献

  • Twitter
  • Facebook