合并两条线索写成一篇:前端怎么变快(Astro + Cloudflare + VPS 调优记)和这台小机器怎么活下来(资源限制、安全加固、零暴露端口)。 两件事本来就是一件——在 1 核 1G 上,前端的每一个字节和后端的每一 MB 配额抢的是同一块内存。
站点:静态 Astro + Nginx + Cloudflare Tunnel | 源站:1 vCPU / 1 GB RAM
速读#
- 页面自身没有问题,差距全在网络。 同一份构建,本地 LCP 532–556ms,线上 2.3–7.4s。CLS = 0、long task = 0、INP ≤ 48ms 在两端一致(图 1、图 3)。
- 有效的那一个改动是悬停预取。 生产环境 A/B:click→FCP −47%、click→load −36%(图 2)。
- CI/CD 重构彻底消灭 VPS 编译负担。 从 9 分钟双重编译升级为 GitHub Actions 产物直接推送(Artifact 架构),VPS 0 编译负荷,全自动秒级直达上线(见 §9.1)。
- 修复 Nginx Gzip 代理漏配,单页体积暴降 85%。 修复
gzip_proxied遗漏,传输体积从 96.2 KB 降至 14.4 KB,下载时延从 400ms 缩减至 41ms(见 §9.2)。 - Cloudflare Cache Rules 精准动静隔离,100% 边缘秒开。 自定义过滤规则精准排除探针与管理端,全站公开页面实现
CF-Cache: HIT毫秒级直出(见 §9.3)。 - 零暴露端口不是”改了个高端口”。 Nginx
listen 127.0.0.1:80,Node 服务在 4180/4181 回环,公网入站为零,全靠cloudflared出站回源。 - 1 GB 的账要算到 MB。 常驻服务配额上限合计 500 MB,留 524 MB 给系统与页缓存(图 4)。
- 小机器上最常见的事故不是被黑,是磁盘被日志写满。 见 §5.2 与 §5.4。
一、口径:哪些数字是量的,哪些是定的#
写性能文章最容易翻车的地方是把”配置目标”当成”实测结果”。这里先分开:
| 类别 | 内容 | 性质 |
|---|---|---|
| 实测 | 图 1 / 图 2 / 图 3 的全部数值、HTML 体积 172KB→88KB | Playwright + Chrome CDP 采集,每页 3–5 次全新 context 取中位数,屏蔽全部跨域请求 |
| 规划值 | 图 4 的内存配额、MemoryMax / mem_limit 数字 | 是”该设成多少”的上限目标,不是压测曲线 |
| 配置事实 | Nginx / systemd / webhook 的各项参数 | 直接来自仓库里的配置文件,可逐条核对 |
三点补充,避免误读:
- 这是实验室数据(lab),不是真实访客数据(RUM)。 它反映页面自身质量,不反映真实用户的网络分布。要 RUM 得另接
web-vitals上报。 - 本文不声称任何 Lighthouse 分数。 采集工具是自研的 CDP 探针(TTFB / FCP / LCP / CLS / longtask / INP),不是 Lighthouse,两者的评分模型和节流设置都不同,不能互相换算。
- INP 是探针值不是真实交互值。 探针动作固定为 Ctrl+K 打开搜索、300ms 后 Esc 关闭,取最大 event duration。
判定阈值统一取 Google Core Web Vitals:
| 指标 | Good | 需改进 | 差 |
|---|---|---|---|
| LCP | ≤ 2.5s | ≤ 4.0s | > 4.0s |
| INP | ≤ 200ms | ≤ 500ms | > 500ms |
| CLS | ≤ 0.1 | ≤ 0.25 | > 0.25 |
| TTFB | ≤ 0.8s | ≤ 1.8s | > 1.8s |
二、机器与约束#
一台 1 vCPU / 1 GB / 小盘的境内 VPS。配置决定了三条硬约束,后面所有取舍都是从这三条推出来的:
| 约束 | 推论 |
|---|---|
| 内存 1 GB,无独立构建机 | 构建峰值和常驻服务抢同一块内存 → 常驻部分必须有明确上限,构建期必须有 swap 兜底 |
| 磁盘小 | 保留的 release 数、pnpm store、容器日志、journald 都必须有天花板,且要在部署前就检查 |
| 单核 | 构建期 CPU 打满 → 健康检查、限流、TLS 握手都会被拖慢,部署必须能失败回滚而不是卡死 |
它带来的直接结果是:这个站是纯静态的。 没有运行时渲染、没有数据库、没有应用服务器。运行时只剩两个 Node 小服务(OAuth 与部署 webhook),Nginx 直接发 dist/ 里的文件。这是 1 核 1G 上最省的架构选择,也是后面所有性能数字的前提。
三、请求怎么进来:零暴露端口#
3.1 连接方向反过来了#
传统做法是源站开 80/443 等着别人连。这里反过来:cloudflared 从机器内部主动向外建立长连接,Cloudflare 边缘沿这条既有通道把请求送回来。公网侧因此不需要任何入站 Web 端口。
3.2 落到配置上是三行#
| 位置 | 配置 | 效果 |
|---|---|---|
nginx.conf | listen 127.0.0.1:80; | Nginx 不绑公网网卡,外部无法直连 |
| OAuth 服务 | proxy_pass http://127.0.0.1:4180; | 服务只在回环监听,不经 Nginx 到不了 |
| 部署 webhook | proxy_pass http://127.0.0.1:4181; | 同上 |
验证方法(在机器上和机器外各做一次):
# 机器内:所有监听项都应该是 127.0.0.1,没有 0.0.0.0 / :::ss -ltnp
# 机器外:全端口扫描应当扫不到 Web 服务nmap -Pn -p- <公网IP>3.3 代价要写清楚#
零暴露端口不等于”没有攻击面”:
- 公开域名仍然对所有人开放。 Tunnel 缩小的是”直连源站”的面,不是”访问网站”的面。应用自身的认证一点没少。
- Cloudflare 成了 TLS 与流量路径上的受信第三方。 Tunnel 凭据泄露、出站网络中断、账号或边缘故障都会直接影响服务。凭据要按密钥管理,泄露即轮换。
- 管理面必须走另一条链。 公共 Web 走 Tunnel,SSH / 运维走独立的 WireGuard、受限 SSH 或云厂商控制台。两条链不能互为唯一救援入口——否则 Tunnel 一挂,你连不进去修。
3.4 一个已知的信任边界(不修,只记录)#
限流以 Cloudflare 注入的 CF-Connecting-IP 为 key,缺失时回落到 socket 地址:
map $http_cf_connecting_ip $astro_limit_key { default $http_cf_connecting_ip; '' $binary_remote_addr;}limit_req_zone $astro_limit_key zone=astro_oauth:1m rate=30r/m;limit_req_zone $astro_limit_key zone=astro_deploy:1m rate=10r/m;- 成立前提:所有公网流量都经 Tunnel 到达回环,只有 Cloudflare 能注入这个头。
- 边界:任何能直连本机回环的进程都可以伪造这个头绕过限流。这是”本地已失陷”前提下的缺陷,不是公网可利用的漏洞。
- 回落方向是安全的:头缺失时所有 tunnel 流量共享一个桶,会误伤正常 webhook,但方向是”过严”而非”放行”。
把这种东西写进文档而不是假装不存在,比修一个假问题有价值。
3.5 边缘缓存闭环:动静精准分离与 100% Edge HIT#
由于境内 VPS 到 Cloudflare 海外边缘节点存在物理往返延迟,若每次请求都穿透 Tunnel 回源,TTFB 难以突破 1 秒大关。
我们在 Cloudflare 边缘部署了严格的自定义过滤规则:
(http.host eq "s1oopx.bond")and not ( http.request.uri.path eq "/healthz" or http.request.uri.path eq "/health.json" or http.request.uri.path eq "/admin" or starts_with(http.request.uri.path, "/admin/") or http.request.uri.path eq "/oauth" or starts_with(http.request.uri.path, "/oauth/") or http.request.uri.path eq "/deploy/github")- 策略:
Eligible for cache➔Edge TTL: 1 day➔Browser TTL: Bypass cache; - 收益:公开页面(
/、/projects/、/posts/、/_astro/*.webp)全量返回cf-cache-status: HIT,由全球边缘节点毫秒级直出;同时动态探针/health.json与后台/admin严格返回cf-cache-status: DYNAMIC / BYPASS,保证发布验证与鉴权 100% 实时。
四、前端调优:数据说了什么#
4.1 基线:页面自身没问题#
同一份构建,一端跑本地 pnpm preview,一端跑线上生产:
| 指标(中位数) | 本地 / | 本地 /posts/ | 本地 文章页 | 线上 / | 线上 /posts/ | 线上 文章页 |
|---|---|---|---|---|---|---|
| TTFB | 314ms | 313ms | 309ms | 5862ms | 2379ms | 1559ms |
| FCP | 520ms | 532ms | 524ms | 7364ms | 3344ms | 2116ms |
| LCP | 556ms (img) | 532ms (h2) | 544ms (img) | 7364ms (img) | 3344ms (h2) | 2276ms (img) |
| CLS | 0 | 0 | 0 | 0 | 0 | 0 |
| long tasks | 0 | 0 | 0 | 0 | 0 | 0 |
| INP (kbd) | 32ms | 32ms | 24ms | 48ms | 40ms | 32ms |
三个结论:
- 渲染与交互质量是结构性优秀的。 CLS = 0、零 long task、INP ≤ 48ms,本地线上一致 —— 布局稳定性和 JS 运行时都不是瓶颈。
- 全部差距在 TTFB。 本地 LCP ~550ms vs 线上 2.3–7.4s,差值几乎完全由网络往返贡献。
- 不是体积问题。 HTML 压缩后仅 ~25 KB,瓶颈是”中国大陆 → CF 境外边缘 → Tunnel → 境内源站”的往返时延。再压缩 10 KB 也救不了这个数字。
线上 / 还有 1/3 次连接直接失败(ERR_CONNECTION_CLOSED),成功样本 TTFB 方差 1.6–5.9s。这个方差本身就是后面 §4.6 的伏笔。
4.2 本地预算占用:四项都远低于阈值#
首页本地实测占 Good 阈值的比例:TTFB 39.3%、LCP 22.2%、INP 16.0%、CLS 0%。这张图的用途是把”页面本身够快”这句话变成可证伪的数字——如果哪天某根柱子逼近 100%,就是页面自己退化了,而不是网络的锅。
4.3 唯一有效的那个改动:站点级悬停预取#
prefetch: { prefetchAll: true, defaultStrategy: 'hover'}方法:同一浏览器内交替两组——OFF = 拦截 page.js(等价改动前行为),ON = 线上正常,悬停预取完成后点击。5 轮取中位数,ON 组 5/5 轮均观测到预取请求。
| click→ | 改动前 OFF | 改动后 ON | Δ |
|---|---|---|---|
| TTFB | 563ms | 371ms | −34% |
| FCP | 1760ms | 928ms | −47% |
| load | 2162ms | 1393ms | −36% |
必须说清的两条边界:
- 这是”预取完成后点击”的理想档。真实收益 =
min(悬停时长, 目标页下载时间),悬停越久省得越多,划过就点等于没省。 - 预取完全不改善首次加载。 首屏没有任何预取发生。任何把冷加载改善算到 prefetch 头上的说法都是错的。
在一个 TTFB 以秒计的链路上,这个改动之所以值,正是因为它把”用户已经在页面上停留的时间”变成了下一跳的下载窗口——它优化的不是带宽,是等待的重叠。
4.4 减字节:三件按需加载与反代 Gzip 漏配修复#
| 改动 | 做法 | 效果 |
|---|---|---|
| KaTeX 样式表 | frontmatter math: true 才注入,rehype-katex 仍全局跑,只闸门 CSS | ~3.6 KB gzip 不再进入每一页 |
| 搜索索引 | 构建期产出 /api/search.json,首屏不请求;打开搜索才动态 import Fuse.js,indexPromise 缓存单次;索引剥掉 code block | 首页零索引请求 |
| 可选客户端库 | mermaid / 灯箱 / 画廊延后加载 | 不用到的页面不付代价 |
| Nginx 反代 Gzip 修复 | 显式开启 gzip_proxied any 与全量 MIME 类型,解决 Tunnel 回环流量未压缩隐患 | 单页传输 96.2 KB → 14.4 KB(−85%),下载时延 400ms → 41ms |
一个隐蔽的配置缺陷与实测反思:
尽管 Nginx 配置文件中声明了gzip on;,但由于所有外部流量均经由 Cloudflare Tunnel 本地回环(127.0.0.1)代理进入,Nginx 默认未激活gzip_proxied any;且gzip_types处于注释状态。这导致生产环境长期向公网吐出 96.2 KB 的原始未压缩 HTML!
激活 Gzip Level 6 动态压缩后,传输体积直接暴降 85%,sendfile 数据吞吐性能提升近 10 倍。
这几件事均由自检脚本与断言守着,杜绝配置漂移。
4.5 分页:一次真实的体积腰斩#
文章列表从”全量渲染”改成每页 12 篇(contentTypes.posts.pageSize 可配):
- zh-cn 54 篇 → 5 页,en 合并 60 篇 → 5 页
/posts/HTML 172 KB → 88 KB(−49%)- 108 个 HTML 全链接解析通过,浏览器测试 21/21,
ASTRO_BASE子路径构建下分页链接正确
在 1 核 1G 上这条还有个额外好处:更小的 HTML 意味着 Nginx 的 sendfile 更快结束,连接更早释放。
4.6 一条负面结论:冷加载对比不采信#
prefetch 上线后同日重跑冷加载,“部署后”的数字全面变差:
| 指标 | 部署前 | 部署后 | 结论 |
|---|---|---|---|
/ TTFB / LCP | 2084 / 2644ms | 4699 / 5932ms | 网络方差,非改动影响 |
/posts/ TTFB / LCP | 1769 / 2280ms | 2575 / 3280ms | 同上 |
| 文章页 TTFB / LCP | 1891 / 2784ms | 4641 / 5812ms | 同上 |
诚实结论:该链路冷加载 TTFB 方差达 0.5–5.9s、连接失败率 10–30%,冷加载对比不构成任何改动依据;且 prefetch 本就不影响首次加载。所以改动效果以 §4.3 的 A/B 为准。
这段之所以留在文档里,是因为它是这轮调优最有价值的一条方法论:当噪声远大于效应量时,正确的动作是换测量方法,不是换个说法把数字讲圆。
4.7 页面过渡:选了不动 JS 的那条路#
用 W3C 跨文档视图过渡(global.css 里的 @view-transition + #main-content 独立过渡组),而不是 SPA 化:
| 路线 | 代价 | 结论 |
|---|---|---|
| 跨文档视图过渡(选中) | MPA 模型不变,11 个浏览器脚本零改动 | Chrome/Edge 126+、Safari 18.2+ 生效,Firefox 自然降级,prefers-reduced-motion 自动禁用 |
| ClientRouter(SPA 化) | 需重构全部脚本生命周期,giscus / 内联脚本重跑等已知坑 | 放弃,留作后续可选升级 |
验证:Chrome 中 pagereveal 事件确认过渡触发;浏览器测试 21/21(含像素级截图用例,无抖动)。
五、1 核 1G 的资源配额#
5.1 先把账算出来#
| 组件 | 配额上限 | 依据 |
|---|---|---|
| 系统 + 内核 + sshd | 180 MB | 留给 OS 的底噪,不设限但要算进预算 |
| Nginx | 32 MB | 纯静态发文件,worker 常驻很小 |
| cloudflared | 64 MB | 单隧道连接器 |
| OAuth 服务(Node) | 96 MB | 短连接、无状态 |
| 部署 webhook(Node) | 128 MB | 需要 fork 部署子进程,留得比 OAuth 宽 |
| 常驻小计 | 500 MB | |
| 余量 / 页缓存 | 524 MB | 构建峰值 + Nginx 页缓存 + 突发 |
| 合计 | 1024 MB |
这是
MemoryMax/mem_limit的目标值,不是压测曲线。作用是让 OOM killer 在越界时杀掉那一个服务,而不是随机杀掉整机上最大的进程(很可能正是你的构建)。
5.2 Docker 侧:限制写全了才叫限制#
如果这台机器上还跑着容器化的服务,mem_limit 只写一半比不写更危险。一份写全的最小骨架:
services: app: image: ghcr.io/example/app:sha-abc1234 # 用不可变 tag,别用 latest restart: unless-stopped
# ---- 内存 ---- mem_limit: 192m memswap_limit: 192m # 与 mem_limit 相等 = 禁止该容器吃 swap mem_reservation: 64m # 软限,内存紧张时优先回收到这个水位
# ---- CPU / 进程 ---- cpus: 0.5 # 单核机上防止一个容器把整核吃满 pids_limit: 128 # fork 炸弹兜底
# ---- 日志(小盘 VPS 的头号杀手)---- logging: driver: json-file options: max-size: "10m" max-file: "3" # 该容器日志硬上限 30 MB
# ---- 权限 ---- read_only: true tmpfs: [ "/tmp:size=32m" ] cap_drop: [ ALL ] security_opt: [ "no-new-privileges:true" ]
# ---- 端口:只绑回环 ---- ports: - "127.0.0.1:8080:8080"四个必须知道的坑:
memswap_limit不写等于白限。 不设时容器可以额外使用与mem_limit等量的 swap,1 GB 机器上足够把整机拖进 swap 抖动——表现是”没有 OOM,但什么都慢得像死机”。 想彻底禁 swap 就让两者相等。--oom-kill-disable在没有内存上限时是自杀。 容器越界后不会被杀,而是整机僵住。除非你同时设了mem_limit并且清楚自己在做什么,否则永远别开。json-file默认没有大小上限。 一个刷日志的容器能在几天内写满小盘,然后你会看到部署失败、Nginx 报 500、数据库拒写——根因是磁盘满,不是应用出问题。 要么每个服务写logging.options,要么在/etc/docker/daemon.json里设全局默认。- Docker 会绕过 ufw。 Docker 把自己的规则插在 ufw 之前,
-p 8080:8080会真的对公网开放,哪怕ufw deny 8080生效。在零暴露端口的架构里这条尤其致命——端口映射一律写成127.0.0.1:8080:8080,或在DOCKER-USER链里显式加规则。
5.3 systemd 侧:同一件事,另一套语法#
本站的两个 Node 服务不在容器里,配额直接写在 unit 里:
[Service]MemoryHigh=96M # 软限:超过开始激进回收并节流MemoryMax=128M # 硬限:超过触发该 cgroup 内的 OOM killCPUQuota=40% # 单核的 40%TasksMax=64MemoryHigh / MemoryMax 成对写是关键:只有硬限时进程会在毫无征兆的情况下被杀;加上软限,内核会先节流回收,给你一个”变慢”的观察窗口而不是直接消失。
5.4 真正的内存峰值在构建期#
常驻部分只占 500 MB,但这台机器是自己构建的:deploy-vps.sh 在 VPS 上跑 typecheck、两个 self-test(oauth / deploy)、astro build(含 sharp 图片处理)、再加链接 / CSP / 资源预算 / SEO 四项静态检查。峰值远超任何一个常驻服务。
应对,按性价比排序:
| 手段 | 做法 |
|---|---|
| 给 Node 显式堆上限 | NODE_OPTIONS=--max-old-space-size=512,让 V8 提前 GC 而不是等 OOM |
| swap 兜底 | 2 GB swapfile + vm.swappiness=10(只在真吃紧时用,不日常驻留) |
| 部署前查磁盘 | DEPLOY_MIN_FREE_MB=2048,空间不足在拿锁之前就拒绝启动 |
| 控制 release 数 | KEEP_RELEASES=3;每个 release 目录带着自己的 node_modules 一起被删 |
| 清 pnpm store | 剪枝旧 release 后跑 pnpm store prune 丢掉无引用条目 |
| 限制 journald | /etc/systemd/journald.conf 里 SystemMaxUse=200M |
如果这些做完仍然 OOM,下一步不是加内存,而是换信任边界——在 CI 构建出带 SHA-256 的不可变 artifact,VPS 只下载校验解压。 代价是 CI 产物成为新的供应链信任对象,这是架构级取舍,不是顺手改的。
六、安全加固:四层最小权限#
这一层的核心不是”装了多少加固指令”,而是部署用户能写的东西,和 systemd 实际执行的东西,不是同一份文件:
绿框是 root 拥有的两份东西,systemd 从这里取代码和凭据;橙框是部署用户唯一能写的地方。顺着实线读是一条单向链:绿框加载 → 部署进程只写橙框 → Nginx 只读发布。两条打叉的虚线才是这张图的意义:部署进程既改不动自己正在执行的代码,也读不到凭据。
四层各自负责的事:
| 层 | 机制 | 挡住什么 |
|---|---|---|
| ① 身份 | 三个系统用户:astro-narrow-deploy(可写 release)、astro-narrow-oauth(无写权限)、www-data(经共享组只读) | 一个服务被拿下,拿不到另一个的写权限 |
| ② 代码 | 运行时代码在 /opt 的 root 副本,部署产物在 /var/www | 被污染的部署改不动正在执行的代码 |
| ③ 内核 | systemd 沙箱(见 §6.2) | 提权、模块加载、cgroup 写入、容器逃逸常用面 |
| ④ 凭据 | /etc/astro-narrow/*.env,root 600,仓库外 | 凭据不随代码走,不进 git,不进子进程环境 |
6.1 为什么运行时代码要放 /opt#
systemd unit 执行的是 /opt/astro-narrow/services/ 下 root 拥有的副本,不是 deploy 用户可写的 /var/www/astro-narrow。
结论是硬的:一次被污染或有 bug 的部署,改不动正在运行的服务代码。 代价是这份副本不会自动更新——改了 server/ 就必须手动 cp 加 restart,否则服务还在跑旧代码。这个代价是刻意付的。
6.2 systemd 沙箱清单#
| 指令 | 作用 |
|---|---|
NoNewPrivileges=true | 禁止提权 |
ProtectSystem=strict | 整个文件系统只读,除白名单 |
ReadWritePaths=/var/www/astro-narrow | 只有 webhook unit 有,因为部署子进程要写 |
ProtectHome / PrivateTmp / PrivateDevices | 隔离家目录、tmp、设备节点 |
ProtectKernelTunables / ProtectKernelModules / ProtectControlGroups | 挡住 /proc/sys、模块加载、cgroup 写入 |
RestrictNamespaces / LockPersonality / RestrictRealtime | 关掉容器逃逸和调度类常用面 |
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX | 只留必要的 socket 族 |
CapabilityBoundingSet=(空) | 清空全部 capability |
UMask=0027 | 新建文件默认不给 other |
6.3 两个花了真实代价换来的坑#
这两条是这套配置里最值钱的注释,都用注释写死在 unit 文件里防止后人”顺手加回来”:
# 不要加 MemoryDenyWriteExecute:它会打断 Node 的 JIT。# SystemCallErrorNumber=EPERM 必须与 SystemCallFilter 成对:# 否则被过滤的系统调用会以 SIGSYS 杀死进程 —— TypeScript 7 的原生 tsc# 在构建期探测 fanotify_init(300),曾被直接 core dump(exit 159)。SystemCallFilter=@system-serviceSystemCallErrorNumber=EPERMMemoryDenyWriteExecute破坏 Node JIT。 这是安全加固清单里被推荐得最多的一条,对 JIT 运行时直接不可用。SystemCallErrorNumber=EPERM不是可选项。 不加它,被过滤的调用抛 SIGSYS 直接杀进程;加了它返回 EPERM,工具链能优雅回退。症状是构建随机 exit 159,非常难查。
加固清单不能照抄,每条都要在自己的运行时上验证一遍。
6.4 部署 webhook 的验签面#
| 项 | 实现 |
|---|---|
| HMAC 验签 | 校验 X-Hub-Signature-256,crypto.timingSafeEqual 常量时间比较 |
| 防重放 | delivery ID 去重 + HMAC 签名记忆 24h(副作用:GitHub “Redeliver” 按钮也会被忽略) |
| 仓库 / 分支白名单 | 仅 s1oopX/astro-narrow + main;fork、PR run、其他 workflow 一律忽略 |
| 触发条件 | 只接受 Verify site workflow 成功完成的 workflow_run 事件 |
| commit 锁定 | head_sha 注入为 DEPLOY_EXPECTED_COMMIT,git reset --hard 到该 commit;分支已前进则退出不部署 |
| 请求体上限 | DEPLOY_MAX_BODY_BYTES 默认 1 MiB |
| 子进程环境 | 显式白名单 CHILD_ENV_ALLOWLIST;DEPLOY_WEBHOOK_SECRET 不进子进程环境 |
| 不拼 shell | commit SHA 走环境变量注入,不参与命令拼接 |
| 并发 | 部署中收到新的已验证 commit 返回 202 Deploy queued,多个排队合并为最新那一个 |
拉取用只读 Deploy Key,ssh-keyscan 预置 known_hosts 保证非交互,全程不用会过期的 PAT。
6.5 CSP 用哈希,不用 unsafe-inline#
script-src 'self' 'sha256-wxemOofw2Lr…' 'sha256-bhUNXfKWl+S…' … https://giscus.app;object-src 'none'; base-uri 'self'; form-action 'self';内联脚本(主题防闪等)逐个上 sha256- 白名单。代价是改任何一行内联脚本都必须重算哈希并走维护发布——pnpm run csp:self-test 在 CI 和部署链路里都会跑,改了不重算直接构建失败。这是刻意的:让”忘记更新 CSP”变成构建错误,而不是线上白屏。
/admin 另有一套更严的策略(X-Frame-Options: DENY)。
七、发布链路:从本地双重编译到 GitHub Artifact 极速直推#
早期的发布架构由 GitHub Actions 跑完测试后发送 Webhook,由 VPS 在单核 1G 环境下从零 git pull 并全量编译 86 张高清封面图片。随着内容规模扩大,单机耗时近 5 分钟,CPU 与 500MB 内存被打满,全流程达 9 分钟。
我们将其彻底重构为基于云端产物直接推送(Artifact-based Deployment)的极速交付流水线:
三道门具体卡什么:
| 关卡 | 检查内容 | 不通过 |
|---|---|---|
| CI 验证 | GitHub Actions Verify site 全绿(类型检查、自研链路、SEO、资产预算断言) | 构建中止,产物不推送,机器上什么都不发生 |
| 交付与切换门 | SSH 传输 dist.tar.gz ➔ 解包至 /personal/s1oopx-migration/releases/<commit>/dist ➔ ln -sfn 原子切换软链接 | 传输失败不影响现有 current 软链接 |
| 健康门 | /health.json 的 revision 必须 == 本次部署 commit + 首页 200 | 自动切回上一个 release |
三个设计点:
① health.json 的 revision 一致性。 构建期将 Git SHA 写入 dist/health.json,部署后立即请求验证,必须等于本次 commit,否则直接回滚。Nginx 与 Cloudflare Cache Rules 对它强制设为 DYNAMIC / no-store,保证读到真实源站状态。
test "$(curl -fsS https://s1oopx.bond/health.json | jq -r '.revision')" \ = "$(git -C /var/www/astro-narrow rev-parse HEAD)"② 两阶段维护发布。 改了 server/、systemd unit 或 nginx.conf 时,静态文件不能先于匹配的运行时上线。必须先更新运行时/Nginx,再激活目标 commit。
③ 磁盘自动垃圾回收。 每次发布成功后,自动保留最新 3 个 Release 版本(ls -dt */ | tail -n +4 | xargs rm -rf),杜绝 1 核 1G 小主机因频繁发布导致磁盘溢出。
八、复盘:有效、无效、没做#
有效(有数据)#
| 改动 | 证据 | 效果 |
|---|---|---|
| 站点级悬停预取 | 生产 A/B,5 轮中位数 | click→FCP −47%,click→load −36% |
| 文章列表分页 12/页 | 构建产物实测 | /posts/ HTML 172 KB → 88 KB(−49%) |
| CI/CD 产物直推架构 | 生产环境流水线实测 | 彻底解除 VPS 编译负担,全流程 9 分钟 ➔ 秒级直达 |
| Nginx 动态 Gzip 修复 | curl 链路实测抓包 | 页面传输体积 96.2 KB → 14.4 KB(−85%),下载耗时 400ms → 41ms |
| Cloudflare Cache Rules | 全球边缘抓包断言 | 公开页面 100% Edge HIT,探针/管理端 0 缓存风险 |
| KaTeX 按页加载 | frontmatter 闸门 | ~3.6 KB gzip 不再进入每一页 |
| 搜索索引懒加载 | Playwright 断言 | 首页零索引请求 |
| 跨文档视图过渡 | pagereveal 事件 + 21/21 截图用例 | 零脚本改动拿到过渡动画 |
无效 / 不采信#
| 项 | 为什么 |
|---|---|
| 冷加载前后对比 | TTFB 方差 0.5–5.9s、连接失败率 10–30%,噪声远大于效应量 |
| 盲目拆微服务 | 1 核 1G 机器内 RPC 额外消耗内存,单进程模块化单体才是最优解 |
| SPA 化(ClientRouter) | 需重构全部脚本生命周期,收益不抵已知坑 |
还没做#
- RUM。 现在全是 lab 数据。要知道真实访客的分布,得接
web-vitals上报。 - Service Worker。 针对回头客的网络往返,是这条高延迟链路上理论收益最大的下一步。
- 补齐早期改动的量化。 KaTeX、搜索索引、图片、延迟加载四项都在性能记录建立之前,只有提交信息没有前后数据。
附录 A:命令速查#
# 性能采集(本地需先 pnpm build && pnpm preview)node scripts/measure-vitals.mjs local 3node scripts/measure-vitals.mjs prod 3
# 暴露面自查ss -ltnp # 机器内:应当全是 127.0.0.1nmap -Pn -p- <公网IP> # 机器外:应当扫不到 Web 服务
# 资源水位free -h && vmstat 1 5systemd-cgtop -1df -h /var/www && du -sh /var/www/astro-narrow/releases/*journalctl --disk-usagedocker system df # 若机器上有容器
# 版本一致性(三者必须对得上)git -C /var/www/astro-narrow rev-parse HEADbasename "$(dirname "$(readlink -f /var/www/astro-narrow/current)")"curl -fsS https://s1oopx.bond/health.json | jq -r '.revision'附录 B:图表复现#
四张图由 scripts/charts/make-charts.mjs 生成,改数据后重跑即可:
node scripts/charts/make-charts.mjs- 配色为经校验的分类色板(对比度、色觉障碍可分辨性、明暗两套均已通过检查),SVG 内置
prefers-color-scheme自适应。 - 图 1–3 的数值全部来自性能记录,与正文表格一一对应;图 4 为配额规划值,正文同步给出表格。
- 已知不一致:SVG 通过
<img>加载,内部样式只能读到系统的深浅偏好,读不到本站的手动主题切换。正文三张 mermaid 图同理——它们在%%{init}%%里写死了浅色themeVariables,因为那几种颜色是有语义的(绿=root 持有,橙=部署可写)。系统浅色而站点手动切到深色时,这些图仍是浅色底。是配色不匹配,不影响可读性。
附录 C:文件对照#
| 内容 | 位置 |
|---|---|
| 性能记录(本文数据源) | docs/performance-log.md |
| 采集脚本 | scripts/measure-vitals.mjs |
| 部署加固与信任边界 | docs/deployment-hardening.md |
| VPS 安装与维护流程 | docs/vps.md |
| Nginx(监听、CSP、限流) | deploy/nginx.conf |
| systemd unit(沙箱、配额) | deploy/astro-narrow-*.service |
| 部署脚本 / 回滚脚本 | scripts/deploy-vps.sh、scripts/rollback-vps.sh |
| 图表生成 | scripts/charts/make-charts.mjs |
本文只写这台机器上的取舍和实测数字。相邻主题的原理和推演另有文章,不在这里重复:
- 连接方向为什么反过来:从开放 80/443 到 Cloudflare Tunnel
- 一个请求完整的到达路径:一个页面请求怎样到达我的 Astro 站点
- 为什么 CI 不直接握 SSH 私钥:GitHub Actions 不直接 SSH
- systemd 守护的其余部分:systemd 守护服务
- 先收监听再谈封禁:UFW + fail2ban
