Mitaka Nova在线快照数据丢失问题及解决方法




问题背景

公司云平台架构是OpenStack+kvm,产品规划在这个月的迭代中支持云主机系统盘不停机做自定义镜像(nova live snapshot),原因有2个:一是我们已经支持了定期备份功能,如果不能做到不停机做备份,那么定期备份功能的用户体验就很差,因为极大的影响了用户的业务运行;二是做自定义镜像的时候如果能不影响业务,那也是极好的。另外一个技术原因是,从M版本之后的某个版本(Pike 16.0版本还没改,应该是master分支改的,也就是Q版本会变成默认开启)开始,live snapshot已经是nova做系统盘快照的默认方式(M版本默认还是cold snapshot),可见该功能已经非常成熟稳定。

问题症状

经过修改nova配置项[workaround]disable_libvirt_livesnapshot=False简单测试,发现nova image-create命令执行后,云主机确实不会再中断一下了,在整个快照过程中,都正常可用,卡顿现象都没发生,试着从自定义镜像创建新云主机,也可以正常进入操作系统,非常兴奋,以为果然好用。

结果有一天在这个开启了live snapshot的环境部署了一台测试机,安装了一些软件,想通过自定义镜像复制几台新的云主机,打完快照,从快照新建,启动后登陆到云主机内部,发现装好的软件不见了,镜像内容跟基础镜像一样,之后就郁闷了。

于是不甘心,在使用ceph后端做nova系统盘的环境下测试,发现没有问题,看了下nova代码的live snapshot流程,ceph后端场景下live snapshot的流程是直接调用rbd后端接口做snap,因此没有这个问题。需要注意的是,即便是ceph后端做系统盘,默认也是cold snapshot方式做自定义镜像。

看来这个问题只在本地镜像文件做系统盘的场景下才会发生。

问题定位

于是只能继续分析nova代码,老的cold snapshot流程跟之前用了很久的H版本中一样,都是先休眠虚拟机,然后通过qemu-img convert命令合并instance/UUID目录下的cow部分disk文件和_base目录下的backing file基础部分,合成一个完整的qcow2镜像(或者你在nova配置文件中指定的snapshot镜像格式),然后就可以唤醒虚拟机,最后通过glanceclient调用update image api更新镜像内容(镜像uuid在nova api服务里面已经调用glance api生成了,提前预留好)。这个过程中,从虚拟机休眠到唤醒期间,都是停服的,虽然唤醒后虚拟机里面的CPU状态、内存数据都还在,但毕竟是无法响应外部请求的,对用户影响很大。

而live snapshot在线快照流程,则是不对虚拟机进行休眠操作,只是freeze一下虚拟机内部的文件系统(只针对系统盘,也就是vda盘),freeze文件系统这一步也不是强制的,但是最好支持,否则正在写入的或者缓存中的磁盘数据可能刷不到系统盘上,导致文件系统校验错误(windows虚拟机启动时会提示非正常关机,进入启动选项的选择模式:“安全模式”、“正常启动”等那几条选项,默认是等待30s进入正常启动模式)。

nova/virt/libvirt/driver.py:
    def _live_snapshot(self, context, instance, guest, disk_path, out_path,
                       source_format, image_format, image_meta):
        """Snapshot an instance without downtime."""
        dev = guest.get_block_device(disk_path)

        # Save a copy of the domain's persistent XML file
        xml = guest.get_xml_desc(dump_inactive=True, dump_sensitive=True)

        # Abort is an idempotent operation, so make sure any block
        # jobs which may have failed are ended.
        try:
            dev.abort_job()    ### 终止虚拟机vda盘的block job
        except Exception:
            pass

        # NOTE (rmk): We are using shallow rebases as a workaround to a bug
        #             in QEMU 1.3. In order to do this, we need to create
        #             a destination image with the original backing file
        #             and matching size of the instance root disk.
        src_disk_size = libvirt_utils.get_disk_size(disk_path,
                                                    format=source_format)
        src_back_path = libvirt_utils.get_disk_backing_file(disk_path,
                                                        format=source_format,
                                                        basename=False)
        disk_delta = out_path + '.delta'
        libvirt_utils.create_cow_image(src_back_path, disk_delta,
                                       src_disk_size)
        quiesced = False
        try:   ### 通过qemu guest agent尝试freeze虚拟机系统盘的文件系统,系统盘进入只读模式
            self._set_quiesced(context, instance, image_meta, True)
            quiesced = True
        except exception.NovaException as err:
            if self._requires_quiesce(image_meta):
                raise
            LOG.info('Skipping quiescing instance: %(reason)s.',
                     {'reason': err}, instance=instance)

        try:
            # NOTE (rmk): blockRebase cannot be executed on persistent
            #             domains, so we need to temporarily undefine it.
            #             If any part of this block fails, the domain is
            #             re-defined regardless.
            if guest.has_persistent_configuration():
                support_uefi = self._has_uefi_support()
                guest.delete_configuration(support_uefi)

            # NOTE (rmk): Establish a temporary mirror of our root disk and
            #             issue an abort once we have a complete copy.
            ##### time.sleep(10)
            dev.rebase(disk_delta, copy=True, reuse_ext=True, shallow=True)
            ##### time.sleep(10)
 
            ### M版本判断数据是否rebase完成有bug,可能还没开始就认为已经完成了,
            ### 提前终止了rebase job
            while not dev.is_job_complete(): 
                time.sleep(0.5)
            dev.abort_job()
            ##### time.sleep(10)
            nova.privsep.path.chown(disk_delta, uid=os.getuid())
        finally:
            self._host.write_instance_config(xml)
            if quiesced:   ### 通过qemu guest agent恢复虚拟机系统盘为读写模式
                self._set_quiesced(context, instance, image_meta, False)

        # Convert the delta (CoW) image with a backing file to a flat
        # image with no backing file.
        ##### time.sleep(10)
        libvirt_utils.extract_snapshot(disk_delta, 'qcow2',
                                       out_path, image_format)

看了下代码,关键的步骤是在dev.rebase那里,这个操作前后的流程都是用qemu-img命令做一些准备和收尾工作,这部分的原理还不是特别清楚,应该是libvirt通过qemu monitor socket进行系统盘数据同步工作(我理解是类似本地文件镜像启动的虚拟机的热迁移流程),反复迭代同步数据,同步过程中记录新的脏数据,然后下个迭代把上一轮迭代过程中产生的脏数据继续同步,直到收敛到一个合适的值之后一次同步完所有脏数据(不知道会不会有短暂的vcpu pause过程,感觉应该会有)。

于是开始加断点单步调试,调试过程中,会把rebase前、后的中间过程snapshot文件(一般是instances目录下的snapshot目录,在里面会为每次snapshot过程创建一个tmp开头的临时目录)拷贝到另外的目录,然后用qemu-nbd+kpartx命令挂载到物理机上进行snapshot文件内容查看(参考:使用nbd设备挂载镜像进行内容修改),发现这个过程中,dev.rebase之后的镜像就有基础镜像创建之后新增的文件,内容也一致,然后最后一步extract_snapshot(通过qemu-img convert命令把cow和backing file合成一个完整镜像)合并完整镜像,也是没问题的,尤其是这一步最不值得怀疑,因为我本身也经常这么操作,从来没遇到过数据丢失问题。

于是去掉断点,继续做snapshot,发现基础镜像创建后新增的数据又没有了。。。

思来想去,想不到啥原因,在这里中断了3天都没思路,中间也反复断点调试了好几次,确认freeze操作都是可用的(虚拟机里面写入文件比如touch命令会卡住,virsh qemu-agent-command命令查看虚拟机freeze-status,也是freezed状态),证明不是freeze过程出现问题。还有一个明显的现象是,断点调试过程中做的自定义镜像(snapshot),都是有新增数据的,而没有加断点单步调试的时候做的snapshot,都是没有新增数据的。

晚上睡觉前整理思路,思考加断点和不加断点两种操作有啥区别,一个是权限问题,一个就是延时问题,权限问题:不加断点模式systemd启动时用的是nova用户启动nova-compute进程,而加入断点后前台启动nova-compute是在root账号下,但感觉不太可能,毕竟没报权限不足之类的错误,为了排除这种可能,断点调试启动也切换到了nova账号下,发现新增数据还是有的,非root只要加断点就有数据。那就剩下最后一种可能了,延时问题,于是在代码里加time.sleep(10),第一次不知道问题出在哪里,所以一共加了4个(上面的代码里面有注释具体位置),重点加在了dev.rebase前后,加完之后不加断点systemd启动nova-compute,执行live snapshot,之后启动虚拟机,发现数据果然都在,反复试验了好几次都在,换了几个镜像也ok,确认是延时问题导致。

于是跟几个前同事交流,看有没有啥想法,他们也没遇到过这种问题,一个是在H版本上加入的自己写的live  snapshot过程,跟官方的流程不太一样,另一个是没用过live snapshot。不过H版本的同事提醒我会不会是nova的bug。

如果是bug要想找到patch,就得先找到出现问题的位置,于是四个sleep,先保留第一个,其他去掉,发现问题还在,保留第二个,发现问题不在了,确认是在dev.rebase之后出现的问题,找到问题点之后,继续对比查看master分支,发现while not dev.is_job_complete()这行有改动,对比了M版本和master的差异,通过git blame命令发现改了2次,于是都pick过来测了下,问题解决了。

具体bug原因可以看nova/virt/libvirt/guest.py里is_job_complete这个方法的注释,大致意思就是说之前判断rebase job是否结束的代码有bug,会把还没有开始当作已经结束,并且第一次改动后的代码还不够完善,于是redhat的人又改了一次,果然还是人家redhat libvirt/qemu玩的溜啊!