今天来分解一下 Quaily 的技术架构。先确立几个原则:
- 不被供应商锁定(vendor-lock-in)——包括不被 cloudflare 锁定,可以随时迁移到 Linux 裸机上。
- 能减少依赖就少依赖。
- 能不学新技术就不学。
- 能满足需求就不雕花。
- 只在最需要运行效率的场景考虑运行效率。
就酱。现在开始。
路由
打开 quaily.com 就是 Quaily 的首页。这是一个部署在 Cloudflare 上的 Hugo 站点。
但域名 quaily.com 并不是直接指向 Hugo 部署的 worker,而是指向一个负责分流的 worker,叫做 quaily-router
好了。
这个 router 会根据请求的路径等条件,把流量分到下面几个 workers 去
routes = ["quaily.com/*"]
services = [
{ binding = "front", service = "front" },
{ binding = "dashboard", service = "dashboard" },
{ binding = "portal", service = "portal" },
{ binding = "tools", service = "tools" },
]
其中 front 和 dashboard 是 Vue 的 SPA,portal 是首页,tools 是 Tools 一个手写的静态站。
同时,quaily-router 还负责处理所有静态资源的请求,包括:
- 对文章页的请求,例如 https://quaily.com/lyric/p/quaily-technical-architecture-introduction
- 对文章列表页的请求,例如 https://quaily.com/lyric
- 对 sitemap 和 feed 的请求,例如 https://quaily.com/lyric/sitemap_index.xml 和 https://quaily.com/lyric/feed/atom
这些静态资源都放置在 Cloudflare R2 里。
所以一次对主域 quaily.com 的完全请求,流量示意图如下:
虽然
quaily-router
用 worker 实现,但如果需要的话,也可以用 nginx 这样传统的方式;而 R2 和 S3 兼容。所以没有被 cloudflare lock in
前端
dashboard 和 front 是简单的 Vue SPA。
其中,
- dashboard 工作在
quaily.com/dashboard
,负责登录以后的所有 UI 业务。 - front 工作在除了文章和文章列表以外的所有页,例如
quaily.com/lyric/about
等等。
从上面的示意图可以看出来了,文章和文章列表不是 SPA,也不是后端渲染的结果,而是静态化的 HTML 网页。
这样做的好处是,从开始请求到内容展示的延时小,只需要让 quaily-router
从 R2 读出来然后返回就行。平均延时在 100ms 以内,比常见的内容站点快 3~5 倍。
文章和文章列表上的动态内容(比如订阅表单)通过在 HTML 挂载 Vue SPA 来实现。这些内容的加载需要额外时间,但是在他们加载完成之前,不影响人类和搜索引擎读文章内容和列表。
后端
前端通过 RESTful API 和后端交互,端点在 api.quail.ink
,指向一台负载均衡器。负载均衡器使用 AWS 的默认配置,但需要的话可以随时换成 nginx,也不会 lock in。
负载均衡器背后有多台实例提供 API 服务,还有一台 worker 实例处理后台任务。他们其实都是用 Go 写的同一个程序,连接到相同的 postgresql 数据库实例。
完整的前后端架构大概是这样:
运维
数据库备份
通过一个 cron 脚本来完成,每天备份到加密 S3,然后发通知到 telegram 频道。
很多业务通知都会发到 telegram
日志收集
Quaily 所有服务进程都通过 systemd 来运行,日志就是 syslog。所以最简单的配置方法是配置 rsyslog,然后把日志发送到一台集中的日志服务器,放在 /var/log/hosts/HOSTNAME
下。
对于业务实例,配置 rsyslog.conf
$PreserveFQDN on
*.* @@LOG_SERVER_ADDR:514
$ActionQueueFileName queue
$ActionQueueMaxDiskSpace 1g
$ActionQueueSaveOnShutdown on
$ActionQueueType LinkedList
$ActionResumeRetryCount -1
对于日志实例,配置 rsyslog.conf
module(load="imtcp")
input(type="imtcp" port="514")
$AllowedSender TCP, 127.0.0.1, 172.26.0.0/24
template(name="PerHostLog" type="string" string="/var/log/hosts/%HOSTNAME%/%PROGRAMNAME%.log")
*.* action(type="omfile" DynaFile="PerHostLog")
需要观察实时日志的时候,把他们合并到一起:
multitail -cS slog -f /var/log/hosts/quail-0/quail.log -cS slog -I /var/log/hosts/quail-1/quail.log ...
扩容
因为我不会也不想学 k8s 这样的知识,所以写了一个脚本,只需要运行这个脚本,就可以在一台全新的 Linux 实例上配置好所有内容,包括 systemd,rsyslog 等等。
有时需要批量操作的话,会使用 zellij 的 sync mode(ctrl + t, s),可以同时在一个 tab 下的所有 pane 输入内容。
监控
监控使用的是 uptimerobot 和 sentry。
业务报警使用的是 telegram。
构建
大部分构建使用 github action 完成。少部分在打包机上完成,然后通过脚本上传到实例,类似这样:
#! /bin/bash
set -e
declare -a arr=(
"inst-0"
"inst-1"
"inst-2"
# ...
)
echo "📦 build..."
VER=$(git describe --tags --abbrev=0)
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o quail -ldflags="-X main.Version=$VER -X ..."
for host in "${arr[@]}"
do
echo "📤 scp to $host"
scp quail $host:/opt/quail/quail-new
echo "🚀 restart..."
ssh $host "cd /opt/quail && mv quail-new quail && sudo systemctl restart quail.service"
echo "🙆 deployed!"
done
于是后端加上运维相关是这样的:
AI
Quaily 用到了一些 AI 服务,包括 OpenAI 和 Claude.ai 的。因为处理 AI 相关的业务经常遇到一些繁杂的事情,包括重试、负载均衡、格式化、任务路由、CoT 等等,所以这部分工作交给一套单独的服务,包含一个调度器和若干和 proxy 组成,其中:
- 调度器负责调度 AI 任务。比如这个任务交给哪个 proxy,比如这个任务的 proxy 429 了要重新调度
- proxy 负责处理任务,比如一个 proxy 专门处理小任务,就可以交给 4o-mini;另一个 proxy 负责处理文字类任务,可以交给 claude-ai-3-5-sonnet-v2。
大概长这样:
以上就是 Quaily 的技术架构。完整的示意图如下: