WebP Cloud Services Blog

使用 Cloudflare Workers 在边缘让服务就近回源——降低全球平均延迟

· Nova Kwok

如果你有一个服务希望可以在全球范围内都能有很快的响应速度,你的第一反应会是什么? CDN?分区解析?

我们从一个简单的例子开始,假设你有一个服务,它的功能是给访问的人提供一个 UUID,假设你的服务部署在德国,你的服务器 IP 是 159.69.27.1,然后你通过 DNS 让 uuid.example.com 指向了这个 IP,这样当有人访问 uuid.example.com 的时候,他们就会得到一个 UUID。

这么做是第一步,所有的人访问 uuid.example.com 的时候都会解析到对应的 IP,然后浏览器发起请求,经过了若干秒之后,成功返回了我们要的结果,且从世界各地可能会得到不一样的延迟效果,比如从中国大陆的延迟是这样的:

PING 159.69.27.1 (159.69.27.1) 56(84) bytes of data.
64 bytes from 159.69.27.1: icmp_seq=1 ttl=37 time=373 ms
64 bytes from 159.69.27.1: icmp_seq=2 ttl=37 time=423 ms
64 bytes from 159.69.27.1: icmp_seq=6 ttl=37 time=388 ms

而从美国的延迟是这样的:

PING 159.69.27.1 (159.69.27.1) 56(84) bytes of data.
64 bytes from 159.69.27.1: icmp_seq=1 ttl=56 time=114 ms
64 bytes from 159.69.27.1: icmp_seq=2 ttl=56 time=113 ms
64 bytes from 159.69.27.1: icmp_seq=4 ttl=56 time=113 ms

为了防止服务器 IP 暴露导致有些脚本小子来攻击,以及为了 “加速访问”,这里可能很多人会选择使用 CDN,比如 Cloudflare,在域名接入了 Cloudflare 之后我们会发现 uuid.example.com 解析到的 IP 就不再是我们服务器 IP,而可能是 104.16.132.229 这种 Cloudflare 持有的 IP。

由于这个时候其他的请求再访问你的服务的时候,大家通过 ping 指令就会发现延迟都在 10ms 左右了:

PING 104.16.133.229 (104.16.133.229) 56(84) bytes of data.
64 bytes from 104.16.133.229: icmp_seq=1 ttl=62 time=10.8 ms
64 bytes from 104.16.133.229: icmp_seq=2 ttl=62 time=10.2 ms
64 bytes from 104.16.133.229: icmp_seq=3 ttl=62 time=10.3 ms

这个时候我们从 ping 上看到的延迟下降了,许多人就会认为实际的服务延迟下降了,但是仔细想想就能想到,这里的延迟只是我们访客到 Cloudflare 提供的 Anycast 节点的延迟(Cloudflare 通过 Anycast 将一个 IP 在世界范围内宣告,所以不同国家的机器都会共享这个 IP,每次 ping 的时候其实都是在 ping 离自己最近的城市的 Cloudflare 节点,中国/朝鲜除外),但是实际的 HTTP 请求发到了 Cloudflare 节点之后,对应的节点还是需要走公网来访问我们的源站来获得响应,这里我们就需要通过 TTFB 来测试实际的延迟了。

KeyCDN 有个 Performance Test 可以让我们很方便的知道一个服务在几个主要区域的延迟情况,以曾经的 WebP Cloud Services——Public Service 为例,由于我们的服务器全部在德国 Hetzner ,从下图我们可以看到, 由于我们在使用 Cloudflare 的 CDN,所以 CONNECT 部分延迟都很低(因为这个延迟是从 KeyCDN 的探测点到 Cloudflare 边缘节点的延迟),而从 TTFB 我们就可以发现,只有德国区域延迟是最低的,其他区域的延迟都是在 100ms 以上。

WebP Cloud Services——Public Service 提供 Gravatar 和 GitHub Avatar 的反向代理,解决了两个问题:

且这个是公共服务,完全免费,目前有大量用户在使用,包括但不限于 CNX SoftwareIndienova

从 Cloudflare 的统计面板可以看到,在过去的 30 天我们处理了 6M+ 个请求,且大部分请求来源是美国和中国:

这里有个背景补充,对于除了中国移动的用户来说,大部分的中国访客在访问 Cloudflare 的时候都会被路由到 Cloudflare 美国西部的节点上,一般是 SJC。

根据上面我们的理论,我们 1/2 左右的用户都会先访问到 Cloudflare 美西节点,然后再由 Cloudflare 走公网回源了我们位于德国的源站服务器,又产生了额外的 110+ms 的延迟,给用户一种我们服务响应比较慢的感觉。

所以应该怎么做?

在这个场景下,我们其实有许多隐含条件,如下:

  • 我们需要继续使用 Cloudflare 来保护我们源站地址,同时使用 Cloudflare 边缘来做一些计算和 WAF 规则
  • 我们不能直接把服务 “迁移” 到美国,因为我们依然有欧洲用户,所以我们会同时在美国和欧洲有服务器
  • 由于访客的大头是美国和中国,而中国用户都会访问到美国节点,所以我们的优化重心是美国区域的访问速度
  • 我们的需求是让美国和中国的用户访问到美国的服务器上,欧洲的用户访问到德国的服务器上,其余地区的用户访问到就近的服务器上

鉴于此,我们想到了以下几个方案:

  • 使用私有的 ASN + IPv6 ,通过 Vultr 等服务在美国和欧洲各部署一个节点,然后通过 BGP Anycast 来做负载均衡,类似 Nova 的博客文章 「搭建 Cloudflare 背后的 IPv6 AnyCast 网络」 一样。
    • 这么做的话成本就是: ASN 的费用,IPv6 的费用,以及 Vultr 的费用,而且还需要自己维护这个网络,比较麻烦
  • 直接使用 BuyVM 的 Anycast 服务,在三个地方都购买一个 VPS,然后通过 BuyVM 的 Anycast 服务来做负载均衡
    • 这么做的话成本就是: BuyVM 的 VPS 费用,大约是 3 * 3.5 = 10.5 美元/月
  • 使用 Cloudflare Load Balancer 做 Geo Load Balancing
    • 这么做的话成本就是: Cloudflare Load Balancer 的费用,大约是 5 美元/月(500,000 个请求),超出部分 0.5 美元 500,000 个请求,如果希望做到我们需求的基于地区的路由的话,额外 10 美元/月,套用我们 6M 个请求的话价格就是 20.5 美元/月
  • 使用 Cloudflare Workers 这个已经在 Cloudflare 所有数据中心部署的 Serverless 服务来做负载均衡
    • 这么做的话成本就是: Cloudflare Workers 的费用,大约是 5 美元/月(每个月可以处理 10M 个请求,大于我们每月请求数量)

从上面的方案来看,使用 Cloudflare Workers 的方案比较省心,付钱,开冲!

Cloudflare Workers

Cloudflare Workers 是 Cloudflare 提供的 Serverless 服务,它可以让我们在 Cloudflare 的所有数据中心部署代码,且可以通过 Cloudflare Workers KV 来存储数据,这样我们就可以在全球范围内部署代码,且可以在全球范围内读写数据。

对于我们而言,这里使用 Cloudflare Workers 的主要逻辑如下:

  • 给定一个请求,在 Workers 上判定来源 IP 所在的国家(也极大概率就是 Workers 执行的机器的所在地)
  • 根据我们定义的一个 Mapping ,将请求转发到物理距离最近的服务器上
  • 同时处理一下各种异常请求,自动 Failover 等逻辑

https://developers.cloudflare.com/fundamentals/reference/http-request-headers/#cf-ipcountry 我们可以知道,对于每个请求来说,可以通过 CF-IPCountry 拿到请求来源地区的代码,而这里我们简单起见基于大洲来规划流量,所以我们可以很快写出一个简单的 Mapping:

function getContinentByISOCode(isoCode) {
    const continentMap = {
        'AD': 'Europe',
        'AE': 'Asia',
        ...
        'CN': 'North America', // China should be Asia, but we're using North America because China users are routed to the North America Edge
        ...
        'ZW': 'Africa',
      };
  
    const continent = continentMap[isoCode];
  
    if (continent) {
      return continent;
    } else {
      return 'Unknown';
    }
  }

下一步在各个地区启动服务,并规划一下服务的 Endpoint 地址,然后在 Workers 根据大洲来规划我们的实际后端服务 Mapping :

const BACKEND_MAP = {
    ...
    'Europe': 'https://eu-west-2-entrance.webp.se',
    'North America': 'https://us-west-2-entrance.webp.se',
    ...
    'Unknown': 'https://eu-west-1-entrance.webp.se'
}

最终,我们的 Workers 代码就可以大概写成这样:

export default {
	async fetch(request, env, ctx) {
		const url = new URL(request.url);
		const path = url.pathname;
		// Original Path: /avatar/?d=mm, full path with query string
		const original_path = url.pathname + url.search;
		const CF_IP_COUNTRY = request.headers.get('cf-ipcountry');

		const continent = getContinentByISOCode(CF_IP_COUNTRY);
		const backend_url = BACKEND_MAP[continent];

		return handleProxy(request, backend_url, original_path, url.hostname,CF_IP_COUNTRY);
	},
};

其中 handleProxy 的关键代码如下:

async function handleProxy(request,backend_url, path, secret_host,CF_IP_COUNTRY){
	// Host: eu-public-service.webp.se
	// some-secret-header-to-backend: gravatar.webp.se
	const headers = {
		'Accept': request.headers.get('Accept'),
		'User-Agent': request.headers.get('User-Agent'),
		'Referer': request.headers.get('Referer'),
		'x-real-ip': request.headers.get('x-real-ip'),
		'some-secret-header-to-backend': secret_host,
		'country-code': CF_IP_COUNTRY,
	};

	const backend_path = backend_url + path;

	const timeoutPromise = new Promise((resolve, reject) => {
		setTimeout(() => {
		  reject(new Error('Timed out'));
		}, 2500);
	  });
	  
	const fetchPromise = fetch(backend_path, {
		headers: headers
	  });

	try {
		const res = await Promise.race([fetchPromise, timeoutPromise]);
		if (res.ok) {
			return res;
		}
	} catch (error) {
		// Timed out, continue with backup backends
	}

	// Some other failover logic here...

	return res;
}

是不是非常简洁明了?

注意 handleProxy(request, backend_url, path, url.hostname, CF_IP_COUNTRY); 这个函数有 backend_url, path, url.hostname 三个参数,因为我们服务最终对外提供访问的是 gravatar.webp.se 这种地址,而不是 eu-west-2-entrance.webp.se,但是 Workers 在通过 fetch() 访问源站的时候只能使用后者,所以这里我们 fetch() 中需要额外传入一个 Header 告诉后端服务对应的请求实际上是在请求哪个域名。

例如,Fetch 请求中 Host Header 是 eu-west-2-entrance.webp.seWebP Workers Header 是 gravatar.webp.se,我们的实际后端在发现有 WebP Workers Header 存在的情况下就会把这个 Header 当作 Host 来判断。

效果对比

使用 Workers 之前,源站全部在 Hetzner 德国

使用 Workers 之后,源站在 Hetzner 德国和 Hetzner Hillsboro,可以看到美国的两个检测点的 TTFB 都有了明显的下降,从 300+ms 下降到了 100+ms

通过 x-powered-by 可以看到对应的请求是从那个区域的节点输出的:

Hetzner 德国

Hetzner Hillsboro

截止本文发布时,我们已经按照这个架构运行了接近 3 天,从监控情况来看,HIO 节点启动后,立即接管了接近一半的流量,符合我们的预期:

Workers 上的统计信息如下:

References

  1. https://developers.cloudflare.com/fundamentals/reference/http-request-headers/#cf-ipcountry
  2. 搭建 Cloudflare 背后的 IPv6 AnyCast 网络

WebP Cloud Services 团队是一个来自上海和赫尔辛堡的三人小团队,由于我们不融资,且没有盈利压力 ,所以我们会坚持做我们认为正确的事情,力求在我们的资源和能力允许范围内尽量把事情做到最好, 同时也会在不影响对外提供的服务的情况下整更多的活,并在我们产品上实践各种新奇的东西。

如果你觉得我们的这个服务有意思或者对我们服务感兴趣,欢迎登录 WebP Cloud Dashboard 来体验,如果你好奇它还有哪些神奇的功能,可以来看看我们的文档 WebP Cloud Services Docs,希望大家玩的开心~


Discuss on Hacker News