Edited at

Linux ファイルシステムを理解したい


検証環境

]# cat /etc/redhat-release

CentOS Linux release 7.7.1908 (Core)
]# uname -a
Linux localhost.localdomain 3.10.0-1062.1.2.el7.x86_64 #1 SMP Mon Sep 30 14:19:46 UTC 2019 x86_64 x86_64 x86_64 GNU/Linux


ファイルシステムとは何か?

データを管理/操作するための仕組み。

ファイルとディレクトリで構成されていて、/ を基点とした木構造になっている。

# ls -l /

合計 56
lrwxrwxrwx. 1 root root 7 8月 25 01:17 bin -> usr/bin
dr-xr-xr-x. 6 root root 4096 9月 29 15:51 boot
drwxr-xr-x. 19 root root 3200 9月 29 12:29 dev
drwxr-xr-x. 151 root root 12288 9月 29 12:30 etc
-rw-r--r--. 1 root root 21 9月 29 17:52 hello.txt
drwxr-xr-x. 3 root root 20480 9月 29 17:52 home
lrwxrwxrwx. 1 root root 7 8月 25 01:17 lib -> usr/lib
lrwxrwxrwx. 1 root root 9 8月 25 01:17 lib64 -> usr/lib64
drwxr-xr-x. 2 root root 6 4月 11 2018 media
drwxr-xr-x. 2 root root 6 4月 11 2018 mnt
drwxr-xr-x. 3 root root 16 8月 25 01:21 opt
dr-xr-xr-x. 242 root root 0 9月 29 12:29 proc
dr-xr-x---+ 20 root root 4096 9月 29 21:40 root
drwxr-xr-x. 45 root root 1300 9月 29 19:30 run
lrwxrwxrwx. 1 root root 8 8月 25 01:17 sbin -> usr/sbin
drwxr-xr-x. 2 root root 6 4月 11 2018 srv
dr-xr-xr-x. 13 root root 0 9月 29 12:29 sys
drwxrwxrwt. 32 root root 4096 9月 30 03:34 tmp
drwxr-xr-x. 13 root root 155 8月 25 01:17 usr
drwxr-xr-x. 21 root root 4096 8月 24 16:26 var

各ディストリビューションごとにディレクトリをどう配置するのかは基本的に自由だが、可搬性を高めるために FHS という共通の仕様が定められている。

参考 Wikipedia Filesystem Hierarchy Standard

データはファイルに格納されている。ユーザにファイルという統一したフォーマットを提供することで、データ格納方法の違いをユーザが透過的に扱えるようになっている。

ファイルはopen read write といったシステムコールを通して操作される。このシステムコールを使っていれば、ブロックデバイスへのデータの保存方法の違いや(ext4やxfs)、データがどこから発生しているか(proc はカーネルから情報を取得する)、外部通信が裏で行われるかどうか(sockfs)、などの差をユーザが気にせずに、ファイルを通して統一的にデータを操作することができる

vfs_overview.png

例えば、次のように、 open read write のシステムコールを使って、引数に与えられたファイルの中身を出力するプログラムを実装したとする。


main.c

#include <stdio.h>

#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>

int main(int argc, char *argv[]) {
char buf[10];
int fd, n;

// 検証コードのため、エラーハンドリングはしない
fd = open(argv[1], O_RDONLY);
while(1) {
n = read(fd, buf, sizeof(buf));
if (n == 0) break;
write(STDOUT_FILENO, buf, n);
}
close(fd);
exit(0);
}


]# gcc -Wall main.c -o mycat

このプログラムは、 xfs のファイルシステムに対しても動作するし、

// xfs ファイルシステムに、ファイルを作成する

]# df -T
ファイルシス タイプ 1K-ブロック 使用 使用可 使用% マウント位置
...
/dev/mapper/centos-root xfs 52403200 20404416 31998784 39% /
...
]# echo "hello xfs filesystem" > /hello.txt

]# ./mycat /hello.txt
hello xfs filesystem

ext4 のファイルシステムに対しても動作する。

// ext4 ファイルシステムに、ファイルを作成する

]# df -T
ファイルシス タイプ 1K-ブロック 使用 使用可 使用% マウント位置
...
/dev/mapper/centos-home ext4 42306664 49196 40085360 1% /home
...
]# echo "hello ext4 filesystem" > /home/hello.txt

]# ./mycat /home/hello.txt
hello ext4 filesystem

また、 proc ファイルシステムに対しても動作する。

]# mount -l | grep proc

proc on /proc type proc (rw,nosuid,nodev,noexec,relatime)
systemd-1 on /proc/sys/fs/binfmt_misc type autofs (rw,relatime,fd=27,pgrp=1,timeout=0,minproto=5,maxproto=5,direct,pipe_ino=15139)
binfmt_misc on /proc/sys/fs/binfmt_misc type binfmt_misc (rw,relatime)
]# ./mycat /proc/meminfo
MemTotal: 7990268 kB
MemFree: 2395808 kB
MemAvailable: 4204892 kB
Buffers: 6080 kB
Cached: 1969472 kB
SwapCached: 0 kB

さらに、なんと、デバイスファイルに対しても動作する。

]# ./mycat /dev/sda2 | head

LABELONE餬酩 LVM2 001mu31U2kbiRP5ZtYc935Jv0HEPNBvVI02霓鞦)荼 LVM2 x[5A%r0N*>霑 J}
centos {
id = "K3vVW6-PQyT-yQDX-pbJG-Y9Pu-mdXf-Mac0J8"
seqno = 1
format = "lvm2"
status = ["RESIZEABLE", "READ", "WRITE"]
flags = []
extent_size = 8192
max_lv = 0
max_pv = 0
...

デバイスファイルとは

デバイスドライバとのインタフェースになる仮想的なファイル。ファイルへの I/O 要求を通して、デバイスドライバを実行することができる。

データを一定の塊ごとに扱うものをブロックデバイスという。(例えばHDDやSSD)

ブロックデバイスは、 ls -al したときの表示が b から始まる。

]# ls -al /dev/ | grep "^b"

brw-rw----. 1 root disk 253, 0 9月 29 12:29 dm-0
brw-rw----. 1 root disk 253, 1 9月 29 12:29 dm-1
brw-rw----. 1 root disk 253, 2 9月 29 12:29 dm-2
brw-rw----. 1 root disk 2, 0 9月 29 12:29 fd0
brw-rw----. 1 root disk 8, 0 9月 29 12:29 sda
brw-rw----. 1 root disk 8, 1 9月 29 12:29 sda1
brw-rw----. 1 root disk 8, 2 9月 29 12:29 sda2
brw-rw----+ 1 root cdrom 11, 0 9月 29 12:29 sr0

ブロックデバイスへの読み書きはファイルシステムをバイパスするため、前述した例のように、ハードディスクを単にファイルのように読める。(ファイルシステムがディスクを利用している場合、ファイルシステムが書き込んでいる情報がそのまま読める)これは raw/io とも呼ばれる。

データを1文字ごとに扱うものをキャラクタデバイスという。(キーボードやマウスなど)

キャラクタデバイスは、 ls -al したときの表示が c から始まる。

]# ls -al /dev/ | grep tty | head

crw-rw-rw-. 1 root tty 5, 2 9月 29 18:16 ptmx
crw-rw-rw-. 1 root tty 5, 0 9月 29 12:29 tty
crw--w----. 1 root tty 4, 0 9月 29 12:29 tty0
crw--w----. 1 root tty 4, 1 9月 29 12:29 tty1
crw--w----. 1 root tty 4, 10 9月 29 12:29 tty10
crw--w----. 1 root tty 4, 11 9月 29 12:29 tty11

例えば、 ssh ログイン時に仮想的に割り当てられるコンソールに対して書き込みをすることで、別ユーザのコンソールに文字列を出力することができる。

参考 tty と pts

いたずらされる人 ]# tty

/dev/pts/1
いたずらする人 ]# echo "キサマ見ているな" > /dev/pts/1
いたずらされる人 ]# キサマ見ているな #<- 勝手に表示される


ファイルアクセスの詳細

ここからは具体例を説明するために、 HDD などのストレージデバイスに話を絞って処理の流れを追いかける。

参考 Documentation/filesystems/vfs.txt


ストレージデバイスの特徴

ディスクへの読み書きはメモリと比較して非常に遅い。次のサイトを参考に、実際のアクセス速度の差を見てみよう。

名前
読込速度
読込速度(MB/s)

メモリ
10GB/s
10000MB/s

SSD(シーケンシャルアクセス)
100MB/s
100MB/s

HDD(シーケンシャルアクセス)
100MB/s
100MB/s

SSD(ランダムアクセス)
10MB/s
10MB/s

HDD(ランダムアクセス)
1MB/s
1MB/s

引用 プログラマが知っておくべき、メモリ/ディスク/ネットワークの速度まとめ


メモリとストレージデバイスではかなりの性能差があることがわかる。そのため、ディスクへの I/O を減らすことが重要になる。これを実現するために、 Linux ではディスクから取得した情報をメモリに余裕がある限りキャッシュするようになっている。メモリに余裕がある限り、キャッシュをじゃぶじゃぶと使い、メモリに余裕がなくなってきたタイミングで回収される。ページ回収の仕組みは今回は扱わないため、詳細を知りたい方は 4.8 スワップアウトとページの破棄 を参考にすること。

disk_cache.gif


open

まず、構造体の関係性を図示する。

参考 Linux 仮想ファイルシステム・スイッチの徹底調査

open.png


inode

inode は各ファイルやディレクトリの実体を管理する構造体。

参考 linux/include/linux/fs.h

大まかに、次のような情報を持っている。

]# echo "hello world" > /test/hello.txt


// アクセス権、所有者、タイムスタンプ、デバイス、ブロックサイズ を持つ
// ファイル名は inode で持たない。 dentry が持つ。
// ディスク上の位置情報 は inode で持たない。ディスク上の位置情報への変換をファイルシステムごとの get_block_t ハンドラが行う。
]# stat /test/hello.txt
File: `/test/hello.txt'
Size: 12 Blocks: 8 IO Block: 4096 通常ファイル
Device: fd03h/64771d Inode: 110 Links: 1
Access: (0644/-rw-r--r--) Uid: ( 0/ root) Gid: ( 0/ root)
Access: 2019-10-05 21:21:41.544219298 +0900
Modify: 2019-10-05 21:26:23.344879923 +0900
Change: 2019-10-05 21:26:23.344879923 +0900
Birth: -

inode は、 inode 番号と呼ばれるパーティション内で一意の番号がふられている。inode 番号は ls コマンドで表示できる。

// ハードリンクの場合、 inode を共有する

// シンボリックリンクの場合、 inode は共有しない(inode へのマッピング情報を持つ)
]# ls -li
合計 8
99 -rw-r--r-- 2 root root 6 10月 5 19:48 sample.txt
99 -rw-r--r-- 2 root root 6 10月 5 19:48 sample_hard_link.txt
100 lrwxrwxrwx 1 root root 10 10月 5 19:49 sample_symbolic_link.txt -> sample.txt

これらの inode は、ディスク上のスーパーブロックに配置されている。この情報は xfs ファイルシステムであれば xfs_db で参照できる。各構造の詳細は Chapter 4. On-disk Inode を参照する。ただし、メモリ上の inode とディスク上の inode は構造が異なる点に注意する。

]# umount /test/

xfs_db /dev/centos/test
xfs_db> inode 110
xfs_db> p
core.magic = 0x494e
core.mode = 0100644
core.version = 3
core.format = 2 (extents)
core.nlinkv2 = 1
core.onlink = 0
core.projid_lo = 0
core.projid_hi = 0
core.uid = 0
core.gid = 0
core.flushiter = 0
core.atime.sec = Sat Oct 5 21:21:41 2019
core.atime.nsec = 544219298
core.mtime.sec = Sat Oct 5 21:21:41 2019
core.mtime.nsec = 544219298
core.ctime.sec = Sat Oct 5 21:21:41 2019
core.ctime.nsec = 544219298
core.size = 12
core.nblocks = 1
...


dentry

ディレクトリの階層構造を管理する構造体。

参考 include/linux/dcache.h

主に、ファイルパスとファイル名と inode を保持する。次のようなイメージ。


 以下、「testDir」配下に2つのファイル(article_1、article_2)が存在している状態のdentryのディレクトリ階層管理を見てみましょう(図2)。

testDir

testDir/article_1

testDir/article_2

x

引用 ファイル名を管理するキャッシュdentry (1/2)


これらの情報は、ディスク上にあるディレクトリの inode などから取得する。この情報は xfs ファイルシステムであれば xfs_db で参照できる。

]# mkdir /test/sample_dir

]# touch /test/sample_dir/sample_{1..10}.txt
]# ls -li /test/
合計 0
101 drwxr-xr-x 2 root root 207 10月 5 21:08 sample_dir
]# umount /test/

]# xfs_db /dev/centos/test
// sample_dir の inode を表示する
xfs_db> inode 101
xfs_db> p
core.magic = 0x494e
core.mode = 040755
core.version = 3
core.format = 1 (local)
...
v3.uuid = e48fcf95-cbc3-41cd-8c9d-152aea1ad383
u3.sfdir3.hdr.count = 10
u3.sfdir3.hdr.i8count = 0
u3.sfdir3.hdr.parent.i4 = 96
// ディレクトリに含まれるファイルパスの一覧が取得できる。
u3.sfdir3.list[0].namelen = 12
u3.sfdir3.list[0].offset = 0x60
u3.sfdir3.list[0].name = "sample_1.txt"
u3.sfdir3.list[0].inumber.i4 = 99
u3.sfdir3.list[0].filetype = 1
u3.sfdir3.list[1].namelen = 12
u3.sfdir3.list[1].offset = 0x78
u3.sfdir3.list[1].name = "sample_2.txt"
u3.sfdir3.list[1].inumber.i4 = 100
u3.sfdir3.list[1].filetype = 1
u3.sfdir3.list[2].namelen = 12
u3.sfdir3.list[2].offset = 0x90
u3.sfdir3.list[2].name = "sample_3.txt"
u3.sfdir3.list[2].inumber.i4 = 102
u3.sfdir3.list[2].filetype = 1
u3.sfdir3.list[3].namelen = 12
u3.sfdir3.list[3].offset = 0xa8

実行結果からもわかるが、ディレクトリが持つのはその直下のファイル一覧だけ。そのため/parent_dir/sub_dir/sample.txt というファイルにアクセスしようと思ったら、まず parent_dir の inode をディスクから検索して dentry を作成し、次に sub_dir の inode を をディスクから検索して dentry を作成し、最後に sample.txt の inode を取得する必要がある。このように、ファイルパスの検索は何度も I/O が発生する重たい処理のため、一度作成した dentry はキャッシュされる。また、存在しないパスへのアクセスも negative dentry としてキャッシュされる。

dentry_cache.png


file

各プロセスのオープンファイルの状態を管理する構造体。(シーク位置などを管理する)

参考 include/linux/fs.h

file はテーブル構造で管理されている。ユーザは open 時に取得した fd 番号を readwrite 時に指定して読み書きを行う。


dentry キャッシュと inode キャッシュの効果を測定する

dentry キャッシュや inode キャッシュがどの程度有効なのかを確認する。

// 一度キャッシュを捨ててから全ディレクトリパスにアクセスする

]# echo 3 > /proc/sys/vm/drop_caches
]# time ls -R / > /dev/null

real 0m9.140s
user 0m1.708s
sys 0m2.113s

// キャッシュに乗っていると 1/10 で済む
]# time ls -R / > /dev/null

real 0m0.747s
user 0m0.508s
sys 0m0.237s

2回目の実行は爆速で終了していることがわかる。このときのリソース情報を vmstat で取得しグラフ化すると、次のようになる。図からも、うまくキャッシュを利用して I/O を削減していることがわかる。

vmstat.png


read

read の全体像は次の図のとおり。(open で割愛したディスク I/O の流れも記載している)

vfs_details.png

各層で何をやっているかをざっくりとまとめる。


  • VFS(ファイルシステム)


    • open で取得した inode 等をもとに bio 構造体を作成する(bio は I/O を行う実際のセクタ番号や I/O サイズを管理する構造体)

    • 変換には、ファイルシステムごとに用意されている get_block_t 型のハンドラを利用する。

    • bio を汎用ブロック層に渡す。



  • 汎用ブロック層


    • bio をもとに I/O のリクエストを発行する。

    • bio をマージして I/O リクエストの数を減らす。



  • I/O スケジューラ


    • I/O リクエストを優先度に基づいて入れ替える。



  • デバイスドライバ


    • 実際にディスクへの読み書きをする




ページキャッシュとバッファキャッシュ

ほとんどのファイルシステムでは、ディスクから読み込んだデータをキャッシュする。データへのアクセス方式によって、ページキャッシュとバッファキャッシュに分けられる。

read.png


ページキャッシュ

ファイルシステム経由でアクセスしたデータのキャッシュ。

ページキャッシュが利用される様子を実際に確認する。

// 大きなファイルを用意する

]# dd if=/dev/zero of=/test/large.txt count=100 bs=10M
100+0 レコード入力
100+0 レコード出力
1048576000 バイト (1.0 GB) コピーされました、 0.453517 秒、 2.3 GB/秒
]# echo 3 > /proc/sys/vm/drop_caches

// ページキャッシュに乗せる前のメモリ使用量を確認する
]# vmstat
procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----
r b swpd free buff cache si so bi bo in cs us sy id wa st
1 0 0 4368608 0 178284 0 0 30 14 12 40 0 0 99 0 0

]# cat /test/large.txt > /dev/null

// ページキャッシュに乗った後のメモリ使用量を確認する
]# vmstat
procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----
r b swpd free buff cache si so bi bo in cs us sy id wa st
1 0 0 3345876 0 1202524 0 0 38 14 12 40 0 0 99 0 0

キャッシュに乗ったデータへのアクセスは高速であることも確認してみよう。

// 1回目

]# time cat /test/large.txt > /dev/null

real 0m0.471s
user 0m0.006s
sys 0m0.347s

// 2回目
]# time cat /test/large.txt > /dev/null

real 0m0.179s
user 0m0.005s
sys 0m0.174s


バッファキャッシュ

raw I/O 経由でアクセスしたデータをキャッシュする。実装上は、ブロックデバイスに対するページキャッシュになっている。

バッファキャッシュが利用される様子を実際に確認する。

]# vmstat

procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----
r b swpd free buff cache si so bi bo in cs us sy id wa st
1 0 0 4369104 0 178344 0 0 46 22 12 40 0 0 99 0 0
]# dd if=/dev/sda of=/dev/null count=100 bs=10M
100+0 レコード入力
100+0 レコード出力
1048576000 バイト (1.0 GB) コピーされました、 0.999714 秒、 1.0 GB/秒
// バッファキャッシュ(buff)が増加する
]# vmstat
procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----
r b swpd free buff cache si so bi bo in cs us sy id wa st
1 0 0 3340504 1028096 178780 0 0 54 22 12 40 0 0 99 0 0

キャッシュに乗ったデータへのアクセスは高速であることも確認してみよう。

// 1回目

]# time dd if=/dev/sda of=/dev/null count=100 bs=10M
100+0 レコード入力
100+0 レコード出力
1048576000 バイト (1.0 GB) コピーされました、 1.023 秒、 1.0 GB/秒

real 0m1.054s
user 0m0.003s
sys 0m0.481s

// 2回目
]# time dd if=/dev/sda of=/dev/null count=100 bs=10M
100+0 レコード入力
100+0 レコード出力
1048576000 バイト (1.0 GB) コピーされました、 0.190108 秒、 5.5 GB/秒

real 0m0.195s
user 0m0.000s
sys 0m0.195s

また、ブロックデバイスへの直接の read 以外にも、バッファキャッシュが増えるタイミングがある。それは、スーパーブロックへのアクセス時である。ほとんどのファイルシステム(ext4 など)では、inode を取得するときにスーパーブロックに対するアクセス結果をバッファキャッシュに追加する。

// 大量のファイルを用意する

]# mount -l | grep /test
/dev/mapper/centos-test on /test type ext4 (rw,relatime,data=ordered)
]# touch /test/{1..50000}.txt
]# echo 3 > /proc/sys/vm/drop_caches

]# vmstat
procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----
r b swpd free buff cache si so bi bo in cs us sy id wa st
1 0 0 4344936 248 179412 0 0 54 24 13 42 0 0 99 0 0
]# ls -al /test/ > /dev/null
// デバイスファイルへ明示的に read していなくても、バッファキャッシュ(buff)が増加する
]# vmstat
procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----
r b swpd free buff cache si so bi bo in cs us sy id wa st
1 0 0 4268816 14452 241360 0 0 54 24 13 42 0 0 99 0 0

ただし、この動きはファイルシステムに依存する。 xfs などのファイルシステムでは、 inode の管理を自前で実装するためにバッファキャッシュに追加しない。

参考 xfs.org Improving inode Caching

]# mount -l | grep /test

/dev/mapper/centos-test on /test type xfs (rw,relatime,attr2,inode64,noquota)
]# touch /test/{1..50000}.txt
]# echo 3 > /proc/sys/vm/drop_caches
]# vmstat
procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----
r b swpd free buff cache si so bi bo in cs us sy id wa st
1 0 0 4347372 0 178668 0 0 54 22 13 41 0 0 99 0 0
]# ls -al /test/ > /dev/null
// バッファキャッシュ(buff)が増加していない
]# vmstat
procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----
r b swpd free buff cache si so bi bo in cs us sy id wa st
1 0 0 4266984 0 230964 0 0 55 24 13 43 0 0 99 0 0

この挙動に限らず、メモリの使い方はファイルシステムに依存する部分が大きいため、ファイルシステムごとの特徴を押さえる必要があると思う。


direct I/O

ファイルシステム経由でアクセスするが、 I/O の結果をキャッシュに追加しない。たとえば、利用頻度は低いが大きなデータを読み込んだ場合、既存のヒット率の高いキャッシュが追い出されてしまう可能性がある。direct I/O を使うことで、この問題を回避できる。

direct I/O は、 open 時に O_DIRECT フラグを付与することで有効になる。


O_DIRECT (Linux 2.4.10 以降)

このファイルに対する I/O のキャッシュの効果を最小化しようとする。このフラグを使うと、一般的に性能が低下する。しかしアプリケーションが独自にキャッシングを行っているような特別な場合には役に立つ。ファイルのI/Oはユーザー空間バッファに対して直接行われる。O_DIRECTフラグ自身はデータを同期で転送しようとはするが、O_SYNCフラグのようにデータと必要なメタデータの転送が保証されるわけではない。同期I/Oを保証するためには、O_DIRECTに加えてO_SYNCを使用しなければならない。下記の「注意」の節の議論も参照。

参考 open(2) man ページ


]# vmstat

procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----
r b swpd free buff cache si so bi bo in cs us sy id wa st
1 0 0 4345392 0 178832 0 0 70 24 13 42 0 0 99 0 0
]# dd if=/dev/sda of=/dev/null count=100 bs=10M iflag=direct
100+0 レコード入力
100+0 レコード出力
1048576000 バイト (1.0 GB) コピーされました、 1.11345 秒、 942 MB/秒
// キャッシュ(buff) が増加していない
]# vmstat
procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----
r b swpd free buff cache si so bi bo in cs us sy id wa st
1 0 0 4344952 0 179316 0 0 78 24 13 42 0 0 99 0 0


先読み

必要なデータをユーザから要求があるたびに同期的に読み込んでいると、ディスクの I/O 待ちに時間がかかる。そのため、 I/O 要求がシーケンシャルリードだと判断した時点で要求されていない後続のブロックを先読みしておき、キャッシュを充実させる。先読みしたデータは使われるかもしれないし、無駄になるかもしれない。

先読みすることでどの程度効率化されているかを動作確認する。

// 先読みが有効であることを確認する

]# cat /sys/block/sda/queue/read_ahead_kb
4096
]# echo 3 > /proc/sys/vm/drop_caches
]# time dd if=/dev/sda of=/dev/null bs=10M count=100
100+0 レコード入力
100+0 レコード出力
1048576000 バイト (1.0 GB) コピーされました、 1.02561 秒、 1.0 GB/秒

real 0m1.038s
user 0m0.000s
sys 0m0.523s

// 先読みを無効にする
]# echo 0 > /sys/block/sda/queue/read_ahead_kb
]# echo 3 > /proc/sys/vm/drop_caches
]# time dd if=/dev/sda of=/dev/null bs=10M count=100
100+0 レコード入力
100+0 レコード出力
1048576000 バイト (1.0 GB) コピーされました、 44.871 秒、 23.4 MB/秒

real 0m44.884s
user 0m0.000s
sys 0m6.466s

1GB のシーケンシャルリード、かつ 1 プロセスがサーバを占有している、というアプリケーション的にほぼありえないパターンではあるけれど、 40 倍以上の差がでた。


汎用ブロック層

連続した bio を1つの I/O リクエストにマージする。

参考 block/blk-core.c

リクエストのマージでどの程度効率化されているかを確かめる。なお、リクエストのマージは I/O 要求が溜まらないといけないため、検証には HDD を利用する。また、先読みのサイズを減らし、なおかつ並列にアクセスさせる。

]# echo 8 > /sys/block/sdb/queue/read_ahead_kb

]# cat /sys/block/sdb/queue/scheduler
noop [deadline] cfq
]# fio -filename=/hdd/test -directory=/hdd --direct=0 -rw=read -bs=100k -iodepth=64 -size=2g --numjobs=64 -name=file1 --group_reporting -ioengine=libaio

マージの有無は /sys/block/sdb/queue/nomerges で切り替える。

参考 Documentation/block/queue-sysfs.txt

3回測定した結果の平均は次のようになった。マージが有効なほうが若干スループットが高いようだが、測定したスループットの標準偏差が 100 程度だったので、いまいち効果があるかわからなかった。(util はいずれも 100% でディスク中心のワークロード)

マージの有無
throughput
ios
merge
ticks
in_queue

有効
5145
121817.3333/0
12496/0
50129.66667/0
50082.33333/0

無効
5118
136854.3333/0
0/0
56798.66667/0
56750.33333/0


I/O スケジューラ

I/O リクエストを優先度に基づいて入れ替える。当然ながら、 I/O のワークロードや物理構成によって何を優先すべきか(つまり選択すべきスケジューラ)は変わる。

例えば、 SSD だとディスクのシークコストがないため、 noop が性能が出る可能性が高い。

参考 SSDにしたのでI/Oスケジューラを noop に変更してみた

仮想環境ではホスト側で I/O リクエストを効率化するため、ゲスト側は noop にすることが多い。

参考 各OSでのデフォルトI/Oスケジューラの違い

また CPU やストレージの進化によって既存のスケジューラが消えたり、新しいスケジューラが考案されたりもする。例えば、 CPU のマルチコア化やハードディスクの I/O がマルチキュー化しているため、アップストリープのカーネルでは並列性を上げたスケジューラが追加されている。

参考 Linuxカーネル5.0ではCFQ, Deadlineと言ったシングルキュースケジューラが全て削除されてカーネルのデフォルトIOスケジューラ設定も無くなっていました

I/O スケジューラは、このテーマだけで相当深いため、今回はさらっと流す。各スケジューラついての詳細は RHEL7 8.1.1. I/O スケジューラー を参考にする。また、チューニング可能なパラメータについては RHEL 7 パフォーマンスチューニング を参考にする。

現在の I/O スケジューラは次のコマンドで表示できる。

]# cat /sys/block/sda/queue/scheduler

noop [deadline] cfq

I/O スケジューラごとの違いを確認してみる。今回はスケジューラの違いが出やすいように hdd を利用し、noop と deadline を比較した。その結果、 deadline のほうが noop よりも 20% ほどスループットが良い結果となった。

]# echo noop >  /sys/block/sdb/queue/scheduler

]# cat /sys/block/sdb/queue/scheduler
[noop] deadline cfq
]# fio -filename=/hdd/test -rw=randwrite -bs=10k -size=1G -numjobs=8 -ioengine=libaio -group_reporting -runtime=20s -name=test
test: (g=0): rw=randwrite, bs=(R) 10.0KiB-10.0KiB, (W) 10.0KiB-10.0KiB, (T) 10.0KiB-10.0KiB, ioengine=libaio, iodepth=1
...
fio-3.7
Starting 8 processes
Jobs: 8 (f=8): [w(8)][100.0%][r=0KiB/s,w=1330KiB/s][r=0,w=133 IOPS][eta 00m:00s]
test: (groupid=0, jobs=8): err= 0: pid=32426: Fri Oct 11 22:20:45 2019
write: IOPS=93, BW=937KiB/s (960kB/s)(18.4MiB/20060msec)
slat (usec): min=16, max=4475.3k, avg=73492.30, stdev=404226.78
clat (usec): min=3, max=162, avg= 6.67, stdev= 5.83
lat (usec): min=21, max=4475.4k, avg=73503.51, stdev=404227.11
clat percentiles (usec):
| 1.00th=[ 4], 5.00th=[ 6], 10.00th=[ 6], 20.00th=[ 7],
| 30.00th=[ 7], 40.00th=[ 7], 50.00th=[ 7], 60.00th=[ 7],
| 70.00th=[ 7], 80.00th=[ 7], 90.00th=[ 7], 95.00th=[ 7],
| 99.00th=[ 42], 99.50th=[ 47], 99.90th=[ 71], 99.95th=[ 163],
| 99.99th=[ 163]
bw ( KiB/s): min= 19, max= 1460, per=43.40%, avg=406.68, stdev=404.33, samples=92
iops : min= 1, max= 146, avg=40.52, stdev=40.44, samples=92
lat (usec) : 4=3.83%, 10=94.63%, 50=1.33%, 100=0.16%, 250=0.05%
cpu : usr=0.05%, sys=0.83%, ctx=4292, majf=0, minf=257
IO depths : 1=100.0%, 2=0.0%, 4=0.0%, 8=0.0%, 16=0.0%, 32=0.0%, >=64=0.0%
submit : 0=0.0%, 4=100.0%, 8=0.0%, 16=0.0%, 32=0.0%, 64=0.0%, >=64=0.0%
complete : 0=0.0%, 4=100.0%, 8=0.0%, 16=0.0%, 32=0.0%, 64=0.0%, >=64=0.0%
issued rwts: total=0,1880,0,0 short=0,0,0,0 dropped=0,0,0,0
latency : target=0, window=0, percentile=100.00%, depth=1

Run status group 0 (all jobs):
WRITE: bw=937KiB/s (960kB/s), 937KiB/s-937KiB/s (960kB/s-960kB/s), io=18.4MiB (19.3MB), run=20060-20060msec

Disk stats (read/write):
sdb: ios=1794/3185, merge=0/0, ticks=20931/780247, in_queue=801177, util=100.00%

]# echo deadline > /sys/block/sdb/queue/scheduler
]# cat /sys/block/sdb/queue/scheduler
noop [deadline] cfq
]# fio -filename=/hdd/test -rw=randwrite -bs=10k -size=1G -numjobs=8 -ioengine=libaio -group_reporting -runtime=20s -name=test
test: (g=0): rw=randwrite, bs=(R) 10.0KiB-10.0KiB, (W) 10.0KiB-10.0KiB, (T) 10.0KiB-10.0KiB, ioengine=libaio, iodepth=1
...
fio-3.7
Starting 8 processes
Jobs: 8 (f=8): [w(8)][100.0%][r=0KiB/s,w=1237KiB/s][r=0,w=123 IOPS][eta 00m:00s]
test: (groupid=0, jobs=8): err= 0: pid=32535: Fri Oct 11 22:22:27 2019
write: IOPS=119, BW=1193KiB/s (1222kB/s)(23.4MiB/20061msec)
slat (usec): min=8, max=4108.3k, avg=66912.99, stdev=346560.95
clat (nsec): min=600, max=139800, avg=4697.79, stdev=3896.23
lat (usec): min=9, max=4108.3k, avg=66921.25, stdev=346560.99
clat percentiles (nsec):
| 1.00th=[ 1304], 5.00th=[ 1496], 10.00th=[ 1608], 20.00th=[ 1800],
| 30.00th=[ 2096], 40.00th=[ 5984], 50.00th=[ 6112], 60.00th=[ 6112],
| 70.00th=[ 6176], 80.00th=[ 6176], 90.00th=[ 6304], 95.00th=[ 6304],
| 99.00th=[ 6688], 99.50th=[ 9920], 99.90th=[ 49408], 99.95th=[ 54016],
| 99.99th=[140288]
bw ( KiB/s): min= 19, max= 1400, per=35.38%, avg=422.12, stdev=405.30, samples=113
iops : min= 1, max= 140, avg=42.08, stdev=40.58, samples=113
lat (nsec) : 750=0.04%
lat (usec) : 2=26.65%, 4=10.40%, 10=62.45%, 20=0.25%, 50=0.13%
lat (usec) : 100=0.04%, 250=0.04%
cpu : usr=0.07%, sys=0.66%, ctx=4713, majf=0, minf=266
IO depths : 1=100.0%, 2=0.0%, 4=0.0%, 8=0.0%, 16=0.0%, 32=0.0%, >=64=0.0%
submit : 0=0.0%, 4=100.0%, 8=0.0%, 16=0.0%, 32=0.0%, 64=0.0%, >=64=0.0%
complete : 0=0.0%, 4=100.0%, 8=0.0%, 16=0.0%, 32=0.0%, 64=0.0%, >=64=0.0%
issued rwts: total=0,2394,0,0 short=0,0,0,0 dropped=0,0,0,0
latency : target=0, window=0, percentile=100.00%, depth=1

Run status group 0 (all jobs):
WRITE: bw=1193KiB/s (1222kB/s), 1193KiB/s-1193KiB/s (1222kB/s-1222kB/s), io=23.4MiB (24.5MB), run=20061-20061msec

Disk stats (read/write):
sdb: ios=2341/567, merge=0/0, ticks=21031/131570, in_queue=152597, util=100.00%


write


バッファリング

ディスクへの読み書きは非常に遅い。そのため、書き込みはメモリ上のページキャッシュに対して行った時点でユーザに完了を返す。その後、更新データをカーネルが適当なタイミングでディスクに非同期で書き込む。この仕組みによって、複数の write がひとつのディスク I/O にまとめられたり、負荷が低いときに書き込むことができる。この考え方は、データベースのバッファキャッシュと非常に近いと思う。

vfs_write.gif


ディスクへの書き込みタイミング

ディスクへの書き込みのタイミングは、次のようなものがある。いずれも、カーネルの flusher スレッドが実行する。


  • 決まった時間が経過した

  • sync(2) fsync(2) fdatasync(2) が呼ばれた

  • ダーティページの割合や合計値のしきい値を超えた

  • ページキャッシュに空きが無くなった

詳細なパラメータについては次のサイトを参考にする。

参考 Linuxのdirty page関連パラメータからコードを読む

またページの回収処理中に書き込みされるケースもある。

参考 vm.min_free_kbytes からの wmark_{min|low|high} 算出式


書き込みの同期

書き込んだ内容がバッファリングされるということは、何らかの理由でディスクへの書き込みが失敗することがあるということ。これを防ぐために open 時の O_SYNC オプションやファイルシステムの sync オプション(マウントオプションとして対応されていれば)を使うことで、 write 時のディスクへの同期が保証される。また sync などのシステムコールを利用することで、その時点でのダーティページを書き出すことができる。

たとえば、データベースの WAL 領域への書き込みなどで利用される。

参考 PostgreSQL WALログの仕組みとタイミングを理解したい

バッファリングする場合としない場合で、どの程度性能差が出るかを確認する。自分の環境では、だいたい 5 倍近くの性能差があった。

// バッファリングする

]# fio -filename=/hdd/test -rw=randwrite -bs=1k -size=100M -numjobs=64 -ioengine=libaio -group_reporting -runtime=60s -name=test
test: (g=0): rw=randwrite, bs=(R) 1024B-1024B, (W) 1024B-1024B, (T) 1024B-1024B, ioengine=libaio, iodepth=1
...
fio-3.7
Starting 64 processes
Jobs: 64 (f=64): [w(64)][100.0%][r=0KiB/s,w=288KiB/s][r=0,w=288 IOPS][eta 00m:00s]
test: (groupid=0, jobs=64): err= 0: pid=42348: Mon Oct 7 06:40:15 2019
write: IOPS=172, BW=173KiB/s (177kB/s)(10.2MiB/60287msec)
...
Run status group 0 (all jobs):
WRITE: bw=173KiB/s (177kB/s), 173KiB/s-173KiB/s (177kB/s-177kB/s), io=10.2MiB (10.7MB), run=60287-60287msec

Disk stats (read/write):
sdb: ios=9192/4100, merge=0/0, ticks=61725/294743, in_queue=356457, util=100.00%

// バッファリングしない
]# fio -filename=/hdd/test -rw=randwrite -bs=1k -size=100M -numjobs=64 -ioengine=libaio -group_reporting -runtime=60s -sync=1 -name=test
test: (g=0): rw=randwrite, bs=(R) 1024B-1024B, (W) 1024B-1024B, (T) 1024B-1024B, ioengine=libaio, iodepth=1
...
fio-3.7
Starting 64 processes
Jobs: 35 (f=35): [w(6),_(1),w(2),_(1),w(3),_(1),w(1),_(1),w(1),_(4),w(5),_(8),w(4),_(4),w(1),_(3),w(6),_(2),w(1),_(1),w(1),_(2),w(2),_(1),w(2)][3.4%][r=0KiB/s,w=28KiB/s][r=0,w=28 IOPS][eta 28m:59s]
test: (groupid=0, jobs=64): err= 0: pid=42606: Mon Oct 7 06:42:53 2019
write: IOPS=33, BW=33.5KiB/s (34.4kB/s)(2081KiB/62032msec)
...
Run status group 0 (all jobs):
WRITE: bw=33.5KiB/s (34.4kB/s), 33.5KiB/s-33.5KiB/s (34.4kB/s-34.4kB/s), io=2081KiB (2131kB), run=62032-62032msec

Disk stats (read/write):
sdb: ios=2008/4159, merge=0/0, ticks=18993/74751, in_queue=93878, util=100.00%


(参考) ファイルの削除

ファイル削除に利用する rm コマンドは、ディスク上からファイルのデータを消すことは保証していない。ファイルシステムごとに詳細の実装は異なるが、ファイルシステム上から該当のファイルを見えないようにしてるだけの事が多い。そのため、消したはずのデータを参照する、ということができる。

実際に確かめてみる。

// 空の xfs ファイルシステムを作成する

]# mkfs.xfs -f /dev/centos/test
meta-data=/dev/centos/test isize=512 agcount=4, agsize=65536 blks
= sectsz=4096 attr=2, projid32bit=1
= crc=1 finobt=0, sparse=0
data = bsize=4096 blocks=262144, imaxpct=25
= sunit=0 swidth=0 blks
naming =version 2 bsize=4096 ascii-ci=0 ftype=1
log =internal log bsize=4096 blocks=2560, version=2
= sectsz=4096 sunit=1 blks, lazy-count=1
realtime =none extsz=4096 blocks=0, rtextents=0

// ディレクトリにマウントし、秘密のファイルを作成する
]# mount /dev/centos/test /test/
]# echo "PASSWORD: secret" > /test/password.txt
]# sync

// 秘密のファイルを削除する
]# rm /test/password.txt
]# sync

// デバイスファイル経由でディスクの中身を見ると、削除したファイルの残骸が見える
]# strings /dev/mapper/centos-test
XFSB
...
PASSWORD: secret
d h]
...
`password.txt
...

ということで、ファイルを消したから安全だ、というわけではない。ディスクを廃棄するときは専門業者に頼むか、少なくとも全ブロックを /dev/zero で埋めたほうが良い。(/dev/zero で埋めた場合でも、ハードウェアの都合でデータを読み出せる可能性がある。詳細はディスクの完全消去を参照する。)


I/O の種別

今回は read write による同期 I/O の場合のみを説明したが、非同期 I/O もある。また mmap を使ってファイルをメモリ上にマップする方法もある。詳細はまた別の機会に。


参考


おひねり

もしこの記事がお役に立てたなら、継続的なアウトプットのためにご支援お願いします。

note kimullaa おひねり