You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

19 KiB

53 | 存储虚拟化(上):如何建立自己保管的单独档案库?

前面几节我们讲了CPU和内存的虚拟化。我们知道完全虚拟化是很慢的而通过内核的KVM技术和EPT技术加速虚拟机对于物理CPU和内存的使用我们称为硬件辅助虚拟化。

对于一台虚拟机而言除了要虚拟化CPU和内存存储和网络也需要虚拟化存储和网络都属于外部设备这些外部设备应该如何虚拟化呢

当然一种方式还是完全虚拟化。比如有什么样的硬盘设备或者网卡设备我们就用qemu模拟一个一模一样的软件的硬盘和网卡设备这样在虚拟机里面的操作系统看来使用这些设备和使用物理设备是一样的。当然缺点就是qemu模拟的设备又是一个翻译官的角色。虽然这个时候虚拟机里面的操作系统意识不到自己是运行在虚拟机里面的但是这种每个指令都翻译的方式实在是太慢了。

另外一种方式就是,虚拟机里面的操作系统不是一个通用的操作系统,它知道自己是运行在虚拟机里面的,使用的硬盘设备和网络设备都是虚拟的,应该加载特殊的驱动才能运行。这些特殊的驱动往往要通过虚拟机里面和外面配合工作的模式,来加速对于物理存储和网络设备的使用。

virtio的基本原理

在虚拟化技术的早期,不同的虚拟化技术会针对不同硬盘设备和网络设备实现不同的驱动,虚拟机里面的操作系统也要根据不同的虚拟化技术和物理存储和网络设备,选择加载不同的驱动。但是,由于硬盘设备和网络设备太多了,驱动纷繁复杂。

后来慢慢就形成了一定的标准,这就是virtio,就是虚拟化I/O设备的意思。virtio负责对于虚拟机提供统一的接口。也就是说在虚拟机里面的操作系统加载的驱动以后都统一加载virtio就可以了。

在虚拟机外我们可以实现不同的virtio的后端来适配不同的物理硬件设备。那virtio到底长什么样子呢我们一起来看一看。

virtio的架构可以分为四层。

  • 首先在虚拟机里面的virtio前端针对不同类型的设备有不同的驱动程序但是接口都是统一的。例如硬盘就是virtio_blk网络就是virtio_net。
  • 其次在宿主机的qemu里面实现virtio后端的逻辑主要就是操作硬件的设备。例如通过写一个物理机硬盘上的文件来完成虚拟机写入硬盘的操作。再如向内核协议栈发送一个网络包完成虚拟机对于网络的操作。
  • 在virtio的前端和后端之间有一个通信层里面包含virtio层virtio-ring层。virtio这一层实现的是虚拟队列接口算是前后端通信的桥梁。而virtio-ring则是该桥梁的具体实现。

virtio使用virtqueue进行前端和后端的高速通信。不同类型的设备队列数目不同。virtio-net使用两个队列一个用于接收另一个用于发送而 virtio-blk仅使用一个队列。

如果客户机要向宿主机发送数据客户机会将数据的buffer添加到virtqueue中然后通过写入寄存器通知宿主机。这样宿主机就可以从virtqueue 中收到的buffer里面的数据。

了解了virtio的基本原理接下来我们以硬盘写入为例具体看一下存储虚拟化的过程。

初始化阶段的存储虚拟化

和咱们在学习CPU的时候看到的一样Virtio Block Device也是一种类。它的继承关系如下

static const TypeInfo device_type_info = {
    .name = TYPE_DEVICE,
    .parent = TYPE_OBJECT,
    .instance_size = sizeof(DeviceState),
    .instance_init = device_initfn,
    .instance_post_init = device_post_init,
    .instance_finalize = device_finalize,
    .class_base_init = device_class_base_init,
    .class_init = device_class_init,
    .abstract = true,
    .class_size = sizeof(DeviceClass),
};

static const TypeInfo virtio_device_info = {
    .name = TYPE_VIRTIO_DEVICE,
    .parent = TYPE_DEVICE,
    .instance_size = sizeof(VirtIODevice),
    .class_init = virtio_device_class_init,
    .instance_finalize = virtio_device_instance_finalize,
    .abstract = true,
    .class_size = sizeof(VirtioDeviceClass),
};

static const TypeInfo virtio_blk_info = {
    .name = TYPE_VIRTIO_BLK,
    .parent = TYPE_VIRTIO_DEVICE,
    .instance_size = sizeof(VirtIOBlock),
    .instance_init = virtio_blk_instance_init,
    .class_init = virtio_blk_class_init,
};

static void virtio_register_types(void)
{
    type_register_static(&virtio_blk_info);
}

type_init(virtio_register_types)

Virtio Block Device这种类的定义是有多层继承关系的。TYPE_VIRTIO_BLK的父类是TYPE_VIRTIO_DEVICETYPE_VIRTIO_DEVICE的父类是TYPE_DEVICETYPE_DEVICE的父类是TYPE_OBJECT。到头了。

type_init用于注册这种类。这里面每一层都有class_init用于从TypeImpl生产xxxClass。还有instance_init可以将xxxClass初始化为实例。

在TYPE_VIRTIO_BLK层的class_init函数virtio_blk_class_init中定义了DeviceClass的realize函数为virtio_blk_device_realize这一点在CPU那一节也有类似的结构。

static void virtio_blk_device_realize(DeviceState *dev, Error **errp)
{
    VirtIODevice *vdev = VIRTIO_DEVICE(dev);
    VirtIOBlock *s = VIRTIO_BLK(dev);
    VirtIOBlkConf *conf = &s->conf;
......
    blkconf_blocksizes(&conf->conf);
    virtio_blk_set_config_size(s, s->host_features);
    virtio_init(vdev, "virtio-blk", VIRTIO_ID_BLOCK, s->config_size);
    s->blk = conf->conf.blk;
    s->rq = NULL;
    s->sector_mask = (s->conf.conf.logical_block_size / BDRV_SECTOR_SIZE) - 1;
    for (i = 0; i < conf->num_queues; i++) {
        virtio_add_queue(vdev, conf->queue_size, virtio_blk_handle_output);
    }
    virtio_blk_data_plane_create(vdev, conf, &s->dataplane, &err);
    s->change = qemu_add_vm_change_state_handler(virtio_blk_dma_restart_cb, s);
    blk_set_dev_ops(s->blk, &virtio_block_ops, s);
    blk_set_guest_block_size(s->blk, s->conf.conf.logical_block_size);
    blk_iostatus_enable(s->blk);
}

在virtio_blk_device_realize函数中我们先是通过virtio_init初始化VirtIODevice结构。

void virtio_init(VirtIODevice *vdev, const char *name,
                 uint16_t device_id, size_t config_size)
{
    BusState *qbus = qdev_get_parent_bus(DEVICE(vdev));
    VirtioBusClass *k = VIRTIO_BUS_GET_CLASS(qbus);
    int i;
    int nvectors = k->query_nvectors ? k->query_nvectors(qbus->parent) : 0;

    if (nvectors) {
        vdev->vector_queues =
            g_malloc0(sizeof(*vdev->vector_queues) * nvectors);
    }
    vdev->device_id = device_id;
    vdev->status = 0;
    atomic_set(&vdev->isr, 0);
    vdev->queue_sel = 0;
    vdev->config_vector = VIRTIO_NO_VECTOR;
    vdev->vq = g_malloc0(sizeof(VirtQueue) * VIRTIO_QUEUE_MAX);
    vdev->vm_running = runstate_is_running();
    vdev->broken = false;
    for (i = 0; i < VIRTIO_QUEUE_MAX; i++) {
        vdev->vq[i].vector = VIRTIO_NO_VECTOR;
        vdev->vq[i].vdev = vdev;
        vdev->vq[i].queue_index = i;
    }
    vdev->name = name;
    vdev->config_len = config_size;
    if (vdev->config_len) {
        vdev->config = g_malloc0(config_size);
    } else {
        vdev->config = NULL;
    }
    vdev->vmstate = qemu_add_vm_change_state_handler(virtio_vmstate_change,
                                                     vdev);
    vdev->device_endian = virtio_default_endian();
    vdev->use_guest_notifier_mask = true;
}

从virtio_init中可以看出VirtIODevice结构里面有一个VirtQueue数组这就是virtio前端和后端互相传数据的队列最多VIRTIO_QUEUE_MAX个。

我们回到virtio_blk_device_realize函数。接下来根据配置的队列数目num_queues对于每个队列都调用virtio_add_queue来初始化队列。

VirtQueue *virtio_add_queue(VirtIODevice *vdev, int queue_size,
                            VirtIOHandleOutput handle_output)
{
    int i;
    vdev->vq[i].vring.num = queue_size;
    vdev->vq[i].vring.num_default = queue_size;
    vdev->vq[i].vring.align = VIRTIO_PCI_VRING_ALIGN;
    vdev->vq[i].handle_output = handle_output;
    vdev->vq[i].handle_aio_output = NULL;

    return &vdev->vq[i];
}

在每个VirtQueue中都有一个vring用来维护这个队列里面的数据另外还有一个函数virtio_blk_handle_output用于处理数据写入这个函数我们后面会用到。

至此VirtIODeviceVirtQueuevring之间的关系如下图所示。这是在qemu里面的对应关系请你记好后面我们还能看到类似的结构。

qemu启动过程中的存储虚拟化

初始化过程解析完毕以后我们接下来从qemu的启动过程看起。

对于硬盘的虚拟化qemu的启动参数里面有关的是下面两行

-drive file=/var/lib/nova/instances/1f8e6f7e-5a70-4780-89c1-464dc0e7f308/disk,if=none,id=drive-virtio-disk0,format=qcow2,cache=none
-device virtio-blk-pci,scsi=off,bus=pci.0,addr=0x4,drive=drive-virtio-disk0,id=virtio-disk0,bootindex=1

其中第一行指定了宿主机硬盘上的一个文件文件的格式是qcow2这个格式我们这里不准备解析它你只要明白对于宿主机上的一个文件可以被qemu模拟称为客户机上的一块硬盘就可以了。

而第二行说明了使用的驱动是virtio-blk驱动。

configure_blockdev(&bdo_queue, machine_class, snapshot);

在qemu启动的main函数里面初始化块设备是通过configure_blockdev调用开始的。

static void configure_blockdev(BlockdevOptionsQueue *bdo_queue, MachineClass *machine_class, int snapshot)
{
......
    if (qemu_opts_foreach(qemu_find_opts("drive"), drive_init_func,
                          &machine_class->block_default_type, &error_fatal)) {
.....
    }
}

static int drive_init_func(void *opaque, QemuOpts *opts, Error **errp)
{
    BlockInterfaceType *block_default_type = opaque;
    return drive_new(opts, *block_default_type, errp) == NULL;
}

在configure_blockdev中我们能看到对于drive这个参数的解析并且初始化这个设备要调用drive_init_func函数这里面会调用drive_new创建一个设备。

DriveInfo *drive_new(QemuOpts *all_opts, BlockInterfaceType block_default_type, Error **errp)
{
    const char *value;
    BlockBackend *blk;
    DriveInfo *dinfo = NULL;
    QDict *bs_opts;
    QemuOpts *legacy_opts;
    DriveMediaType media = MEDIA_DISK;
    BlockInterfaceType type;
    int max_devs, bus_id, unit_id, index;
    const char *werror, *rerror;
    bool read_only = false;
    bool copy_on_read;
    const char *filename;
    Error *local_err = NULL;
    int i;
......
    legacy_opts = qemu_opts_create(&qemu_legacy_drive_opts, NULL, 0,
                                   &error_abort);
......
    /* Add virtio block device */
    if (type == IF_VIRTIO) {
        QemuOpts *devopts;
        devopts = qemu_opts_create(qemu_find_opts("device"), NULL, 0,
                                   &error_abort);
        qemu_opt_set(devopts, "driver", "virtio-blk-pci", &error_abort);
        qemu_opt_set(devopts, "drive", qdict_get_str(bs_opts, "id"),
                     &error_abort);
    }

    filename = qemu_opt_get(legacy_opts, "file");
......
    /* Actual block device init: Functionality shared with blockdev-add */
    blk = blockdev_init(filename, bs_opts, &local_err);
......
    /* Create legacy DriveInfo */
    dinfo = g_malloc0(sizeof(*dinfo));
    dinfo->opts = all_opts;

    dinfo->type = type;
    dinfo->bus = bus_id;
    dinfo->unit = unit_id;

    blk_set_legacy_dinfo(blk, dinfo);

    switch(type) {
    case IF_IDE:
    case IF_SCSI:
    case IF_XEN:
    case IF_NONE:
        dinfo->media_cd = media == MEDIA_CDROM;
        break;
    default:
        break;
    }
......
}

在drive_new里面会解析qemu的启动参数。对于virtio来讲会解析device参数把driver设置为virtio-blk-pci还会解析file参数就是指向那个宿主机上的文件。

接下来drive_new会调用blockdev_init根据参数进行初始化最后会创建一个DriveInfo来管理这个设备。

我们重点来看blockdev_init。在这里面我们发现如果file不为空则应该调用blk_new_open打开宿主机上的硬盘文件返回的结果是BlockBackend对应我们上面讲原理的时候的virtio的后端。

BlockBackend *blk_new_open(const char *filename, const char *reference,
                           QDict *options, int flags, Error **errp)
{
    BlockBackend *blk;
    BlockDriverState *bs;
    uint64_t perm = 0;
......
    blk = blk_new(perm, BLK_PERM_ALL);
    bs = bdrv_open(filename, reference, options, flags, errp);
    blk->root = bdrv_root_attach_child(bs, "root", &child_root,
                                       perm, BLK_PERM_ALL, blk, errp);
    return blk;
}

接下来的调用链为bdrv_open->bdrv_open_inherit->bdrv_open_common.

static int bdrv_open_common(BlockDriverState *bs, BlockBackend *file,
                            QDict *options, Error **errp)
{
    int ret, open_flags;
    const char *filename;
    const char *driver_name = NULL;
    const char *node_name = NULL;
    const char *discard;
    QemuOpts *opts;
    BlockDriver *drv;
    Error *local_err = NULL;
......
    drv = bdrv_find_format(driver_name);
......
    ret = bdrv_open_driver(bs, drv, node_name, options, open_flags, errp);
......
}

static int bdrv_open_driver(BlockDriverState *bs, BlockDriver *drv,
                            const char *node_name, QDict *options,
                            int open_flags, Error **errp)
{
......
    bs->drv = drv;
    bs->read_only = !(bs->open_flags & BDRV_O_RDWR);
    bs->opaque = g_malloc0(drv->instance_size);

    if (drv->bdrv_open) {
        ret = drv->bdrv_open(bs, options, open_flags, &local_err);
    } 
......
}

在bdrv_open_common中根据硬盘文件的格式得到BlockDriver。因为虚拟机的硬盘文件格式有很多种qcow2是一种raw是一种vmdk是一种各有优缺点启动虚拟机的时候可以自由选择。

对于不同的格式打开的方式不一样我们拿qcow2来解析。它的BlockDriver定义如下

BlockDriver bdrv_qcow2 = {
    .format_name        = "qcow2",
    .instance_size      = sizeof(BDRVQcow2State),
    .bdrv_probe         = qcow2_probe,
    .bdrv_open          = qcow2_open,
    .bdrv_close         = qcow2_close,
......
    .bdrv_snapshot_create   = qcow2_snapshot_create,
    .bdrv_snapshot_goto     = qcow2_snapshot_goto,
    .bdrv_snapshot_delete   = qcow2_snapshot_delete,
    .bdrv_snapshot_list     = qcow2_snapshot_list,
    .bdrv_snapshot_load_tmp = qcow2_snapshot_load_tmp,
    .bdrv_measure           = qcow2_measure,
    .bdrv_get_info          = qcow2_get_info,
    .bdrv_get_specific_info = qcow2_get_specific_info,

    .bdrv_save_vmstate    = qcow2_save_vmstate,
    .bdrv_load_vmstate    = qcow2_load_vmstate,

    .supports_backing           = true,
    .bdrv_change_backing_file   = qcow2_change_backing_file,

    .bdrv_refresh_limits        = qcow2_refresh_limits,
......
};

根据上面的定义对于qcow2来讲bdrv_open调用的是qcow2_open。

static int qcow2_open(BlockDriverState *bs, QDict *options, int flags,
                      Error **errp)
{
    BDRVQcow2State *s = bs->opaque;
    QCow2OpenCo qoc = {
        .bs = bs,
        .options = options,
        .flags = flags,
        .errp = errp,
        .ret = -EINPROGRESS
    };

    bs->file = bdrv_open_child(NULL, options, "file", bs, &child_file,
                               false, errp);
    qemu_coroutine_enter(qemu_coroutine_create(qcow2_open_entry, &qoc));
......
}

在qcow2_open中我们会通过qemu_coroutine_enter进入一个协程coroutine。什么叫协程呢我们可以简单地将它理解为用户态自己实现的线程。

前面咱们讲线程的时候说过如果一个程序想实现并发可以创建多个线程但是线程是一个内核的概念创建的每一个线程内核都能看到内核的调度也是以线程为单位的。这对于普通的进程没有什么问题但是对于qemu这种虚拟机如果在用户态和内核态切换来切换去由于还涉及虚拟机的状态代价比较大。

但是qemu的设备也是需要多线程能力的怎么办呢我们就在用户态实现一个类似线程的东西也就是协程用于实现并发并且不被内核看到调度全部在用户态完成。

从后面的读写过程可以看出协程在后端经常使用。这里打开一个qcow2文件就是使用一个协程创建一个协程和创建一个线程很像也需要指定一个函数来执行qcow2_open_entry就是协程的函数。

static void coroutine_fn qcow2_open_entry(void *opaque)
{
    QCow2OpenCo *qoc = opaque;
    BDRVQcow2State *s = qoc->bs->opaque;

    qemu_co_mutex_lock(&s->lock);
    qoc->ret = qcow2_do_open(qoc->bs, qoc->options, qoc->flags, qoc->errp);
    qemu_co_mutex_unlock(&s->lock);
}

我们可以看到qcow2_open_entry函数前面有一个coroutine_fn说明它是一个协程函数。在qcow2_do_open中qcow2_do_open根据qcow2的格式打开硬盘文件。这个格式官网就有,我们这里就不花篇幅解析了。

总结时刻

我们这里来总结一下,存储虚拟化的过程分为前端、后端和中间的队列。

  • 前端有前端的块设备驱动Front-end driver在客户机的内核里面它符合普通设备驱动的格式对外通过VFS暴露文件系统接口给客户机里面的应用。这一部分这一节我们没有讲放在下一节解析。
  • 后端有后端的设备驱动Back-end driver在宿主机的qemu进程中当收到客户机的写入请求的时候调用文件系统的write函数写入宿主机的VFS文件系统最终写到物理硬盘设备上的qcow2文件。
  • 中间的队列用于前端和后端之间传输数据在前端的设备驱动和后端的设备驱动都有类似的数据结构virt-queue来管理这些队列这一部分这一节我们也没有讲也放到下一节解析。

课堂练习

对于qemu-kvm来讲qcow2是一种常见的文件格式。它有精妙的格式设计从而适应虚拟化的场景请你研究一下这个文件格式。

欢迎留言和我分享你的疑惑和见解,也欢迎收藏本节内容,反复研读。你也可以把今天的内容分享给你的朋友,和他一起学习和进步。