人们总问我为什么不直接用 WordPress、Ghost 或者坦白说,任何现成的平台。用那些工具的话,我能在一两小时内发出第一篇文章,而不是像现在这样折腾了一整年。短版本的答案是我想学些东西,长版本的答案就是这整篇文章。
我最初其实看过现成方案
决定自己动手之前,我确实认真看过那些明显的选择。WordPress 感觉不太对劲,插件太多,大多数我用不上,而且 PHP 这一套我完全没兴趣为了一个博客去学。Ghost 更接近一点,开箱即用体验不错,但说到底还是别人的平台。这个项目一开始就不是"有个博客",而是"建个足够真实的东西,这样我就没法只靠糊弄过关了"。托管平台不会问你登录怎么工作,上传文件怎么存储,或者两百个人同时访问一个页面会发生什么。我要的是问这些问题的系统,而不是有问题之前就替我答了的。
那段时间我在零零散散地学 Go 和 SvelteKit,主要通过一些小脚本和不少半途而废的教程项目。都没转化成什么实际的东西。一个博客看起来是个好借口。足够小能真的完成,又有足够多的真实部分,账户、数据库、公开接口,没法靠糊弄过关。所以我没选装现成的东西,反而决定建个能教我最多的那种,即使知道要花远更长的时间。
最后造出来的东西
五个服务,一个 Docker Compose 文件,跑在我房间里的单个小盒子上。这绝不是第一天的计划。只是很多小决定(大多数因为之前的办法坏了)最后演变成的样子。
services:
postgres:
auth:
blog:
frontend:
caddy:为什么特别选了 Go 和 SvelteKit,而不是更流行的东西
这两个选择都不是在选"最好"的工具。选 Go 是因为我想要编译型、无趣的东西(好的那种无趣),这语言没有十五种被普遍认可的方式来搭建一个小 web 服务,标准库本身就够你走大半的路到一个能用的 HTTP 服务器,不需要默认伸手去找框架。SvelteKit 主要是因为那时候它是我清单上最新的东西,我想让这个项目的前端部分也是一个学习项目,而不仅仅是把一个熟悉工具套在两个 Go 服务上。Django 或 Express 能让我更快搞出一个可工作的网站,两个我进来时都相当了解的语言。但速度不是真正的目标。
选 Docker Compose 而不是什么更高大上的方案,理由同样无趣。这只跑在一台机器上。Kubernetes 解决的是我没有的问题,多台机器的协调、零停机跨集群部署,这些对于一台单独坐在我房间里的服务器都不适用。能从头到尾一分钟内读完的 Compose 文件,比一个需要花好几周学习、对这个规模毫无益处的"更正式"方案好得多。
auth 服务简单来说
一个小 Go 服务,一个工作职责:知道你是谁,向其他所有东西证明这一点,但不让任何地方直接信任密码。注册、登录、拿到一个短期访问令牌加上更长期的刷新 cookie,这样你能保持登录状态,同时访问令牌本身不会永远活着,万一泄了也没事。
func (s *Server) Login(w http.ResponseWriter, r *http.Request) {
var req loginRequest
if err := decodeJSON(w, r, &req); err != nil {
writeDecodeError(w, r, err)
return
}
user, err := s.Queries.GetUserByUsername(r.Context(), req.Username)
if err != nil {
writeError(w, r, http.StatusUnauthorized, "invalid_credentials", "invalid username or password")
return
}
ok, _ := password.Verify(req.Password, user.PasswordHash)
if !ok {
writeError(w, r, http.StatusUnauthorized, "invalid_credentials", "invalid username or password")
return
}
s.issueTokensAndRespond(w, r, user, http.StatusOK)
}密码永远不会直接存储,肯定是,只有哈希值。而且是特意设计得很慢的哈希(argon2id),这样即使数据库泄露了,反向破解哈希回真实密码的代价也大到不值得在规模上尝试。
blog 服务简单来说
第二个 Go 服务,完全独立,自己的数据库,管理文章、评论、标签、分类、媒体元数据。它根本不知道密码相关的东西,只是信任 auth 服务签发的令牌,就像保镖相信腕带不需要在场地里的每道门再验证你的身份一样。
r.Route("/admin/posts", func(r chi.Router) {
r.Use(authmw.RequireCapability(verifier, matrix, capability.PostsManage))
r.Get("/", s.AdminListPosts)
r.Post("/", s.AdminCreatePost)
r.Patch("/{id}", s.AdminUpdatePost)
})把这个保持为独立于 auth 的第二个服务,最初看起来像是过度设计,额外的网络跳跃没有明显的收益,直到我真的想单独推理"账户系统会出什么问题"和"文章系统会出什么问题"。两个更小的、无趣的问题,而不是一个大恐怖问题。
SvelteKit 前端简单来说
这是任何访问网站的人真正看到的部分:服务端渲染的页面、登录表单、文章编辑器,全部都是。它不从浏览器直接跟两个 Go 服务通话,而是通过自己的服务端路由首先代理所有请求,这最后比我预期重要得多,一旦我真的遇到了反向代理和后面的速率限制的麻烦。
async function handle(event: RequestEvent): Promise<Response> {
const upstreamPath = event.url.pathname.replace(/^\/api/, '') || '/';
const target = new URL(upstreamPath + event.url.search, env.BLOG_INTERNAL_URL);
const upstream = await forwardRequest(event, target, extraHeaders);
return new Response(upstream.body, { status: upstream.status });
}PostgreSQL 简单来说
两个独立的数据库,一个给 auth,一个给博客,而不是一个把所有东西混在一起的共享模式。最初看起来像是为没理由的东西做多余的设置,直到第一次我想单独备份或迁移其中之一而不碰另一个,就感觉这明显是对的。
MinIO 简单来说
上传的图片和头像不活在 Postgres 里,而是在 MinIO,它说的 API 和亚马逊 S3 一样,但只是我自己盒子上的另一个容器。我特别喜欢这个因为这意味着以后万一我需要换到真实的云存储桶,只需要改改配置而不用重写。我是否真的会需要那个是另一回事。预先有这个选项几乎没有成本。
Caddy 简单来说
整个栈里唯一暴露公网端口的东西。其他一切只通过内部 Docker 网络彼此通话,除了通过 Caddy 外没有任何东西能从外面直接到达。它终止 TLS,所以我永远不用想证书续期的事,而且它是真正强制执行每一个大小限制和速率限制的东西,这篇文章后面要吐槽的第一次搞错的都是它。
handle /api/admin/* {
request_body {
max_size 5MB
}
reverse_proxy frontend:3000
}基本上就是这个架构。现在来讲真的出错的部分,每一个都值得用诚实的独立章节来讲,而不是一个模糊的"后来我修了些 bug"段落。
上传大小限制的传奇
上传是第一个真的难题。小的测试图片能上传好的,然后我试了一个真实的从我手机拍的照片,得到一个神秘的错误没有有用的消息,浏览器控制台里也没什么帮助。原来是 Caddy 前面有一个请求大小限制,加上应用自己的限制,我把它设得太小了而没真正理解两个分离的层都需要达成一致什么算"太大"。
# before: media uploads fell under the same small default as everything else
handle /api/admin/* {
request_body {
max_size 2MB
}
reverse_proxy frontend:3000
}Caddy 自己的默认值远不够大来处理现代手机照片,直到我真的去查,我完全不知道它还是一个独立的限制,跟 Go 服务自己强制的那个不同。

一个晚上困惑地盯着日志,我才找到这个限制在哪儿,更不用说它为什么存在。一半的修复来自一个五年前的论坛帖子,不是官方文档,有人在一个完全无关的项目里描述过同样的症状,这样我才想起来去看 Caddy 的配置,而不是一直假设 bug 在我自己的 Go 代码里。
handle /api/admin/media* {
request_body {
max_size 1100MB
}
reverse_proxy frontend:3000
}一个独立的、大得多的限制,特别只为媒体上传路径,应用里的其他一切保持小默认。一旦两层真的同意了彼此,真实照片就开始不抱怨地上传了。
评论和登录花了比预期长得多的时间
我想我一开始假定"用户输入密码,服务器检查"基本就是整个特性。实际下面有惊人的多东西:正确地散列密码,发出短期令牌加上更长期的用来保持登录状态,然后是真的让我花时间的部分,反向代理后的速率限制。评论后来有了它们自己这节课的较小版本。评论表单看起来简单,直到你也要处理垃圾、每用户的速率限制和审核,这些在任何教程的"加一个评论框"例子里都不会出现。
学会反向代理实际做什么
每个登录尝试看起来都来自同一个 IP,因为 Caddy 是唯一直接跟我应用通话的东西,而我没告诉它信任那个真正说谁是实际访问者的头部。花了很长时间才把这个理解为一个 bug,因为症状看起来像"速率限制坏了"而不是"我真的不明白反向代理在机械上怎么改变请求的"。反向代理是个我多年来一直在用却从没真正理解它在干什么的术语,建这个东西让我真的坐下来想清楚,而不是下次有人提到时就点头。

速率限制咬住了我
一旦我明白了反向代理的问题,真正的修复很小,只信任一跳的 forwarded-for 头,和我前面有的那唯一代理匹配。但为了到达那里,我得先注意到十个从十个不同真实人的失败登录尝试不知怎么都落在了同一个桶里,变得可疑是什么地方根本上坏了我怎样识别一个请求来自谁,然后才意识到这个请求的表观源 IP 每次都是 Caddy 自己的容器地址,对每个访问者都一样,因为 Caddy 真的是唯一打开到前端的套接字的东西。从应用的角度看,每个访问者共享一个身份,这有多坏就有多坏,不会投出实际错误来告诉你所以。
重设计,通用优先,然后才是真实的 Lovund
这个网站设计的第一个真实版本,回顾起来,就是一个博客模板磨掉了序列号。蓝色的口音,默认系统字体,间距看起来和其他所有人周末快速搞出来的都一样。它能用。也可能是任何人的博客,看得越久就越开始烦我,一旦实际功能足够稳定,剩下的就是设计看起来不完成的。
几周前我重做了整个视觉部分。绿色的口音,正文用衬线字,间距比我开始时的默认更紧。不是因为老版本坏了。它看起来和其他所有快速博客模板一样,我想要的是感觉像一个实际选择的东西,而不是启动工具包默认给的随便什么。(我也说过重设计一发布就不再碰 CSS。那坚持了大概四天。)
绿色特别不是随意的。住在像 Lovund 这样夏天绿得满眼的地方,苔藓和山坡和水在多云天空下的那种深蓝色,感觉这是这个网站从一开始就应该有的口音,而不是我不想什么就默认的那个通用蓝。一旦我选了这个,重设计的其他部分大多就跟随了,让其他东西足够安静,这样绿色能真的站出来,而不是和六个其他颜色竞争注意力。
一个我后来才加的小功能
博客服务做的一个我最初没计划的东西:定时发布。一篇文章能以草稿形式坐着,指定一个将来的发布时间,后台工作每三十秒检查一次有没有到时间的东西,把它改为上线,在那个时刻而不是我碰保存时戳上实际的发布时间戳。很小的机制,下面就一个循环和两条 SQL,不需要什么工作队列或更高级的东西。我主要建它是为了能在一个慢晚上一次写完几篇文章,让它们自动间隔发布,而不用记得回来一个一个点发布。
自托管的现实,因为那部分也不是免费的
自己跑这个而不是付钱给平台,意味着每次停机我都得注意到并自己修,按我的时间表,通常是我试图检查什么东西时才发现。备份在这里比在托管平台上更关键,因为没有供应商悄悄在某个地方替我备份数据库。我每晚倾倒两个 Postgres 数据库并推到这台机器外面,因为单机器设置也是单点故障,在真实磁盘故障期间才发现这个就太糟糕了。
实际坏的东西,在实践中,比我预期的小得多也无趣得多:一个容器偶尔在主机重启后需要重启(我忘了考虑),磁盘空间增长比我猜测慢但确实在增长,唯一真的吓到我的是一个 Postgres 数据目录权限错配,在迁移到新存储后花了一个晚上排查,短暂地让我特别庆幸备份真的存在而不只是一个我一直想测试但从没有的东西。
重启一个服务是一回事,当这台盒子就坐几米远的时候。知道整个机器彻底宕机后恢复计划是拿一台新盒子、恢复数据库转储、从头开始跑 Docker Compose,可能要花一整个晚上,前提是我写下的关于设置的一切都准确而且从上次检查后没有悄悄漂移,这是完全不同的感觉。我还没测试过这个完整场景,这正是我知道应该在某个平静周末做的事,而不是在真实停机时才发现。
接下来的事
真的评论审核工具,因为现在我是直接通过数据库手工做,这在现在的小规模下还行,但不会一直行。真的去写更多,现在"建平台"这个不写东西的借口基本已经用完了。还有,可能最后,真的去爬一座我一直在渡轮上看的山,而不只是写想要爬它的事。