LoginSignup
11
2

More than 3 years have passed since last update.

Rustで学ぶFUSE (2) ファイルの書き込み

Last updated at Posted at 2020-01-30

ReadWrite

RustでFUSEを利用したファイルシステムを作成する記事の2回目です。
最新のソースコードはこちらにあります。

記事一覧

Rustで学ぶFUSE (1) リードオンリーなファイルシステムの実装
Rustで学ぶFUSE (2) ファイルの書き込み
Rustで学ぶFUSE (3) ファイル作成と削除
Rustで学ぶFUSE (4) ディレクトリ作成と削除
Rustで学ぶFUSE (5) 名前の変更と移動

概要

前回は、ファイルの読み込みができるファイルシステムを作成しました。
今回は、それに加えてファイルの書き込みができるようにします。

必要なのは以下の関数です。
ファイルサイズの変更を受け付ける必要があるので、今回の目的である write だけでなく、ファイルのメタデータを変更する setattr も実装する必要があります。

fn write(&mut self, _req: &Request<'_>, _ino: u64, _fh: u64, _offset: i64, _data: &[u8], _flags: u32, reply: ReplyWrite) {
    ...
}

fn setattr(&mut self, _req: &Request<'_>, _ino: u64, _mode: Option<u32>, _uid: Option<u32>, _gid: Option<u32>, _size: Option<u64>, _atime: Option<Timespec>, _mtime: Option<Timespec>, _fh: Option<u64>, _crtime: Option<Timespec>, _chgtime: Option<Timespec>, _bkuptime: Option<Timespec>, _flags: Option<u32>, reply: ReplyAttr) {
    ...
}

なお、以下では実装する関数と同名のシステムコールと区別をつけるために、 システムコールは write(2) のように、末尾に(2)を付けた表記をします。

DB関数

今回追加したDB側の関数は以下になります。

    /// inodeのメタデータを更新する。ファイルサイズが縮小する場合はtruncateをtrueにする
    fn update_inode(&self, attr: DBFileAttr, truncate: bool) -> Result<(), Error>;

    /// 1ブロック(4096byte)分のデータを書き込む
    fn write_data(&self, inode:u32, block: u32, data: &[u8], size: u32) -> Result<(), Error>;

write

fn write(
    &mut self,
    _req: &Request<'_>,
    ino: u64,
    fh: u64,
    offset: i64,
    data: &[u8],
    flags: u32,
    reply: ReplyWrite
);

ファイルにデータを書き込むための関数です。
引数の ino で指定されたinode番号を持つファイルに、引数の data で渡ってきたデータを書き込みます。書き込み位置は引数の offsetで指示されます。

C言語で write(2) のようなシステムコールを使う場合はファイルオフセットが今どこを指しているかを意識する必要がありますが、FUSEではカーネルがオフセットの管理をしてくれていて、常に引数でオフセットが指示されるので、 pwrite(2) 相当の関数を一つ実装するだけで済むようになっています。

マウントオプション direct_io を使用していない場合(通常はこちら)、エラーを返す場合を除いて、write 関数は 渡されたデータのサイズと同じ数字を引数のreply に入れて返さないといけません。つまり、何らかの事情で渡されたデータをすべて書き込めなかった場合、エラーにする必要があります。
マウントオプションdirect_ioを使用している場合は、途中までしか書き込めなかった場合でも実際に書き込んだバイト数を返します。

引数の fh はファイルの open 時にファイルシステムが指定した値です。今回はまだopenを実装していないので、常に0になります。

引数の flags は書き込み時のフラグです。以下の2種類が設定される可能性があります。

  1. FUSE_WRITE_CACHE : ページキャッシュからの書き込みです。引数のfh や、reqが正しくない可能性があります。
  2. FUSE_WRITE_LOCKOWNER: lock_owner 引数が正しい事を示しています…が、fuse-rs ではこの引数が実装されていません。

ちなみに、cpコマンドなどでファイルを上書きすると頭から後ろまで順番にwrite 関数が実行されますが、アプリケーションによってはファイルの一部分だけ更新する、という処理や、ばらばらの順番で書き込む、という処理はよく発生します。書き込みをキャッシュしたりしている場合は気をつけてください。

O_APPEND

ファイルを open した時のフラグに O_APPEND が設定されている場合は適切に処理しなければなりません。まだ open を実装していませんが、説明しておきます。

ライトバックキャッシュが有効か無効かの場合で動作が異なります。マウントオプションに -o writeback がある場合、ライトバックキャッシュが有効になっています。

ライトバックキャッシュが無効の時、ファイルシステムは O_APPEND を検知して、全ての write の中で offset の値にかかわらずデータがファイル末尾に追記されるようにチェックします。
ライトバックキャッシュが有効の時、 offset はカーネルが適切に設定してくれます。 O_APPEND は無視してください。

(今のところ)カーネルはO_APPENDが設定されていても offset をきちんと設定してくれるようです。なので、現状は O_APPEND は無視し、 open 実装時に対応します。
ただし、ネットワーク上のストレージを利用しているファイルシステムなどで複数のマシンから書き込みがあった場合、カーネルの認知しているファイル末尾と実際のファイル末尾がずれるので、問題が発生します。
こういった問題が発生しうるファイルシステムを作る場合は、O_APPENDに適切に対処する必要があります。

ここまでのコード

fn write(&mut self, _req: &Request<'_>, ino: u64, _fh: u64, offset: i64, data: &[u8], flags: u32, reply: ReplyWrite) {
    let block_size = self.db.get_db_block_size();
    let ino = ino as u32;
    let size = data.len() as u32;
    let offset = offset as u32;
    let start_block = offset / block_size + 1;
    let end_block = (offset + size - 1) / block_size + 1;
    // 各ブロックに書き込む
    for i in start_block..=end_block {
        let mut block_data: Vec<u8> = Vec::with_capacity(block_size as usize);
        let b_start_index = if i == start_block {offset % block_size} else {0};
        let b_end_index = if i == end_block {(offset+size-1) % block_size +1} else {block_size};
        let data_offset = (i - start_block) * block_size;

        // 書き込みがブロック全体に及ばない場合、一度ブロックのデータを読み込んで隙間を埋める
        if (b_start_index != 0) || (b_end_index != block_size) {
            let mut data_pre = match self.db.get_data(ino, i, block_size) {
                Ok(n) => n,
                Err(err) => {reply.error(ENOENT); debug!("{}", err); return;}
            };
            if data_pre.len() < block_size as usize {
                data_pre.resize(block_size as usize, 0);
            }
            if b_start_index != 0 {
                block_data.extend_from_slice(&data_pre[0..b_start_index as usize]);
            }
            block_data.extend_from_slice(&data[data_offset as usize..(data_offset + b_end_index - b_start_index) as usize]);
            if b_end_index != block_size {
                block_data.extend_from_slice(&data_pre[b_end_index as usize..block_size as usize]);
            }
        } else {
            block_data.extend_from_slice(&data[data_offset as usize..(data_offset + block_size) as usize]);
        }
        // ここで書き込む。必要な場合はwrite_data内でファイルサイズの更新が行われる
        match self.db.write_data(ino, i, &block_data, (i-1) * block_size + b_end_index) {
            Ok(n) => n,
            Err(err) => {reply.error(ENOENT); debug!("{}", err); return;}
        }
    }
    // 書き込み後のサイズをreplyに入れる
    reply.written(size);
}

マウントオプション

main.rs のReadOnlyのマウントオプションを削除します。

let options = ["-o", "fsname=sqlitefs"]
    .iter()
    .map(|o| o.as_ref())
    .collect::<Vec<&OsStr>>();

実行結果

現状ではファイルの新規作成ができないので、前回作成したデータベースファイルを使って、 hello.txt の末尾にデータを追記します。

  • コマンド

# hello.txtに追記する
$ echo "append" >> ~/mount/hello.txt
# 追記された内容の確認
$ cat ~/mount/hello.txt
Hello world!
append
  • FUSEログ

write 関数が呼ばれている事が分かります。

[2019-10-28T11:52:08Z DEBUG fuse::request] INIT(2)   kernel: ABI 7.31, flags 0x3fffffb, max readahead 131072
[2019-10-28T11:52:08Z DEBUG fuse::request] INIT(2) response: ABI 7.8, flags 0x1, max readahead 131072, max write 16777216
[2019-10-28T11:52:14Z DEBUG fuse::request] LOOKUP(4) parent 0x0000000000000001, name "hello.txt"
[2019-10-28T11:52:14Z DEBUG fuse::request] OPEN(6) ino 0x0000000000000002, flags 0x8401
[2019-10-28T11:52:14Z DEBUG fuse::request] FLUSH(8) ino 0x0000000000000002, fh 0, lock owner 9742156966771960265
[2019-10-28T11:52:14Z DEBUG fuse::request] GETXATTR(10) ino 0x0000000000000002, name "security.capability", size 0
[2019-10-28T11:52:14Z DEBUG fuse::request] WRITE(12) ino 0x0000000000000002, fh 0, offset 13, size 7, flags 0x0
[2019-10-28T11:52:14Z DEBUG fuse::request] RELEASE(14) ino 0x0000000000000002, fh 0, flags 0x8401, release flags 0x0, lock owner 0

read, write時のメタデータの更新

一般的にファイルを read した時にはメタデータの atime を更新します。
また、 write した時には、 size (必要な場合) mtime ctime の3つを更新します。
DB関数側でこれらを更新できるようにしておきます。

今回はメタデータもデータもデータベース上にあり、元々パフォーマンスが悪い事が予想されるのであまり意識していませんが、メタデータの更新にある程度コストがかかる場合、ファイルサイズとタイムスタンプはメモリ上にキャッシュした方がいいです。
キャッシュを書き込むのは flush が呼ばれたタイミングです。

マウントオプションで -o noatime が指定された場合、 atime の更新は行いません。後々マウントオプションを追加する時に対応します。

ファイルサイズ

write 時に書き込まれたデータの末尾が、既存のファイルサイズより大きくなる場合は、ファイルサイズを更新する必要があります。
また、書き込みのオフセットに現在のファイルサイズより大きい値が指定された場合、ファイルに何も書かれていない穴ができます。このエリアのデータが読まれた場合、ファイルシステムは0(NULLバイト)の列を返す必要があります。0埋めしたデータを書き込むか、そもそもその部分のデータを作らずに、読み込み時に検出して0のデータを返すようにします。
後者の方法はスパースファイルといい、見かけ上のファイルサイズより実ファイルサイズを小さくする事ができます。

ちなみに、ファイルのブロックをばらばらの順番で書き込む、というのはよくある処理なので、0バイト目から順番に書き込まれていく事は期待しないでください。特に write 実行時にファイルサイズを変更する場合は、現在のファイルサイズと比較して小さくならないように気をつけてください。

タイムスタンプ

各関数とどのタイムスタンプを更新すべきかの対応表です。
Linuxプログラミングインターフェースのシステムコールとタイムスタンプの対応表を参考に、FUSEの関数にマップしました。
左側の a, m, c が操作対象のファイルまたはディレクトリのタイムスタンプ、右側の a, m, c は親ディレクトリのタイムスタンプです。

関数名 a m c 親a 親m 親c 備考
setattr o
setattr(*) o o * ファイルサイズが変わる場合
link o o o
mkdir o o o o o
mknod o o o o o
create o o o o o
read o
readdir o
setxattr o
removexattr o
rename o o o 移動前/移動後の両方の親ディレクトリを変更
rmdir o o
symlink o o o o o リンク自体のタイムスタンプで、リンク先は変更しない
unlink o o o 参照カウントが2以上でinode自体が消えない場合、ファイルのctimeを更新
write o o

setattr

fn setattr(
    &mut self,
    _req: &Request<'_>,
    ino: u64, // 更新対象のinode番号
    mode: Option<u32>, // アクセス権
    uid: Option<u32>, // ファイル所有者のUID
    gid: Option<u32>, // ファイル所有グループのUID
    size: Option<u64>, // ファイルサイズ
    atime: Option<Timespec>, // 最終アクセス時刻
    mtime: Option<Timespec>, // 最終更新時刻
    _fh: Option<u64>,
    crtime: Option<Timespec>, // mac用
    chgtime: Option<Timespec>, // mac用
    bkuptime: Option<Timespec>, // mac用
    flags: Option<u32>, // mac用
    reply: ReplyAttr
);

write は実装しましたが、このままでは追記しかできません。テキストエディタでファイルを開いて編集したりすると問題が起こります。多くのプログラムでは、ファイルの更新は一度ディスク上のファイルの内容を消して、メモリ上のデータをすべて書き込む…といった処理をしているからです。
ファイルを丸ごと更新するために、ファイルサイズを0にする(truncateに相当) 処理を実装します。

fuse-rsでは、 setattr を実装する事でファイルサイズの変更が可能になります。今回は、ついでに setattr で変更できる全てのメタデータを変更できるようにします。

一般的なファイルのメタデータを変更するシステムコールのほかに、 truncate(2) でファイルサイズを変更する時と、 open(2)O_TRUNC を指定した時も、この関数が呼ばれます。

setattr は各引数に Option 型で値が指定されるので、中身がある場合はその値で更新していきます。引数のreply に返事として入れる値は、更新後のメタデータです。

なお、 ctime は 現在のfuse-rsがプロトコルのバージョンの問題で未対応なので、引数には入っていません。基本的に ctime は自由に設定する事ができず、 setattr を実行すると現在時刻になるはずなので、問題はありません。

open 時に O_TRUNC を指定した場合のように、ファイルサイズに0が指定された場合は既存のデータを全て破棄すればいいですが、truncate(2) で元のファイルサイズより小さい0以外の値が指定された場合、残すべきデータは残しつついらないデータがきちんと破棄されるように気をつけてください。
また、元のファイルサイズより大きい値が指定された場合、間のデータが0(\0)で埋められるようにしてください。

残りの引数としてmacOS用に chgtimebkuptime が引数にありますが、今回はスルーします。設定しなくてもとりあえず動作はします。

fn setattr(
    &mut self,
    _req: &Request<'_>,
    ino: u64,
    mode: Option<u32>,
    uid: Option<u32>,
    gid: Option<u32>,
    size: Option<u64>,
    atime: Option<Timespec>,
    mtime: Option<Timespec>,
    _fh: Option<u64>,
    crtime: Option<Timespec>,
    _chgtime: Option<Timespec>,
    _bkuptime: Option<Timespec>,
    flags: Option<u32>,
    reply: ReplyAttr
) {
    // 現在のメタデータを取得
    let mut attr = match self.db.get_inode(ino as u32) {
        Ok(n) => n,
        Err(err) => {reply.error(ENOENT); debug!("{}", err); return;}
    };
    // ファイルサイズの変更チェック(小さくなる場合、不要なデータを破棄するため)
    let old_size = attr.size;
    // 引数で上書き
    if let Some(n) = mode {attr.perm = n as u16};
    if let Some(n) = uid {attr.uid = n};
    if let Some(n) = gid {attr.gid = n}; 
    if let Some(n) = size {attr.size = n as u32};
    if let Some(n) = atime {attr.atime = datetime_from_timespec(&n)};
    if let Some(n) = mtime {attr.mtime = datetime_from_timespec(&n)};
    if let Some(n) = crtime {attr.crtime = datetime_from_timespec(&n)};
    if let Some(n) = flags {attr.flags = n};
    // 更新
    match self.db.update_inode(attr, old_size > attr.size) {
        Ok(_n) => (),
        Err(err) => {reply.error(ENOENT); debug!("{}", err); return;}
    };
    // 更新後のメタデータをreplyに入れる
    reply.attr(&ONE_SEC, &attr.get_file_attr());
}

実行結果は以下のようになります。
helllo.txt を新しい内容で上書きしています。


# hello.txt を上書きする
$ echo "Update hello world" > ~/mount/hello.txt
# 内容の確認
$ cat ~/mount/hello.txt
Update hello world

FUSEのログです。writeの前に、 setattr が呼ばれている事が分かります。

[2019-10-28T12:08:10Z DEBUG fuse::request] INIT(2)   kernel: ABI 7.31, flags 0x3fffffb, max readahead 131072
[2019-10-28T12:08:10Z DEBUG fuse::request] INIT(2) response: ABI 7.8, flags 0x1, max readahead 131072, max write 16777216
[2019-10-28T12:08:37Z DEBUG fuse::request] LOOKUP(4) parent 0x0000000000000001, name "hello.txt"
[2019-10-28T12:08:37Z DEBUG fuse::request] OPEN(6) ino 0x0000000000000002, flags 0x8001
[2019-10-28T12:08:37Z DEBUG fuse::request] GETXATTR(8) ino 0x0000000000000002, name "security.capability", size 0
[2019-10-28T12:08:37Z DEBUG fuse::request] SETATTR(10) ino 0x0000000000000002, valid 0x208
[2019-10-28T12:08:37Z DEBUG fuse::request] FLUSH(12) ino 0x0000000000000002, fh 0, lock owner 17171727478964840688
[2019-10-28T12:08:37Z DEBUG fuse::request] WRITE(14) ino 0x0000000000000002, fh 0, offset 0, size 19, flags 0x0
[2019-10-28T12:08:37Z DEBUG fuse::request] RELEASE(16) ino 0x0000000000000002, fh 0, flags 0x8001, release flags 0x0, lock owner 0

まとめ

これでファイルの書き込み(追記、上書き)ができるようになりました。
writeは特にデータの破損やパフォーマンスの低下が起こりやすい関数なので、仕様通りに動作するか注意してください。

余談ですが、通常のファイルシステムであれば、ファイルの固定長ヘッダーをちょこちょこ変更する、という処理はファイルが何GBあろうと低コストで行えるので画像ファイルなどでよく行われますが、FUSEでネットワーク上のストレージを利用している場合、ファイル全てを更新する方法しかなくてパフォーマンスでストレスがマッハになる事があります。

ここまでのコードは githubに置いてあります。
次回は、ファイルの作成と削除を実装します。

次の記事
Rustで学ぶFUSE (3) ファイル作成と削除

11
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
11
2