🦜

GoでFUSEを使ってS3をマウントする

に公開

この記事は Qiita Advent Calendar 2025 "Go シリーズ 1" の5日目の記事です。

成果物

https://github.com/nobonobo/s3fs

FUSE(Filesystem in Userspace)について

Linuxカーネルの機能の一つで、ユーザー空間に独自のファイルシステムを作成できる仕組みです。カーネル空間に依存せずにユーザーレベルでファイルシステムを動かすことができ、開発や実験がしやすい特徴があります。APIはlibfuseで提供され、openやreadなどのファイルシステム操作をユーザー側で実装して動作させます。

FUSEを使ってクラウドのAWS-S3の特定オブジェクトの配下をローカルフォルダからアクセス可能にしてみようと思います。

ちなみにFUSEの考え方自体はLinuxだけでなく、OSXFUSE/macFUSEやDokanなどで類似のことができるらしい。

https://github.com/macfuse/macfuse
https://dokan-dev.github.io/

採用したラッパーライブラリはこれ。
https://github.com/hanwen/go-fuse

  • v2にて、fs抽象化APIとfuse低レイヤAPIがあるがfs-APIが推奨
  • macFUSE/OSXFUSEをサポートしています
  • Linux/WSL対応
  • Windows対応はまだ

LinuxやWSLでも動作するのでWindowsの人はWSLで使うくらいが無難だと思う。(Linuxに比べWindowsのほうが考慮すべき細かい挙動が多い)

類似のプロジェクト

https://github.com/ma91n/localstackmount

その後、aws-sdk-for-go-v2がリリースされv1は非推奨になったことや、go-fuseもv2になりfs抽象化APIの利用が推奨されるようになりましたので作り直してみようと思いました。

FUSE実装のハマりやすさ

  • 意外と想像以上に「細切れの操作」でファイルやフォルダはアクセスされている
  • アプリ実装側でいうところのOpen/Create/Read/Write/Flush/Closeよりもさらに細かい単位の操作になる
  • バッファを介した「細切れの操作」によるアクセスが基本
  • オペレーションが2層に分かれているのでまずどっちの層に属しているのかを把握する必要がある
  • さらにややこしいのは、親ノードから見た子ノードの操作の場合と自身のノードに対する操作の場合の2種類がある
  • ある程度の範囲の操作実装を揃えてあげないと動かして試せない
  • さらに結構フル機能をカバーしようとすると実装しなければならない操作の数が思っていたよりも多い
  • 「細切れの操作」を実直にクラウドAPIを叩いていたら小さな一つのファイル操作を行うだけで何度もAPIコールを呼んでしまう
  • パフォーマンスを維持するにはキャッシュのような考え方が必須になる
  • 一度読み込んだファイルの情報や内容はメモリに残して置き、書き換えが発生したらキャッシュを破棄する

2層の役割

ひとつのファイルパスに対し、以下の2つのオブジェクトが紐づけられる

  • ノード(Node): ファイルを開かずに行う操作を担当するオブジェクト
  • ファイル(File): ファイルを開いた状態での操作を担当するオブジェクト

ひとつのオブジェクトに兼任させる実装方法もあるっちゃあるが、分けておいた方が明示的で理解しやすいので分けるやり方がおススメ。go-fuse/v2ではこれらのカスタムオブジェクトを実装するのが基本です。

どんなAPIを持ちうるのかは fs/api.go に列挙されています。

fsのAPIにおけるTips

  • 「カスタムNode」も「カスタムFile」も「fs.Inode」構造体を埋め込むのが基本です
  • 「fs.Inode」は全てのイベントハンドラが実装済み(多くは「fuse.ENOTSUP」を返し未サポート扱い)
  • IO処理が中断した場合は「fuse.EIO」を返し、参照ノードが見つからない場合は「fuse.ENOENT」を返し、正常終了した場合は「fs.OK」を返すのが習わしです
  • syscall.E???fuse.E???で再定義されていますが、エラーなしだけfs.OKの定義です
  • fs.InodeEmbedderは「カスタムNode」インスタンスが入っているのでタイプアサーションで元型を参照できます
  • fs.FileHandleは「カスタムFile」インスタンスが入っているのでタイプアサーションで元型を参照できます
  • 上記以外で元型を参照する方法はありませんので、もし元型へのアクセス手段を確保したい場合、元型の参照をどうにか確保する必要があります
  • iノード番号がユニークである必要があるがおそらくノータッチなら良きに計らってくれてそう
  • 今回はS3のキーからfnvハッシュを作ってみた
  • 既存のファイルを書き換える系はSetattrが呼ばれます
  • Open/Createのどっちが呼ばれるのかはちょっとややこしいです
    • 存在しないところにファイル作成するときだけCreate
    • それ以外はOpenだけどflags引数を見て準備することは多少アレンジが必要です

最小限実装すべきAPI

File役割オブジェクト(fs.Inodeを埋め込む構造体で以下のメソッドを持つ)

  • Read(ctx context.Context, dest []byte, off int64) (fuse.ReadResult, syscall.Errno)
  • Write(ctx context.Context, data []byte, off int64) (uint32, syscall.Errno)
  • Flush(ctx context.Context) syscall.Errno

Node役割オブジェクト(fs.Inodeを埋め込む構造体で以下のメソッドを持つ)

  • OnAdd(ctx context.Context)
  • Getattr(ctx context.Context, fh fs.FileHandle, out *fuse.AttrOut) syscall.Errno
  • Open(ctx context.Context, flags uint32) (fs.FileHandle, uint32, syscall.Errno)
  • Create(ctx context.Context, name string, flags uint32, mode uint32, out *fuse.EntryOut) (*fs.Inode, fs.FileHandle, uint32, syscall.Errno)
  • Mkdir(ctx context.Context, name string, mode uint32, out *fuse.EntryOut) (*fs.Inode, syscall.Errno)
  • Unlink(ctx context.Context, name string) syscall.Errno
  • Rmdir(ctx context.Context, name string) syscall.Errno
  • Rename(ctx context.Context, name string, newParent fs.InodeEmbedder, newName string, flags uint32) syscall.Errno

推奨実装に挙げていないNode.LookupNode.OpendirとかNode.ReaddirというAPIはファイルやフォルダへのアクセス時に毎回呼ばれるAPIでこれらを実装するときは注意が必要。本当に毎回呼ばれるのでキャッシュなどをうまく利用しないと、ここが重い処理になりかなりレスポンスが悪化してしまいます。

なので、OnAddはこのライブラリ特有で、フォルダNodeがマウントされるときに呼ばれるイベントハンドラで、これを実装することをお勧めします。ここでReaddir相当および、Nodeオブジェクトの生成をすましておきます。

こうしておくと生成済みのNodeオブジェクトを利用してLookupやOpendir、Readdirなどの処理がメモリ上にすでにあるNodeインスタンスツリーの処理だけで実行されます。

特に今回のようにクラウドとつなぐ場合、LookupやOpendir、ReaddirをいちいちクラウドのAPIを呼んでしまうとコール回数が爆発するし、いちいち遅いファイルシステムになってしまう。

また、頻繁に呼ばれるのに`Node.Getattr'がありますが、これはキャッシュするように工夫してみます。fs.Inodeの標準実装がある程度親切になっていてOnAddとGetattrさえ正しい情報を返していれば、AccessやLookup、Opendir、Readdirのハンドリングは内部で処理されます。

デバッグモードで見るとわかりますが、実はもっといろんなイベントが発火しています。それが未実装だった場合に上記のAPIを呼ぶイベントが発火するものもあります。

  • CopyFileRange(ctx context.Context, fhIn fs.FileHandle,
    offIn uint64, out *fs.Inode, fhOut fs.FileHandle, offOut uint64,
    length uint64, flags uint64) (uint32, syscall.Errno)

これも実装が無い場合、Read/Write/Flushを組み合わせてコピー処理が行われる。

上記Node用APIも操作の対象になるNodeがノードそのものの場合と、子ノードになるものもの場合の2通りがあることを意識しておきましょう。

例えば、Node.Openは自身のノードに対する操作ですが、Node.Createは自身が親ノードであり子ノードを作成する操作となります。分類としては第二引数に`name string'があるものは子ノードに対する操作で、自身ノードは親ノードです。(GetattrとOpen、Fileオブジェクトメソッドが自身のノードに対する操作ということになります)

クラウドとつなぐときの注意

  • 想像以上に細かく頻繁にFUSEのイベントは発生します
  • 巨大なファイルの読み書きも小さなバッファによる読み書きを繰り返すような挙動になります
  • それらを真っ正直にクラウドAPI呼び出しをしてはめちゃくちゃレスポンスが悪化しますし、場合によってはクラウドの利用料金も膨らみます
  • 実装したものの中でGetattrはかなり頻度が高いのでここだけ対策すればずいぶん動作が軽くなります
  • FileオブジェクトはオンメモリでRead/Writeを実装し、Flushしたときだけまとめて書き込む様にします
  • Getattrはキャッシュをノード単位で残すが、Writeをするたびにキャッシュ無効化フラグを立てておきます
  • キャッシュ無効化している状態のGetattrではクラウドAPIを呼んでオブジェクトの状態を取得します

S3連携時の注意点

  • バケットルートとその配下とでフォルダーの命名のルールが異なります
  • 配下ではサフィックスに"/"がつくルールだけど、バケットルートには付きません
  • なので、バケットルートかどうかで処理の分岐が必要になります
  • ここは単に実装が複雑化するので配下のフォルダをマウントルートにする仕様にしました
  • RenameObjectのAPIは備わっていないので、CopyObjectしてDeleteObjectします

実行環境に関する注意

  • sudo権限が必要(もしくはfuseマウント権限を持つグループに入る?)
  • fuseランタイム(sudo apt install fuse、brew install macfuse)
  • panicで終わったりするとアンマウントが正常に行われないことがある
  • そういう場合は実行前にsudo fusermount -u <マウント先>でアンマウントをしておく

ルートNodeのマウント

usersグループのIDが100であるとハードコードしてしまっています。
また、今回の実装はフォルダは0775、ファイルは0664にハードコードしています。
これによりusersグループのユーザーはマウント先を読み書きできるようになります。

必要ならOS側とやり取りして適切なIDを取得してください。
AllowOtherフラグはルートでないユーザーにアクセスを許す。
DebugはtrueだとFUSEイベントのログが出力されるようになります。

root := s3fs.New(&s3fs.Config{
	Client:  client,
	Bucket:  bucket,
	RootKey: rootKey,
	Hooks:   hooks,
})
server, err := fs.Mount(flag.Arg(0), root, &fs.Options{
	GID: 100,
	RootStableAttr: &fs.StableAttr{
		Mode: fuse.S_IFDIR | 0775,
		Ino:  StringToIno(root.key),
	},
	MountOptions: fuse.MountOptions{
		AllowOther:     true,
		RememberInodes: false,
		DisableXAttrs:  true,
		Debug:          false,
		FsName:         "s3mount",
		Name:           "s3",
		Options:        []string{"default_permissions"},
	},
})

AWS側の準備

S3

  • グローバルにユニークなバケット名でバケットを作成しておく
  • ルートになるフォルダを作成しておく

IAM

  • S3アクセス権限を持つユーザーを作成
  • CLI向け認証情報を作成(アクセスキーIDとシークレットアクセスキーなど)

認証キーのセットアップ

awsコマンドラインツールのセットアップ

sudo apt install -y curl unzip python-is-python3`
curl "https://s3.amazonaws.com/aws-cli/awscli-bundle.zip" -o "awscli-bundle.zip"
unzip awscli-bundle.zip
sudo ./awscli-bundle/install -i /usr/local/aws -b /usr/local/bin/aws
sudo aws configure # sudoユーザー用のクレデンシャルをセットアップ

ソースからのビルド

go install github.com/go-task/task/v3/cmd/task@latest
git clone https://github.com/nobonobo/s3fs
cd s3fs
task build
task install

いきなりインストール

go install github.com/nobonobo/s3fs/cmd/s3mount@latest

起動

mkdir dest
sudo s3mount -region ap-northeast-1 -bucket <bucket-name> -root root dest

確認したこと

出来た事

  • ファイル作成・書き込み・クローズ
  • ファイルオープン・読み出し・クローズ
  • echo text > ファイルパス # 作成・上書き
  • echo text >> ファイルパス # 追記
  • rm ファイルパス
  • mkdir フォルダパス
  • rmdir フォルダパス
  • cp ファイルパス ファイルパス
  • mv ファイルパス ファイルパス
  • ls ファイルパス

出来ない事

  • ioctl
  • mknode
  • chmod
  • chown
  • 特殊属性の管理
  • 他もろもろ

まとめ

  • FUSE実装は過去何回か挑戦したんだけど、意外とハマりどころは多いので難しい
  • 既存のFS互換を確保できるところまでがなかなかハードルが高くてすごく簡略化したものしか作れなかった
  • 今回、納得いくところまでは実装できたので満足
  • めちゃ重いという事もなくめちゃ軽いわけでもない
  • ファイル単位でまるまるメモリに積むから超巨大ファイルは扱えない
  • クラウド側でいじったらファイルツリーがオンメモリキャッシュに載っているため動作に齟齬が発生する
  • 堅牢にするためには齟齬が出たらキャッシュをクリアするなどが必要?

Discussion