Closed17

100日チャレンジ day36 (FUSE+SQLiteの独自ファイルシステム)

riddle_tecriddle_tec

昨日
https://zenn.dev/gin_nazo/scraps/1ef0285d9a2dab


https://blog.framinal.life/entry/2025/04/14/154104

100日チャレンジに感化されたので、アレンジして自分でもやってみます。

やりたいこと

  • 世の中のさまざまなドメインの簡易実装をつくり、バックエンドの実装に慣れる(dbスキーマ設計や、関数の分割、使いやすいインターフェイスの切り方に慣れる
  • 設計力(これはシステムのオーバービューを先に自分で作ってaiに依頼できるようにする
  • 生成aiをつかったバイブコーティングになれる
  • 実際にやったことはzennのスクラップにまとめ、成果はzennのブログにまとめる(アプリ自体の公開は必須ではないかコードはgithubにおく)

できたもの


https://github.com/lirlia/100day_challenge_backend/tree/main/day36_fuse_sqlite_fs_go

riddle_tecriddle_tec

なるほど!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/sql
    • github.com/mattn/go-sqlite3
  • UI: なし。OS標準のコマンドラインツール (ls, mkdir, cat, echo, rm など) やファイルエクスプローラー (Finder, Explorer等) からマウントポイントを通じて操作する。
  • ユーザー分離: 今回は実装スコープ外とし、単一のファイルシステム空間とします。

作業順序 (FUSE版):

  1. プロジェクト初期化:
    • day36_fuse_sqlite_fs_go ディレクトリを作成します。
    • go mod init github.com/lirlia/100day_challenge_backend/day36_fuse_sqlite_fs_go を実行します。
    • go get bazil.org/fusego get github.com/mattn/go-sqlite3 を実行します。
    • main.go (マウント処理)、fs.go (FUSEインターフェース実装)、store.go (SQLite操作)、db/schema.sql (DBスキーマ) のようなファイル構造を作成します。
    • README.md にアプリ概要を記述します。
  2. データモデリングとDB設定:
    • db/schema.sql に、ファイルとディレクトリの情報を格納するテーブルスキーマを定義します。inode、名前、親inode、種類(ファイル/ディレクトリ)、サイズ、パーミッション、タイムスタンプ、ファイル内容(BLOBまたは別テーブル)などを考慮します。
    • store.go に、DB接続・初期化処理と、FUSE操作に対応するSQLite操作関数(例: CreateNode, ReadNode, WriteNodeData, ListDir, GetNodeAttributes)を実装します。
  3. 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)の管理が重要になります。
  4. マウント処理 (main.go):
    • コマンドライン引数からマウントポイントのパスを受け取ります。
    • SQLiteデータベースを初期化します(必要ならスキーマ適用も)。
    • fuse.Mount を呼び出して、実装したファイルシステム (fs.goFS 構造体) を指定されたマウントポイントに接続します。
    • 適切にアンマウントできるようにシグナルハンドリング(Ctrl+Cなど)を実装します。
  5. ビルドとテスト:
    • 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データベースの内容が更新されるかを確認します。
  6. ドキュメント作成: README.md に使い方や実装の詳細を記述し、knowledge.mdc を更新します。

このFUSEを使った実装方針で進めましょう。準備ができましたら、まずプロジェクトの初期化から始めます。

riddle_tecriddle_tec

作業手順

承知しました。複雑なプロジェクトになるため、詳細な計画とステップごとのテストは非常に重要ですね。以下にディレクトリ構造、ファイル一覧、そしてテストを組み込んだ具体的な作業ステップを提案します。

ディレクトリ構造案:

/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 とストア)

  1. プロジェクト初期化と依存関係:
    • 実装:
      • day36_fuse_sqlite_fs_go ディレクトリ作成。
      • go mod init github.com/lirlia/100day_challenge_backend/day36_fuse_sqlite_fs_go 実行。
      • go get bazil.org/fusego get github.com/mattn/go-sqlite3 実行。
      • 上記ディレクトリ構造の空ファイルとディレクトリを作成 (.gitignore, README.md も)。
    • テスト: なし (初期設定)。
  2. 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)。
    • テスト: スキーマの妥当性を目視確認。
  3. 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: ヘルパーを使い NewDBConnectionInitializeSchema がエラーなく実行できるかテスト。
  4. データモデル定義 (models/node.go):
    • 実装: inodes テーブルに対応する Node 構造体を定義(Goの型で)。タイムスタンプは time.Time
    • テスト: なし (構造体定義のみ)。
  5. ノードストア実装 (store/node_store.gostore/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.goNodeStore の各メソッドに対するユニットテストを作成 (CRUD操作、親子関係、存在しない場合の挙動など)。
  6. データストア実装 (store/data_store.gostore/store.go):
    • 実装:
      • store/store.go: DataStore インターフェースを定義 (ReadData, WriteData, DeleteData)。
      • store/data_store.go: DataStore インターフェースを実装する構造体とメソッドを定義。ReadData は指定オフセット/サイズで file_data から SELECTWriteDataREPLACE INTO または INSERT OR REPLACE でデータを書き込み、関連する inodes テーブルの sizemtime も更新 (トランザクション考慮)。DeleteDataDELETE
    • テスト: tests/store_test.goDataStore の各メソッドに対するユニットテストを作成 (読み書き、オフセット、サイズ更新、データ削除など)。

Phase 2: FUSE 実装

  1. FUSE FS実装 (fusefs/fs.go):
    • 実装:
      • FileSystem 構造体を定義 (NodeStore, DataStore を保持)。
      • fuse/fs.FS インターフェースの Root() メソッドを実装。NodeStore.GetNode(1) でルートノードを取得し、fusefs.Dir (次のステップで作成) として返す。
    • テスト: この時点では統合テストのみ可能。
  2. 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を使用。
    • テスト: 統合テストでの確認が主。
  3. 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 を使用。ファイル削除と空ディレクトリ削除に対応)。
    • テスト: 統合テストでの確認が主。
  4. 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 を作成して返す)。
    • テスト: 統合テストでの確認が主。
  5. 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: 結合とテスト

  1. メイン処理とマウント (main.go):

    • 実装:
      • flag パッケージ等でコマンドライン引数(マウントポイント、DBファイルパス)を処理。
      • store.NewDBConnection, store.InitializeSchema を呼び出しDB準備。
      • NodeStore, DataStore のインスタンスを作成。
      • fusefs.NewFileSystem (コンストラクタ) でファイルシステムインスタンス作成。
      • fuse.Mount を呼び出しマウント実行。エラーハンドリング。
      • signal.NotifySIGINT, SIGTERM を捕捉し、fuse.Unmount を呼び出す後処理を追加。
    • テスト (結合テスト):
      • go build で実行ファイル作成。
      • マウントポイント作成 (mkdir ./mnt)。
      • マウント (sudo ./day36_fuse_sqlite_fs_go -db ./db/dev.db ./mnt または FUSEの設定によっては sudo 不要)。
      • 別ターミナルでマウントポイント (./mnt) に対して以下のコマンドを実行し、エラーなく動作すること、DBの内容が意図通り変更されることを確認:
        • ls -la ./mnt
        • mkdir ./mnt/testdir
        • ls -l ./mnt
        • echo "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.txt
        • rmdir ./mnt/testdir
        • ls -la ./mnt (空になっていること)
      • Ctrl+Cでプロセスを停止し、正常にアンマウントされることを確認。
  2. ドキュメントと最終化:

    • 実装: README.md にビルド方法、マウント方法、使い方、制限事項(未実装機能など)を記述。knowledge.mdc を更新。コード内のコメントを整備。
    • テスト: 全体の動作を再確認。

このステップで進めていきましょう。まずは Phase 1 のステップ1から始めます。準備ができたら指示してください。

riddle_tecriddle_tec
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
riddle_tecriddle_tec
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

なんか成功した

riddle_tecriddle_tec

できた〜〜

macFuse 経由でマウントとして sqlite をバックエンドにしたファイルシステム!

riddle_tecriddle_tec

今回やったこと

はい、承知いたしました。FUSE ファイルシステム、特に今回の Go と SQLite を使った実装がどのように動いているのか、基本的な仕組みと使用したツール(ライブラリ)の役割を説明しますね。

1. FUSE とは? - ファイルシステムを「自作」する仕組み

  • FUSE (Filesystem in Userspace): 通常、ファイルシステム(データを整理し、ファイルとしてアクセス可能にする仕組み)は OS のカーネルというコア部分で動作します。FUSE は、このファイルシステムのロジックを カーネルの外 、つまり通常のアプリケーションと同じ「ユーザースペース」で開発・実行できるようにする仕組みです。
  • 仲介役: OS カーネルには FUSE をサポートするためのモジュール (macOS なら macfuse、Linux なら fuse) があり、これがユーザースペースで作られたファイルシステムプロセス (今回は sqlitefs) とカーネルの間の通信を仲介します。

2. どうやって動いているの? - OS と自作プログラムの連携プレー

ユーザーがマウントポイント (今回は mnt ディレクトリ) に対してファイル操作を行うと、以下のような流れで処理が進みます。

  1. ユーザー操作: あなたがターミナルで ls mnt/echo "hello" > mnt/file.txt などのコマンドを実行します。
  2. カーネルが検知: OS カーネルが、/path/to/mnt ディレクトリへの操作要求を検知します。
  3. FUSE モジュールへ: カーネルは、mnt が FUSE によってマウントされていることを知っているので、その操作要求を FUSE カーネルモジュールに渡します。
  4. sqlitefs へリクエスト: FUSE カーネルモジュールは、マウント時に指定された我々のプログラム sqlitefs (ユーザースペースで動いているプロセス) に対して、「mnt ディレクトリの内容を教えて (Readdir)」や「mnt/file.txt を作成して (Create)」といった具体的なリクエストを送ります。
  5. sqlitefs の処理:
    • sqlitefs プログラムは FUSE モジュールからのリクエストを受け取ります。
    • リクエスト内容に応じて、実装した処理を実行します。例えば、「mnt ディレクトリの内容を教えて」なら、SQLite データベース (db/dev.db) の nodes テーブルを検索して、該当するファイルやディレクトリのリストを取得します。
    • mnt/file.txt を作成して」なら、nodes テーブルに新しいファイルのエントリを追加し、file_data テーブルにも(最初は空の)データエントリを追加します。
  6. sqlitefs から応答: 処理結果(成功したか、エラーか、要求されたデータは何かなど)を FUSE カーネルモジュールに返します。
  7. カーネルへ応答: FUSE カーネルモジュールは、sqlitefs からの応答を OS カーネルに伝えます。
  8. ユーザーへ反映: OS カーネルは、受け取った結果を最終的にユーザーが実行したコマンド(lsecho)に返します。これにより、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

  1. シェルが mnt/hello.txt を作成しようとします。
  2. カーネル -> FUSE モジュール -> sqlitefs プロセスに Lookup リクエスト ("hello.txt")。
  3. sqliteRoot.Lookup が呼ばれ、store.GetChildNode(1, "hello.txt") を実行。
  4. store は DB を検索し、「見つからない」(store.ErrNotFound) を返す。
  5. sqliteRoot.Lookupsyscall.ENOENT (ファイルなし) を FUSE モジュールに返す。
  6. シェルはファイルがないことを理解し、次に Create リクエストを送る。
  7. カーネル -> FUSE モジュール -> sqlitefs プロセスに Create リクエスト ("hello.txt", パーミッションなど)。
  8. sqliteRoot.Create が呼ばれる。
  9. (内部で再度 store.GetChildNode を呼び存在しないことを確認)
  10. store.CreateNode を呼び出し、nodes テーブルに hello.txt のレコード (is_dir=false) を、file_data テーブルに空のレコードを作成。DB から新しい node_id (例: 2) が払い出される。
  11. sqliteRoot.Create は、新しいファイルを表す sqliteFile (ID=2) と、ファイルを操作するための sqliteHandle を作成し、FUSE モジュールに返す。
  12. シェルはファイルが開かれたことを認識し、次に "hi\n" を書き込む Write リクエストを送る。
  13. カーネル -> FUSE モジュール -> sqlitefs プロセスに Write リクエスト (ハンドル、データ "hi\n"、オフセット 0)。
  14. sqliteHandle.Write が呼ばれる。
  15. store.WriteData(2, 0, []byte("hi\\n")) を呼び出す。
  16. store.WriteDatafile_data テーブルの node_id=2data を更新し、nodes テーブルの node_id=2size, mtime, ctime を更新する。新しいサイズ (3) を返す。
  17. sqliteHandle.Write は書き込んだバイト数 (3) を FUSE モジュールに返す。
  18. シェルは書き込みが成功したことを認識し、ファイルを閉じる (Release リクエスト)。
  19. カーネル -> FUSE モジュール -> sqlitefs プロセスに Release リクエスト。
  20. sqliteHandle.Release が呼ばれる (今回は特に何もしない)。成功を返す。

このように、OS カーネル、FUSE モジュール、自作の sqlitefs プロセス (hanwen/go-fuse ライブラリ利用)、そして SQLite データベースが連携して、一連のファイル操作が実現されています。

少し長くなりましたが、これで全体の流れと各コンポーネントの役割が掴めたでしょうか? 不明な点があれば、さらに質問してください。

riddle_tecriddle_tec

なるほどということは、通常は意識しないが 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 とネイティブファイルシステムの違いがより明確になったでしょうか?

riddle_tecriddle_tec

はい、今回作成したFUSEファイルシステムにおける「node (ノード)」は、文脈によっていくつかの意味合いで使われています。整理して説明しますね。

  1. ファイルシステムの基本概念としての「ノード (Inode)」:

    • 一般的なファイルシステム(ext4, APFS, NTFS など)では、「inode (アイノード、index node)」という概念があります。これは、ファイルやディレクトリといったファイルシステム上のオブジェクト一つ一つを表す管理情報のことです。
    • inode には、そのオブジェクトの メタデータ (種類[ファイルかディレクトリか]、パーミッション、所有者ID、グループID、サイズ、タイムスタンプ、そして実際のデータがディスクのどこにあるかを示すポインタなど) が格納されています。
    • 重要な点: ファイル名自体は通常 inode には含まれず、ディレクトリのデータ部分に「ファイル名と対応する inode 番号」のリストとして格納されます。ls コマンドは、このディレクトリ情報を読み取ってファイル名を表示し、ls -l などでは inode 番号を使って inode 情報を取得し、詳細を表示します。
  2. データベース上の表現: models.Nodenodes テーブル:

    • 今回のプロジェクトでは、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 も含んでいます。
  3. FUSE ライブラリ内の表現: fs.Inode (hanwen/go-fuse/v2)

    • 使用した Go FUSE ライブラリ (hanwen/go-fuse/v2) も、内部でファイルシステム上のノードを管理するための fs.Inode という構造体を持っています。
    • これは、FUSE カーネルモジュールと通信し、ノードの状態 (例えば、カーネルがどのノードを認識しているか、参照カウントなど) を管理するためのライブラリ内部の表現です。
    • 我々が実装した sqliteNode は、この fs.Inode埋め込む (embed) ことで、FUSE ライブラリの機能と連携しています。
  4. 我々の実装における表現: sqliteNode, sqliteRoot, sqliteDir, sqliteFile

    • fusefs パッケージ内で定義したこれらの構造体が、FUSE ライブラリからのリクエストに応答する具体的な実装です。
    • sqliteNode:
      • fs.Inode を埋め込み、FUSE ライブラリとの基本的な接続を提供します。
      • store.Store (データベースアクセス用) と *models.Node (DBから読み込んだメタデータ) への参照を持ちます。
      • Getattr のような共通の操作の一部を実装します。
    • sqliteRoot, sqliteDir, sqliteFile:
      • それぞれファイルシステムのルート、ディレクトリ、ファイルを表します。
      • sqliteNode を埋め込み、共通機能を引き継ぎます。
      • それぞれの種類に応じた FUSE 操作 (インターフェース) を実装します。例えば、sqliteDirLookup, Readdir, Mkdir などを、sqliteFileOpen, Read, Write などを実装します。

まとめ:

今回のプロジェクトにおける「node」は、

  • データベース上: models.Node 構造体と nodes テーブルとして存在し、ファイル/ディレクトリのメタデータを永続的に保存します。
  • FUSEライブラリ内: fs.Inode として存在し、カーネルとの通信状態を管理します。
  • 我々のGoコード内: sqliteNode をベースとし、具体的な種類 (sqliteRoot, sqliteDir, sqliteFile) として存在し、FUSE 操作に対する実際の振る舞い (ロジック) を実装します。これらの構造体が fs.Inode を埋め込み、models.Node を参照することで、全体が連携しています。

このように、異なる層で「ノード」という概念がそれぞれの役割を持って表現されている、と理解すると分かりやすいかと思います。

riddle_tecriddle_tec

はい、承知いたしました。各コンポーネントの関係性を Mermaid を使って図示します。

図の説明:

  1. ユーザー操作: lsecho などのコマンドが起点です。
  2. OS カーネル空間:
    • カーネル (VFS) が操作を受け付け、それが FUSE マウントポイントに対するものだと認識します。
    • FUSE カーネルモジュールに処理を依頼します。
  3. ユーザースペース (sqlitefs プロセス):
    • FUSE ライブラリ: FUSE カーネルモジュールからの低レベルな要求を受け取り、Go のインターフェース (fs.NodeLookuper など) に変換して呼び出します。ノードの状態 (fs.Inode) も管理します。
    • ファイルシステム実装 (fusefs): 私たちが実装した部分です。ライブラリのインターフェースを実装し、具体的な処理を行います (sqliteNode をベースに sqliteRoot, sqliteDir, sqliteFile, sqliteHandle が対応)。
    • データストア層 (store): ファイルシステム実装からの要求を受け、データベース (SQLiteDB) に対する実際の読み書き (SQL実行) を行います。
    • データモデル (models): データベースのテーブル構造 (nodes) に対応する Go の構造体 (models.Node) を定義します。
  4. SQLite データベース: 実際のメタデータとファイル内容が保存される場所です。

処理の流れ (例: ls mnt/)

  1. ls コマンド実行。
  2. カーネル (VFS) が FUSE モジュールに mnt/Readdir (ディレクトリ内容読み取り) を要求。
  3. FUSE モジュールが sqlitefs プロセスに Readdir リクエストを送信。
  4. Go FUSE ライブラリがリクエストを受け取り、sqliteRoot (または sqliteDir) の Readdir メソッドを呼び出す。
  5. Readdir メソッドは store パッケージの ListChildren 関数を呼び出す。
  6. ListChildren は SQLite DB の nodes テーブルを SELECT し、子ノードのリストを取得して返す。
  7. Readdir メソッドは取得したリストを FUSE が要求する形式に変換し、ライブラリに返す。
  8. ライブラリ → FUSE モジュール → カーネル (VFS) → ls コマンド へ結果が返され、ファイル名が表示される。

この図で、各コンポーネントがどのように連携しているかイメージが掴みやすくなったでしょうか?

このスクラップは2ヶ月前にクローズされました