为什么需要短链服务#

博客里有时要贴一些很长的 URL——GitHub 链接、Grafana 面板、API 文档之类的。长 URL 在 Markdown 里虽然无所谓,但分享到微信、邮件时看起来乱糟糟的。

另一个场景:跳转(或者外部)链接如果能统一走自己的短域名,更整洁,也方便以后追踪点击数据和做链接的安全检查和处理。

为什么选 Sink#

Sink 是一个基于 Cloudflare Workers 的无服务器短链服务,核心特性:

功能 说明
自定义 slug 手动指定或 AI 自动生成
Analytics 访客统计(设备类型、地区、来源)
二维码生成 每条短链自带 QR Code
链接过期 可设置有效期
批量导入导出 JSON / CSV
AI 辅助 Cloudflare Workers AI 生成 slug
每日备份 R2 Bucket 定时备份

对我来说比较吸引的一点是:Sink 完全运行在 Cloudflare Workers 上,不需要额外维护服务器或数据库。 按我当前的个人使用量,它也还在 Cloudflare 的免费额度范围内。

与其他自托管短链方案(Shlink、YOURLS、Kutt)相比,Sink 在我当前这套环境里不需要额外上 K8s,也不会继续占 Oracle 云的资源。再加上我本来就在用 Cloudflare,这条链路接起来更顺手一些。

架构#

用户 → Cloudflare DNS (s.meirong.dev)
         → Cloudflare Worker (sink)
              ├── KV Namespace     短链存储(slug → 目标 URL)
              ├── R2 Bucket        每日 00:00 UTC 自动备份
              ├── Analytics Engine 访客点击统计
              └── Workers AI       AI slug 建议

DNS 解析由 Cloudflare 自动处理,不经过 Cloudflare Tunnel,也不过 K8s 的 Traefik——这是一条独立链路。

与 Homelab 其他服务的对比:

其他服务:Internet → Cloudflare Tunnel → Traefik (K8s) → Pod
Sink:    Internet → Cloudflare Worker(边缘直接响应)

部署过程#

1. 将 Sink 作为 git submodule 引入#

把 Sink 源码作为 submodule 引入 homelab 仓库,方便版本追踪和后续更新:

mkdir -p cloudflare/workers
git submodule add https://github.com/miantiao-me/Sink cloudflare/workers/sink

目录结构:

cloudflare/workers/
├── justfile          # 部署命令
└── sink/             # Sink 源码(submodule)
    ├── wrangler.jsonc
    ├── server/middleware/rate-limit.ts  # 自定义限速中间件
    └── .env          # 本地配置(gitignored)

2. 创建 Cloudflare 资源#

Sink 需要 KV Namespace 和 R2 Bucket,我这次是直接通过 Cloudflare API 创建的:

# 创建 KV Namespace
curl -X POST "https://api.cloudflare.com/client/v4/accounts/${ACCOUNT_ID}/storage/kv/namespaces" \
  -H "Authorization: Bearer ${API_TOKEN}" \
  -H "Content-Type: application/json" \
  --data '{"title": "SINK_KV"}'

# 创建 R2 Bucket
curl -X POST "https://api.cloudflare.com/client/v4/accounts/${ACCOUNT_ID}/r2/buckets" \
  -H "Authorization: Bearer ${API_TOKEN}" \
  --data '{"name": "sink"}'

把返回的 KV namespace ID 填入 wrangler.jsonc

3. 配置 wrangler.jsonc#

在 Sink 的 wrangler.jsonc 里添加 Analytics Engine、Rate Limiting 绑定和环境变量:

{
  "vars": {
    "NUXT_HOME_URL": "https://s.meirong.dev",
    "NUXT_REDIRECT_STATUS_CODE": "308",
    "NUXT_CF_ACCOUNT_ID": "your-account-id"
  },
  "analytics_engine_datasets": [
    { "binding": "ANALYTICS", "dataset": "sink" }
  ],
  "ratelimits": [
    {
      "name": "RATE_LIMITER",
      "namespace_id": "1001",
      "simple": { "limit": 30, "period": 10 }
    }
  ],
  "kv_namespaces": [
    { "binding": "KV", "id": "your-kv-namespace-id" }
  ],
  "r2_buckets": [
    { "binding": "R2", "bucket_name": "sink" }
  ]
}

Rate Limiting 设置为 30 请求/10 秒/IP(等价于平均 3/s),这是 Workers Rate Limiting binding 支持的最细粒度(最小 period = 10s)。

4. 添加限速中间件#

Sink 基于 Nuxt/Nitro 构建,可以在 server/middleware/ 下添加 Nitro 中间件来接入 Workers Rate Limiting API:

// server/middleware/rate-limit.ts
export default defineEventHandler(async (event) => {
  const { cloudflare } = event.context
  if (!cloudflare?.env?.RATE_LIMITER) return

  const ip = getHeader(event, 'cf-connecting-ip') || 'unknown'
  const { success } = await (cloudflare.env.RATE_LIMITER as any).limit({ key: ip })

  if (!success) {
    setResponseStatus(event, 429)
    setResponseHeader(event, 'Retry-After', '1')
    return { error: 'Too Many Requests' }
  }
})

这个中间件对每个请求都会检查 IP 限速,超限返回 429。

5. 构建和部署#

# 构建(NUXT_HOME_URL 必须在编译时注入)
NUXT_HOME_URL=https://s.meirong.dev pnpm build

# 部署
npx wrangler deploy

注意NUXT_HOME_URL 是 Nuxt 的 public runtimeConfig,会在编译时写入 bundle,必须通过环境变量在 build 阶段注入,而不是 wrangler vars(后者只对运行时私有变量有效)。

6. 绑定自定义域名#

Wrangler 的 custom domain 路由需要 Zone Workers Routes:Edit 权限,但我的 API Token 没有这个权限。直接通过 Cloudflare API 绑定即可:

curl -X PUT "https://api.cloudflare.com/client/v4/accounts/${ACCOUNT_ID}/workers/domains" \
  -H "Authorization: Bearer ${API_TOKEN}" \
  -H "Content-Type: application/json" \
  --data '{
    "environment": "production",
    "hostname": "s.meirong.dev",
    "service": "sink",
    "zone_id": "your-zone-id"
  }'

按我这次部署后的表现,这个绑定是持久的,不需要每次 deploy 都重新设置。

7. 设置管理员 Token#

通过 Cloudflare API 设置 Worker secret(NUXT_SITE_TOKEN 是访问 /dashboard 的密码):

curl -X PUT "https://api.cloudflare.com/client/v4/accounts/${ACCOUNT_ID}/workers/scripts/sink/secrets" \
  -H "Authorization: Bearer ${API_TOKEN}" \
  -H "Content-Type: application/json" \
  --data '{"name": "NUXT_SITE_TOKEN", "text": "your-token", "type": "secret_text"}'

上线效果#

部署完成后:

  • 短链跳转:访问 https://s.meirong.dev/xxx 即跳转到目标 URL(308 永久重定向)
  • 管理面板https://s.meirong.dev/dashboard 用 Site Token 登录,可创建/管理短链
  • Analytics:每次跳转自动写入 Analytics Engine,可在 dashboard 查看访客数据

justfile 封装了常用操作:

# 更新 Sink 版本
update:
    cd sink && git pull origin master && pnpm install && \
    NUXT_HOME_URL=https://s.meirong.dev pnpm build && \
    npx wrangler deploy

踩坑记录#

1. wrangler deploy 每次都报 custom domain 错误

这是因为 Token 缺少 Zone Workers Routes:Edit 权限,但 domain 本身已经通过 API 绑定好了。至少在我这次环境里,这个报错可以忽略,Worker 仍然正常提供服务。

2. Analytics Engine 需要手动激活

第一次 deploy 含 analytics_engine_datasets 绑定时会报错,需要去 Dashboard → Workers & Pages → Analytics Engine 点一次 “Enable”。之后不再需要操作。

3. Rate Limiting period 最小值是 10 秒

Workers Rate Limiting binding 的 period 只支持 10 或 60,没有 1 秒选项。要做到"平均 3 次/秒",配置为 limit: 30, period: 10 即可。

4. NUXT_HOME_URL 必须在 build 时注入

Nuxt 的 public runtimeConfig(如 NUXT_HOME_URL)会在编译时写入 bundle。如果只设置在 wrangler vars 里,运行时 Worker 读的还是编译时的默认值。正确做法是在 build 命令前设置环境变量。

总结#

对我自己的 Homelab 场景来说,Sink 目前比较符合需求:

  • 额外运维负担比较低:不占 K8s 资源,也不用单独维护数据库
  • 对个人流量来说成本可控:按我当前的访问量,Cloudflare 的免费额度还够用
  • 功能覆盖面够用:短链、统计、二维码、导入导出这些我需要的能力基本都有
  • 和现有仓库流程比较贴合:源码以 git submodule 形式纳入 homelab 仓库,后续更新路径也更清楚

如果你也已经有 Cloudflare 账号和自定义域名,这条路线是值得试试的;只是实际部署时间还是会受 API 权限、域名配置和你自己的目录结构影响。