初试 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 ID
和 AppKey
的组合来验证用户信息。该信息可以在账户面板里找到,但出于安全原因,只有首次生成的时候可见,最好找个安全的地方记录下来。同时 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。