ファイルの「穴」の話
$ dd if=/dev/zero of=testfile1 bs=1 seek=104857599 count=1 ; ls -ls testfile1
1+0 records in
1+0 records out
1 bytes transferred in 0.000114 secs (8775 bytes/sec)
8 -rw-r--r-- 1 user staff 104857600 5 25 13:24 testfile
$
ファイルサイズは100MBだけど、消費しているブロック数は8ブロックだけ、というファイルができる。Linux (coreutils) のコマンドtruncateや、qemuの管理コマンドqemu-imgコマンドを使っても、似たようなことができる。
$ truncate -s 100M testfile2
$ qemu-img create -f raw testfile3 100M
$
ddはlseek(2)、write(2)という2つのシステムコールを使い、truncateとqemu-imgはftruncate(2)というシステムコールを使っているが、結果はほぼ同じである1。
これらのファイルを読むと、「0」で埋まったデータが読み出され、また書き込むとその時点でディスクブロックがアロケートされて書いた情報が保存される。この、ディスクブロックがアロケートされていない部分を「穴」「hole」といい、そのような部分を持つファイルのことを「穴あきファイル」「sparse file」と呼ぶ。古くから、DBファイルのように文字通り疎な (情報量と比較してファイルサイズが大きい) ファイルは穴あきであることもよくあったし、最近ではQemu/KVM仮想マシンの仮想ディスクイメージが穴あきであることがある。
穴の検出と穴あけ
このファイルの穴は、ファイルシステム内部でどのようにディスクスペースを利用するかという話であって、伝統的にはAPIには現れてきてこなかった。通常のPOSIX APIの外で、DMAPIというものもあったが、あまり流行らなかったようだ。
古くからLinuxでは、ファイルを開いてioctl(2)を発行する方法でファイルの穴を検出することができた。FIBMAPというioctlは、ファイルデータの格納されているディスクブロックの番号を返す。穴の部分はディスクブロック番号として0を返すので、それで検出できる。問題は、このioctlを実行するためにはrootの特権が必要であることと、1ブロック調べるのに1回のioctl(2)発行が必要である点だ。
Linuxにはもう一つ、FS_IOC_FIEMAPというioctl(2)もある。これは、FIBMAPの強化版ともいえるもので、ファイルの指定範囲の情報を一気に取得することができる上に、root特権は要らない。FIEMAPは、2008年10月、2.6.28の開発途上で導入された。
FIBMAP、FS_IOC_FIEMAPともに、ファイルデータが格納されている具体的なディスク上の位置を知る方法で、その副作用として穴がわかるものであった。穴を検出する専用のAPIとして、lseek(2)システムコールにSEEK_HOLE、SEEK_DATAというオプションがある。これは当初Solarisで実装され、のちにFreeBSDやLinuxにも実装されているため、標準ではないけれどもある程度の移植性があると考えて良さそうだ。
SEEK_HOLEは、指定されたオフセット以降の最初の穴に移動する。SEEK_DATAは、指定されたオフセット以降の最初の穴ではない部分に移動する。適切に組み合わせれば、ファイル全体の穴を正しく列挙することができる。
一方で、穴を開ける方はどうか。最初に述べたlseek(2)+write(2)や、ftruncate(2)による方法は、ファイルの末尾に穴を追加することしかできない。つまり、既にデータブロックがアロケートされた部分を穴にするようなことはできない。
ここでもやはりSolarisが先行し、fcntl(2)にF_FREESPというコマンドが追加されている。ファイルの指定された領域に穴を開けることができる。このAPIは他のOSが追従することはなく2、Linuxではfallocate(2)というシステムコールにFALLOC_FL_PUNCH_HOLEというフラグが追加されており、やはり指定した領域に穴を開けることができる。どちらの実装でも、指定された領域に記録されていたデータは失われる (読み出すと0で埋められたデータが返る)。
穴を保存してコピーする
穴を検出するのに (root特権なく) 利用できるAPIが追加される以前から、coreutilsのcpコマンドや、GNU tarなどでは穴あきファイルを検出して効率的にコピーやアーカイブが可能であった。これはどうやって検出していたのだろうか。
coreutilsの更新履歴によると、FS_IOC_FIEMAPによる穴検出が実装されたのは、2010年5月のcommitである。これ以前のコードを見てみればよい。
ざっくり追いかけると、通常ファイルのコピーはcopy_reg()でやっている。707行目あたりのwhileループでコピーが実行されている。
注目は746行目あたりで、0の数を数えてそれだけ分シークしている。つまり、コピー元のデータが0ならコピー先に0を書くのではなく、単にシークしているので、もしかすると穴になってくれるかもしれない、という感じだ。
なお、最新のcoreutilsでも、FIEMAPが使えないときにはこの方法で穴を処理している。
ファイルの穴とTRIM (UNMAP) コマンド
話は変わるが、SSDが普及してきた数年前に、TRIMコマンドという言葉が話題になった。TRIMは、SSDの記憶媒体であるNANDフラッシュメモリが、書き換えに先立って消去という操作を必要とするために、データの書き込みが比較的遅いという問題を解消するためのものだ。OS (ファイルシステム) からSSDに向けて未使用ないし解放済みのブロックを知らせてやることで、SSDはあらかじめ消去を済ませておくことができる。このほか、書き込み回数に上限のあるNANDフラッシュのブロックごとの書き込み頻度を平準化するウェアレベリングにおいても有利となる。
TRIMはSATAのコマンドであるが、SCSIにおいてもUNMAPという似たようなコマンドがある。SCSI接続のSSDも存在するが、SANなどでシンプロビジョニングを行う場合にも有効である。ファイル削除時などに、アロケート済みの領域をストレージに返却できるので、従来のように利用領域が単調増加するばかりではなくなる。
シンプロビジョニングといえば、仮想マシン用の仮想ディスクはシンプロビジョニングで提供されることがある。たとえば、QemuのQcow2ファイルは、ファイルフォーマット自体がシンプロビジョニング可能な形式であるし、rawイメージでも穴あきファイルとして作成すると、シンプロビジョニングとなる。ということで、穴に戻ってきた。
QemuとTRIM/UNMAPコマンド
QemuのエミュレートするSATAディスクやSCSIディスクは、TRIM (UNMAP) コマンドを理解して、バックエンドのディスクイメージを適切に操作する (オプションによる)。
まず、SATAは、2011年5月のcommitでTRIMを解釈するようになった。SCSIディスクの方は、2012年8月である。VirtIOブロックデバイスの場合はずっと新しくて2019年2月だ。それぞれ、Qemuのバージョンで言えば、0.15、1.2.0、4.0のリリースサイクルにあたる。
いっぽう、バックエンドの仮想イメージファイルのドライバのうち、Qcow2は2011年1月に、TRIM (UNMAP) された部分を解放するようになった。ただし、ファイルサイズが縮小されることはなく、開放された領域はfallocateにより穴を開けられる。また、raw形式では2013年1月にfallocate()によりTRIM (UNMAP) された領域を開放するようになった。それ以前からXFSではioctl()による解放を行なっていたようだ。
さて、実際にUbuntu 20.04 LTS (focal、Linux-5.4系、Qemu-4.2系) の環境で仮想マシンを作成すると、仮想ディスク (いずれもVirtIO SCSIの例) は「シンプロビジョニング可能」なディスクとしてゲストOSから見えるようになっている。たとえば、Linuxゲストを起動すると、以下のようにsysfsのディスクのエントリにthin_provisioning、provisioning_modeというファイルができるが、これが以下のようになっている。
$ cat /sys/bus/scsi/devices/0\:0\:0\:0/scsi_disk/0\:0\:0\:0/thin_provisioning
1
$ cat /sys/bus/scsi/devices/0\:0\:0\:0/scsi_disk/0\:0\:0\:0/provisioning_mode
unmap
$
Windowsゲストの場合、「ドライブのデフラグと最適化」ツール (スタート→Windows 管理ツール配下) で見ると、「メディアの種類」項が「仮想プロビジョニング対応ドライブ」になっており、「最適化」を選ぶとTRIMが実行される (図中のZ:はiSCSIディスク)。
通常のディスクを繋いでいるホスト側で、Linuxゲストで見たのと同じファイルを見ると、
$ cat /sys/bus/scsi/devices/0\:0\:0\:0/scsi_disk/0\:0\:0\:0/thin_provisioning
0
$ cat /sys/bus/scsi/devices/0\:0\:0\:0/scsi_disk/0\:0\:0\:0/provisioning_mode
full
$
のようになっており、シンプロビジョニングではない (thick provisioning) とわかる。
実際に、ゲストがUNMAPコマンド (SCSIなので) を発行すると、そのブロックデバイスにdiscardオプションがついていれば、fallocate(2)で領域が開放される。Ubuntu 20.04など、Linux 4.9以降のカーネルを利用していれば、仮想ディスクがファイルの時だけではなく、ホストデバイスである時 (LVMのボリュームなど) にもfallocate(2)が働き、TRIM (UNMAP) がpaththroughされる。
実際にrawイメージで実験してみた。
host# qemu-img create -f raw vol.img 20G
Formatting 'vol.img', fmt=raw size=21474836480
host# qemu-img info vol.img ; ls -lsh vol.img
image: vol.img
file format: raw
virtual size: 20 GiB (21474836480 bytes)
disk size: 4 KiB
4.0K -rw-r--r-- 1 libvirt-qemu kvm 20G May 30 13:42 vol.img
host#
このディスクをゲストのsdbにアタッチする。discardオプションをつけるため、libvirtなら
<driver name='qemu' type='raw'/>
のようになっているところを、
<driver name='qemu' type='raw' discard='unmap'/>
のようにする。ゲストを起動し、対象のディスクを特定したら、これをフォーマットする。ここではXFSにした。
guest:~# mkfs.xfs /dev/sdb
meta-data=/dev/sdb isize=512 agcount=4, agsize=1310720 blks
= sectsz=512 attr=2, projid32bit=1
= crc=1 finobt=1, sparse=0, rmapbt=0, reflink=0
data = bsize=4096 blocks=5242880, 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=512 sunit=0 blks, lazy-count=1
realtime =none extsz=4096 blocks=0, rtextents=0
guest:~#
ホスト側でイメージを調べてみると、メタデータ分大きくなっていることがわかる。
host# qemu-img info vol.img ; ls -lsh vol.img
image: vol.img
file format: raw
virtual size: 20 GiB (21474836480 bytes)
disk size: 10.3 MiB
11M -rw-r--r-- 1 libvirt-qemu kvm 20G May 30 13:51 vol.img
host#
これをmountし、512MBのファイルを作る。
guest:~# mount /dev/sdb /mnt
guest:~# dd if=/dev/urandom of=/mnt/randomfile bs=1M count=512
512+0 records in
512+0 records out
536870912 bytes (537 MB, 512 MiB) copied, 2.95154 s, 182 MB/s
guest:~#
ホスト側でイメージを調べると、512MBほど大きくなっていることがわかる。
host# qemu-img info vol.img ; ls -lsh vol.img
image: vol.img
file format: raw
virtual size: 20 GiB (21474836480 bytes)
disk size: 522 MiB
523M -rw-r--r-- 1 libvirt-qemu kvm 20G May 30 13:52 vol.img
host#
次に、このファイルを削除してみる。TRIMが発行されるのは、mountオプションにdiscardをつけたときか、fstrim(8)を実行したときであるので、fstrimを実行する (-vはverbose)。
guest:~# rm /mnt/randomfile
guest:~# fstrim -v /mnt
/mnt: 20 GiB (21464170496 bytes) trimmed
guest:~#
イメージを調べると、mkfs直後とだいたい同じ大きさに戻っているのがわかる。
host# qemu-img info vol.img ; ls -lsh vol.img
image: vol.img
file format: raw
virtual size: 20 GiB (21474836480 bytes)
disk size: 10.1 MiB
11M -rw-r--r-- 1 libvirt-qemu kvm 20G May 30 13:53 vol.img
host#
最近のDebianやubuntuにはzerofreeというコマンドがあり、ext3やext4の空き領域をゼロフィルしてくれるようだ。これと、qemuのdetect-zeros=unmapオプションを組み合わせると、ディスクイメージのアロケート済みの領域を縮小することができるかもしれない。