Nova Kwok's Awesome Blog

Halo 官方镜像源在 Serverless(Cloudflare Workers + R2) 上的实践

一转眼两年过去了,在两年前的实践中,我利用 Cloudflare 的 Serverless 平台——Workers 做出了两个小玩具 「knatnetwork/g2fs-serverless」和「knatnetwork/g2ww-serverless」,分别可以将 Grafana 的报警信息推送到飞书和企业微信上,解决了当时在 PingCAP 内部使用对应聊天工具时没法很方便收到来自 Grafana 报警的问题。

去年,Cloudflare 发布了一个新的产品——R2,Announcing Cloudflare R2 Storage: Rapid and Reliable Object Storage, minus the egress fees,一个 S3 兼容的对象存储,同时提供了 Workers 的支持,苦于那段时间一直在和公司内的 Jenkins 和 Kubectl 做斗争,没有太多时间来实践,从 PingCAP 离开后正好有些闲暇的时间开着自己的车在赛道上划船,赛道之余便开始思考 Cloudflare 的这么些个产品在我自己这边的实际应用场景。

也许大家都知道, 我有一辆十代两厢手动思域, 我运行着 Halo 的官方镜像源,这个镜像源的主要功能为提供一个在 GitHub Release 之外的一个相对稳定的 Halo Jar 包下载地,作为镜像站,它需要满足以下几个功能:

在之前我是这么做的:在自己的集群中找了一些机器和存储空间,并部署了一个自动同步 Jar 包的脚本,用 crontab 定期运行(一小时一次),这样能保证本地文件系统的文件和 GitHub Releases 上保持尽可能一致,有了本地文件系统之后接下来只需要一个 Nginx 的 autoindex ,然后设置好 Load balancer,就可以做到类似这样的效果,并且保证一定的可用性。

做运维和建站固然是个很开心的事情,MJJ 们可能也是这么想的,但是一旦自己手上需要维护的服务多了起来,且都需要保证一定的可用性的时候,我们便不能像大学的时候一样一个个给自己的服务器取名字,然后一点点手动编写配置文件并一顿微操了。应该在条件许可的情况下能用 Managed 就用 Managed,锅能丢给服务商就丢给服务商,在这里也是如此,为了保证可用性和 Cloudflare 一致,我打算利用 Workers 和 R2 改造一下这个项目,一来减少自己维护(带来的心理)压力,二来可以以此为契机学习一下 Cloudflare 的这一套 Serverless 理论。

让我们开始吧!

Why Cloudflare as CDN

主要原因是考虑到免费,不怎么容易被花钱,虽然国内有些城市访问的速度可能不怎么好,但是只要自己的域名没有用来做些什么很奇怪的事情的话,还是不会被因为墙 IP 等被彻底搞没的,至于为啥不容易被墙 IP,容我引用一段话:

if your ip and port are marked for tls tunnel proxy behavior, at ordinary times they are used to test a set of tools that automatically blocking obfuscating/encrypted inner tls fingerprint packets, are used to test blocking tls in tls packets without affects normal website access, such as cloudflare, akamai, azure cdn.

just because of some things, this plan was disrupted.

HyeonSeungri@https://github.com/net4people/bbs/issues/129

Jar downloading

在前文中我们知道,要做到和上游同步并保证存储地不一样,这里我们直接使用 Cloudflare R2 作为存储,但是由于要尽量摆脱对于人工/自己机器的介入,我们并不考虑通过 CLI 或者 SDK 的方式访问 R2 并修改内容,而是使用 Workers 来完成这个操作,那么在这里,我们需要做的操作就是用 Worker 下载 Jar 包并保存到 R2 中的对应位置。

R2

R2 的存储相对来说比 S3 便宜,价格如下:

FreePaid - Rates
Storage10 GB / month$0.015 / GB-month
Class A Operations1 million requests / month$4.50 / million requests
Class B Operations10 million requests / month$0.36 / million requests

其中 A 类操作是指 ListBuckets, PutBucket, ListObjects, PutObject, CopyObject, CompleteMultipartUpload, CreateMultipartUpload, ListMultipartUploads, UploadPart, UploadPartCopy and PutBucketEncryption. 这些和写更多相关的操作

B 类操作是指 HeadBucket, HeadObject, GetObject, UsageSummary, GetBucketEncryption and GetBucketLocation. 这类看上去比较只读的操作

截止本文编写时,Halo 众多 Jar 包的存储空间没有超过 6G,所以存储的部分是绝对不会超过 Free 的限额,并且只要在设置好合适的 Rate limit 的情况下,Class B 操作应该也不会太容易超过免费的限制。

(如果真玩超了那 Ryan Wang 的钱包上估计会出现个大洞…

虽然 Cloudflare 声称 R2 是个 Global object storage,但是肯定它的文件是存在于某个数据中心中的(而不像 Bunny CDN 可能会在你选定的 Region 中自动 Replicate),为了确认文件具体是存放在哪儿的,可以通过 https://tools.keycdn.com/performance 这个工具来推测,比如后文中部署好了这一套之后我们请求一个文件,通过观察 TTFB 的方式来尝试推断文件的实际位置:

我们可以观测到 TTFB 时间中新加坡的是最小的,这里猜测是由于我创建 R2 的时候来源 IP 是新加坡,所以 R2 的实际存储就创建在了一个靠近新加坡的位置,这一点在 https://community.cloudflare.com/t/cloudflare-r2-doesnt-distribute-files/400666 这个帖子上也有类似的影子。

Workers

虽然我们在 Workers 的 Pricing 页面看到 Worker 的限制是 Up to 10ms CPU time per request,但是从个人实践的角度来看,Worker 的每个 request 至少能运行 1 分钟以上,这就给了我们下载文件并保存的机会,只需要在 wrangler.toml 中配置好 binding:

r2_buckets = [
  { binding = "dl_halo_run", bucket_name = "dl-halo-run", preview_bucket_name = "" }
]

然后只要两行,我们便可以利用 Worker 下载文件并放到 R2 中的对应位置,fetch 用来下载文件,env.BUCKET_NAME.put 用来把下载下来的内容存到 R2 中,代码如下:

const response = await fetch(download_url);
await env.dl_halo_run.put(download_filname, response.body);

是不是看上去很简单?在明白了核心的部分之后接下来只需要一些简单的业务逻辑,比如判断需要下载的文件名,判断是否已经存储在 R2 中等等便可完成下载部分的处理,样例代码如下

let download_filname = ""
if (filename.includes('beta') || filename.includes('alpha')) {
  download_filname = "prerelease/" + filename;
} else {
  download_filname = "release/" + filename;
}

// Check if exist in R2
console.log("Checking if exist in R2");
const check_file = await env.dl_halo_run.get(download_filname);
console.log("Check file", check_file);
if (!check_file) {
  console.log('Downloading file' + filename);
  const response = await fetch(download_url);
  await env.dl_halo_run.put(download_filname, response.body);
} else{
  console.log('File already exist in R2 ' + filename);
}

由于我们需要定期查询 GitHub 上的情况,所以这里的函数需要写在:

async scheduled(event, env, ctx) {
}

内部,并在 wrangler.toml 中配置一个 cron 来执行:

[triggers]
crons = ["0 */1 * * *"]

至此,我们的下载逻辑已经全部完成,Cloudflare Workers 会每小时执行一遍 scheduled 函数中的操作并下载所需要的 jar 文件了,此时你的 R2 看上去应该类似这样:

File Listing API

现在我们已经有了稳定的存储和定期的下载同步了,作为一个镜像站,我们肯定需要对外提供展示页面,单纯给 R2 绑定个域名用于下载看上去没有问题,但是没有 Listing 的能力的话会让用户没法知道镜像站上有啥内容,所以这里我们需要做一个简单的 API 对外展示内容,而这个部分就更加简单了,我们继续修改上面的 Workers 中的文件,只不过这次是写在:

async fetch(request, env, ctx) {
}

函数内,编写逻辑如下:

代码样例如下:

对于访问的 URI 是 /api 的请求:

// Check if vising /api, list all the files
if (uri == "api") {
  const listed = await env.dl_halo_run.list(options);

  const listed_objects = listed['objects'];
  for (const listed_object of listed_objects) {
    delete listed_object.customMetadata;
    delete listed_object.httpMetadata;
    delete listed_object.version;
    delete listed_object.httpEtag;
    delete listed_object.etag;
  }

  return new Response(JSON.stringify(listed_objects), {
    headers: {
      'content-type': 'application/json; charset=UTF-8',
    }
  });
} 

此外的所有请求:

const file = await env.dl_halo_run.get(objectName);
if (!file) {
  return new Response('File not found', { status: 404 })
}
const headers = new Headers()
file.writeHttpMetadata(headers)
headers.set('etag', file.httpEtag)
return new Response(file.body, {
  headers
})

是不是很容易? 此时访问对应的 /api 接口就能看到类似如下的返回结果:

[
  {
    "uploaded": "2022-11-13T06:34:09.717Z",
    "checksums": {
      "md5": "10e25e056c2bea90a9386e27a9450bfb"
    },
    "size": 61,
    "key": "config/Caddyfile2.x"
  },
...
  {
    "uploaded": "2022-11-13T07:05:29.984Z",
    "checksums": {
      "md5": "44f8a1a6821dbfe69d3577c451862bac"
    },
    "size": 79495690,
    "key": "release/halo-v1.4.14.jar"
  }
]

并且 URI 改为 key 中对应的路径也可以正常下载对应的文件了。

以上完整代码被我放到了 halo-sigs/halo-dl-api,各位如果有兴趣的话可以 一键三联(Star,Fork,Watch) 来围观下。

Ratelimit

这里我们为了减少一些潜在的恶意流量,我们可以加一点 Ratelimit 在这里,今年 Cloudflare 宣布了对于所有 Plan 都有 Unmetered Rate limiting: Back in 2017 we gave you Unmetered DDoS Mitigation, here’s a birthday gift: Unmetered Rate Limiting 之后,免费用户可以针对 Path 来做一点 Ratelimit 了,设置为「对于一个 IP 而言 10 秒钟访问了超过 20 次就自动 429」 ,类似如下:

File Frontend

现在就到了整个旅程的最后一步——提供一个活人能看的页面了,由于前端方面我是几乎一窍不通,所以这里只好暂时用了一下 NextJS 的快速开始模板并加入一些魔改,在和 ESLint 以及 next 做了许多斗争,期间不厌其烦地打扰 @tukideng 询问各种奇葩问题并被白眼数次之后,往 Cloudflare Pages 上一挂,用 nextbuild,嘿,就出现了这么个神奇的页面。

网址: https://download.halo.run/ 看上去像是能用的,不是嘛?

Speedtest

最后我们来随意测个速吧,测速的机器为 Hetzner 德国的机器,分别下载以下两个地址:

速度分别如下:

wget https://github.com/halo-dev/halo/releases/download/v1.6.0/halo-1.6.0.jar
--2022-11-14 08:31:07--  https://github.com/halo-dev/halo/releases/download/v1.6.0/halo-1.6.0.jar
Resolving github.com (github.com)... 140.82.121.3
Connecting to github.com (github.com)|140.82.121.3|:443... connected.
HTTP request sent, awaiting response... 302 Found
Location: https://objects.githubusercontent.com/github-production-release-asset-2e65be/126178683/8ee886dc-48a4-47ce-a096-47871737d506?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAIWNJYAX4CSVEH53A%2F20221114%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20221114T003107Z&X-Amz-Expires=300&X-Amz-Signature=60fa40ea63def87878b9f8c499f12bdcc7b41775b394b78d3e436b8df8963ef9&X-Amz-SignedHeaders=host&actor_id=0&key_id=0&repo_id=126178683&response-content-disposition=attachment%3B%20filename%3Dhalo-1.6.0.jar&response-content-type=application%2Foctet-stream [following]
--2022-11-14 08:31:07--  https://objects.githubusercontent.com/github-production-release-asset-2e65be/126178683/8ee886dc-48a4-47ce-a096-47871737d506?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAIWNJYAX4CSVEH53A%2F20221114%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20221114T003107Z&X-Amz-Expires=300&X-Amz-Signature=60fa40ea63def87878b9f8c499f12bdcc7b41775b394b78d3e436b8df8963ef9&X-Amz-SignedHeaders=host&actor_id=0&key_id=0&repo_id=126178683&response-content-disposition=attachment%3B%20filename%3Dhalo-1.6.0.jar&response-content-type=application%2Foctet-stream
Resolving objects.githubusercontent.com (objects.githubusercontent.com)... 185.199.108.133, 185.199.109.133, 185.199.110.133, ...
Connecting to objects.githubusercontent.com (objects.githubusercontent.com)|185.199.108.133|:443... connected.
HTTP request sent, awaiting response... 200 OK
Length: 96866731 (92M) [application/octet-stream]
Saving to: ‘halo-1.6.0.jar’

halo-1.6.0.jar             100%[=====================================>]  92.38M  23.3MB/s    in 4.1s    

2022-11-14 08:31:12 (22.3 MB/s) - ‘halo-1.6.0.jar’ saved [96866731/96866731]
wget https://dl-r2.halo.run/release/halo-1.6.0.jar 
--2022-11-14 08:32:02--  https://dl-r2.halo.run/release/halo-1.6.0.jar
Resolving dl-r2.halo.run (dl-r2.halo.run)... 2a06:98c1:3121::3, 2a06:98c1:3120::3, 188.114.96.3, ...
Connecting to dl-r2.halo.run (dl-r2.halo.run)|2a06:98c1:3121::3|:443... connected.
HTTP request sent, awaiting response... 200 OK
Length: 96866731 (92M) [application/zip]
Saving to: ‘halo-1.6.0.jar’

halo-1.6.0.jar             100%[=====================================>]  92.38M  27.6MB/s    in 3.4s    

2022-11-14 08:32:07 (27.6 MB/s) - ‘halo-1.6.0.jar’ saved [96866731/96866731]

以上,希望能给耐心看到这里的你带来一些新的灵感~

References

  1. Announcing Cloudflare R2 Storage: Rapid and Reliable Object Storage, minus the egress fees
  2. Limits · Cloudflare Workers docs
  3. How to disable ESLint for some lines, files or folders

#Chinese #Cloudflare