初试 BackBlaze B2 云存储

版权声明:所有博客文章除特殊声明外均为原创,允许转载,但要求注明出处。

作为(伪)松鼠党,多年来也我积累了大量资源,终于到了好几个移动硬盘都装不下的地步。我也很担心万一哪个盘某天突然坏了,里面的所有内容都付之东流,于是动了把这些资源转存到云端的心思。

当然,在作这个迁移之前,首先我应当考虑清楚自己的需求,以及有哪些可能的解决方案。这些资源中大部分是从网络上收集来的,属于有价值、但是万一丢失也可以接受的内容,同时容量太大(几个 TB)。因此不同于个人的重要资料,我需要找一个可长期保存、以允许非 100% 的可靠性来换取较低平均价格的方案。同时,考虑各种现实方案的可行性:

  • 百度盘?付费其实不是问题——我愿意为稳定的存储付钱,而且公平地说,百度盘的价格算是很低廉了。但我无法接受的是,不知道哪一天某些资源突然就“被8秒”了,而用户连补救的机会都没有。虽然我可以接受部分资料丢失,但不能容忍以这样的方式丢失。
  • iCould?价格还可以接受。问题在于,iCloud 目前在 Android 上似乎不可用。我也尝试了 Web 网站和 Windows 客户端,体验都比较差,同时速度也并不让人满意。如果前提是使用苹果全家桶的话,iCloud 恐怕是最佳选择,然而对我来说情况并非如此。
  • 自建 NAS?NAS 方案的最大优点可能在于使用方便,但是对于熟悉云服务而对硬件苦手的我来说,这个优点并不明显;太多的本地盘也难以管理,长期来说出错乃至报废的可能性较高;以中国的网络现实来说,把服务暴露在公网上,益处很小,代价和风险太大。
  • 市面上较为流行的云存储方案,包括 Google Drive、DropBox、OneDrive、坚果云等等,用来管理日常工作数据非常好,但对于 TB 级别的数据来说,价格就太高了,有些根本就不允许这么大的容量;
  • 我也考察了 AWS 的 S3 低频存储、Glacier 以及其他类似的服务。单从价格看,AWS S3 Glacier 确实做到了很低,需要“解冻”数据也是可以接受的。但在查找资料时,我看到有部分用户因为不熟悉 AWS 服务规则而收到天价账单的案例。这有点让我心有余悸。我们不能简单地责怪这些用户没看清协议,因为 AWS 的规则确实对大多数人来说是过于复杂了,估计没有什么人敢说自己能避开所有的坑吧。Azure 和 Google Cloud、阿里云等也存在类似的问题。

在否定上述方案后,最终我选定了 BackBlaze 的 B2 云存储,价格每 TB $5,算是非常低了,同时也有 10 GB 的免费额度。BackBlaze 专注于云存储,因此价格方案非常简明易懂,不太可能有什么天坑,同时从网上的评论看,这一家的口碑也还算不错。另外,BackBlaze 还有一个简化的个人备份系统叫 Personal Backup,它所作的是简单粗暴地备份整个系统,但对我的需求来说并不合适。B2 云存储则是提供 API,需要用户自行构建的基础服务。

接下来的工作是了解 B2 提供的接口。B2 目前提供了 Python/Java 两种 SDK,简单浏览了一下,其实两种版本都没有非常详细的接口文档,需要根据官方文档的 REST API 说明自己转换一下。其中又以 Java 版的完成度更高一些,有些简单的示例可供参考。但我个人更倾向于使用 Python,在测试了一天之后终于摸清了它的用法,同时也遇到一些“坑”,记录在此以供参考。

首先需要说明的是,在使用 API 之前,最好先通读官方文档,特别是 API Key 的管理、bucket 与文件的命名规则、大文件的处理方式等等有一个概念,这样到具体使用时才能做到心中有数。

要使用 B2 的 Python SDK,最简单也是最常用的方式是通过 pip 安装:

pip install b2sdk

要通过命令行测试的话,也可以安装 B2 的命令行工具,方法:

pip install b2

如果要从源码安装的话,B2 SDK 的代码托管在 Github 上,地址在 这里

库安装成功后,我们需要指定 AppKey IDAppKey 的组合来验证用户信息。该信息可以在账户面板里找到,但出于安全原因,只有首次生成的时候可见,最好找个安全的地方记录下来。同时 B2 的接口设计有点特别,我们需要指定一个可存储的 AccountInfo,如果未指定的话,接口会默认基于 Sqlite 的存储。但我自己并不情愿为了这个目的使用 Sqlite,宁可使用内存(InMemory)的方式。

from b2sdk.api import B2Api
from b2sdk.account_info import InMemoryAccountInfo

APP_KEY_ID = '<YOUR APP KEY ID>'
APP_KEY = '<YOUR APP KEY>'

account_info = InMemoryAccountInfo()
api = B2Api(account_info=account_info)
api.authorize_account(realm='production',
                      application_key_id=APP_KEY_ID,
                      application_key=APP_KEY)    
print('minimum_part_size: ', saccount_info.get_minimum_part_size())

用户验证成功后,会在 AccountInfo 中记录服务器返回的一些信息,其中比较重要的可能是 minimum_part_size,这是上传大文件的时候数据分片的大小,但只是官方推荐的值,我们也可以在上传时自行修改。不过该值的名字有一些歧义,它实际上应当代表“推荐的分片 size”,并不是最小 size。测试可知官方推荐的分片是 100 MB,对国内并不稳定的网络环境来说这个值可能有点过大了。我目前实际使用 10 MB 的分片大小(按照文档描述,接口允许的最小值是 5 MB)。

一旦用户验证通过,我们就可以调用其他所有接口了。通常来说我们需要首先确定文件存在哪个 bucket,也可以创建新的 bucket

for bucket in (api.list_bucket()):
    print('bucket:', bucket)
lifecycle_rules = [
    {
        "daysFromHidingToDeleting": 30,
        "daysFromUploadingToHiding": None,
        "fileNamePrefix": ""
    }
]
api.create_bucket(name='myname-test-bucket', 
                  bucket_type='allPrivate', 
                  bucket_info=None, 
                  cors_rules=None, 
                  lifecycle_rules=lifecycle_rules)

在创建 bucket 时有几点需要注意:

  • Bucket 的名称是全局唯一的(也就是不能和其他用户的 bucket 名称冲突)。因此,最好以你的用户名作为前缀;
  • 类型一般是公开可见(allPublic)或私有(allPrivate),更细致的权限划分请参考文档;
  • bucket_info 允许存储一些自定义的信息,cores_rules 指定当文件允许公开访问时限制跨域规则。对私有库来说我们可以不关心它;
  • lifecycle_rules 指定当文件存在多版本时的管理策略,比如超过一定日期后删除旧的版本。这一部分最好仔细阅读文档。

找到 Bucket 以后,我们可以上传文件了:

import mimetypes
from b2sdk.progress import make_progress_listener


def upload_file(account_info: AccountInfo,
                bucket: Bucket, 
                local_path: str, file_name: str, 
                min_part_size: int = None, 
                progress_listener = None):
    content_type = mimetypes.guess_type(local_path)[0]
    sha1_sum = get_file_sha1(local_path)
    min_part_size = min_part_size or account_info.get_minimum_part_size()
    listener = make_progress_listener('uploading progress', quiet=False)
    result = bucket.upload_local_file(local_path, file_name,
        content_type=content_type, 
        file_infos=None, 
        sha1_sum=sha1_sum, 
        min_part_size=min_part_size, 
        progress_listener=listener)

上传文件显然需要指定本地/远程文件名。对于远程文件名,也应该首选阅读文档,了解对于文件命令规则有哪些限制。其他参数包括:

  • 内容类型:用户可以自行指定,在下载或公开文件时会作为 Content-Type HTTP 头部信息。大多数情况下我们可以用 mimetypes,根据文件后缀自动判断;
  • SHA1 校验值:并不是必须的,但是服务器可以借此省去重复传输的成本,同时也有助于验证文件的完整性,因此最好还是提供;
  • 上传分片大小:对于大型文件,B2 内部是使用分片传输的,我们可以自己自己指定,也可以使用服务器提供的默认值;
  • 进度改变通知:同样对于大文件传输,提供此项以便于在传输过程中提供反馈信息。

上述 progress_listener 使用了 B2 提供的辅助函数 make_progress_listener。如果你去看它的实现的话,大致是这样的:

def make_progress_listener(description, quiet):
    if quiet:
        return DoNothingProgressListener()
    elif tqdm is not None:
        return TqdmProgressListener(description)
    else:
        return SimpleProgressListener(description)

也就是说,如果有 tqdm 包可用的话,B2 默认会用它来显示控制台进度提示。当然你也可以自己创建 ProgressListener 派生类来提供自定义逻辑。

对于大文件上传,实际上 B2 内部有一个复杂的分片上传过程,但 SDK 内部封装了这个步骤,你只需要关心分片大小(min_part_size) 和进度指示(progress_listener)两个参数即可。当然,该过程仍然有可能会出错,为此你可以用 b2_list_unfinished_large_files 来检查是否有未完成的上传,并且用 b2_cancel_large_file 来取消它。

文件上传完毕后即可下载。按说这应该是个很简单的操作,调用 download_file_by_id 或者 download_file_by_name 接口即可。没想到在这里遇到了一个坑,执行的话会出现运行时异常。从代码跟踪进去,发现问题在于:尽管按照源码的声明,上述两个接口的 download_dest 参数应该是一个字符串,但内部实现实际上是要求一个 DownloadDestination 对象。再到 Github Issues 上看看,这个问题最近已经有人提过了:

error when using bucket download_file_by_name

按照官方回复,已经修复了这个问题,但还未正式发布,所以我们从 pip 上下载的仍然是旧版。因此,目前需要这样写:

def download_file(bucket: Bucket, file_id: str, local_path: str):
    dest = DownloadDestLocalFile(local_path)
    return bucket.download_file_by_id(file_id=file_id, 
                                download_dest=dest, 
                                progress_listener=None, 
                                range_=None)

除了上传/下载文件之外,我们还需要获取文件列表。由于一个 Bucket 的内容可能非常多,所以获取文件列表的操作是分页的,我们需要执行一个不算太复杂的遍历操作,并且每次将服务器返回我们的 nextFileName 作为起点:

def list_file_names(bucket: Bucket):
    files = []
    start_filename = None
    while True:
        resp = bucket.list_file_names(start_filename=start_filename)
        next_files = resp.get('files', None)
        start_filename = files.get('nextFileName', None)
        if next_files:
            files.extend(next_files)
        if not start_filename:
            break
    return files

整个 SDK 需要关心的接口主要就是这些。接下来我准备测试上传一批文件,考察一下稳定性和速度如何(同时也关心一下账单)。如果结果比较满意的话,我可能会再写一个同步工具把文件批量上传到 BackBlaze。