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

原文：<https://www.halo.run/archives/halo-mirror-serverless>

> 转载自：[https://nova.moe/halo-mirror-serverless/](https://nova.moe/halo-mirror-serverless/)

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

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

![](https://1e674d5.webp.li/pics/halo-serverless/track.jpg)

也许大家都知道， ~我有一辆十代两厢手动思域，~ 我运行着 [Halo 的官方镜像源](https://docs.halo.run/getting-started/downloads/)，这个镜像源的主要功能为提供一个在 GitHub Release 之外的一个相对稳定的 Halo Jar 包下载地，作为镜像站，它需要满足以下几个功能：

-   Jar 包需要和上游（这里是 GitHub Releases）尽量保持同步
    
-   存储地不能和上游一样（比如这里不能是个 GitHub 的反代）
    
-   尽量减少开销（减少被刷爆的概率）
    

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

![](https://1e674d5.webp.li/pics/halo-serverless/autoindex.png)

做运维和建站固然是个很开心的事情，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 便宜，价格如下：

<table style="width: 863px"><colgroup><col style="width: 238px"><col style="width: 272px"><col style="width: 353px"></colgroup><tbody><tr style="height: 60px;"><td colspan="1" rowspan="1" colwidth="238"><p style=""></p></td><td colspan="1" rowspan="1" colwidth="272"><p style=""><strong>Free</strong></p></td><td colspan="1" rowspan="1" colwidth="353"><p style=""><strong>Paid - Rates</strong></p></td></tr><tr style="height: 60px;"><td colspan="1" rowspan="1" colwidth="238"><p style="">Storage</p></td><td colspan="1" rowspan="1" colwidth="272"><p style="">10 GB / month</p></td><td colspan="1" rowspan="1" colwidth="353"><p style="">$0.015 / GB-month</p></td></tr><tr style="height: 60px;"><td colspan="1" rowspan="1" colwidth="238"><p style="">Class A Operations</p></td><td colspan="1" rowspan="1" colwidth="272"><p style="">1 million requests / month</p></td><td colspan="1" rowspan="1" colwidth="353"><p style="">$4.50 / million requests</p></td></tr><tr style="height: 60px;"><td colspan="1" rowspan="1" colwidth="238"><p style="">Class B Operations</p></td><td colspan="1" rowspan="1" colwidth="272"><p style="">10 million requests / month</p></td><td colspan="1" rowspan="1" colwidth="353"><p style="">$0.36 / million requests</p></td></tr></tbody></table>

其中 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](https://github.com/ruibaby) 的钱包上估计会出现个大洞…

![](https://1e674d5.webp.li/pics/halo-serverless/money.jpg)

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

![](https://1e674d5.webp.li/pics/halo-serverless/keycdn.png)

我们可以观测到 TTFB 时间中新加坡的是最小的，这里猜测是由于我创建 R2 的时候来源 IP 是新加坡，所以 R2 的实际存储就创建在了一个靠近新加坡的位置，这一点在 [https://community.cloudflare.com/t/cloudflare-r2-doesnt-distribute-files/400666](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：

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

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

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

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

```js
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 上的情况，所以这里的函数需要写在：

```js
async scheduled(event, env, ctx) {
}
```

内部，并在 `wrangler.toml` 中配置一个 cron 来执行：

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

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

![](https://1e674d5.webp.li/pics/halo-serverless/r2.png)

### File Listing API

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

```js
async fetch(request, env, ctx) {
}
```

函数内，编写逻辑如下：

-   如果访问的 URI 是 `/api` 的话，就输出 R2 中的所有内容（的 JSON 格式）
    
-   此外的所有请求就尝试通过 URI 作为 Key 去 R2 中取文件
    

代码样例如下：

对于访问的 URI 是 `/api` 的请求：

```js
// 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',
    }
  });
} 
``` 

此外的所有请求：

```js
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` 接口就能看到类似如下的返回结果：

```json
[
  {
    "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](https://github.com/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](https://blog.cloudflare.com/unmetered-ratelimiting/) 之后，免费用户可以针对 Path 来做一点 Ratelimit 了，设置为「对于一个 IP 而言 10 秒钟访问了超过 20 次就自动 429」 ，类似如下：

![](https://1e674d5.webp.li/pics/halo-serverless/ratelimit.png)

### File Frontend

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

![](https://1e674d5.webp.li/pics/halo-serverless/frontend.png)

网址： [https://download.halo.run/](https://download.halo.run/) 看上去像是能用的，不是嘛？

### Speedtest

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

-   [https://github.com/halo-dev/halo/releases/download/v1.6.0/halo-1.6.0.jar](https://github.com/halo-dev/halo/releases/download/v1.6.0/halo-1.6.0.jar)
    
-   [https://dl-r2.halo.run/release/halo-1.6.0.jar](https://dl-r2.halo.run/release/halo-1.6.0.jar)
    

速度分别如下：

```shellscript
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]
```

```shellscript
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](https://blog.cloudflare.com/introducing-r2-object-storage/)
    
2.  [Limits · Cloudflare Workers docs](https://developers.cloudflare.com/workers/platform/limits/#request)
    
3.  [How to disable ESLint for some lines, files or folders](https://learn.coderslang.com/0023-eslint-disable-for-specific-lines-files-and-folders/)
