使用 Hono 搭建一个轻量的COS内网反代
在使用 R2 + Cloudinary 图片 CDN 一段时间后,我决定将图片服务迁移到腾讯云的 COS + 数据万象,原因如下:
- Cloudinary 免费套餐不支持自有存储,会将原图再复制一份存在它的云里,而存储也要收钱
- Cloudinary 作为一体解决方案是自带 CDN 的,我并不需要
- 为了让 Cloudinary 搭配 R2 使用我写了一个有些扭曲的 Cloudflare Worker 反代
- Cloudinary 压缩大尺寸图片时直接 503, 而数据万象失败后会返回原图,体验更好
观察了一下桶的总体积不算太大,我还有一些腾讯云老用户的免费 COS 额度,数据万象的图片处理收费看起来也很便宜,还能有什么问题呢?
隐形费用
在仔细研究了一遍对象存储的费用表后,我发现了一个问题:如果将 COS 作为 CDN 的回源对象,不但要为产生的 CDN 流量费用付费,还要再为 CDN 回源的这部分流量付费。算了一下大概价格在 0.1 元/GB 左右。也许对某些读者来说不贵,但是对我而言,蚊子肉也是肉!
降本增笑
又经过一番调研,发现腾讯云目前对同地域内网访问 COS 不收回源费用,如果你前几年正好脑子一热打折时囤了一些机器(CVM 和轻量都可以),就可以在上面搭建一个反代来访问 COS。 不但可以省钱,还可以防止被盗刷产生巨额费用账单:
- CDN 的统计有延迟,阈值可能会被打超 ❌
- 轻量机器的流量套餐有上限,要么流量耗尽要么被打进黑洞 ✅
不过自有反代的问题是大多数机器的带宽都不会很高,只适合小流量网站使用这种直接访问方案,否则还是需要配置 CDN。
迁移数据
创建一个和你已有的服务器同地域的私有 COS 对象存储桶,由于 Cloudflare R2 和腾讯 COS 都是兼容 S3 的产物,使用腾讯云的迁移工具,将 S3 的常见参数 SecretId/SecretKey/Region/BucketURL/Name 填好等队列完成就行。
配置 API 密钥
由于上一步创建的是私有桶(别问为什么是私有桶,你也不想公开桶地址漏了被打爆吧),因此访问时需要密钥进行请求签名。
前往腾讯云后台开设一个子账户,授予对存储桶的 只读权限,如果要使用 数据万象 还要一并授予对应权限,保存好 SecretId 和 SecretKey,现在的腾讯云安全策略不允许在创建后再查看密钥 🙄。
速通 Hono.js
选择 Hono 最重要的原因是感觉它非常小巧,做这种事情跑个 express 感觉也太重了 🤔
这里只展示思路,具体代码可以戳这里:Repo
你需要对 NodeJS 项目有这些基础的知识:
- env 的使用
- NodeJS 项目的部署
- Nginx 或 Caddy 反代
将之前获取到的 API 密钥配置到.env
文件或者想办法注入到process.env
中,桶地址可以在存储桶的首页找到:
BUCKET_URL=EXAMPLE.cos.ap-shanghai.myqcloud.com
SecretId=
SecretKey=
核心思路:
import COS from "cos-nodejs-sdk-v5";
// 不想被访问到桶目录就先把根目录注册掉
app.all("/", (c) => {
return c.text("Welcome to COS image proxy.", 404);
});
// 把所有请求的路径都代理到COS
app.all("*", async (c) => {
const { BUCKET_URL } = env<NodeJSEnv>(c);
const url = new URL(c.req.url);
const pathname = url.pathname;
return fetchCOSObject(
`https://${BUCKET_URL}${pathname}`,
c.req.method as COS.Method,
pathname,
c
);
});
// 对404请求返回一个自定义结果,我不想看 S3 桶的那个Object not found
app.notFound((c) => {
return c.text("Welcome to COS proxy, your visit URL is not exist.", 404);
});
function fetchCOSObject(
url: string,
method: COS.Method,
objectKey: string,
c: Context<Env, "*", BlankInput>
) {
const { SecretId, SecretKey, BUCKET_URL } = env<NodeJSEnv>(c);
const authToken = COS.getAuthorization({
SecretId: SecretId,
SecretKey: SecretKey,
Method: method,
Key: objectKey,
// Expires: 60,
Query: {},
Headers: {},
});
const response = await fetch(url, {
headers: {
...c.req.header,
Host: BUCKET_URL,
authorization: authToken,
},
});
if (response.status === 404) {
return c.notFound();
}
const newResponse = new Response(response.body, response);
newResponse.headers.set("Content-Disposition", "inline");
return newResponse;
}
到此,这个反代就可以工作了!你可以使用 PM2 或者是 Docker 将其容器化后部署到你的内网机器上,然后使用你熟悉的工具——不管是 nginx 还是 Caddy,将服务域名反代到对应的端口即可。
加点调料
你是否觉得 数据万象 的参数 URL 复杂到无与伦比?比如光是转换图片到指定格式,URL 就有这么长:
imageMogr2/format/${format}/minisize/1/ignore-error/1
如果你还想裁剪一下图片,并设置图片质量呢?
imageMogr2/thumbnail/${width}x/ignore-error/1
imageMogr2/quality/${quality}/ignore-error/1/minisize/1
你肯定不会想在你的网页里使用 URL 这么长的图片对吧,那么对我们上面的反代稍作改造:
export function buildURL({
url,
format,
width,
height,
quality,
}: {
url: URL;
format: string | null;
width?: string | null;
height?: string | null;
quality?: string | null;
}) {
const queryParams = [];
const widthBuilder = (width: string) =>
`imageMogr2/thumbnail/${width}x/ignore-error/1`;
const qualityBuilder = (quality: string) =>
quality !== "auto"
? `imageMogr2/quality/${quality}/ignore-error/1/minisize/1`
: "imageSlim";
const formatBuilder = (format: string) =>
`imageMogr2/format/${format}/minisize/1/ignore-error/1`;
if (width) {
queryParams.push(widthBuilder(width));
}
if (quality) {
queryParams.push(qualityBuilder(quality));
}
if (format) {
queryParams.push(formatBuilder(format));
}
const finalQueryParams = queryParams.join("|");
// remove search params.
url.search = "";
const pathname = url.pathname;
const finalPathname = `${pathname}${
finalQueryParams.length ? `?${finalQueryParams}` : ""
}`;
return finalPathname;
}
使用这个函数,你的图片访问格式就可以变得十分人性化!
https://www.example.com/logo.png?f=avif&w=100&q=75
最后
我的简陋代码可以在这里找到:Repo,你可以根据自己的需求修改~