昨晚十一点多决定收拾一下家里的 homelab。起因很小:我有两套服务注册表在并行跑,干的是一模一样的事,想合成一套。
一套在我那台正经 API 平台(代号 Project Hail Mary,下面简称 phm)里,是 components/registry,FastAPI 跑在 8002 端口,SQLite 存储,带 ACL、M2M 令牌、心跳、审计,功能齐全。另一套是个独立的小注册表,功能简单,就是个让服务自报到的目录。
整晚做完三件事:注册表合并、烤肉流水线服务化、以及一条端到端调通过程中最磨人的 YouTube 403 排查。下面按顺序记。
第一个坑:注册表根本不在我以为的机器上
我最初让它去合并「mini 上那个注册表」。查下来发现:那东西根本不在 Mac mini 上,在树莓派上。
它通过一个 Tailscale 的 VIP 服务对外,内部代理到本机 8070:
service.<tailnet>.ts.net → 127.0.0.1:8070没有鉴权,还自带一个 FastMCP 接口给 AI agent 查服务。是我自己记错了机器。这种事值得记一笔——动手前先确认目标在哪台机器上,比改完发现搞错了机器再回滚省事得多。
合并注册表:把 pi 那套的能力并进 phm
pi 那套有三个有用的 MCP 只读工具:list_services、discover、get_service_info。把它们移植进 phm 的 registry,改成查 SQLite 而不是查内存。同时给 REST 侧补上对应端点:
GET /api/services/discover/{capability} # 按能力发现服务GET /health # 健康检查再给服务表加一个 metadata 列(additive,不破坏老数据)。MCP 挂载点用一个 ASGI 中间件 MCPAuthGuard 套上和 REST 读端点同一套鉴权——不能因为加了 MCP 入口就开了一个无鉴权的旁门。
这里踩了一个坑。mcp==1.27.2 默认开启 DNS-rebinding 保护,只认 loopback 的 Host header,经反向代理用域名访问 streamable-http MCP 会直接返回:
HTTP/1.1 421 Misdirected Request421 是「服务器认为这个请求发错了地方」。MCP SDK 在 1.x 引入了这层保护,配置类是 TransportSecuritySettings,得显式把允许的 host 加进白名单:
from mcp.server.transport_security import TransportSecuritySettings
security = TransportSecuritySettings( allowed_hosts=["registry.example.com", "registry.example.com:*", "127.0.0.1:*"],)加上之后域名访问才通。这是个安全特性不是 bug——它防的是 DNS rebinding 攻击,但自托管经反代时必须手动放行自己的域名。
部署走 bootstrap/09-registry-init.sh。这脚本最后一步是:
systemctl enable --now registry.serviceenable --now 对一个已经在跑的服务不会重启,只会确保它开机自启 + 当前在跑。结果是新代码部署了但旧进程还在跑,改动没生效。得手动补一刀:
systemctl restart registry.service合并完成后,把依赖方切过来。mini 上的 asr 和 tts 两个服务原来往 pi 报到,改成往新地址注册,走 registry 现成的 TOFU(Trust On First Use)X-Service-Secret 路径:
https://registry.example.com # asr-mac-mini / tts-mac-mini 重新注册确认两个服务在新注册表里 healthy、可被 discover 之后,把树莓派那套停掉、写一个 DEPRECATED.md 立碑,清掉它的 tailscale serve 和 VIP。service.<tailnet>.ts.net 从此是死地址。
烤肉:把手动流水线固化成常驻服务
先解释一句「烤肉」:把 YouTube 视频下下来,机器翻译配中文字幕,再传到 B 站。圈里叫烤肉,生肉烤成熟肉。这套流水线我攒了挺久,一直命令行手动跑,这次想固化成常驻服务,配个网页面板,再开一个 LLM 能调的 REST 端点。
打开仓库发现一个情况:烤肉有两套实现并存。我这边写了一套基于单文件 jobs.py 的版本,另一个 session 在 mini 上写了一套基于 queue/ 的多阶段队列(cli / daemon / db / stages / worker 分层),而且是验证过、能跑通的。
两套撞在一起,得选一个当底座。结论是保留验证过的 queue/daemon 那套——能跑通的代码比结构更漂亮的代码值钱。它的设计是 SQLite 里一张 jobs 表,任务在几个阶段间流转:
pending → downloaded → translated → burned → uploaded每个阶段一个 worker。我在它上面加了新的一层:REST API(含 /openapi.json 给 LLM 读)、网页面板、鉴权、以及任务完成后的清理。
这台 mini 最终跑三个 launchd 常驻服务:
app.example.kaorou.api # REST + 网页面板,127.0.0.1:8096app.example.kaorou.daemon # 队列各阶段 workerapp.example.kaorou.potoken # 下面会讲到的 PO-Token 服务,:4416全部 RunAtLoad + KeepAlive,开机自启、崩溃自恢复。
内网面板的登录:cookie 跨不了 apex 域
面板我只想让它待在内网,通过 tailscale serve 暴露:
https://mac-mini.<tailnet>.ts.net → 127.0.0.1:8096tailscale serve 只在 tailnet 内可达(要公网得用 tailscale funnel,是另一个命令),符合「仅内部服务」的要求。
但登录卡了一会儿。我那套 GitHub 登录种的 cookie 叫 phm_jwt,是 HttpOnly 的、域是 .example.com。浏览器不会把它发给一个 .ts.net 的页面;而且一个 apex 域的响应根本没法给另一个 apex 域种 cookie,浏览器直接拒。这是同源策略的硬约束,绕不过去。
我不想在面板里手动粘 token——那很蠢。最后的解法是在认证服务这头加一个 GET /authorize 端点,让它做一次「持有则换发」:
- 读浏览器现有的
phm_jwtcookie(用户如果在.example.com上登录过,这个 cookie 就在); - 用和登录态完全相同的 RS256 校验来验它;
- 验过就直接签一个新的短期 access token,302 跳回面板,并把令牌塞进回跳 URL 的 fragment(井号后面那段):
https://mac-mini.<tailnet>.ts.net/#access_token=<JWT>选 fragment 而不是 query 是有讲究的:fragment 不会发给服务器、不进 Referer、不进访问日志,比把 token 放在 ?access_token= 里安全。面板的 JS 从 location.hash 取出令牌,存进 sessionStorage,之后每个请求当 Bearer 带上:
const token = new URLSearchParams(location.hash.slice(1)).get("access_token");if (token) sessionStorage.setItem("kaorou_token", token);history.replaceState(null, "", location.pathname); // 把 token 从地址栏抹掉服务端每次都把这个 Bearer 转发给 auth.example.com/api/users/me 现验,再比对 allowlist(管理员 id / email)。.<tailnet>.ts.net 加进认证服务的 return_to 白名单,没登录过就退回走一次 GitHub。
效果是:点一下登录就进,全程一个 token 都不用手敲,mini 本地也不存任何长期凭证。
翻译默认切到 DeepSeek
原来的 detect_llm_url 会去探测本地和内网的 Qwen。我把默认改成 DeepSeek:
_DEFAULT_LLM_BASE_URL = "https://api.deepseek.com/v1" # model: deepseek-chat# 优先级:显式传参 → 环境变量 KAOROU_LLM_BASE_URL → DeepSeek 默认key 从 .env 的 DEEPSEEK_API_KEY 读,显式设 KAOROU_LLM_BASE_URL 仍可覆盖回内网模型。
端到端调通:三连失败到查出真凶
全弄完跑一次端到端,扔进去一个 YouTube 视频。下面是整晚最磨人的部分,一步步记。
失败一:ffmpeg 不在 PATH 里
第一次直接崩,报错:
ERROR: You have requested merging of multiple formatsbut ffmpeg is not installed. Aborting due to no merging.这台机器明明装了 ffmpeg:
$ which ffmpeg/opt/homebrew/bin/ffmpeg问题在 launchd。launchd 给后台进程的 PATH 是 macOS 的最小集,不含 Homebrew 的 /opt/homebrew/bin。yt-dlp 下到的是分离的视频流 + 音频流(格式 401+140,401 是视频、140 是 AAC-LC 128k 的 m4a 音频),合并这一步要调 ffmpeg,找不到就崩。字幕烧录、ASR 抽音频也都依赖它。
修法是在服务的环境脚本 kaorou-env.sh 里显式补 PATH:
export PATH=/opt/homebrew/bin:$PATH失败二:视频流 403,但字幕能下
ffmpeg 修好后,字幕(.en.vtt)能下、缩略图能下,唯独视频流 403 Forbidden。日志里两条警告:
WARNING: no impersonate target is availableWARNING: Ignoring unsupported remote component(s): ejs第一条说的是 yt-dlp 的浏览器指纹模拟(impersonate)功能没启用——这个功能依赖 curl_cffi,没装就用不了。我先把它装上:
uv pip install curl_cffi还是 403。
接着换 player client 试。yt-dlp 可以伪装成不同的 YouTube 客户端拿不同的接口:
yt-dlp --extractor-args "youtube:player_client=web_safari" ...# 再试 tv / mweb / androidweb_safari、tv、mweb、android 四个挨个来。要么 403,要么「requested format not available」。
翻机器的时候发现一个东西:~/Codes/bgutil-ytdlp-pot-provider。这是个 PO-Token(Proof of Origin Token)provider。YouTube 现在要求视频流带一个绑定来源的令牌,缺了它 googlevideo 就返回 403。这个项目专门发这种令牌,跑一个 HTTP server(默认端口 4416)+ 一个 yt-dlp 插件配合。
它的 node 服务其实还在 :4416 上跑着:
$ curl -s http://127.0.0.1:4416/ping{"server_uptime":...,"version":"..."}但对应的 yt-dlp 插件没装进烤肉的 venv,所以 yt-dlp 根本没去取令牌。补上插件,再用 extractor-args 接上:
uv pip install bgutil-ytdlp-pot-provideryt-dlp --extractor-args \ "youtubepot-bgutilhttp:base_url=http://127.0.0.1:4416" ...还是 403。
真凶:IPv6 段被 ban
到这儿才停下来认真想:令牌也有了、模拟也有了、插件也接上了,问题大概率不在应用层。
是 IPv6。我家这条宽带的 IPv6 段落在 YouTube 拉黑的池子里——googlevideo 在网络层就把请求拒了,带不带 po-token 都没用。这是 yt-dlp 社区里反复出现的已知现象:浏览器走 IPv4 正常,命令行走 IPv6 就 403。强制走 IPv4:
ydl_opts["source_address"] = "0.0.0.0" # 等价于 --force-ipv4通了。
通了之后
整条线一气呵成:
- 格式
401+140,1.9 GB 的视频流下完; - DeepSeek 分 19 批翻了 375 条字幕,约 81 秒;写出双语 SRT;
- ffmpeg 用
h264_videotoolbox(macOS 上走 Apple VideoToolbox 的硬件编码器)把字幕烧进画面,耗时约 1454 秒(24 分钟),输出 1.17 GB; - 这次跑的是
--no-upload,停在上传前。
最后把那个 po-token 服务也做成 launchd(app.example.kaorou.potoken,:4416,开机自启 + 崩溃自恢复)。不然机器重启一次,又得从 403 开始查。
收尾:重试和「无痕」
调通之后补了两样。
重试。 之前取消的任务,状态被写成 done + error=cancelled。原来的 retry_failed 只认 failed 状态,碰不到这种被取消的任务,所以取消掉的任务没法重来。加了一个 db.retry_job,把任务从头(stage=pending)重新入队,配上端点和按钮:
POST /jobs/{id}/retry # 带鉴权 + owner 校验面板上对应一个「重试」按钮。daemon 也会对真正 failed 的任务自动 requeue_retryable(attempts < max_attempts=3)。
无痕。 把 keep_result 默认改成 false:任务跑到终态后,整个 per-video 工作目录全部清掉,真无痕。但结果不能丢——bvid(B 站视频号)这种存在数据库 jobs.bvid 字段里,清目录不影响它。想留中间产物就显式传 keep_result=true。
一条经验
整晚最有用的一条:遇到一个跨了好几层的报错,先别在最熟的那一层反复试。
我在 player client、impersonate、po-token 这些应用层手段上耗了好几轮,每一轮都「看起来该有用」,但真正的死因在网络层——IPv6 被 ban。三个应用层修复叠在一起也救不了一个网络层的拒绝。脏活累活可以交出去,但「会不会问题其实在更底下一层」这个拐弯,得自己拐。
这套流水线的「视频理解」部分(下载 → ASR 转录 → 场景截图 → VLM 描述)之前单独写过一篇:《用自建 Tailnet 服务搭一条视频处理流水线》。这篇是它的另一面——把同一条下载链路从手动脚本固化成带鉴权、能自恢复、有面板的常驻服务,以及那个折腾了我半宿的 403。