Cinder数据卷备份原理与实践

Posted by int32bit on March 30, 2017
本文阅读量:

一、背景

1.1 数据保护技术概述

快照(Snapshot)、复制(Replication)、备份(Backup)是存储领域中最为常见的数据保护技术。快照用于捕捉数据卷在某一个时刻的状态,用户可以随时回滚到这个状态,也可以基于该快照创建新的数据卷。备份就是对数据进行导出拷贝并传输到远程存储设备中。当数据损坏时,用户可以从远端下载备份的数据,手动从备份数据中恢复,从而避免了数据损失。快照类似于git的commit操作,我们可以随时reset/checkout到任意历史commit中,但一旦保存git仓库的磁盘损坏,提交的commit信息将永久丢失,不能恢复。而备份则类似于git的push操作,即使本地的数据损坏,我们也能从远端的git仓库中恢复。简而言之,快照主要用于快速回溯,而备份则用于容灾,还能避免误删除操作造成数据丢失。数据复制则类似于mysql的master/slave主从同步,通常只有master支持写操作,slave不允许用户直接写数据,它只负责自动同步master的数据,但一旦master出现故障,slave能够提升为master接管写操作。因此复制不仅提供了实时备份的功能,还实现了故障自动恢复(即高可用)。

1.2 Cinder数据保护功能介绍

Cinder是OpenStack中相对比较成熟的组件(总分为8分的成熟度评分中获得了8分满分),也是OpenStack的基础服务组件之一,为OpenStack云主机提供弹性的块存储服务,类似AWS的EBS服务。Cinder数据卷(volume)承载着用户业务数据,数据的损坏或者丢失必然导致非常严重的后果,因此数据的可靠性、完全性以及完整性至关重要。好在Cinder对volume的保护方面支持度还是比较给力的,目前Cinder已经同时实现了对volume的快照、复制和备份接口。

快照应该是Cinder非常熟悉非常受欢迎的功能了,也是Cinder默认支持的功能,几乎所有的存储后端都支持快照。而备份作为Cinder的可选功能之一,由于数据卷的存储后端很多已经提供了多副本功能(比如Ceph存储后端默认为三副本),通常很少人会再部署一套备份存储集群,因此部署率并不是很高。复制也是Cinder的可选功能之一,目前支持的存储后端还非常有限,最常采用的存储后端rbd也是在Ocata版本才开始支持,并且要求Ceph版本需要支持rbd-mirror(jewel版本以上),因此受到用户的关注度还不是很高,部署率较低。

二、Cinder数据卷备份原理

2.1 cinder backup功能介绍

Cinder backup为用户volume实例提供备份(backup)和恢复(restore)功能,实现了基于块的容灾功能。从K版本开始,Cinder引入了增量备份(incremental backup)功能,相对全量备份(full backup)需要拷贝和传输整个volume,增量备份只需要传输变化的部分,大大节省了传输开销和存储开销。通常情况下,当用户执行备份或者恢复操作时,需要手动卸载(detach)volume,即数据卷不支持在挂载状态下(in-use)热备份。从L版本开始,新增了force选项,当用户指定force选项时能够对挂载的volume强制执行备份操作,这样可能带来数据不一致的风险,不过社区针对这种情况做了些优化,比如在创建备份前先基于该数据卷快照创建临时数据卷,然后基于临时数据卷执行后续备份操作。

Cinder开启备份功能,需要单独部署cinder-backup服务。cinder-backup服务和cinder-volume服务类似,也支持各种不同的驱动,对接不同的存储后端,目前支持的存储驱动列表如下:

  • swift,备份数据保存在OpenStack Swift对象存储中。
  • google,备份数据保存在Google Cloud Storage(GCS)中。
  • glusterfs,保存到glusterfs中。
  • nfs,保存到NFS中。
  • posix,保存到本地文件系统。
  • tsm,保存在IBM Tivoli Storage Manager(TSM)。
  • ceph,保存到ceph集群中。

从列表中看,目前Cinder backup尚不支持备份数据到AWS S3中。

除了数据卷本身的备份,cinder backup还支持将元数据序列化导出(export record),这样即使数据库中的数据丢失了,也能从导出的元数据中恢复。

2.2 cinder backup原理剖析

前面提到cinder backup支持多种后端存储驱动,但大体可以分为两类:

  • 存储系统本身就提供块存储服务,比如ceph。这种情况只需要直接导入到该存储系统即可。
  • 存储系统不支持块存储服务,只支持基于文件的存储,以上除了ceph和tsm都属于此类。此时备份采取了分块备份策略,即首先把volume切割为一个个独立的文件块(chunk),然后分别把这些文件存储到设备中。恢复时只需要重组这些小文件即可。

接下来我们针对此两种情况深入研究下cinder backup的实现原理。

2.2.1 分块备份策略

在介绍之前先了解两个重要的参数,参考[6]:

  • chunk_size: 表示将volume切割成多大的块进行备份,一个块称为一个chunk。在NFS中这个值叫做backup_file_size,默认是1999994880Byte,大约1.8G。在Swift中这个值叫做backup_swift_object_size,默认是52428800Byte,也就是50M。这个参数决定数据卷备份后块的数量(Object Count),比如一个2GB的数据卷,如果chunk_size为500MB,则大约需要4个块,如果使用本地文件系统存储的话,对应就是4个文件。
  • sha_block_size: 这个值用于增量备份,决定多大的块求一次hash,hash相同说明内容没有变化,不需要备份。它决定了增量备份的粒度。在NFS中,这个值叫做backup_sha_block_size_bytes,在Swift中,这个值叫做backup_swift_block_size。默认都是32768Byte,也就是32K。在Ceph,没有对应的概念。

对一个数据卷做全量备份时,每次从数据卷读入chunk_size字节的数据构成一个chunk,然后每sha_block_size个字节做一次sha计算,并将结果保存起来,最后把chunk_size的数据进行压缩(可以配置不压缩)后保存到对应的存储系统上,这就形成了NFS上的一个文件或者Swift中的一个object[6]。如此循环直到把整个数据卷都备份到存储系统。

以备份到OpenStack为例,Cinder backup会把每个chunk保存为一个object,object名称:

volume_${volume_id}/${timestamp)/az_${az}_backup_${backup_id}-${serial number}

比如备份volume aa16e224-ae79-4aa5-b125-a200fa89f7b7,backup id为981359d1-ddbd-47f0-8f2e-be9636fcccbb,则上传的Swift object列表为:

备份对象列表

那恢复的时候怎么重组呢?这就需要保存元数据信息,元数据信息包括:

  • backup信息:其实就是数据库中的信息,或者说就是一个backup object实例的序列化,包括backup name、description、volume_id等。
  • volume信息:数据卷信息,即volume实例的序列化,包括size、name等。
  • 块信息:即objects信息,这是最重要的数据,记录了每一个块的长度、偏移量、压缩算法、md5值,md5值用于校验chunk的完整性,防止被篡改。恢复时主要通过这些块信息拼接而成。
  • 版本:序列化和持久化必不可少的参数。

上图中xxxx_metadata即为元数据文件,文件内容如下:

{
  "backup_description": "Created by Mistral at 2017-08-11 02:33:03.803745",
  "backup_id": "981359d1-ddbd-47f0-8f2e-be9636fcccbb",
  "backup_name": "int32bit-\u4e2d\u6587_snap_2017_08_11_02_33_03",
  "created_at": "2017-08-11 02:33:03+00:00",
  "objects": [
    {
      "volume_aa16e224-ae79-4aa5-b125-a200fa89f7b7/20170811023352/az_nova_backup_981359d1-ddbd-47f0-8f2e-be9636fcccbb-00001": {
        "compression": "zlib",
        "length": 52428800,
        "md5": "25e317773f308e446cc84c503a6d1f85",
        "offset": 0
      }
    },
   ... /* 省去其它对象信息 */
  ],
  "parent_id": null,
  "version": "1.0.0",
  "volume_id": "aa16e224-ae79-4aa5-b125-a200fa89f7b7",
  "volume_meta": "{\"volume-base-metadata\": {\"migration_status\": null, \"provider_id\": null, \"availability_zone\": \"nova\", \"terminated_at\": null, \"updated_at\": \"2017-08-11T02:33:03.000000\", \"provider_geometry\": null, \"replication_extended_status\": null, \"replication_status\": \"disabled\", \"snapshot_id\": null, \"ec2_id\": null, \"deleted_at\": null, \"id\": \"aa16e224-ae79-4aa5-b125-a200fa89f7b7\", \"size\": 1, \"user_id\": \"a61a3c0659bd4cca8cb5f66ea2fe3df7\", \"display_description\": null, \"project_id\": \"42ee53fa480f49149ce5c3df4a953a6b\", \"launched_at\": \"2017-08-10T02:11:55.000000\", \"scheduled_at\": \"2017-08-10T02:11:54.000000\", \"status\": \"backing-up\", \"volume_type_id\": null, \"multiattach\": false, \"deleted\": false, \"provider_location\": null, \"host\": \"cinder@ssd-ceph#ssd-ceph\", \"consistencygroup_id\": null, \"source_volid\": null, \"provider_auth\": null, \"previous_status\": \"available\", \"display_name\": \"int32bit-\\u4e2d\\u6587\", \"bootable\": false, \"created_at\": \"2017-08-10T02:11:54.000000\", \"attach_status\": \"detached\", \"_name_id\": null, \"encryption_key_id\": null, \"replication_driver_data\": null}, \"version\": 2}"
}

除了保存以上元数据信息,Cinder还会按顺序保存每一个block的sha256值。这些信息主要用于支持增量备份。做增量备份时,也是每次从数据卷读入chunk_size字节的chunk数据,然后计算该chunk的每个block的sha值。不同的是,Cinder会把每一个block的sha值与其父备份对应的sha值比较,仅当该block的sha值与父备份block的sha值不一样时,才保存对应的block数据。如果sha值和父备份的sha值相同,说明这个block的数据没有更新,不需要重新保存该block数据,而只需要保存sha值。当然,如果有多个连续block的sha值都不一样,则保存时会合并成一个object,通过元数据记录该object在原volume的偏移量以及长度。

上图中的xxxx_sha256file就是hash文件,文件内容如下:

{
  "backup_description": "Created by Mistral at 2017-08-11 02:33:03.803745",
  "backup_id": "981359d1-ddbd-47f0-8f2e-be9636fcccbb",
  "backup_name": "int32bit-\u4e2d\u6587_snap_2017_08_11_02_33_03",
  "chunk_size": 32768,
  "created_at": "2017-08-11 02:33:03+00:00",
  "sha256s": [
    "c35020473aed1b4642cd726cad727b63fff2824ad68cedd7ffb73c7cbd890479",
    /* 省略其它sha256值 */
  ],
  "version": "1.0.0",
  "volume_id": "aa16e224-ae79-4aa5-b125-a200fa89f7b7"
}

如图2所示,假设一个chunk分为9个block,每个block为100KB,注意每个block都保存了sha256值,图中没有标识。基于该chunk做一次增量备份后,假设只有block 2、7、8有更新,则增量备份只会保存block 2、7、8,由于7和8是连续的,因此会自动合并成一个chunk,而block 2单独形成一个chunk,即原来的chunk分裂成了两个chunk,但总大小为300KB,节省了1/3的存储空间。

incremental_backup

图2 增量备份原理图

备份的恢复参考文献[6]讲得非常清楚,这里直接引用:

全量备份的恢复很简单,只需要找到对应的备份,将其内容写回对应的volume即可。那么这里有个问题,每个备份都对应存储上哪些文件呢,每个文件又对于原始volume中哪些数据?还记得创建备份时生成的metadata文件吗,答案就在其中。恢复备份时,会读取这个文件,然后将每一个备份文件恢复到对应的位置。当然,如果有压缩也会根据metadata中的描述,先解压再写入对应的volume中[6]。

增量备份的恢复稍微复杂一些,正如之前的描述,增量备份之间有依赖,会形成备份链,我们需要恢复所选备份及其在备份链上之前所有的数据。在恢复时,需要查询数据库,获取当前备份及备份链上之前的所有备份,其顺序是[所选备份,父备份,父父备份,…,全量备份],恢复的时候会按照相反的顺序依次进行,即首先恢复全量备份,接着创建的第一个增量备份,第二个增量备份,直到所选的备份。每个备份恢复时依赖创建备份时生成的metadata文件,将备份包含的文件,恢复到volume中。每个备份的恢复和全量备份的恢复方式一样。

从备份的原理可以看出,增量备份能够节省存储空间,但随着备份链长度越来越长,恢复时会越来越慢,实际生产环境中应该权衡存储空间和性能,控制备份链的长度

Swift、NFS、本地文件系统、GCS等都是使用以上的备份策略,实际上实现也是完全一样的,区别仅仅在于实现不同存储系统的read、write接口。

2.2.2 直接导入策略

直接导入策略即把原数据卷导出后直接导入到目标存储系统中。对于支持差量导入的存储系统,增量备份时则可以进一步优化。

以Ceph为例,我们知道Ceph RBD支持将某个image在不同时刻的状态进行比较后导出(export-diff)补丁(patch)文件,然后可以随时将这个补丁文件打到某个image中(import-diff)。即ceph原生支持差量备份,利用该特性实现增量备份就不难了。不过有个前提是,必须保证cinder-volume后端和cinder-backup后端都是ceph后端,否则仍然是一块一块的全量拷贝。

如果是对volume进行第一次备份,则:

  1. 在用于备份的ceph集群创建一个base image,size和原volume一样,name为"volume-VOLUMD_UUID.backup.base" % volume_id
  2. 在原volume创建一个新的快照,name为backup.BACKUP_ID.snap.TIMESTRAMP
  3. 在原RBD image上使用export-diff命令导出与创建时比较的差量数据,然后通过管道将差量数据导入刚刚在备份集群上新创建的RBD image中。

如果不是对volume第一次备份,则:

  1. 在原volume中找出满足r"^backup\.([a-z0-9\-]+?)\.snap\.(.+)$"的最近的一次快照。
  2. 在原volume创建一个新的快照,name为backup.BACKUP_ID.snap.TIMESTRAMP
  3. 在原RBD image上使用export-diff命令导出与最近的一次快照比较的差量数据,然后通过管道将差量数据导入到备份集群的RBD image中。

恢复时相反,只需要从备份集群找出对应的快照并导出差量数据,导入到原volume即可。

注意:

  • volume和backup都使用ceph后端存储时,每次都会尝试使用增量备份,无论用户是否传递incremental参数值。
  • 该备份策略不需要保存元数据以及sha256值。

三、踩过的“坑”

虽然在前期做了大量关于Cinder backup的调研工作,但实际部署过程中仍然踩了不少坑,PoC测试过程也非一帆风顺,还好我们在填坑的过程还是比较顺利的。本小节总结我们在实践过程中遇到的坑,避免后来者重复踩“坑”。

3.1 热备份导致quota值异常

我们知道备份是一个IO开销和网络开销都比较大的操作,非常耗时。当对已经挂载的数据卷执行在线备份时,Cinder为了优化性能,减少数据不一致的风险,首先会基于该数据卷创建一个临时卷,然后基于临时卷创建备份,备份完成时会自动删除临时数据卷。

创建临时volume代码逻辑如下:

# cinder/volume/driver.py
def _get_backup_volume_temp_volume(self, context, backup):
"""Return a volume to do backup.
    if previous_status == "in-use":
        temp_vol_ref = self._create_temp_cloned_volume(
            context, volume)
        backup.temp_volume_id = temp_vol_ref.id
        backup.save()
        device_to_backup = temp_vol_ref
        
def _create_temp_volume(self, context, volume):
    kwargs = {
        'size': volume.size,
        'display_name': 'backup-vol-%s' % volume.id,
        'host': volume.host,
        'cluster_name': volume.cluster_name,
        'user_id': context.user_id,
        'project_id': context.project_id,
        'status': 'creating',
        'attach_status': fields.VolumeAttachStatus.DETACHED,
        'availability_zone': volume.availability_zone,
        'volume_type_id': volume.volume_type_id,
    }
    temp_vol_ref = objects.Volume(context=context, **kwargs)
    temp_vol_ref.create()
    return temp_vol_ref

def _create_temp_cloned_volume(self, context, volume):
    temp_vol_ref = self._create_temp_volume(context, volume)
    try:
        model_update = self.create_cloned_volume(temp_vol_ref, volume)
        if model_update:
            temp_vol_ref.update(model_update)
    except Exception:
        with excutils.save_and_reraise_exception():
            temp_vol_ref.destroy()

    temp_vol_ref.status = 'available'
    temp_vol_ref.save()
    return temp_vol_ref

删除临时备份代码如下:

def _cleanup_temp_volumes_snapshots_when_backup_created(
        self, ctxt, backup):
    # Delete temp volumes or snapshots when backup creation is completed.
    if backup.temp_volume_id:
        self._delete_temp_volume(ctxt, backup)

    if backup.temp_snapshot_id:
        self._delete_temp_snapshot(ctxt, backup)

def _delete_temp_volume(self, ctxt, backup):
    try:
        temp_volume = objects.Volume.get_by_id(
            ctxt, backup.temp_volume_id)
        self.volume_rpcapi.delete_volume(ctxt, temp_volume)
    except exception.VolumeNotFound:
        LOG.debug("Could not find temp volume %(vol)s to clean up "
                  "for backup %(backup)s.",
                  {'vol': backup.temp_volume_id,
                   'backup': backup.id})
    backup.temp_volume_id = None
    backup.save()

从代码中看,创建临时卷时是在由cinder-volume完成,调用的并不是标准的create方法,没有计算quota,换句话说,创建的临时数据卷是不占用quota值,这样做是合理的,能够避免由于用户volume配额不足时导致备份失败。但删除时我们发现cinder-backup是通过RPC请求cinder-volume删除,调用的是标准的删除接口,该接口会释放对应数据卷占用的数据卷quota值(主要影响gigabytes和volumes值)。也就是说,创建的临时volume使volume quota值只减不增,甚至出现负数,用户可以通过这种方式绕过quota限制,存在潜在的DDoS攻击(写个脚本不断创建备份,volume的quota值越来越小,然后不断创建volume,直到把所有的资源耗尽)。目前该问题社区还未修复,已提交bug:https://bugs.launchpad.net/cinder/+bug/1670636,我们内部已经临时修复该问题,准备推到社区。

3.2 不支持ceph多后端情况

我们内部cinder对接了多个ceph集群,不同的ceph集群通过不同的配置文件区分。但cinder-backup服务向cinder-volume服务获取connection info时并没有返回ceph的配置文件路径,于是cinder-backup服务使用默认的配置文件/etc/ceph/ceph.conf,该ceph集群显然找不到对应volume的RBD image,因此在多backend情况下可能导致备份失败。不过该问题在新版本中不存在了。

3.3 使用ceph存储后端时不支持差量备份

我们前面提到如果cinder-volume和cinder-backup后端都是ceph,则会利用ceph内置的rbd差量备份实现增量备份。那cinder-backup服务怎么判断数据卷对应的后端是否ceph呢?实现非常简单,只需要判断数据卷的连接信息是否存在rbd_image属性,实现代码如下:

def _file_is_rbd(self, volume_file):
    """Returns True if the volume_file is actually an RBD image."""
    return hasattr(volume_file, 'rbd_image')

社区从M版开始把与存储后端交互的代码独立出来,建立了一个新的项目–os-brick,与之前的ceph驱动存在不兼容,没有rbd_image这个属性。因此backup服务会100%判断数据卷不是ceph后端,因此100%执行全量备份。该问题社区还未完全修复,可参考https://bugs.launchpad.net/cinder/+bug/1578036。

四、我们的改进

4.1 获取父备份ID

当备份存在子备份时,用户无法直接删除该备份,而必须先删除所有依赖的子备份。目前Cinder API只返回备份是否存在依赖的子备份,而没有返回子备份的任何信息,也没有返回父备份的信息。当用户创建了很多备份实例时,很难弄清楚备份之间的父子关系。我们修改了Cinder API,向用户返回备份的父备份id(parent_id),并且支持基于parent_id过滤搜索备份。当用户发现备份存在依赖时,能够快速检索出被依赖的子备份。当然,如果存在很长的父子关系时,需要一层一层判断,仍然不太方便,并且不能很清楚的输出备份的父子关系。于是我们引入了备份链的概念,下节详细讨论。

4.2 引入备份链概念

为了方便查看备份之间的父子关系,我们引入了备份链(backup chain)的概念,一个数据卷可以有多个备份链,每条备份链包括一个全量备份以及多个增量备份组成。我们新增了两个API,其中一个API输出指定数据卷的备份链列表,另一个API输出指定备份链的所有备份点,按照父子关系输出。目前我们的备份链只支持线性链,暂时不支持分叉的情况。通过备份链,用户能够非常方便地查看备份之间的父子关系和备份时间序列,如图3。 backupchain

图3 备份链展示

4.3 创建备份时指定备份链

创建增量备份时,默认是基于时间戳选择最新的备份点作为父备份,我们扩展了该特性,支持用户选择在指定备份链上创建备份,这样也可以避免备份链过长的情况。

五、后续工作

Cinder backup功能已经相对比较完善了,但仍然存在一些功能不能满足客户需求,我们制定了二期规划,主要工作包括如下:

5.1 级联删除

目前Cinder不支持备份的级联删除,即如果一个备份实例存在依赖的子备份,则不能删除该备份,必须先删除其依赖的所有子备份。如果备份链很长时,删除备份时非常麻烦。在二期规划中,我们将实现备份的级联删除功能,通过指定--force选项,支持删除备份以及其依赖的所有备份,甚至删除整个备份链。

5.2 获取增量备份大小

目前Cinder备份的实例大小是继承自原volume的大小,基于分块策略备份还有Object Count(chunk 数量)的概念,但这只是显示分成了几个chunk,每个chunk大小不一定是一样的,并不能根据chunk数量计算实际占用的存储空间。备份存储空间是我们计费系统的计量标准之一,全量备份和增量备份成本肯定是不一样的,如果价钱一样,则用户并不一定乐于使用增量备份。在二期规划中,我们将实现计算备份占用的实际存储空间的接口。目前已经实现了Swift备份后端,增加disk_size属性到backup对象中。

5.3 备份到S3

很多私有云用户考虑各种成本,不一定会部署额外用于备份的Ceph集群,也不一定需要Swift对象存储,而更倾向于将数据备份到价格低廉AWS S3中。目前Cinder backup后端还不支持S3接口,为了满足客户需求,我们计划在二期中实现S3接口,用户可以随时把volume数据备份到S3中。

六、总结

本文首先介绍了存储领域中数据保护的三种常用技术(snapshot、backup、replication),备份是数据保护的关键技术之一。接下来重点介绍了cinder backup的原理,对比了基于分块备份策略和直接导入策略。最后分享了我们在实践中踩到的各种“坑”以及我们做的一些优化改进工作和后期规划工作。

参考文献

  1. Wikipedia: Backup.
  2. Backup vs replication, snapshots, CDP in data protection strategy.
  3. Back up and restore volumes and snapshots.
  4. Inside Cinder’s Incremental Backup.
  5. OpenStack and Backup.
  6. Openstack 中cinder backup三种backend的对比.