跳到正文
1 核 1G VPS 上的个人站:Astro 调优记与零暴露端口
1 核 1G VPS 上的个人站:Astro 调优记与零暴露端口

1 核 1G VPS 上的个人站:Astro 调优记与零暴露端口

同一份构建,本地 LCP 550ms、线上 2.3–7.4s,差距全在网络;悬停预取把 click→FCP 压掉 47%。附 1GB 内存配额、零暴露端口、systemd 沙箱与发布回滚的完整取舍。

合并两条线索写成一篇:前端怎么变快(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→88KBPlaywright + Chrome CDP 采集,每页 3–5 次全新 context 取中位数,屏蔽全部跨域请求
规划值图 4 的内存配额、MemoryMax / mem_limit 数字是”该设成多少”的上限目标,不是压测曲线
配置事实Nginx / systemd / webhook 的各项参数直接来自仓库里的配置文件,可逐条核对

三点补充,避免误读:

  1. 这是实验室数据(lab),不是真实访客数据(RUM)。 它反映页面自身质量,不反映真实用户的网络分布。要 RUM 得另接 web-vitals 上报。
  2. 本文不声称任何 Lighthouse 分数。 采集工具是自研的 CDP 探针(TTFB / FCP / LCP / CLS / longtask / INP),不是 Lighthouse,两者的评分模型和节流设置都不同,不能互相换算。
  3. 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 端口。

%%{init:{'theme':'base','themeVariables':{'fontFamily':'system-ui,-apple-system,Segoe UI,sans-serif','fontSize':'13px','lineColor':'#a8a69e','clusterBkg':'#f7f7f4','clusterBorder':'#e1e0d9','edgeLabelBackground':'#fcfcfb'},'flowchart':{'curve':'basis','nodeSpacing':36,'rankSpacing':64,'padding':10}}}%% flowchart LR U(["浏览器"]) ==> E["Cloudflare 边缘<br/>TLS 终止 · 缓存 · WAF"] E == "沿既有隧道回源" ==> C X(["公网扫描 · 直连尝试"]) -. "扫不到监听端口" .-x N subgraph V[" VPS 境内源站 · 零公网入站端口 "] direction TB C["cloudflared<br/>出站长连接,由它主动建立"] N["Nginx<br/>listen 127.0.0.1:80"] S["静态 release"] O["OAuth · 127.0.0.1:4180"] W["webhook · 127.0.0.1:4181"] C ==> N N --> S N --> O N --> W end classDef ext fill:#fdf0e9,stroke:#eb6834,stroke-width:1.5px,color:#0b0b0b classDef tun fill:#eef4fc,stroke:#2a78d6,stroke-width:1.6px,color:#0b0b0b classDef svc fill:#f5f9fe,stroke:#8fb8e6,stroke-width:1px,color:#52514e classDef blk fill:#f7f7f4,stroke:#a8a69e,stroke-width:1px,stroke-dasharray:4 3,color:#898781 class U,E ext class C,N tun class S,O,W svc class X blk

3.2 落到配置上是三行#

位置配置效果
nginx.conflisten 127.0.0.1:80;Nginx 不绑公网网卡,外部无法直连
OAuth 服务proxy_pass http://127.0.0.1:4180;服务只在回环监听,不经 Nginx 到不了
部署 webhookproxy_pass http://127.0.0.1:4181;同上

验证方法(在机器上和机器外各做一次):

Terminal window
# 机器内:所有监听项都应该是 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 cacheEdge TTL: 1 dayBrowser TTL: Bypass cache
  • 收益:公开页面(//projects//posts//_astro/*.webp)全量返回 cf-cache-status: HIT,由全球边缘节点毫秒级直出;同时动态探针 /health.json 与后台 /admin 严格返回 cf-cache-status: DYNAMIC / BYPASS,保证发布验证与鉴权 100% 实时。

四、前端调优:数据说了什么#

4.1 基线:页面自身没问题#

同一份构建,一端跑本地 pnpm preview,一端跑线上生产:

本地 vs 线上 LCP 对比
图 1:同一份构建,本地 LCP 约 550ms,线上 2.3–7.4s,差值几乎全部来自网络往返。
指标(中位数)本地 /本地 /posts/本地 文章页线上 /线上 /posts/线上 文章页
TTFB314ms313ms309ms5862ms2379ms1559ms
FCP520ms532ms524ms7364ms3344ms2116ms
LCP556ms (img)532ms (h2)544ms (img)7364ms (img)3344ms (h2)2276ms (img)
CLS000000
long tasks000000
INP (kbd)32ms32ms24ms48ms40ms32ms

三个结论:

  1. 渲染与交互质量是结构性优秀的。 CLS = 0、零 long task、INP ≤ 48ms,本地线上一致 —— 布局稳定性和 JS 运行时都不是瓶颈。
  2. 全部差距在 TTFB。 本地 LCP ~550ms vs 线上 2.3–7.4s,差值几乎完全由网络往返贡献。
  3. 不是体积问题。 HTML 压缩后仅 ~25 KB,瓶颈是”中国大陆 → CF 境外边缘 → Tunnel → 境内源站”的往返时延。再压缩 10 KB 也救不了这个数字。

线上 / 还有 1/3 次连接直接失败(ERR_CONNECTION_CLOSED),成功样本 TTFB 方差 1.6–5.9s。这个方差本身就是后面 §4.6 的伏笔。

4.2 本地预算占用:四项都远低于阈值#

CWV 预算占用
图 3:首页本地实测占 Core Web Vitals Good 阈值的比例,四项都远低于上限。

首页本地实测占 Good 阈值的比例:TTFB 39.3%、LCP 22.2%、INP 16.0%、CLS 0%。这张图的用途是把”页面本身够快”这句话变成可证伪的数字——如果哪天某根柱子逼近 100%,就是页面自己退化了,而不是网络的锅。

4.3 唯一有效的那个改动:站点级悬停预取#

astro.config.mjs
prefetch: {
prefetchAll: true,
defaultStrategy: 'hover'
}
悬停预取 A/B
图 2:生产环境 A/B,5 轮中位数。预取完成后点击的理想档,不代表首次加载。

方法:同一浏览器内交替两组——OFF = 拦截 page.js(等价改动前行为),ON = 线上正常,悬停预取完成后点击。5 轮取中位数,ON 组 5/5 轮均观测到预取请求。

click→改动前 OFF改动后 ONΔ
TTFB563ms371ms−34%
FCP1760ms928ms−47%
load2162ms1393ms−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 / LCP2084 / 2644ms4699 / 5932ms网络方差,非改动影响
/posts/ TTFB / LCP1769 / 2280ms2575 / 3280ms同上
文章页 TTFB / LCP1891 / 2784ms4641 / 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 先把账算出来#

1GB 内存配额分配
图 4:常驻服务配额上限合计 500 MB,留 524 MB 给构建峰值和页缓存。这是规划值,不是压测曲线。
组件配额上限依据
系统 + 内核 + sshd180 MB留给 OS 的底噪,不设限但要算进预算
Nginx32 MB纯静态发文件,worker 常驻很小
cloudflared64 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"

四个必须知道的坑:

  1. memswap_limit 不写等于白限。 不设时容器可以额外使用与 mem_limit 等量的 swap,1 GB 机器上足够把整机拖进 swap 抖动——表现是”没有 OOM,但什么都慢得像死机”。 想彻底禁 swap 就让两者相等。
  2. --oom-kill-disable 在没有内存上限时是自杀。 容器越界后不会被杀,而是整机僵住。除非你同时设了 mem_limit 并且清楚自己在做什么,否则永远别开。
  3. json-file 默认没有大小上限。 一个刷日志的容器能在几天内写满小盘,然后你会看到部署失败、Nginx 报 500、数据库拒写——根因是磁盘满,不是应用出问题。 要么每个服务写 logging.options,要么在 /etc/docker/daemon.json 里设全局默认。
  4. 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 kill
CPUQuota=40% # 单核的 40%
TasksMax=64

MemoryHigh / 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.confSystemMaxUse=200M

如果这些做完仍然 OOM,下一步不是加内存,而是换信任边界——在 CI 构建出带 SHA-256 的不可变 artifact,VPS 只下载校验解压。 代价是 CI 产物成为新的供应链信任对象,这是架构级取舍,不是顺手改的。


六、安全加固:四层最小权限#

这一层的核心不是”装了多少加固指令”,而是部署用户能写的东西,和 systemd 实际执行的东西,不是同一份文件

%%{init:{'theme':'base','themeVariables':{'fontFamily':'system-ui,-apple-system,Segoe UI,sans-serif','fontSize':'13px','lineColor':'#a8a69e','clusterBkg':'#f7f7f4','clusterBorder':'#e1e0d9','edgeLabelBackground':'#fcfcfb'},'flowchart':{'curve':'basis','nodeSpacing':30,'rankSpacing':72,'padding':10}}}%% flowchart TB RO["/opt/…/services/*.mjs<br/>root:root 644"] EV["/etc/astro-narrow/*.env<br/>root:root 600 · 仓库外"] RO == "ExecStart" ==> W["部署 webhook 服务<br/>User=astro-narrow-deploy"] EV == "EnvironmentFile" ==> W W ==> DP["deploy-vps.sh<br/>fork 出的部署子进程"] DP == "唯一可写的地方" ==> RW["/var/www/astro-narrow/<br/>repo · releases · current"] RW == "原子切换后只读发布" ==> NG["Nginx<br/>www-data ∈ astro-narrow 组"] RO -. "✕ 部署无写权限<br/>改不动执行中的代码" .-x DP EV -. "✕ 部署无读权限<br/>拿不到凭据" .-x DP classDef run fill:#eef4fc,stroke:#2a78d6,stroke-width:1.6px,color:#0b0b0b classDef wr fill:#fdf0e9,stroke:#eb6834,stroke-width:1.6px,color:#0b0b0b classDef locked fill:#f2f7f4,stroke:#1baf7a,stroke-width:1.6px,color:#0b0b0b class W,DP run class RW,NG wr class RO,EV locked

绿框是 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/ 就必须手动 cprestart,否则服务还在跑旧代码。这个代价是刻意付的。

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-service
SystemCallErrorNumber=EPERM
  • MemoryDenyWriteExecute 破坏 Node JIT。 这是安全加固清单里被推荐得最多的一条,对 JIT 运行时直接不可用。
  • SystemCallErrorNumber=EPERM 不是可选项。 不加它,被过滤的调用抛 SIGSYS 直接杀进程;加了它返回 EPERM,工具链能优雅回退。症状是构建随机 exit 159,非常难查。

加固清单不能照抄,每条都要在自己的运行时上验证一遍。

6.4 部署 webhook 的验签面#

实现
HMAC 验签校验 X-Hub-Signature-256crypto.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_COMMITgit reset --hard 到该 commit;分支已前进则退出不部署
请求体上限DEPLOY_MAX_BODY_BYTES 默认 1 MiB
子进程环境显式白名单 CHILD_ENV_ALLOWLISTDEPLOY_WEBHOOK_SECRET 不进子进程环境
不拼 shellcommit SHA 走环境变量注入,不参与命令拼接
并发部署中收到新的已验证 commit 返回 202 Deploy queued,多个排队合并为最新那一个

拉取用只读 Deploy Keyssh-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)的极速交付流水线

%%{init:{'theme':'base','themeVariables':{'fontFamily':'system-ui,-apple-system,Segoe UI,sans-serif','fontSize':'13px','lineColor':'#a8a69e','edgeLabelBackground':'#fcfcfb'},'flowchart':{'curve':'basis','nodeSpacing':34,'rankSpacing':44,'padding':8}}}%% flowchart TB A(["push main"]) ==> B{"GitHub Actions<br/>4核16G 云端 Runner"} B -. "类型检查/自检失败" .-x B2(["中止构建"]) B == "单次编译通过 (~30s)" ==> C["打包 dist.tar.gz<br/>Ed25519 SSH 直传 VPS"] C ==> D{"碰到<br/>运行时改动?"} D == "否 · 纯静态" ==> I["VPS 远端解包<br/>ln -sfn 原子切换 current"] D == "是" ==> M["两阶段维护发布<br/>stage → 装 unit/nginx → apply:commit"] M -. "apply 汇入同一道门" .-> I I ==> J{"健康门校验"} J == "通过 (health.json 匹配)" ==> K(["自动修剪旧 release (保留3代)<br/>清空中间包,防磁盘膨胀"]) J -. "失败" .-x N(["自动切回上一个 release"]) classDef gate fill:#fdf7e8,stroke:#eda100,stroke-width:1.6px,color:#0b0b0b classDef proc fill:#eef4fc,stroke:#2a78d6,stroke-width:1.6px,color:#0b0b0b classDef good fill:#f2f7f4,stroke:#1baf7a,stroke-width:1.6px,color:#0b0b0b classDef stop fill:#f7f7f4,stroke:#a8a69e,stroke-width:1px,stroke-dasharray:4 3,color:#52514e class B,D,J gate class C,I,M proc class A,K good class B2,N stop

三道门具体卡什么:

关卡检查内容不通过
CI 验证GitHub Actions Verify site 全绿(类型检查、自研链路、SEO、资产预算断言)构建中止,产物不推送,机器上什么都不发生
交付与切换门SSH 传输 dist.tar.gz ➔ 解包至 /personal/s1oopx-migration/releases/<commit>/distln -sfn 原子切换软链接传输失败不影响现有 current 软链接
健康门/health.jsonrevision 必须 == 本次部署 commit + 首页 200自动切回上一个 release

三个设计点:

health.json 的 revision 一致性。 构建期将 Git SHA 写入 dist/health.json,部署后立即请求验证,必须等于本次 commit,否则直接回滚。Nginx 与 Cloudflare Cache Rules 对它强制设为 DYNAMIC / no-store,保证读到真实源站状态。

Terminal window
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:命令速查#

Terminal window
# 性能采集(本地需先 pnpm build && pnpm preview)
node scripts/measure-vitals.mjs local 3
node scripts/measure-vitals.mjs prod 3
# 暴露面自查
ss -ltnp # 机器内:应当全是 127.0.0.1
nmap -Pn -p- <公网IP> # 机器外:应当扫不到 Web 服务
# 资源水位
free -h && vmstat 1 5
systemd-cgtop -1
df -h /var/www && du -sh /var/www/astro-narrow/releases/*
journalctl --disk-usage
docker system df # 若机器上有容器
# 版本一致性(三者必须对得上)
git -C /var/www/astro-narrow rev-parse HEAD
basename "$(dirname "$(readlink -f /var/www/astro-narrow/current)")"
curl -fsS https://s1oopx.bond/health.json | jq -r '.revision'

附录 B:图表复现#

四张图由 scripts/charts/make-charts.mjs 生成,改数据后重跑即可:

Terminal window
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.shscripts/rollback-vps.sh
图表生成scripts/charts/make-charts.mjs

本文只写这台机器上的取舍和实测数字。相邻主题的原理和推演另有文章,不在这里重复:

版权许可

CC BY-NC-SA 4.0 本作品采用知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议进行许可。

相关文章

s1oopX

登录 s1oopX