100日チャレンジ day36 (FUSE+SQLiteの独自ファイルシステム)
昨日
100日チャレンジに感化されたので、アレンジして自分でもやってみます。
やりたいこと
- 世の中のさまざまなドメインの簡易実装をつくり、バックエンドの実装に慣れる(dbスキーマ設計や、関数の分割、使いやすいインターフェイスの切り方に慣れる
- 設計力(これはシステムのオーバービューを先に自分で作ってaiに依頼できるようにする
- 生成aiをつかったバイブコーティングになれる
- 実際にやったことはzennのスクラップにまとめ、成果はzennのブログにまとめる(アプリ自体の公開は必須ではないかコードはgithubにおく)
できたもの
FUSEを用いた独自ファイルシステム を作る
なるほど!FUSE (Filesystem in Userspace) を使いたいんですね。承知しました。これは非常に面白い挑戦です!SQLiteをバックエンドにして、Go言語でユーザー空間ファイルシステムを実装しましょう。OSのファイルシステムコールをフックして、SQLiteへの読み書きに変換する形になりますね。
修正後の提案:
-
目的: Go言語とFUSEライブラリ (
bazil.org/fuse) を使い、SQLiteデータベースを永続化ストレージとするユーザー空間ファイルシステムを実装する。 -
主要機能:
- OSからの標準的なファイルシステム操作(ディレクトリ作成・一覧表示・削除、ファイル作成・読み込み・書き込み・削除、属性取得など)に対応する。
- ファイルとディレクトリの構造、メタデータ、内容をSQLiteデータベース (
db/dev.db) に保存する。 - 指定したディレクトリ(マウントポイント)にこのカスタムファイルシステムをマウントできるようにする。
-
使用ライブラリ:
-
bazil.org/fuseおよびそのサブパッケージ (fuse/fs) database/sqlgithub.com/mattn/go-sqlite3
-
-
UI: なし。OS標準のコマンドラインツール (
ls,mkdir,cat,echo,rmなど) やファイルエクスプローラー (Finder, Explorer等) からマウントポイントを通じて操作する。 - ユーザー分離: 今回は実装スコープ外とし、単一のファイルシステム空間とします。
作業順序 (FUSE版):
-
プロジェクト初期化:
-
day36_fuse_sqlite_fs_goディレクトリを作成します。 -
go mod init github.com/lirlia/100day_challenge_backend/day36_fuse_sqlite_fs_goを実行します。 -
go get bazil.org/fuseとgo get github.com/mattn/go-sqlite3を実行します。 -
main.go(マウント処理)、fs.go(FUSEインターフェース実装)、store.go(SQLite操作)、db/schema.sql(DBスキーマ) のようなファイル構造を作成します。 -
README.mdにアプリ概要を記述します。
-
-
データモデリングとDB設定:
-
db/schema.sqlに、ファイルとディレクトリの情報を格納するテーブルスキーマを定義します。inode、名前、親inode、種類(ファイル/ディレクトリ)、サイズ、パーミッション、タイムスタンプ、ファイル内容(BLOBまたは別テーブル)などを考慮します。 -
store.goに、DB接続・初期化処理と、FUSE操作に対応するSQLite操作関数(例:CreateNode,ReadNode,WriteNodeData,ListDir,GetNodeAttributes)を実装します。
-
-
FUSE実装 (
fs.go):-
fuse/fsパッケージの主要なインターフェース (FS,Node,Handleなど) を実装するGoの構造体 (FS,Dir,Fileなど) を定義します。 - OSからの各ファイルシステム操作に対応するメソッド(
Root,Lookup,Mkdir,Create,Read,Write,Readdir,Getattr,Setattr,Removeなど)を実装します。これらのメソッド内でstore.goの関数を呼び出し、SQLiteデータベースを操作します。ノード(inode)の管理が重要になります。
-
-
マウント処理 (
main.go):- コマンドライン引数からマウントポイントのパスを受け取ります。
- SQLiteデータベースを初期化します(必要ならスキーマ適用も)。
-
fuse.Mountを呼び出して、実装したファイルシステム (fs.goのFS構造体) を指定されたマウントポイントに接続します。 - 適切にアンマウントできるようにシグナルハンドリング(Ctrl+Cなど)を実装します。
-
ビルドとテスト:
-
go buildで実行ファイルを生成します。 - マウントポイント用のディレクトリを作成します (例:
mkdir ./mnt)。 - ファイルシステムをマウントします (例:
sudo ./day36_fuse_sqlite_fs_go ./mnt※FUSE操作には通常root権限か特定グループへの所属が必要です)。 - 別のターミナルを開き、マウントポイント (
./mnt) に対して標準的なファイル操作コマンド (ls -la,mkdir hello,echo 'world' > hello/world.txt,cat hello/world.txt,rm hello/world.txt,rmdir hello) を実行し、期待通りに動作するか、SQLiteデータベースの内容が更新されるかを確認します。
-
-
ドキュメント作成:
README.mdに使い方や実装の詳細を記述し、knowledge.mdcを更新します。
このFUSEを使った実装方針で進めましょう。準備ができましたら、まずプロジェクトの初期化から始めます。
作業手順
承知しました。複雑なプロジェクトになるため、詳細な計画とステップごとのテストは非常に重要ですね。以下にディレクトリ構造、ファイル一覧、そしてテストを組み込んだ具体的な作業ステップを提案します。
ディレクトリ構造案:
/day36_fuse_sqlite_fs_go/
├── main.go # エントリーポイント、マウント処理、コマンドライン引数処理
├── fusefs/ # FUSEファイルシステムロジック
│ ├── fs.go # FSインターフェース実装 (ルートノード提供)
│ ├── node.go # Nodeインターフェースの基本実装や共通処理 (Attrなど)
│ ├── dir.go # Dirノード (ディレクトリ操作: Lookup, Mkdir, Readdir, Remove)
│ ├── file.go # Fileノード (ファイル操作: Open, Create)
│ └── handle.go # Handleインターフェース実装 (Read, Write)
├── store/ # SQLiteストア関連
│ ├── store.go # DB操作インターフェース定義
│ ├── db.go # SQLite接続、初期化、スキーマ適用
│ ├── node_store.go # ノード(inode, メタデータ)関連のDB操作実装
│ ├── data_store.go # ファイルデータ関連のDB操作実装
│ └── schema.sql # DBスキーマ定義
├── models/ # データモデル
│ └── node.go # ノード情報 (ファイル/ディレクトリ) の構造体定義
├── tests/ # ユニットテスト用
│ ├── store_test.go # storeパッケージのユニットテスト
│ └── helpers_test.go # テスト用のヘルパー関数 (一時DB作成など)
├── .gitignore
├── go.mod
├── go.sum
└── README.md
作業ステップ:
Phase 1: 基盤構築 (DB とストア)
-
プロジェクト初期化と依存関係:
-
実装:
-
day36_fuse_sqlite_fs_goディレクトリ作成。 -
go mod init github.com/lirlia/100day_challenge_backend/day36_fuse_sqlite_fs_go実行。 -
go get bazil.org/fuseとgo get github.com/mattn/go-sqlite3実行。 - 上記ディレクトリ構造の空ファイルとディレクトリを作成 (
.gitignore,README.mdも)。
-
- テスト: なし (初期設定)。
-
実装:
-
DBスキーマ定義 (
store/schema.sql):-
実装:
inodesテーブル(id, parent_id, name, is_dir, mode, size, atime, mtime, ctime)とfile_dataテーブル(inode_id, offset, data)のCREATE TABLE文を記述。ルートディレクトリ用の初期レコードも定義(例: id=1, parent_id=1, name='', is_dir=true)。 - テスト: スキーマの妥当性を目視確認。
-
実装:
-
DB接続と初期化 (
store/db.go):-
実装:
NewDBConnection(dataSourceName string)関数でDB接続 (sql.Open) とPingを実行。InitializeSchema(db *sql.DB)関数でschema.sqlを読み込み、Exec()で実行(エラーハンドリング含む)。 -
テスト:
-
tests/helpers_test.go: テスト用のインメモリDBまたは一時ファイルDBを作成/削除するヘルパー関数を実装。 -
tests/store_test.go: ヘルパーを使いNewDBConnectionとInitializeSchemaがエラーなく実行できるかテスト。
-
-
実装:
-
データモデル定義 (
models/node.go):-
実装:
inodesテーブルに対応するNode構造体を定義(Goの型で)。タイムスタンプはtime.Time。 - テスト: なし (構造体定義のみ)。
-
実装:
-
ノードストア実装 (
store/node_store.goとstore/store.go):-
実装:
-
store/store.go:NodeStoreインターフェースを定義 (GetNode, GetChildNode, ListChildren, CreateNode, UpdateNode, DeleteNode)。 -
store/node_store.go:NodeStoreインターフェースを実装する構造体とメソッドを定義。SQL文を記述し、*sql.DBを使ってDB操作。ルートノード(id=1)が存在しない場合の初期作成処理も考慮。
-
-
テスト:
tests/store_test.goでNodeStoreの各メソッドに対するユニットテストを作成 (CRUD操作、親子関係、存在しない場合の挙動など)。
-
実装:
-
データストア実装 (
store/data_store.goとstore/store.go):-
実装:
-
store/store.go:DataStoreインターフェースを定義 (ReadData, WriteData, DeleteData)。 -
store/data_store.go:DataStoreインターフェースを実装する構造体とメソッドを定義。ReadDataは指定オフセット/サイズでfile_dataからSELECT。WriteDataはREPLACE INTOまたはINSERT OR REPLACEでデータを書き込み、関連するinodesテーブルのsizeとmtimeも更新 (トランザクション考慮)。DeleteDataはDELETE。
-
-
テスト:
tests/store_test.goでDataStoreの各メソッドに対するユニットテストを作成 (読み書き、オフセット、サイズ更新、データ削除など)。
-
実装:
Phase 2: FUSE 実装
-
FUSE FS実装 (
fusefs/fs.go):-
実装:
-
FileSystem構造体を定義 (NodeStore,DataStoreを保持)。 -
fuse/fs.FSインターフェースのRoot()メソッドを実装。NodeStore.GetNode(1)でルートノードを取得し、fusefs.Dir(次のステップで作成) として返す。
-
- テスト: この時点では統合テストのみ可能。
-
実装:
-
FUSE Node基本実装 (
fusefs/node.go):-
実装:
-
Node構造体を定義 (models.Node,NodeStore,DataStoreを保持)。 -
fuse/fs.NodeインターフェースのAttr(ctx context.Context, a *fuse.Attr)メソッドを実装。保持しているmodels.Nodeの情報 (mode, size, times) をfuse.Attrに設定。パーミッション (mode) は適切に設定。Inode番号はDBのIDを使用。
-
- テスト: 統合テストでの確認が主。
-
実装:
-
FUSE Dir実装 (
fusefs/dir.go):-
実装:
-
Dir構造体を定義 (fusefs.Nodeを埋め込み)。 -
fuse/fs.NodeLookuper:Lookup(ctx, name)メソッド実装 (NodeStore.GetChildNodeを使用)。見つかったらfusefs.Dirまたはfusefs.Fileを返す。 -
fuse/fs.NodeMkdirer:Mkdir(ctx, req)メソッド実装 (NodeStore.CreateNodeでディレクトリ作成)。 -
fuse/fs.HandleReadDirAller:ReadDirAll(ctx)メソッド実装 (NodeStore.ListChildrenで子ノード一覧取得しfuse.Direntスライスに変換)。 -
fuse/fs.NodeRemover:Remove(ctx, req)メソッド実装 (NodeStore.DeleteNodeを使用。ファイル削除と空ディレクトリ削除に対応)。
-
- テスト: 統合テストでの確認が主。
-
実装:
-
FUSE File実装 (
fusefs/file.go):-
実装:
-
File構造体を定義 (fusefs.Nodeを埋め込み)。 -
fuse/fs.NodeOpener:Open(ctx, req, resp)メソッド実装。FileHandle(次のステップ) を作成しresp.Handleに設定。フラグ (req.Flags) に応じたパーミッションチェックは簡易的に。 -
fuse/fs.NodeCreater:Create(ctx, req, resp)メソッド実装 (NodeStore.CreateNodeでファイル作成、FileHandleを作成して返す)。
-
- テスト: 統合テストでの確認が主。
-
実装:
-
FUSE Handle実装 (
fusefs/handle.go):-
実装:
-
FileHandle構造体を定義 (models.Node,NodeStore,DataStoreを保持)。 -
fuse/fs.HandleReader:Read(ctx, req, resp)メソッド実装 (DataStore.ReadDataを使用。req.Offset,req.Sizeに対応)。 -
fuse/fs.HandleWriter:Write(ctx, req, resp)メソッド実装 (DataStore.WriteDataを使用。req.Offset,req.Dataを渡し、書き込みバイト数をresp.Sizeに設定)。
-
- テスト: 統合テストでの確認が主。
-
実装:
Phase 3: 結合とテスト
-
メイン処理とマウント (
main.go):-
実装:
-
flagパッケージ等でコマンドライン引数(マウントポイント、DBファイルパス)を処理。 -
store.NewDBConnection,store.InitializeSchemaを呼び出しDB準備。 -
NodeStore,DataStoreのインスタンスを作成。 -
fusefs.NewFileSystem(コンストラクタ) でファイルシステムインスタンス作成。 -
fuse.Mountを呼び出しマウント実行。エラーハンドリング。 -
signal.NotifyでSIGINT,SIGTERMを捕捉し、fuse.Unmountを呼び出す後処理を追加。
-
-
テスト (結合テスト):
-
go buildで実行ファイル作成。 - マウントポイント作成 (
mkdir ./mnt)。 - マウント (
sudo ./day36_fuse_sqlite_fs_go -db ./db/dev.db ./mntまたは FUSEの設定によってはsudo不要)。 -
別ターミナルでマウントポイント (
./mnt) に対して以下のコマンドを実行し、エラーなく動作すること、DBの内容が意図通り変更されることを確認:ls -la ./mntmkdir ./mnt/testdirls -l ./mntecho "hello" > ./mnt/testfile.txt-
cat ./mnt/testfile.txt(内容が "hello" であること) -
ls -l ./mnt(testfile.txt のサイズが正しいこと) echo " world" >> ./mnt/testfile.txt-
cat ./mnt/testfile.txt(内容が "hello world" であること) rm ./mnt/testfile.txtrmdir ./mnt/testdir-
ls -la ./mnt(空になっていること)
- Ctrl+Cでプロセスを停止し、正常にアンマウントされることを確認。
-
-
実装:
-
ドキュメントと最終化:
-
実装:
README.mdにビルド方法、マウント方法、使い方、制限事項(未実装機能など)を記述。knowledge.mdcを更新。コード内のコメントを整備。 - テスト: 全体の動作を再確認。
-
実装:
このステップで進めていきましょう。まずは Phase 1 のステップ1から始めます。準備ができたら指示してください。
https://bazil.org/fuse/ が古くもう対応していないので
https://github.com/hanwen/go-fuse を使う
ビルドするのにめっちゃ苦労してる

きたか・・・?
❯ mkdir mnt/testdir
ls -la mnt
echo "hello fuse" > mnt/hello.txt
cat mnt/hello.txt
ls -la mnt
touch mnt/testdir/emptyfile
ls -la mnt/testdir
rm mnt/hello.txt
rmdir mnt/testdir
ls -la mnt
mnt/testdir
total 0
drwxr-xr-x 3 noname staff 96 5 6 13:47 .
drwxr-xr-x 14 noname staff 448 5 6 13:45 ..
drwxr-xr-x 2 noname staff 64 5 6 13:47 testdir
hello fuse
total 8
drwxr-xr-x 4 noname staff 128 5 6 13:47 .
drwxr-xr-x 14 noname staff 448 5 6 13:45 ..
-rw-r--r-- 1 noname staff 11 5 6 13:47 hello.txt
drwxr-xr-x 2 noname staff 64 5 6 13:47 testdir
total 0
drwxr-xr-x 3 noname staff 96 5 6 13:47 .
drwxr-xr-x 4 noname staff 128 5 6 13:47 ..
-rw-r--r-- 1 noname staff 0 5 6 13:47 emptyfile
rmdir: mnt/testdir: Directory not empty
total 0
drwxr-xr-x 3 noname staff 96 5 6 13:47 .
drwxr-xr-x 14 noname staff 448 5 6 13:45 ..
drwxr-xr-x 3 noname staff 96 5 6 13:47 testdir
macFUSE をそもそも入れないとダメだった
そしてこれを入れるには mac のセキュリティレベルを下げないとダメ
main !?⇡ 🇬️ default v1.24.2
❯ ./sqlitefs -debug -mountpoint mnt
2025/05/06 14:52:34 Using database at: ./db/dev.db
2025/05/06 14:52:34 Root node already exists.
2025/05/06 14:52:34 Database schema initialized successfully.
2025/05/06 14:52:34 Root -> OnAdd() called
2025/05/06 14:52:34 Root -> OnAdd() loaded root model: &{ID:1 ParentID:0 Name: IsDir:true Mode:drwxr-xr-x Size:0 Atime:2025-05-06 14:00:13 +0900 JST Mtime:2025-05-06 14:00:13 +0900 JST Ctime:2025-05-06 14:00:13 +0900 JST UID:0 GID:0}
2025/05/06 14:52:34 rx 2: INIT n0 {7.19 Ra 2097152 ATOMIC_O_TRUNC,CACHE_SYMLINKS,NODE_RWLOCK,RENAME_SWAP,RENAME_EXCL,ALLOCATE,CASE_INSENSITIVE,VOL_RENAME,XTIMES} p4471
2025/05/06 14:52:34 tx 2: OK, {7.19 Ra 2097152 RENAME_SWAP 0/0 Wr 131072 Tg 0 MaxPages 8 MaxStack 1}
2025/05/06 14:52:34 rx 2: STATFS n1 p4471
2025/05/06 14:52:34 tx 2: OK, {blocks (0,0)/0 files 0/0 bs0 nl0 frs0}
2025/05/06 14:52:34 rx 2: STATFS n1 p339
2025/05/06 14:52:34 tx 2: OK, {blocks (0,0)/0 files 0/0 bs0 nl0 frs0}
2025/05/06 14:52:34 rx 2: ACCESS n1 {u=501 g=20 x} p4470
2025/05/06 14:52:34 Root -> Getattr() called
2025/05/06 14:52:34 Node -> Getattr() called for ID: 0
2025/05/06 14:52:34 Node -> Getattr() success for ID: 1, Size
なんか成功した
できた〜〜
macFuse 経由でマウントとして sqlite をバックエンドにしたファイルシステム!
今回やったこと
はい、承知いたしました。FUSE ファイルシステム、特に今回の Go と SQLite を使った実装がどのように動いているのか、基本的な仕組みと使用したツール(ライブラリ)の役割を説明しますね。
1. FUSE とは? - ファイルシステムを「自作」する仕組み
- FUSE (Filesystem in Userspace): 通常、ファイルシステム(データを整理し、ファイルとしてアクセス可能にする仕組み)は OS のカーネルというコア部分で動作します。FUSE は、このファイルシステムのロジックを カーネルの外 、つまり通常のアプリケーションと同じ「ユーザースペース」で開発・実行できるようにする仕組みです。
-
仲介役: OS カーネルには FUSE をサポートするためのモジュール (macOS なら
macfuse、Linux ならfuse) があり、これがユーザースペースで作られたファイルシステムプロセス (今回はsqlitefs) とカーネルの間の通信を仲介します。
2. どうやって動いているの? - OS と自作プログラムの連携プレー
ユーザーがマウントポイント (今回は mnt ディレクトリ) に対してファイル操作を行うと、以下のような流れで処理が進みます。
-
ユーザー操作: あなたがターミナルで
ls mnt/やecho "hello" > mnt/file.txtなどのコマンドを実行します。 -
カーネルが検知: OS カーネルが、
/path/to/mntディレクトリへの操作要求を検知します。 -
FUSE モジュールへ: カーネルは、
mntが FUSE によってマウントされていることを知っているので、その操作要求を FUSE カーネルモジュールに渡します。 -
sqlitefsへリクエスト: FUSE カーネルモジュールは、マウント時に指定された我々のプログラムsqlitefs(ユーザースペースで動いているプロセス) に対して、「mntディレクトリの内容を教えて (Readdir)」や「mnt/file.txtを作成して (Create)」といった具体的なリクエストを送ります。 -
sqlitefsの処理:-
sqlitefsプログラムは FUSE モジュールからのリクエストを受け取ります。 - リクエスト内容に応じて、実装した処理を実行します。例えば、「
mntディレクトリの内容を教えて」なら、SQLite データベース (db/dev.db) のnodesテーブルを検索して、該当するファイルやディレクトリのリストを取得します。 - 「
mnt/file.txtを作成して」なら、nodesテーブルに新しいファイルのエントリを追加し、file_dataテーブルにも(最初は空の)データエントリを追加します。
-
-
sqlitefsから応答: 処理結果(成功したか、エラーか、要求されたデータは何かなど)を FUSE カーネルモジュールに返します。 -
カーネルへ応答: FUSE カーネルモジュールは、
sqlitefsからの応答を OS カーネルに伝えます。 -
ユーザーへ反映: OS カーネルは、受け取った結果を最終的にユーザーが実行したコマンド(
lsやecho)に返します。これにより、lsならファイルリストが表示され、echoなら成功するかエラーメッセージが表示されます。
3. hanwen/go-fuse/v2 ライブラリは何をしている? - FUSE 通信のお手伝い
-
通信の抽象化: 上記の「FUSE カーネルモジュールとのリクエスト/応答のやり取り」は、実際には低レベルな通信プロトコルに基づいています。
hanwen/go-fuse/v2は、この複雑な通信部分を隠蔽し、Go のインターフェースとして扱えるようにしてくれるライブラリです。 -
インターフェース実装: このライブラリは
fs.NodeLookuper(Lookup 操作用)、fs.NodeGetattrer(Getattr 操作用)、fs.NodeReaddirer(Readdir 操作用)、fs.NodeCreater(Create 操作用)、fs.FileWriter(Write 操作用) といったインターフェースを提供します。 -
開発者の役割: 私たちは、これらのインターフェースを満たす Go の構造体 (
sqliteNode,sqliteRoot,sqliteDir,sqliteFile,sqliteHandleなど) を実装するだけで済みます。各メソッド内で、対応するファイルシステム操作のロジック(主にstoreパッケージを呼び出す部分)を記述します。 -
サーバー起動:
fs.Mount()関数を呼び出すことで、実装したファイルシステムロジック (sqliteRootから始まるツリー) を指定したマウントポイントに接続し、FUSE カーネルモジュールからのリクエストを待ち受けるサーバープロセスを開始します。
4. SQLite バックエンド (store パッケージ) は何をしている? - データの保存場所
-
データ永続化: FUSE ファイルシステムはメモリ上だけで完結することもできますが、今回はデータを永続化(プログラムを終了しても消えないように)するために SQLite を使っています。
storeパッケージがデータベースとのやり取りを担当します。 -
nodesテーブル: ファイルシステムの「骨格」となる情報を保存します。- 各行がファイルまたはディレクトリ (inode に相当) を表します。
-
id(一意な識別子)、parent_id(親ディレクトリのID)、name(ファイル/ディレクトリ名)、is_dir(ディレクトリかどうかのフラグ)、mode(パーミッション)、size(ファイルサイズ)、uid,gid(所有者情報)、atime,mtime,ctime(タイムスタンプ) などのカラムを持ちます。 - ディレクトリ構造は
parent_idを辿ることで再現できます。
-
file_dataテーブル: 実際のファイルの内容を保存します。-
node_idカラムでnodesテーブルのファイルエントリと紐づきます。 -
dataカラムにファイルの内容がバイナリデータ (BLOB) として格納されます。
-
-
役割分担:
fusefsパッケージ(FUSE のインターフェース実装)は、「どんな操作が要求されたか」に応じてstoreパッケージの関数 (GetNode,ListChildren,CreateNode,WriteDataなど) を呼び出します。storeパッケージが実際に SQL を実行して、データベースの読み書きを行います。
5. 具体的な処理の流れの例: echo "hi" > mnt/hello.txt
- シェルが
mnt/hello.txtを作成しようとします。 - カーネル -> FUSE モジュール ->
sqlitefsプロセスにLookupリクエスト ("hello.txt")。 -
sqliteRoot.Lookupが呼ばれ、store.GetChildNode(1, "hello.txt")を実行。 -
storeは DB を検索し、「見つからない」(store.ErrNotFound) を返す。 -
sqliteRoot.Lookupはsyscall.ENOENT(ファイルなし) を FUSE モジュールに返す。 - シェルはファイルがないことを理解し、次に
Createリクエストを送る。 - カーネル -> FUSE モジュール ->
sqlitefsプロセスにCreateリクエスト ("hello.txt", パーミッションなど)。 -
sqliteRoot.Createが呼ばれる。 - (内部で再度
store.GetChildNodeを呼び存在しないことを確認) -
store.CreateNodeを呼び出し、nodesテーブルにhello.txtのレコード (is_dir=false) を、file_dataテーブルに空のレコードを作成。DB から新しいnode_id(例: 2) が払い出される。 -
sqliteRoot.Createは、新しいファイルを表すsqliteFile(ID=2) と、ファイルを操作するためのsqliteHandleを作成し、FUSE モジュールに返す。 - シェルはファイルが開かれたことを認識し、次に
"hi\n"を書き込むWriteリクエストを送る。 - カーネル -> FUSE モジュール ->
sqlitefsプロセスにWriteリクエスト (ハンドル、データ"hi\n"、オフセット 0)。 -
sqliteHandle.Writeが呼ばれる。 -
store.WriteData(2, 0, []byte("hi\\n"))を呼び出す。 -
store.WriteDataはfile_dataテーブルのnode_id=2のdataを更新し、nodesテーブルのnode_id=2のsize,mtime,ctimeを更新する。新しいサイズ (3) を返す。 -
sqliteHandle.Writeは書き込んだバイト数 (3) を FUSE モジュールに返す。 - シェルは書き込みが成功したことを認識し、ファイルを閉じる (
Releaseリクエスト)。 - カーネル -> FUSE モジュール ->
sqlitefsプロセスにReleaseリクエスト。 -
sqliteHandle.Releaseが呼ばれる (今回は特に何もしない)。成功を返す。
このように、OS カーネル、FUSE モジュール、自作の sqlitefs プロセス (hanwen/go-fuse ライブラリ利用)、そして SQLite データベースが連携して、一連のファイル操作が実現されています。
少し長くなりましたが、これで全体の流れと各コンポーネントの役割が掴めたでしょうか? 不明な点があれば、さらに質問してください。
なるほどということは、通常は意識しないが xfs / afs / ext4 などを使っている場合は、カーネル空間にそれを制御するアプリが動いていて、ユーザが ls を叩くと kernel からの命令が各fsをコントロールするアプリ?に飛んで行ってるんですかね
はい、その理解は半分正しくて、半分は少し異なります。FUSEの仕組みを理解された上で、それを従来のファイルシステムに当てはめて考えてくださっているのは素晴らしいです!
-
共通点: ユーザーが
lsなどのコマンドを実行すると、OSのカーネルがその要求を受け取り、最終的にファイルシステム固有の処理が実行される、という大まかな流れは同じです。 -
大きな違い: XFS, APFS (AFSではなく、おそらくApple File Systemのことかと思います), ext4 といった従来の(ネイティブな)ファイルシステムは、FUSEのように「ユーザースペースで動く制御アプリ」を持っているわけではありません。これらのファイルシステムのロジックは、OSのカーネル空間に直接組み込まれたモジュール(ドライバ) として実装されています。
流れを比較するとこうなります。
1. FUSE ファイルシステム (今回の sqlitefs の場合):
ユーザー操作 (ls mnt/)
↓
カーネル (VFS: Virtual File System レイヤー)
↓
FUSE カーネルモジュール
↓ (カーネル空間 → ユーザースペースへの委譲)
sqlitefs プロセス (ユーザースペース) ← ここが「制御アプリ」
↓
SQLite データベース (ストレージ)
↓
(結果が逆向きに返る)
2. ネイティブファイルシステム (例: ext4):
ユーザー操作 (ls /home/user/)
↓
カーネル (VFS レイヤー)
↓ (カーネル内部での処理)
ext4 カーネルモジュール (カーネル空間) ← ロジックはカーネル内
↓
ブロックデバイス (HDD/SSD)
↓
(結果が逆向きに返る)
ポイント:
- VFS (Virtual File System): Linux や macOS などの多くの OS カーネルには VFS という共通のインターフェース層があります。ユーザーからのファイル操作要求はまず VFS が受け取ります。
- ファイルシステムドライバ: VFS は、操作対象のパスがどのファイルシステムに属しているかを判断し、そのファイルシステムを担当するカーネル内のドライバ(モジュール) に処理を依頼します。ext4 なら ext4 ドライバ、APFS なら APFS ドライバです。
- カーネル内で完結: ネイティブファイルシステムの場合、VFS から依頼を受けたドライバは、カーネル空間の中で ディスク上のデータ構造(inode やデータブロックなど)を直接読み書きして処理を完結させます。ユーザースペースの別の「制御アプリ」が動いていて、それに処理を依頼する、ということはありません。
- FUSEの特殊性: FUSE は、この VFS からの依頼をカーネル内の FUSE モジュールが受け取り、それをユーザースペースで動作している指定のプロセスに転送する、という点が特殊なのです。これにより、カーネルモジュールを直接開発することなく、比較的安全かつ容易に新しいファイルシステムを実装できます。
ですので、「カーネルから命令が各 FS をコントロールするモジュールに飛んで行ってる」という点では正しいですが、そのモジュールがネイティブファイルシステムの場合はカーネル空間にあり、FUSE の場合はユーザースペースのプロセスである、という違いがあります。
この説明で、FUSE とネイティブファイルシステムの違いがより明確になったでしょうか?
はい、今回作成したFUSEファイルシステムにおける「node (ノード)」は、文脈によっていくつかの意味合いで使われています。整理して説明しますね。
-
ファイルシステムの基本概念としての「ノード (Inode)」:
- 一般的なファイルシステム(ext4, APFS, NTFS など)では、「inode (アイノード、index node)」という概念があります。これは、ファイルやディレクトリといったファイルシステム上のオブジェクト一つ一つを表す管理情報のことです。
- inode には、そのオブジェクトの メタデータ (種類[ファイルかディレクトリか]、パーミッション、所有者ID、グループID、サイズ、タイムスタンプ、そして実際のデータがディスクのどこにあるかを示すポインタなど) が格納されています。
-
重要な点: ファイル名自体は通常 inode には含まれず、ディレクトリのデータ部分に「ファイル名と対応する inode 番号」のリストとして格納されます。
lsコマンドは、このディレクトリ情報を読み取ってファイル名を表示し、ls -lなどでは inode 番号を使って inode 情報を取得し、詳細を表示します。
-
データベース上の表現:
models.Nodeとnodesテーブル:- 今回のプロジェクトでは、SQLite データベースを使ってファイルシステムの構造を保存しています。
-
storeパッケージ内のmodels/node.goで定義したmodels.Node構造体が、データベース上の表現に対応します。 - この構造体は、
nodesテーブルのカラム(id,parent_id,name,is_dir,mode,size,uid,gid,atime,mtime,ctime)にマッピングされます。 - つまり、
models.Nodeは、我々のファイルシステムにおける 永続化された inode 情報 と考えることができます。ただし、本来の inode と違い、便宜上nameも含んでいます。
-
FUSE ライブラリ内の表現:
fs.Inode(hanwen/go-fuse/v2)- 使用した Go FUSE ライブラリ (
hanwen/go-fuse/v2) も、内部でファイルシステム上のノードを管理するためのfs.Inodeという構造体を持っています。 - これは、FUSE カーネルモジュールと通信し、ノードの状態 (例えば、カーネルがどのノードを認識しているか、参照カウントなど) を管理するためのライブラリ内部の表現です。
- 我々が実装した
sqliteNodeは、このfs.Inodeを埋め込む (embed) ことで、FUSE ライブラリの機能と連携しています。
- 使用した Go FUSE ライブラリ (
-
我々の実装における表現:
sqliteNode,sqliteRoot,sqliteDir,sqliteFile-
fusefsパッケージ内で定義したこれらの構造体が、FUSE ライブラリからのリクエストに応答する具体的な実装です。 -
sqliteNode:-
fs.Inodeを埋め込み、FUSE ライブラリとの基本的な接続を提供します。 -
store.Store(データベースアクセス用) と*models.Node(DBから読み込んだメタデータ) への参照を持ちます。 -
Getattrのような共通の操作の一部を実装します。
-
-
sqliteRoot,sqliteDir,sqliteFile:- それぞれファイルシステムのルート、ディレクトリ、ファイルを表します。
-
sqliteNodeを埋め込み、共通機能を引き継ぎます。 - それぞれの種類に応じた FUSE 操作 (インターフェース) を実装します。例えば、
sqliteDirはLookup,Readdir,Mkdirなどを、sqliteFileはOpen,Read,Writeなどを実装します。
-
まとめ:
今回のプロジェクトにおける「node」は、
-
データベース上:
models.Node構造体とnodesテーブルとして存在し、ファイル/ディレクトリのメタデータを永続的に保存します。 -
FUSEライブラリ内:
fs.Inodeとして存在し、カーネルとの通信状態を管理します。 -
我々のGoコード内:
sqliteNodeをベースとし、具体的な種類 (sqliteRoot,sqliteDir,sqliteFile) として存在し、FUSE 操作に対する実際の振る舞い (ロジック) を実装します。これらの構造体がfs.Inodeを埋め込み、models.Nodeを参照することで、全体が連携しています。
このように、異なる層で「ノード」という概念がそれぞれの役割を持って表現されている、と理解すると分かりやすいかと思います。
はい、承知いたしました。各コンポーネントの関係性を Mermaid を使って図示します。
図の説明:
-
ユーザー操作:
lsやechoなどのコマンドが起点です。 -
OS カーネル空間:
- カーネル (VFS) が操作を受け付け、それが FUSE マウントポイントに対するものだと認識します。
- FUSE カーネルモジュールに処理を依頼します。
-
ユーザースペース (
sqlitefsプロセス):-
FUSE ライブラリ: FUSE カーネルモジュールからの低レベルな要求を受け取り、Go のインターフェース (
fs.NodeLookuperなど) に変換して呼び出します。ノードの状態 (fs.Inode) も管理します。 -
ファイルシステム実装 (
fusefs): 私たちが実装した部分です。ライブラリのインターフェースを実装し、具体的な処理を行います (sqliteNodeをベースにsqliteRoot,sqliteDir,sqliteFile,sqliteHandleが対応)。 -
データストア層 (
store): ファイルシステム実装からの要求を受け、データベース (SQLiteDB) に対する実際の読み書き (SQL実行) を行います。 -
データモデル (
models): データベースのテーブル構造 (nodes) に対応する Go の構造体 (models.Node) を定義します。
-
FUSE ライブラリ: FUSE カーネルモジュールからの低レベルな要求を受け取り、Go のインターフェース (
- SQLite データベース: 実際のメタデータとファイル内容が保存される場所です。
処理の流れ (例: ls mnt/)
-
lsコマンド実行。 - カーネル (VFS) が FUSE モジュールに
mnt/のReaddir(ディレクトリ内容読み取り) を要求。 - FUSE モジュールが
sqlitefsプロセスにReaddirリクエストを送信。 - Go FUSE ライブラリがリクエストを受け取り、
sqliteRoot(またはsqliteDir) のReaddirメソッドを呼び出す。 -
ReaddirメソッドはstoreパッケージのListChildren関数を呼び出す。 -
ListChildrenは SQLite DB のnodesテーブルをSELECTし、子ノードのリストを取得して返す。 -
Readdirメソッドは取得したリストを FUSE が要求する形式に変換し、ライブラリに返す。 - ライブラリ → FUSE モジュール → カーネル (VFS) →
lsコマンド へ結果が返され、ファイル名が表示される。
この図で、各コンポーネントがどのように連携しているかイメージが掴みやすくなったでしょうか?
