今天来分解一下 Quaily 的技术架构。先确立几个原则:

  1. 不被供应商锁定(vendor-lock-in)——包括不被 cloudflare 锁定,可以随时迁移到 Linux 裸机上。
  2. 能减少依赖就少依赖。
  3. 能不学新技术就不学。
  4. 能满足需求就不雕花。
  5. 只在最需要运行效率的场景考虑运行效率。

就酱。现在开始。

路由

打开 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 还负责处理所有静态资源的请求,包括:

这些静态资源都放置在 Cloudflare R2 里。

所以一次对主域 quaily.com 的完全请求,流量示意图如下:

An image to describe post

虽然 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 数据库实例。

完整的前后端架构大概是这样:

An image to describe post

运维

数据库备份

通过一个 cron 脚本来完成,每天备份到加密 S3,然后发通知到 telegram 频道。

An image to describe post

很多业务通知都会发到 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 输入内容。

监控

监控使用的是 uptimerobotsentry

业务报警使用的是 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

于是后端加上运维相关是这样的:

An image to describe post

AI

Quaily 用到了一些 AI 服务,包括 OpenAI 和 Claude.ai 的。因为处理 AI 相关的业务经常遇到一些繁杂的事情,包括重试、负载均衡、格式化、任务路由、CoT 等等,所以这部分工作交给一套单独的服务,包含一个调度器和若干和 proxy 组成,其中:

  • 调度器负责调度 AI 任务。比如这个任务交给哪个 proxy,比如这个任务的 proxy 429 了要重新调度
  • proxy 负责处理任务,比如一个 proxy 专门处理小任务,就可以交给 4o-mini;另一个 proxy 负责处理文字类任务,可以交给 claude-ai-3-5-sonnet-v2。

大概长这样:

An image to describe post


以上就是 Quaily 的技术架构。完整的示意图如下:

An image to describe post

封面图源