CDN
目前使用的 CDN 方案来自:https://www.eallion.com/cdn-cname-cloudflare/
一下是 CDN 结构图,目的就是解决全球 CDN 并且成本低
前文备份
前言
国内、境外分流,不光能削减成本,还能提高网站性能,优化 TTFB。
不记得是什么时候开始有了一个这样的闪念,然后就去搜索了一下。 结果发现网上的教程都比较老旧。其中讲得比较多的是通过 CloudFlare for SaaS 接入 CNAME,但是都支持普通的域名,并不能接入 R2 或者 Worker。
从功能的优先级上来说,我最需要的是分区解析功能,这就导致不能把域名的 NS 转入 Cloudflare。 Cloudflare 的 DNS 确实非常优秀,但 Cloudflare 不能分区解析,它有 CNAME 拉平功能,不过它会把所有中国大陆地区的 IP 解析到联通。 相反国内的 DNS 服务商的分区解析就做得好,可能也是因为国内的域名更需要这种功能吧。 无法全心全意付出,又想去贴贴 Cloudflare,那就只能搞些奇技淫巧。
2022 年 3 月份,CloudFlare 宣布更改了 CloudFlare for SaaS 的收费策略,每个账户可以有 100 个域名免费额度,而且超额后每个域名按 0.1 USD/月 收取费用。我们就利用 CloudFlare for SaaS 把域名通过 CNAME 接入 Cloudflare,享受 Cloudflare 强大的边缘计算能力。
对于小网站,比如本博客,以上服务都是免费的,免费额度:
- DNSPod:用的专业版,但免费版本也有分区解析
- 腾讯云 COS:50G/月;200 万请求
- 腾讯云 CDN:10G/月
- Cloudflare CDN:正常使用无上限
- Cloudflare R2: 10G/月; 100 万/1000 万请求
- Backblaze B2: 10G/月; 与 Cloudflare 有 流量联盟
关于腾讯云的配置略过,这里只讲 Cloudflare 的部分。
前提需要 Cloudflare 账号中已经有一个可用的域名。
这个域名用来提供 回退源
(Fallback Origin),假设这个域名是 example.com
。
创建 R2 并绑定自定义域名
- 登录控制面板:https://dash.cloudflare.com/ ,Cloudflare 已支持中文;
- 创建 R2 存储桶的方法这里略过,如创建:
r2-blog-test
; - 在
R2
设置
公开访问
自定义域
连接域
为刚才创建的 R2 添加自定义域名:
然后该域名的 DNS 就会自动出现一条解析:
订阅 CloudFlare for SaaS
- 在
Zones
中选择example.com
这个域名; - 在该域名的
SSL/TLS
中选择自定义主机名
; - 选择 Enable 订阅。可以使用 Paypal 订阅。
添加自定义域名
订阅成功后,先添加 回退源
:images.example.com
,这个回源域名是绑定在 R2 上的自定义域名。
然后点击 添加自定义主机名
,填入 CDN 域名,如 images.eallion.com
,验证方式推荐 TXT 验证。
添加后,需要验证域名,去自己的域名解析控制台,如 DNSPod ,添加 2 条 TXT 记录。
等待 证书状态
和 主机名状态
都变成 有效
。
解析 CNAME
回退源状态
证书状态
和 主机名状态
都变成 有效
后,就去自己的域名解析控制台添加 CNAME 解析。
把用于生产环境的 images.eallion.com
CNAME 指向 images.example.com
。
一般的教程到这里就结束了。 但是这样是访问不了 R2 里面的资源的。 最重要的一步,用 Worker 代理 R2。
新建 Worker 代理 R2
官方有文档介绍怎么通过 Worker 访问 R2: Use R2 from Workers:https://developers.cloudflare.com/r2/api/workers/workers-api-usage/
按照文档教程一步一步来就可以了。
如果比较懒,也不想鉴权。那用我的精简代码就可以了。
直接去掉了 DELETE
和 PUT
的代码,只保留了 GET
。
不用 Wrangle CLI 脚本也可以在后台手动创建 Worker。
左侧切换到 Worker 和 Pages
分栏,创建应用程序
,随便取个名字,随便选个模板部署就可以了,后面再改代码。
点击 快速编辑
把以下代码复制到 worker.js
中,保存并部署:
var worker_default = {
async fetch(request, env) {
if (request.method !== 'GET') {
return new Response('Only GET method allowed', { status: 405 });
}
const url = new URL(request.url);
const key = url.pathname.slice(1);
const object = await env.MY_BUCKET.get(key);
if (!object) {
return new Response('Object not found', { status: 404 });
}
const headers = new Headers();
object.writeHttpMetadata(headers);
headers.set('ETag', object.httpEtag);
return new Response(object.body, {
headers,
});
},
};
export { worker_default as default };
部署成功后返回。
在当前 Worker 的设置中,变量
R2 存储桶绑定
添加绑定:
变量名称
:MY_BUCKET
R2 存储桶
:选择对应的桶
Workers 路由
回到 Zones 中,选择域名,添加 Workers 路由:
路由
:一定要填生产环境用的域名,不要填 Cloudflare 的源域名,如:images.eallion.com/*
;Worker
:选择上一步创建的 Worker;环境
:Production。
至此,你应该就能以 CNAME 的方式访问 Cloudflare R2 里面的内容了。
- https://images.eallion.com/eallion.jpg
Worker 代理 Backblaze B2
其实有 R2 就够了,但是可能会因为各种各样的原因需要用到 B2。
其实是差不多的。
Backblaze 官方也有文档介绍如何通过 Cloudflare Worker 访问 B2。 Docs:Integrate Cloudflare Workers with Backblaze B2
简要介绍一下怎么做吧:(还是建议看官方文档比较好。)
1、新建 Cloudflare Worker,worker.js
(() => {
// node_modules/aws4fetch/dist/aws4fetch.esm.mjs
var encoder = new TextEncoder();
var HOST_SERVICES = {
appstream2: 'appstream',
cloudhsmv2: 'cloudhsm',
email: 'ses',
marketplace: 'aws-marketplace',
mobile: 'AWSMobileHubService',
pinpoint: 'mobiletargeting',
queue: 'sqs',
'git-codecommit': 'codecommit',
'mturk-requester-sandbox': 'mturk-requester',
'personalize-runtime': 'personalize',
};
var UNSIGNABLE_HEADERS = /* @__PURE__ */ new Set([
'authorization',
'content-type',
'content-length',
'user-agent',
'presigned-expires',
'expect',
'x-amzn-trace-id',
'range',
'connection',
]);
var AwsClient = class {
constructor({
accessKeyId,
secretAccessKey,
sessionToken,
service,
region,
cache,
retries,
initRetryMs,
}) {
if (accessKeyId == null)
throw new TypeError('accessKeyId is a required option');
if (secretAccessKey == null)
throw new TypeError('secretAccessKey is a required option');
this.accessKeyId = accessKeyId;
this.secretAccessKey = secretAccessKey;
this.sessionToken = sessionToken;
this.service = service;
this.region = region;
this.cache = cache || /* @__PURE__ */ new Map();
this.retries = retries != null ? retries : 10;
this.initRetryMs = initRetryMs || 50;
}
async sign(input, init) {
if (input instanceof Request) {
const { method, url, headers, body } = input;
init = Object.assign({ method, url, headers }, init);
if (init.body == null && headers.has('Content-Type')) {
init.body =
body != null && headers.has('X-Amz-Content-Sha256')
? body
: await input.clone().arrayBuffer();
}
input = url;
}
const signer = new AwsV4Signer(
Object.assign({ url: input }, init, this, init && init.aws)
);
const signed = Object.assign({}, init, await signer.sign());
delete signed.aws;
try {
return new Request(signed.url.toString(), signed);
} catch (e) {
if (e instanceof TypeError) {
return new Request(
signed.url.toString(),
Object.assign({ duplex: 'half' }, signed)
);
}
throw e;
}
}
async fetch(input, init) {
for (let i = 0; i <= this.retries; i++) {
const fetched = fetch(await this.sign(input, init));
if (i === this.retries) {
return fetched;
}
const res = await fetched;
if (res.status < 500 && res.status !== 429) {
return res;
}
await new Promise((resolve) =>
setTimeout(resolve, Math.random() * this.initRetryMs * Math.pow(2, i))
);
}
throw new Error(
'An unknown error occurred, ensure retries is not negative'
);
}
};
var AwsV4Signer = class {
constructor({
method,
url,
headers,
body,
accessKeyId,
secretAccessKey,
sessionToken,
service,
region,
cache,
datetime,
signQuery,
appendSessionToken,
allHeaders,
singleEncode,
}) {
if (url == null) throw new TypeError('url is a required option');
if (accessKeyId == null)
throw new TypeError('accessKeyId is a required option');
if (secretAccessKey == null)
throw new TypeError('secretAccessKey is a required option');
this.method = method || (body ? 'POST' : 'GET');
this.url = new URL(url);
this.headers = new Headers(headers || {});
this.body = body;
this.accessKeyId = accessKeyId;
this.secretAccessKey = secretAccessKey;
this.sessionToken = sessionToken;
let guessedService, guessedRegion;
if (!service || !region) {
[guessedService, guessedRegion] = guessServiceRegion(
this.url,
this.headers
);
}
this.service = service || guessedService || '';
this.region = region || guessedRegion || 'us-east-1';
this.cache = cache || /* @__PURE__ */ new Map();
this.datetime =
datetime || new Date().toISOString().replace(/[:-]|\.\d{3}/g, '');
this.signQuery = signQuery;
this.appendSessionToken =
appendSessionToken || this.service === 'iotdevicegateway';
this.headers.delete('Host');
if (
this.service === 's3' &&
!this.signQuery &&
!this.headers.has('X-Amz-Content-Sha256')
) {
this.headers.set('X-Amz-Content-Sha256', 'UNSIGNED-PAYLOAD');
}
const params = this.signQuery ? this.url.searchParams : this.headers;
params.set('X-Amz-Date', this.datetime);
if (this.sessionToken && !this.appendSessionToken) {
params.set('X-Amz-Security-Token', this.sessionToken);
}
this.signableHeaders = ['host', ...this.headers.keys()]
.filter((header) => allHeaders || !UNSIGNABLE_HEADERS.has(header))
.sort();
this.signedHeaders = this.signableHeaders.join(';');
this.canonicalHeaders = this.signableHeaders
.map(
(header) =>
header +
':' +
(header === 'host'
? this.url.host
: (this.headers.get(header) || '').replace(/\s+/g, ' '))
)
.join('\n');
this.credentialString = [
this.datetime.slice(0, 8),
this.region,
this.service,
'aws4_request',
].join('/');
if (this.signQuery) {
if (this.service === 's3' && !params.has('X-Amz-Expires')) {
params.set('X-Amz-Expires', '86400');
}
params.set('X-Amz-Algorithm', 'AWS4-HMAC-SHA256');
params.set(
'X-Amz-Credential',
this.accessKeyId + '/' + this.credentialString
);
params.set('X-Amz-SignedHeaders', this.signedHeaders);
}
if (this.service === 's3') {
try {
this.encodedPath = decodeURIComponent(
this.url.pathname.replace(/\+/g, ' ')
);
} catch (e) {
this.encodedPath = this.url.pathname;
}
} else {
this.encodedPath = this.url.pathname.replace(/\/+/g, '/');
}
if (!singleEncode) {
this.encodedPath = encodeURIComponent(this.encodedPath).replace(
/%2F/g,
'/'
);
}
this.encodedPath = encodeRfc3986(this.encodedPath);
const seenKeys = /* @__PURE__ */ new Set();
this.encodedSearch = [...this.url.searchParams]
.filter(([k]) => {
if (!k) return false;
if (this.service === 's3') {
if (seenKeys.has(k)) return false;
seenKeys.add(k);
}
return true;
})
.map((pair) => pair.map((p) => encodeRfc3986(encodeURIComponent(p))))
.sort(([k1, v1], [k2, v2]) =>
k1 < k2 ? -1 : k1 > k2 ? 1 : v1 < v2 ? -1 : v1 > v2 ? 1 : 0
)
.map((pair) => pair.join('='))
.join('&');
}
async sign() {
if (this.signQuery) {
this.url.searchParams.set('X-Amz-Signature', await this.signature());
if (this.sessionToken && this.appendSessionToken) {
this.url.searchParams.set('X-Amz-Security-Token', this.sessionToken);
}
} else {
this.headers.set('Authorization', await this.authHeader());
}
return {
method: this.method,
url: this.url,
headers: this.headers,
body: this.body,
};
}
async authHeader() {
return [
'AWS4-HMAC-SHA256 Credential=' +
this.accessKeyId +
'/' +
this.credentialString,
'SignedHeaders=' + this.signedHeaders,
'Signature=' + (await this.signature()),
].join(', ');
}
async signature() {
const date = this.datetime.slice(0, 8);
const cacheKey = [
this.secretAccessKey,
date,
this.region,
this.service,
].join();
let kCredentials = this.cache.get(cacheKey);
if (!kCredentials) {
const kDate = await hmac('AWS4' + this.secretAccessKey, date);
const kRegion = await hmac(kDate, this.region);
const kService = await hmac(kRegion, this.service);
kCredentials = await hmac(kService, 'aws4_request');
this.cache.set(cacheKey, kCredentials);
}
return buf2hex(await hmac(kCredentials, await this.stringToSign()));
}
async stringToSign() {
return [
'AWS4-HMAC-SHA256',
this.datetime,
this.credentialString,
buf2hex(await hash(await this.canonicalString())),
].join('\n');
}
async canonicalString() {
return [
this.method.toUpperCase(),
this.encodedPath,
this.encodedSearch,
this.canonicalHeaders + '\n',
this.signedHeaders,
await this.hexBodyHash(),
].join('\n');
}
async hexBodyHash() {
let hashHeader =
this.headers.get('X-Amz-Content-Sha256') ||
(this.service === 's3' && this.signQuery ? 'UNSIGNED-PAYLOAD' : null);
if (hashHeader == null) {
if (
this.body &&
typeof this.body !== 'string' &&
!('byteLength' in this.body)
) {
throw new Error(
'body must be a string, ArrayBuffer or ArrayBufferView, unless you include the X-Amz-Content-Sha256 header'
);
}
hashHeader = buf2hex(await hash(this.body || ''));
}
return hashHeader;
}
};
async function hmac(key, string) {
const cryptoKey = await crypto.subtle.importKey(
'raw',
typeof key === 'string' ? encoder.encode(key) : key,
{ name: 'HMAC', hash: { name: 'SHA-256' } },
false,
['sign']
);
return crypto.subtle.sign('HMAC', cryptoKey, encoder.encode(string));
}
async function hash(content) {
return crypto.subtle.digest(
'SHA-256',
typeof content === 'string' ? encoder.encode(content) : content
);
}
function buf2hex(buffer) {
return Array.prototype.map
.call(new Uint8Array(buffer), (x) => ('0' + x.toString(16)).slice(-2))
.join('');
}
function encodeRfc3986(urlEncodedStr) {
return urlEncodedStr.replace(
/[!'()*]/g,
(c) => '%' + c.charCodeAt(0).toString(16).toUpperCase()
);
}
function guessServiceRegion(url, headers) {
const { hostname, pathname } = url;
if (hostname.endsWith('.r2.cloudflarestorage.com')) {
return ['s3', 'auto'];
}
if (hostname.endsWith('.backblazeb2.com')) {
const match2 = hostname.match(
/^(?:[^.]+\.)?s3\.([^.]+)\.backblazeb2\.com$/
);
return match2 != null ? ['s3', match2[1]] : ['', ''];
}
const match = hostname
.replace('dualstack.', '')
.match(/([^.]+)\.(?:([^.]*)\.)?amazonaws\.com(?:\.cn)?$/);
let [service, region] = (match || ['', '']).slice(1, 3);
if (region === 'us-gov') {
region = 'us-gov-west-1';
} else if (region === 's3' || region === 's3-accelerate') {
region = 'us-east-1';
service = 's3';
} else if (service === 'iot') {
if (hostname.startsWith('iot.')) {
service = 'execute-api';
} else if (hostname.startsWith('data.jobs.iot.')) {
service = 'iot-jobs-data';
} else {
service = pathname === '/mqtt' ? 'iotdevicegateway' : 'iotdata';
}
} else if (service === 'autoscaling') {
const targetPrefix = (headers.get('X-Amz-Target') || '').split('.')[0];
if (targetPrefix === 'AnyScaleFrontendService') {
service = 'application-autoscaling';
} else if (targetPrefix === 'AnyScaleScalingPlannerFrontendService') {
service = 'autoscaling-plans';
}
} else if (region == null && service.startsWith('s3-')) {
region = service.slice(3).replace(/^fips-|^external-1/, '');
service = 's3';
} else if (service.endsWith('-fips')) {
service = service.slice(0, -5);
} else if (region && /-\d$/.test(service) && !/-\d$/.test(region)) {
[service, region] = [region, service];
}
return [HOST_SERVICES[service] || service, region];
}
// index.js
var UNSIGNABLE_HEADERS2 = ['x-forwarded-proto', 'x-real-ip'];
function filterHeaders(headers) {
return Array.from(headers.entries()).filter(
(pair) =>
!UNSIGNABLE_HEADERS2.includes(pair[0]) && !pair[0].startsWith('cf-')
);
}
async function handleRequest(event, client2) {
const request = event.request;
if (!['GET', 'HEAD'].includes(request.method)) {
return new Response(null, {
status: 405,
statusText: 'Method Not Allowed',
});
}
const url = new URL(request.url);
let path = url.pathname.replace(/^\//, '');
path = path.replace(/\/$/, '');
const pathSegments = path.split('/');
if (ALLOW_LIST_BUCKET !== 'true') {
if (
(BUCKET_NAME === '$path' && pathSegments[0].length < 2) ||
(BUCKET_NAME !== '$path' && path.length === 0)
) {
return new Response(null, {
status: 404,
statusText: 'Not Found',
});
}
}
switch (BUCKET_NAME) {
case '$path':
url.hostname = B2_ENDPOINT;
break;
break;
case '$host':
url.hostname = url.hostname.split('.')[0] + '.' + B2_ENDPOINT;
break;
default:
url.hostname = BUCKET_NAME + '.' + B2_ENDPOINT;
break;
}
const headers = filterHeaders(request.headers);
const signedRequest = await client2.sign(url.toString(), {
method: request.method,
headers,
body: request.body,
});
return fetch(signedRequest);
}
var endpointRegex = /^s3\.([a-zA-Z0-9-]+)\.backblazeb2\.com$/;
var [, aws_region] = B2_ENDPOINT.match(endpointRegex);
var client = new AwsClient({
accessKeyId: B2_APPLICATION_KEY_ID,
secretAccessKey: B2_APPLICATION_KEY,
service: 's3',
region: aws_region,
});
addEventListener('fetch', function (event) {
event.respondWith(handleRequest(event, client));
});
})();
2、设置 Worker 环境变量
ALLOW_LIST_BUCKET
:trueB2_APPLICATION_KEY
:K004WJZP11111111111111111111QB2_APPLICATION_KEY_ID
:0042e9999999920000000001B2_ENDPOINT
:s3.us-west-004.backblazeb2.comBUCKET_NAME
:eallion-static
APP KEY 和 ID 要去 Backblaze 后台生成,B2_ENDPOINT
要去自己的 B2 存储桶里查看。
3、手动添加 CNAME 解析到 B2
类型
:选CNAME
名称
:用于回退源
,如:b2.example.com
,就填入b2
内容
:填入自己 B2 存储桶分配的S3 URL
,有的教程这里写的是Friendly URL
,没必要,还要多一步反代。
4、配置回退源
Zones
中的域名为 Backblaze B2 设置的 CNAME 名称是什么,那回退源就填什么,如:b2.example.com
。
参考前文即可。
5、配置自定义主机名
参考前文。
6、配置 Worker 路由
路由
:一定要填生产环境用的域名,不要填 Cloudflare 的源域名;Worker
:选择上一步创建的 Worker;环境
:Production。
为 Backblaze B2 添加 Worker 路由与 Cloudflare R2 不同,需要添加 2 条:
b2.example.com/*
也需要加入 Worker 路由中images.eallion.com/*
:::
CDN 证书自动更换
以上算是解决了基本的问题,但是还有一个痛点就是 SSL 证书问题,CF 是提供免费续签更新永不过期证书的,但是国内 CDN 厂商一般都需要手动上传自己申请的证书。。。这个确实不太方便,所以想着能不能找个解决方案:
::: details 自动更新证书脚本
起因:因为配置国内 CDN 基本都是需要手动上传证书,但是我申请的域名证书都是基本三个月保质期的,所以就想着写个自动脚本自动更新证书。 环境: 1panel(国产面板,自动申请证书,还有其他强大功能,挺方便的)
以我用的多吉云为例,其他厂家都可以去找到相应的 SDK。 代码如下:
from hashlib import sha1
import hmac
import requests
import json
import urllib
def dogecloud_api(api_path, data={}, json_mode=False):
"""
调用多吉云API
:param api_path: 调用的 API 接口地址,包含 URL 请求参数 QueryString,例如:/console/vfetch/add.json?url=xxx&a=1&b=2
:param data: POST 的数据,字典,例如 {'a': 1, 'b': 2},传递此参数表示不是 GET 请求而是 POST 请求
:param json_mode: 数据 data 是否以 JSON 格式请求,默认为 false 则使用表单形式(a=1&b=2)
:type api_path: string
:type data: dict
:type json_mode bool
:return dict: 返回的数据
"""
# 这里替换为你的多吉云永久 AccessKey 和 SecretKey,可在用户中心 - 密钥管理中查看
# 请勿在客户端暴露 AccessKey 和 SecretKey,否则恶意用户将获得账号完全控制权
access_key = ''
secret_key = ''
body = ''
mime = ''
if json_mode:
body = json.dumps(data)
mime = 'application/json'
else:
body = urllib.parse.urlencode(data) # Python 2 可以直接用 urllib.urlencode
mime = 'application/x-www-form-urlencoded'
sign_str = api_path + "\n" + body
signed_data = hmac.new(secret_key.encode('utf-8'), sign_str.encode('utf-8'), sha1)
sign = signed_data.digest().hex()
authorization = 'TOKEN ' + access_key + ':' + sign
response = requests.post('https://api.dogecloud.com' + api_path, data=body, headers = {
'Authorization': authorization,
'Content-Type': mime
})
return response.json()
api = dogecloud_api('/cdn/cert/list.json')
if api['code'] == 200:
for cert in api['data']['certs']:
ssl_next_id = cert['id']
delet_api = dogecloud_api('/cdn/cert/delete.json', {
'id': cert['id']
})
else:
print("api failed: " + api['msg']) # 失败
# 下面两个路径就是1panel自动生成的证书路径
with open('/opt/1panel/apps/openresty/openresty/www/sites/xxxx/ssl/fullchain.pem') as fullchain:
full = fullchain.read()
with open('/opt/1panel/apps/openresty/openresty/www/sites/xxxx/ssl/privkey.pem') as privkey:
priv = privkey.read()
api = dogecloud_api('/cdn/cert/upload.json', {
"note": f"自动证书{ssl_next_id}",
"cert": full,
"private": priv
})
if api['code'] == 200:
ssl_id = api['data']['id']
else:
print("api failed: " + api['msg']) # 失败
api = dogecloud_api('/cdn/domain/config.json?domain=cdn.example.com', {
'cert_id': ssl_id
}, True)
基本实现思路就是先删除现有证书,然后添加读取的证书,然后上传并激活上传后的证书,这样就实现 CDN 证书自动配置了。可以利用 1panel 自动定时执行脚本功能每隔一个月执行一次,以此更新证书。真是又实现了一个奇奇怪怪的使用小技巧 ✊✊✊
:::