这个博客表面上只是一组静态页面,实际上背后还有构建流程、Cloudflare 的托管与入口、一台只做边缘转发的小 VPS,以及一套只在内部使用的私有网络。下面按一次请求经过的顺序,把整条链路拆开。
1. 一次访问经过哪些层
博客页面和动态服务走的是两条不同的路径:
浏览器 ├── blog.xqcherry.top │ → Cloudflare Pages(静态产物) │ └── 评论 / 图片 / 资料 → Cloudflare(DNS / TLS / 防护) → Cloudflare Tunnel → cloudflared(VPS 容器) → Docker 内网 → Nginx ├── Artalk 评论服务(VPS) └── MinIO 对象存储(家庭主机,经 WireGuard)| 组件 | 负责什么 | 运行位置 | 公网暴露 |
|---|---|---|---|
| Astro 构建产物 | 页面、样式、脚本 | Cloudflare Pages | Pages 域名 |
| Nginx | TLS 终止、按域名反向代理 | VPS Docker 容器 | 仅容器内网 |
| cloudflared | 建立到 Cloudflare 的出站隧道 | VPS Docker 容器 | 无入站端口 |
| Artalk | 评论的存储与接口 | VPS Docker 容器 | 经 Nginx |
| MinIO | 图片与资料文件 | 家庭主机 | 经 Nginx,只读 |
博客正文本身不会回到 VPS:Pages 直接返回构建好的静态文件。只有评论、图片和资料请求需要经过 Tunnel 和 Nginx,再分别转发给 Artalk 或家庭主机上的 MinIO。
为什么静态前端放在 Pages
早期所有东西都挤在一台 VPS 上:Nginx 既托管静态博客,又反代评论和对象存储。有一次访问稳定返回 525 SSL handshake failed——源站直连正常,Nginx 也能返回握手数据,但 Cloudflare 到源站的 TLS 协商会被对端发 RST 中断。排查过证书、私钥、端口、时间漂移和 SSL 模式,故障边界仍然停在“Cloudflare 到源站公网 443 这一段”。
后来把入口改成 Cloudflare Tunnel:
浏览器 → Cloudflare → Cloudflare Tunnel→ cloudflared → Docker 内网 → Nginxcloudflared 与 Nginx 加入同一个 Docker 网络,再在 Cloudflare Zero Trust 里配置 Public Hostname。隧道由服务器主动向 Cloudflare 发起,动态服务不再依赖源站公网 443 的入站连接,DNS 里也不需要出现源站地址。
静态博客则更进一步,直接交给 Cloudflare Pages:仓库推送到 GitHub 后由 Pages 构建和分发,VPS 上不再保留 dist/。这样博客正文的可用性与 VPS 解耦,也省掉了同步文件的步骤。
代价是入口完全依赖 Cloudflare。Cloudflare 侧配置错误、Pages 部署失败、隧道断开或账号出现问题,都会影响对应部分;这部分风险没法靠源站自己兜底。
2. 站点本身:Astro + Fuwari
站点基于 Astro 5 和 Fuwari 主题,构建结果是静态 HTML、CSS 和少量客户端脚本。
内容用 Astro 的 content collections 管理,目前有六类:
| 集合 | 用途 |
|---|---|
posts | 长文章,按主题分目录 |
dynamic | 短动态,可带图片画廊 |
diary | 每日收获,一天一条 |
projects | 项目展示 |
materials | 资料归档,支持文件、在线阅读和外部链接 |
spec | 预留的结构化内容 |
每类集合在 src/content/config.ts 里都有对应的 Zod schema。必填字段、枚举值和默认值由 schema 约束,写错 frontmatter 会在 astro check 或构建阶段直接报错,而不是等到页面上线后才发现。
页面之外还接了几样东西:
- Pagefind:构建完成后对
dist/建全文索引,搜索完全在前端完成,不需要额外服务。 - RSS 与 sitemap:都在构建阶段生成。
- Swup:站内跳转不整页刷新,只替换主内容区。
- Expressive Code + KaTeX:代码块、行号、折叠区和数学公式都在构建阶段处理。
- GitHub Actions:在 Node.js 22 和 23 上分别跑
astro check与astro build,把类型和构建问题挡在合并之前。
本地构建命令只有一条:
pnpm build它实际执行 astro build && pagefind --site dist。产物由 Cloudflare Pages 从仓库构建并发布,构建过程不依赖评论服务或对象存储。
3. 服务器与内外网
公网入口:Pages 加一台小 VPS
静态页面不在服务器上,真正需要常驻进程的只有动态服务。VPS 上使用 Docker Compose 管理 Nginx、cloudflared 和 Artalk,并建了一个外部网络 web,容器之间用服务名互相访问。
几个约束是刻意保留的:
- 业务容器不映射宿主机端口,只有 Nginx 在容器网络内监听
443。 - Nginx 按域名拆分配置文件,评论、对象存储各自独立,改一个不会牵动另一个。
- 宿主机防火墙只保留必要的管理入口,动态请求统一从 Cloudflare 进来。
- 对象存储不提供浏览器控制台,公开接口只负责读取,管理操作走本地 CLI。
这样做的好处是故障边界清楚:博客正文和评论、图片可以分别失效;坏处是动态服务仍然共享同一台 VPS 和同一个 Docker daemon,单机故障依旧是单点。
内网不等于一个网段
这里的“内网”实际有两层,用途不同。
第一层是 Docker 的 web 网络。它解决的是容器间寻址和隔离,让 Nginx 可以通过容器服务名直接转发,而不必把端口开到宿主机。它不是严格的安全边界,容器逃逸或网络配置错误仍可能绕过,所以不能把它当成唯一防线。
第二层是 WireGuard 私有网络。几台分散的服务器通过一个中心节点组成私有网段,用于 SSH、管理面板和内部服务互访。配置时每个 peer 只放行自己需要的地址,AllowedIPs 不写成 0.0.0.0/0,避免把全部流量都导进 VPN。私钥只留在各节点本机,不进仓库、不进截图。
对象存储就放在家庭主机上:它只监听 WireGuard 地址,VPS 上的 Nginx 通过私有网络回源。家庭内网不碰路由器端口映射,MinIO 的数据也始终留在本机。另一条管理通道则用反向 SSH 隧道:家庭机器主动向 VPS 建立连接,外部访问先到 VPS 的转发端口,再沿隧道回到家庭机器。
隧道用 autossh 维持,并额外加了端到端健康检查——每分钟从家庭机器 SSH 到 VPS,在 VPS 本机连接转发端口并读取 SSH banner;连续失败三次才重启隧道服务,重启后有冷却期,避免故障期间反复拉起。
这个检查是必要的。autossh 能处理 SSH 进程退出后的重连,但处理不了“进程还在、远端转发已经失效”的半故障状态。只有把探测点放在实际业务路径上,才能发现端口不监听但服务显示 active 的情况。
公网与私网的边界
可以用一句话概括当前的分层:
公网:Cloudflare Pages → 静态页面公网:Cloudflare → Tunnel → Nginx → Artalk / MinIO 只读私网:WireGuard / SSH 隧道 → 管理入口公开可访问的只有博客页面、评论接口和对象存储的读取路径。上传、SSH 和数据目录都不直接暴露在公网;对象存储没有后台控制台,日常管理通过私有网络或本地 CLI 完成。
4. MinIO:图片和资料不放进 Git
早期图片直接放在仓库里,文章一多,Git 体积增长得很快,而且同一张图可能在构建产物和源文件里各存一份。现在图片和资料统一放 MinIO,仓库里只保留 Markdown 和元数据。
MinIO 以容器方式跑在家庭主机上,使用固定版本的自定义镜像,数据目录单独持久化。它只绑定 WireGuard 地址上的 S3 端口,不加入公网 Docker 网络,也不开放浏览器控制台;VPS 的 Nginx 通过私有网络访问它,对外只暴露读取域名。
桶的权限分成两类:
- 匿名访问:只允许
GetObject,不能列目录,也不能写入。 - 上传账号:本地 CLI 使用单独的账号,不使用 root;账号权限按上传需要配置。
上传通过仓库里的 CLI 完成:
# 文章配图pnpm image --slug blog-architecture-self-hosting ./images/arch-1.png
# 资料归档pnpm material --title "示例资料" --slug sample-material \ --category 资料 --file ./sample.pdf图片命令会计算文件内容的 SHA-1,把对象 key 组织成:
images/posts/<slug>/<哈希前缀>-<文件名>.<扩展名>materials/<slug>/<slug>-<哈希前缀>.<扩展名>key 里带内容哈希,意味着文件内容一变就会产生新对象,不会和旧缓存冲突。因此对象可以放心设置一年期的 immutable 缓存,更新时换 URL 即可。代价是旧对象不会自动消失,需要定期清理。
命令执行完会直接打印 Markdown 片段或 gallery 配置,粘贴进正文即可。上传成功后本地源文件应当删除,仓库里不保留副本。构建阶段也不会访问 MinIO:对象存储挂了,页面仍然能构建和打开,只是图片加载失败。
目前的限制主要来自入口:CLI 默认单文件上限 50 MB,Cloudflare 代理的请求体大小也有上限。更大的文件需要另开不经代理的路径,或者换用对象存储的其他接入方式。
5. Artalk:评论是独立服务
评论没有塞进静态站,而是自托管了一套 Artalk。它作为独立容器接在 VPS 的 web 网络上,由 Nginx 按评论域名反向代理,数据目录单独挂载持久化,同样不映射宿主机端口。容器还限制了内存上限并配置了日志轮转,避免评论服务拖垮整台小机器。
前端集成在 Comments.astro 里,几个细节值得说明:
- 懒加载:评论容器进入视口前 400 px 时才动态
import("artalk"),首屏不加载评论库。 - 配合 Swup:在
content:replace前销毁实例,在page:view后重新初始化,避免站内跳转后出现重复的评论框。 - 主题同步:监听
<html>的 class 变化,把暗色状态传给 Artalk。 - 页面标识:默认使用
window.location.pathname作为pageKey,每篇文章和留言板各自拥有独立评论区。 - 头像:站点所有者的邮箱哈希映射到本地头像,其他访客交给第三方头像服务生成。
评论是客户端渲染的,所以文章正文可以先出来,评论区域稍后加载。Artalk 服务不可用时,影响范围仅限于评论区,不会阻塞页面阅读。
6. 备份与故障边界
这套结构里,不同组件失效的表现并不一样:
| 失效对象 | 直接影响 | 仍然可用 |
|---|---|---|
| Cloudflare / Pages | 博客页面无法访问 | 已加载的评论、图片可能仍可读取 |
| Cloudflare Tunnel | 评论、图片、资料不可用 | 博客正文 |
| VPS | 评论、图片、资料不可用 | 博客正文 |
| WireGuard / 家庭主机 | 图片、资料不可用 | 博客正文、评论 |
| MinIO | 图片、资料打不开 | 页面正文、评论 |
| Artalk | 评论区加载失败 | 页面正文、图片 |
| Pagefind 索引 | 站内搜索无结果 | 其余页面 |
因此备份要覆盖三类数据:仓库本身(GitHub 和本地各一份)、家庭主机上的 MinIO 数据目录、VPS 上的 Artalk 数据目录。对象存储可以额外用 mc mirror 做一次异地副本;备份真正有价值的前提是做过恢复演练,否则只能算“有一份文件”。
另一个容易被忽略的点是凭证。MinIO 的 root 账号只用于管理,日常上传使用单独账号;.env 被 .gitignore 排除,密钥不进入构建产物。凭证一旦泄露,正确做法是删除旧账号、重建并轮换,而不是只改一个密码。
7. 关于本文的隐私边界
这篇文章只描述结构和取舍,不写真实 IP、私网地址、端口、密钥、内部主机名和数据目录。文中出现的地址、端口和配置都是占位符或通用示例,不能直接用于访问这套系统。
对个人博客来说,公开的是内容,不是基础设施。把“哪些东西应该暴露、哪些只该留在私网”想清楚,比多装一个监控组件更重要。