テストステ論

高テス協会会長が, テストステロンに関する情報をお届けします.

device-mapperの仕組み (1) device-mapperの概要

device-mapperというのは, ブロック層の仮想化レイヤであり, ソフトウェア的な観点からいうとフレームワークである. target_typeと呼ばれるプラグインをカーネルモジュールとして実装することで, device-mapperの上に仮想的なブロックデバイス機能を実現することが可能である.

device-mapperは, 仮想的なブロックデバイスを作成し, その仮想デバイスは, 受け取ったI/Oを処理する. ここで重要なのは, 物理的なブロックデバイスが行うべき処理を完全にemulateすることである. 仮想デバイスのクライアントは, それが仮想的であるか物理的であるかを意識することがない. またこれより, 仮想デバイスは仮想デバイスの上に作成することも可能である. このような特性から, device-mapperによって作成された仮想デバイスは, "stacked device"とも呼ばれる. device-mapperでは, さまざまなシンプルな機能を仮想デバイスとしてstackしていくことによって, 複雑な機能を持った仮想デバイスを実現することが可能である. これは, オブジェクト指向いうところのDecorator Patternである.
device-mapperは, VFS(Virtual File System)と同様に, オブジェクト指向的な設計が徹底されており, 先に述べたtarget_typeは, Javaでいうところのinterfaceである. device-mapperはVFSに比べると後発であり, 設計も緻密に行われていることから, device-mapperが好きな理由として「美しいから」が挙げられることが多い. 美しさの元には, device-mapperという名前に"map"という言葉を含んでいるように, 関数型のテイストも含む.

ところで, ブロックデバイスとは何だろうか. たまに勘違いしている人がいるので明確に述べておくと, Linuxにおいてブロックデバイスは, 同期I/Oをサポートしない. すなわち, I/Oはすべて非同期である. 同期I/Oは, その上位でcompletionをスリープして待つなどの方法で実現される. 以上を踏まえると, ブロックデバイスは, I/O(struct bio)を受け取ったあと, 適切な処理を行ったあと, bioに設定されたI/Oの完了関数(endio)を呼ぶものであると定義出来る. ここで「適切な」というのは, bioにflagが設定されているということである.
dm-lcでは, write barrierの取り扱いとして, REQ_FLUSHとREQ_FUAの取り扱いについて述べた. この他にも, そのI/Oはreadかwriteか, discard I/Oかなど, フラグが存在しており, これらに対して適切に対応する必要がある(すべてを実装する必要はなく, discardはサポートしないなどという主張は可能である. write barrierについても, サポートしないことを表明することは可能であるが, これは実用性の面で選択の余地がない).
仮想デバイスも, 受け取ったI/Oを適切に処理して, 最終的にendioを呼ぶ. その一連の流れの部分部分をtarget_typeで実装するという設計である. これはオブジェクト指向でいうところのTemplate Pattern (or Skeletal Pattern)である.

例として, dm-linearを紹介する. device-mapperを知らない人でも, LVM (Logical Volume Manager)は聞いたことがあるかも知れない. LVMは, 複数のブロックデバイスをプールして, 仮想的なデバイスを切り出すことが出来るツールである. この機能を実現するために, dm-linearは使われている.

dm-liearのtarget_typeは, 以下のように定義されている.

static struct target_type linear_target = {
        .name   = "linear",
        .version = {1, 2, 1},
        .module = THIS_MODULE,
        .ctr    = linear_ctr,
        .dtr    = linear_dtr,
        .map    = linear_map,
        .status = linear_status,
        .ioctl  = linear_ioctl,
        .merge  = linear_merge,
        .iterate_devices = linear_iterate_devices,
};

これをカーネルモジュールの初期化関数において, dm_register_targetすることで, カーネルに対してlinearターゲットというターゲットを登録する.

仮想デバイスを作る時にはlinear_ctrが呼ばれる. この関数は, 仮想デバイスの指定領域に対してdm_targetを設定する. linear_ctrでは, I/Oをlinear mappingする先のデバイスと, それに対するoffsetを独自の構造linear_cに設定し, dm_targerのprivateメンバに設定する. privateポインタにデータを格納する設計パターンは, Linuxカーネルでは良く見られる.

(linear_ctr. 説明のため改変)

/*
 * Construct a linear mapping: <dev_path> <offset>
 */
static int linear_ctr(struct dm_target *ti, unsigned int argc, char **argv)
        struct linear_c *lc = kmalloc(sizeof(*lc), GFP_KERNEL);
        // オフセット(<offset>)を設定している.
        if (sscanf(argv[1], "%llu%c", &tmp, &dummy) != 1) {
                ti->error = "dm-linear: Invalid device sector";
                goto bad;
        }
        lc->start = tmp;
        // 仮想デバイスの下にあるデバイス(<dev_path>)を設定している.
        if (dm_get_device(ti, argv[0], dm_table_get_mode(ti->table), &lc->dev)) {
                ti->error = "dm-linear: Device lookup failed";
                goto bad;
        }
        ti->private = lc;

linear_mapでは, 受け取ったbioについて, 行き先デバイス(bio->bi_bdev)と行き先セクタ(bio->bi_sector)を書き換えている. これは, rewriteモジュールのようだと思う人もいるだろう. linearターゲットだけを見れば, その通りである. ただし, device-mapperは, linearターゲットのように, I/Oの行き先を変更するだけでなく, 例えば, I/Oを遅延させる(dm-delay), 一定確率でfailさせる(dm-flakey)などということも出来る. また, dm-lcのように, 一時的にDRAMバッファにwriteしたらACKを返してしまうものも存在し得る. bioフラグの規約を守る限りにおいて, すべては自由である. filesystemなどはどれもファイルシステムを実現するためにあるが, device-mapperでは, それぞれのモジュールに個性がある点が違う. この点が, device-mapperが人気のある理由でもある.

static sector_t linear_map_sector(struct dm_target *ti, sector_t bi_sector)
{
        struct linear_c *lc = ti->private;

        return lc->start + dm_target_offset(ti, bi_sector);
}

static void linear_map_bio(struct dm_target *ti, struct bio *bio)
{
        struct linear_c *lc = ti->private;

        bio->bi_bdev = lc->dev->bdev;
        if (bio_sectors(bio))
                bio->bi_sector = linear_map_sector(ti, bio->bi_sector);
}

static int linear_map(struct dm_target *ti, struct bio *bio)
{
        linear_map_bio(ti, bio);

        return DM_MAPIO_REMAPPED;
}

最後に, linearターゲットを作成する方法を説明し, 仮想デバイスに対するI/Oに対して「どのように処理をするか」をどのような仕組みによって決定しているかを説明する.

linearターゲットを仮想デバイスに設定するためには,

# echo "0 100 linear /dev/sdc1 10" | dmsetup create mydevice

を実行すれば良い. これは, 「mydeviceという仮想デバイスを作る. その0セクタから100セクタの仮想領域へのI/Oへはlinearターゲットを適用する. I/Oは, offsetを10セクタとして/dev/sdc1に転送される」という意味である.

device-mapperでは, 仮想デバイスは, 以下のようなものである(空想の言語である). I/Oを受け取った時には, add_targetで行ったように, まずlookup処理を行い, targetに対してテンプレート中の処理を委譲する.

class LogicalDevice {
  type Range = (sector, sector)
  type Target = bio -> endio
  Map<Range, Target> table

  add_target(range, typeName, args) = {
     target = lookup_target(typeName)
     target.ctr(args)
     table[range] = target
  }

  handleIO(bio) = { // 実際は, dm_requestという名前
    target = lookup_target_range(bio->bi_sector)
    // map 前処理
    target.map(bio) // 例えば, linear_map
    // ...
  }
}

以上である. 今回は, device-mapperの概要について説明した. しかし読者の中には例えば, 「複数のターゲットを設定出来ることは分かった. しかし, I/Oがターゲットをまたいだらどうするの?」などといった疑問を持った人もいるだろう. それらに対する回答は, 今後のブログに期待出来る. I/Oの具体的な処理について, 今後説明していく.

この記事に書いたようなことは, すでに, 様々な企業, 個人が公開している. 以下にそのいくつかを紹介する. 図が丁寧に書かれており, 理解を助けると思う. 本記事の差分は, ブロックデバイスのemulationであるということの説明をしたことと, 説明を簡便にするためオブジェクト指向の用語を導入したことである. Linuxカーネルにおいて, オブジェクト指向は有効であるにも関わらず, それほど広く理解されてはいないため, 開発のスピードアップが出来ない. 全員が, 用語や概念に精通すれば良いのにと思う.