Switching from S3 to Tigris on Fly.io

Ben Hoyt

在 Fly.io 上从 S3 切换至 Tigris

原文由 Ben Hoyt 发布,订阅该博客

一年前,我把自己的业余项目 GiftyWeddings.com 从托管在 Amazon EC2 上迁移到了 Fly.io。当时玩得很开心,每月还省了几美元。

上周,Tigris 的人联系了我,问我是否愿意试用他们基于 Fly.io 基础设施构建、并已集成到 fly CLI 中的 S3 兼容存储服务的内测版。(Tigris 为这次试用和撰文支付了一小笔费用,但他们并未干预文章内容。)

我之前其实没听说过 Tigris,但这引起了我的兴趣——我在 Fly.io 上的体验一直不错,而且我觉得有更多小公司来挑战 AWS 这个庞然大物是件好事。

Gifty 的服务端(用 Go 编写)已经跑在 Fly.io 上了,但在几天前,文件存储——用户上传的图片和 SQLite 备份——用的还是 Amazon S3。

切换过程很直接:把文件复制过去,把服务端的配置改成指向 Tigris 的 endpoint 而不是 S3,然后 fly deploy 就行了。由于 Tigris 在认证和缓存上的处理方式不同,我做了一些小的代码改动,也碰到了几个小坑,但总体来说非常顺利。

创建 bucket

第一步是为用户上传的图片创建一个测试 bucket。我直接运行了 fly storage create 命令:

$ cd gifty  # change to app directory
$ fly storage create --public
? Choose a name, use the default, or leave blank to generate one:
    test-gifty-registry-images
Your Tigris project (test-gifty-registry-images) is ready. See details and
next steps with: https://fly.io/docs/reference/tigris/

Setting the following secrets on gifty:
BUCKET_NAME
AWS_ENDPOINT_URL_S3
AWS_ACCESS_KEY_ID
AWS_SECRET_ACCESS_KEY
AWS_REGION

Updating existing machines in 'gifty' with rolling strategy

-------
 ✔ Machine 4d89601c6e9487 [app] update succeeded
-------
Checking DNS configuration for gifty.fly.dev

让我吃惊的是,仅仅创建一个 bucket 就自动设置了一堆环境变量并重新部署了我的应用。之所以会这样,是因为我当时正处在存放应用 fly.toml 配置文件的应用目录里。不过,这个行为还是显得有些出乎意料、过于“魔法”了——毕竟我只是想为本地开发创建一个测试 bucket 而已。

这一点在文档中其实有说明,所以某种程度上算是我没仔细看文档——不过他们大概应该在文档里加上“并重新部署”几个字:

在 Fly.io 应用上下文中运行以下命令——在应用目录内或指定 -a yourapp——将会自动为你的应用设置 secrets。

这个功能有可能会导致停机,所以我觉得它应该是可选的,比如提供一个 --update-app--add-secrets 这样的命令行选项。

而且,这些自动设置的 secrets 的值也没有方便的办法查看。这是出于安全考虑的有意设计,但这也意味着我不得不在应用目录之外再创建一个新的测试 bucket,来获取本地测试所需的凭证:

$ cd ..  # change out of app directory
$ fly storage create --public --org ben-hoyt --name test-gifty-registry-images2
Your Tigris project (test-gifty-registry-images2) is ready. See details and
next steps with: https://fly.io/docs/reference/tigris/

Set one or more of the following secrets on your target app.
AWS_REGION: auto
BUCKET_NAME: test-gifty-registry-images2
AWS_ENDPOINT_URL_S3: https://fly.storage.tigris.dev
AWS_ACCESS_KEY_ID: ***hidden***
AWS_SECRET_ACCESS_KEY: ***hidden***

好了,这样就好多了。接着我用那组 access key 和 secret 更新了本地的应用配置,基本就能用了……大多数情况下是这样。我还得先解决几个小问题,下面会讲到。

创建 bucket 时还有一个小怪事——如果 fly CLI 的开发者们看到这篇的话——就是当你运行 fly storage create 时,它打印环境变量的顺序是不确定的。你可以在上面看到:在第一个例子里,最先打印的是 BUCKET_NAME;在第二个例子里,最先的却是 AWS_REGION。这倒不是什么大问题,只是有些奇怪,我还反复确认了一下才发现两次打印的是同一组 key。

Endpoint 的变更

显而易见,我需要开始使用新的 Tigris API endpoint,而不是默认的 AWS endpoint。创建 S3 客户端时,我原来是这样写的:

cfg := aws.NewConfig().WithCredentials(creds).WithRegion(region)

为了使用正确的 endpoint,我加了这几行:

endpoint := os.Getenv("GIFTY_S3_BACKUP_ENDPOINT")
if endpoint != "" {
	cfg = cfg.WithEndpoint(endpoint)
}

注意我用的还是 AWS SDK for Go 的 v1 版本。这在 v2 中写法会有些不同

缓存差异

用 Amazon S3 时,当用户为自己的婚礼清单上传新图片,我发现直接用同一个文件名(key)覆盖上传就行。页面重新加载、重新拉取图片时,新图片就会显示出来。

即使在 S3 上,这么做也不一定算是个好主意,因为 S3 最初只是最终一致性的。不过在 2020 年,他们改成了强读后写一致性,所以在 PUT 之后的所有 GET 都会立刻拿到更新后的内容。

开始用 Tigris 后,重新上传一张图片时,我必须按 Ctrl-Shift-R(强制刷新,忽略缓存)新图片才会出现。Tigris 似乎做了更激进的缓存:查看上传到 Tigris 的图片的响应头,会发现它默认就带有一个 Cache-Control: max-age=3600,即使我根本没设置这个头。

我不确定这是不是个好的默认值,但因为他们既想做存储系统、也想做 CDN,我能理解为什么要这么选。这会迫使人们使用像基于内容的文件名这样的缓存技巧,而这本身是件好事。

更新:读到这篇文章后,Tigris 新增了一个关于缓存的页面,说明了这一行为及其背后的考量。

不管怎样,这也是个改进 Gifty 上传处理逻辑的机会。我改了代码,在文件名里加了一段基于内容的短哈希:不再用 {registryID}.jpg 作为 key,而是用 {registryID}-{contentHash}.jpg,这就解决了缓存问题:

// Add content-based hash to filename to break caching (12 hex digits).
hash := sha1.New()
_, _ = hash.Write(data)
key := fmt.Sprintf("%d-%x.jpg", registry.ID, hash.Sum(nil)[:6])

我还把原来硬编码 amazonaws.com 的完整图片 URL 改成了可配置的 URL 格式:

// Old version
path := fmt.Sprintf("https://%s.s3-%s.amazonaws.com/%s",
    h.config.S3ImageBucket, h.config.S3ImageRegion, key)

// New version; under Tigris, I set S3ImageURLFormat to
// "https://fly.storage.tigris.dev/gifty-registry-images/%[1]s"
path := fmt.Sprintf(h.config.S3ImageURLFormat, key)

认证差异

用 S3 时,我有两个 bucket——一个存清单图片,一个存备份——它们共用同一个 AWS key 和 secret 做认证。这可能不太好(备份本来应该用不同的凭证),但以我的经验,小网站用同一个 key 是很常见的做法。

而在 Tigris 上,每创建一个 bucket 就会得到一套新的 key 和 secret。所以我不得不做一点小的代码改动,新增了两个与 S3 相关的环境变量。改了几行代码后,备份功能就正常了。

拷贝文件与“影子 bucket”

既然测试环境里一切都正常了,我就想把线上站点也切过去。

为了从 S3 迁移,Tigris 有个很酷的功能叫影子 bucket(shadow buckets),你可以让 Tigris bucket 指向原来的 S3 bucket,如果文件在 Tigris bucket 中不存在,它就会无缝地从“影子 bucket”拉取(并复制过去,以便后续请求直接从 Tigris 获取)。

我本来应该试试这个功能,但最后还是没用,因为我最大的 bucket 里也只有几百个文件,而且我想做一次干净的切换,完成后彻底摆脱对 S3 的依赖。

所以我用了 aws s3 sync 命令,先把文件从 S3 同步到本地,再从本地同步到新的 Tigris bucket。我的站点规模很小,切换那几分钟内漏掉文件的可能性极低。

我为新的 Tigris bucket 创建了两个 aws CLI profile,分别叫 gifty-registry-imagesgifty-backup。默认的 profile 依然指向 Amazon S3。

以下是我用来把文件从 S3 拷贝到 Tigris 的命令(我先用了很方便的 --dryrun 选项预演了一下):

# Copy the user-uploaded images
$ mkdir gifty-registry-images
$ cd gifty-registry-images
$ aws s3 sync s3://gifty-registry-images .
...
$ aws s3 sync . s3://gifty-registry-images --profile gifty-registry-images
...

# Copy the backup files
$ cd ..
$ mkdir gifty-backup
$ cd gifty-backup
$ aws s3 sync s3://gifty-backup .
...
$ aws s3 sync . s3://gifty-backup --profile gifty-backup
...

数据库迁移

只要我继续付费给 AWS,现有的 S3 URL 就还能一直正常访问。不过如前所述,我想彻底去掉对 S3 的依赖。

我在站点的 SQLite 数据库里存的是完整的图片 URL。所以拷完文件后,我用一条简单的 SQLite 语句更新了数据库里的图片 URL:

-- First check how many S3 image URLs there are
sqlite> SELECT count(*) FROM registry
        WHERE image_url LIKE 'https://gifty-registry-images.s3%';
389

-- Perform the update
sqlite> UPDATE registry SET image_url = replace(image_url,
        	'https://gifty-registry-images.s3-us-west-1.amazonaws.com/',
        	'https://fly.storage.tigris.dev/gifty-registry-images/')
        WHERE image_url LIKE 'https://gifty-registry-images.s3%';

-- Ensure there are no more S3 URLs
sqlite> SELECT count(*) FROM registry
        WHERE image_url LIKE 'https://gifty-registry-images.s3%';
0

到这一步,站点就完全跑在 Tigris 和 Fly.io 之上了,100% 摆脱了 AWS!

最后的想法

性能

我做了一个快速的、非严谨的测试,对比从旧的 AWS bucket 和新的 Tigris bucket 拉取图片的耗时。Tigris bucket 默认就是“全球化”的,所以它会使用最近的 Fly.io 区域(悉尼),离新西兰比 AWS bucket 所在的 us-west-1 区域近得多。

不出所料,我看到 Tigris 的下载速度明显更快:对于一张小图,在 AWS 上大约要 1 秒,在 Tigris 上只要 300 毫秒。对于中等大小的图片,AWS 上大约是 1.3 秒,Tigris 上则是 600 毫秒。

价格

Gifty 在 AWS 上的账单——这时已经只有 S3 的费用了——每月只有几美分。我敢肯定,他们在我身上花的行政开销都比我付的钱还多。所以价格对我来说不是问题,不过 Tigris 的定价和 S3 差不多:

  • Tigris:存储为每月每 GB $0.02,GET 请求为每 1000 次 $0.0005。
  • S3:存储为每月每 GB $0.023,GET 请求为每 1000 次 $0.0004。

不管怎样,Tigris 在内测注册时给了我 $150 的免费额度。这相当于 7500 GB·月或 3 亿次 GET 请求——几乎肯定比我的小网站一辈子用到的还要多。

免责声明与持久性

有一点你可能想知道:当我注册他们的抢先体验内测时,Tigris 发来一封邮件说:“请在内测期间避免将此服务用于生产环境!”

我问这只是为了免责的套话,还是说真的会“文件随机丢失,内测期间每晚清空所有数据”。他们说这更多只是免责声明,Fly.io 和 Tigris 都是把它当作生产级平台来对待的。显然这其中还是有风险的,但我可以接受,而且他们很快就会结束内测。

我也好奇过 Tigris 的持久性跟 S3 相比如何,S3 宣称其对象的持久性高达 99.999999999%。Tigris 显然在架构上花了很多心思,但我好奇是否有可能衡量他们的持久性并与S3 那个惊人的数字作比较?话又说回来,S3 那 11 个 9 的数字真的有意义吗?

结语

总的来说,这次切换的体验非常好,到目前为止还没遇到任何问题——不过时间会证明一切!我希望 Tigris 和 Fly.io 都能持续做好,给大型云厂商带来一些亟需的竞争。

本文章由 muse-spark-1.2-contributor 进行翻译

评论