ファイルの「穴」の話

## ファイルの「穴」の話

```
$ 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]。

[^1]: ddでは最後に1バイト書いているので、その部分のディスクブロックはアロケートされている。一方truncateやqemu-imgでは、全くアロケートされていない可能性がある。実際にどれだけアロケートされているかはファイルシステムの実装に依存する。

これらのファイルを読むと、「0」で埋まったデータが読み出され、また書き込むとその時点でディスクブロックがアロケートされて書いた情報が保存される。この、ディスクブロックがアロケートされていない部分を「穴」「hole」といい、そのような部分を持つファイルのことを「穴あきファイル」「sparse file」と呼ぶ。古くから、DBファイルのように文字通り疎な (情報量と比較してファイルサイズが大きい) ファイルは穴あきであることもよくあったし、最近ではQemu/KVM仮想マシンの仮想ディスクイメージが穴あきであることがある。

## 穴の検出と穴あけ

このファイルの穴は、ファイルシステム内部でどのようにディスクスペースを利用するかという話であって、伝統的にはAPIには現れてきてこなかった。通常のPOSIX APIの外で、[DMAPI](https://pubs.opengroup.org/onlinepubs/9657099/toc.htm)というものもあったが、あまり流行らなかったようだ。

古くからLinuxでは、ファイルを開いてioctl(2)を発行する方法でファイルの穴を検出することができた。FIBMAPというioctlは、ファイルデータの格納されているディスクブロックの番号を返す。穴の部分はディスクブロック番号として0を返すので、それで検出できる。問題は、このioctlを実行するためにはrootの特権が必要であることと、1ブロック調べるのに1回のioctl(2)発行が必要である点だ。

Linuxにはもう一つ、[FS_IOC_FIEMAP](https://www.kernel.org/doc/Documentation/filesystems/fiemap.txt)というioctl(2)もある。これは、FIBMAPの強化版ともいえるもので、ファイルの指定範囲の情報を一気に取得することができる上に、root特権は要らない。FIEMAPは、[2008年10月](https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/commit/?id=c4b929b85bdb64afacbbf6453b1f2bf7e14c9e89)、2.6.28の開発途上で導入された。

FIBMAP、FS_IOC_FIEMAPともに、ファイルデータが格納されている具体的なディスク上の位置を知る方法で、その副作用として穴がわかるものであった。穴を検出する専用のAPIとして、lseek(2)システムコールにSEEK_HOLE、SEEK_DATAというオプションがある。これは当初Solarisで実装され、のちにFreeBSDや[Linuxにも](https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/commit/?id=982d816581eeeacfe5b2b7c6d47d13a157616eff)実装されているため、標準ではないけれどもある程度の移植性があると考えて良さそうだ。

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というフラグが追加されており](https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/commit/?id=79124f18b335172e1916075c633745e12dae1dac)、やはり指定した領域に穴を開けることができる。どちらの実装でも、指定された領域に記録されていたデータは失われる (読み出すと0で埋められたデータが返る)。

[^2]: ただし、XFSには古くからXFS_IOC_FREESPというioctlがあり、fcntl(F_FREESP)と同じことができる。


## 穴を保存してコピーする

穴を検出するのに (root特権なく) 利用できるAPIが追加される以前から、coreutilsのcpコマンドや、GNU tarなどでは穴あきファイルを検出して効率的にコピーやアーカイブが可能であった。これはどうやって検出していたのだろうか。

[coreutils](https://git.savannah.gnu.org/gitweb/?p=coreutils.git)の更新履歴によると、FS_IOC_FIEMAPによる穴検出が実装されたのは、[2010年5月](https://git.savannah.gnu.org/gitweb/?p=coreutils.git;a=commit;h=dff2b95e4fb22f3e6f3360da0774c784d2f50ff9)のcommitである。これ以前の[コード](https://git.savannah.gnu.org/gitweb/?p=coreutils.git;a=blob;f=src/copy.c;h=9a014ad5aa672c78b7e3cd69de1e684c73563e1f;hb=e1aaf8903db97f3240b1551fd6936ccdc652dfc8)を見てみればよい。

ざっくり追いかけると、通常ファイルのコピーは[copy_reg()](https://git.savannah.gnu.org/gitweb/?p=coreutils.git;a=blob;f=src/copy.c;h=9a014ad5aa672c78b7e3cd69de1e684c73563e1f;hb=e1aaf8903db97f3240b1551fd6936ccdc652dfc8#l458)でやっている。[707行目](https://git.savannah.gnu.org/gitweb/?p=coreutils.git;a=blob;f=src/copy.c;h=9a014ad5aa672c78b7e3cd69de1e684c73563e1f;hb=e1aaf8903db97f3240b1551fd6936ccdc652dfc8#l707)あたりのwhileループでコピーが実行されている。

注目は[746行目](https://git.savannah.gnu.org/gitweb/?p=coreutils.git;a=blob;f=src/copy.c;h=9a014ad5aa672c78b7e3cd69de1e684c73563e1f;hb=e1aaf8903db97f3240b1551fd6936ccdc652dfc8#l746)あたりで、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](https://git.qemu.org/?p=qemu.git;a=commitdiff;h=d353fb72f59cd0e1f67baf773e74719cda761a89)でTRIMを解釈するようになった。SCSIディスクの方は、[2012年8月](5222aaf223e52961cabeb7cabc579892ccd8bc59)である。VirtIOブロックデバイスの場合はずっと新しくて[2019年2月](https://git.qemu.org/?p=qemu.git;a=commitdiff;h=37b06f8d46fe602e630e4bdce24e80a3e0f70cc2)だ。それぞれ、Qemuのバージョンで言えば、0.15、1.2.0、4.0のリリースサイクルにあたる。

いっぽう、バックエンドの仮想イメージファイルのドライバのうち、Qcow2は[2011年1月](https://git.qemu.org/?p=qemu.git;a=commitdiff;h=5ea929e3d13622e5d06ca8795819f1590644cda2)に、TRIM (UNMAP) された部分を解放するようになった。ただし、ファイルサイズが縮小されることはなく、開放された領域はfallocateにより穴を開けられる。また、raw形式では[2013年1月](https://git.qemu.org/?p=qemu.git;a=commitdiff;h=3d4fa43e648f3b169e7ab5dd4e21312e510805d7)に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ディスク)。

<img width="644" alt="スクリーンショット 2020-05-29 16.39.54.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/250050/649848b8-565d-ecf1-ae75-4b4d7162a563.png">

通常のディスクを繋いでいるホスト側で、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オプション](https://www.qemu.org/docs/master/system/invocation.html#hxtool-1)がついていれば、fallocate(2)で領域が開放される。Ubuntu 20.04など、Linux 4.9以降のカーネルを利用していれば、仮想ディスクがファイルの時だけではなく、ホストデバイスである時 (LVMのボリュームなど) にも[fallocate(2)が働き](https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/commit/?id=25f4c41415e513f0e9fb1f3fce2ce98fcba8d263)、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オプション](https://www.qemu.org/docs/master/system/invocation.html#hxtool-1)を組み合わせると、ディスクイメージのアロケート済みの領域を縮小することができるかもしれない。