avatar

使用 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,你可以根据自己的需求修改~