把博客搬回家:一次 NAS + frp + 三系统隔离的架构重构

一台阿里云 ECS,一台放在家里的飞牛 NAS,两个域名,三套完全隔离的系统。

记录这次重构的完整过程,包括踩过的每一个坑。

起因:之前所有服务都挤在那台 ECS 上:

服务

说明

Halo 博客

Java 应用,JVM 本身就很重

自研博客系统

Node.js + Express

随礼记小程序后端

Node.js + MariaDB

1Panel + OpenResty

面板与反向代理

真正的转折点是:我买了 Halo 专业版,但它对这台小机器来说太重了。

于是决定做一次彻底的分家:

  • Halo 搬到家里的 NAS(内存管够,还有 2 个盘位做备份)

  • 服务器只保留轻量的转发与 API

  • 自研系统独立成库,不再和别的系统纠缠

目标架构

                    ┌──────────────────────────────────┐
                    │   阿里云 ECS (2C2G, 杭州)         │
                    ├──────────────────────────────────┤
www.52hq.com  ─────►│ Nginx :443                        │
blog.52hq.com ─────►│  ├── /  → 127.0.0.1:8090 ──┐      │
api.52hq.com  ─────►│  ├── /  → 127.0.0.1:3300   │      │
                    │  └── /  → 127.0.0.1:3000   │      │
                    └────────────────────────────┼──────┘
                                                 │
                                    frp 加密隧道 │
                                                 ▼
                    ┌──────────────────────────────────┐
                    │   家里的 NAS (飞牛 fnOS)          │
                    │   1Panel 托管的 Halo Pro          │
                    │   数据: MariaDB / halo_m8wdn2      │
                    └──────────────────────────────────┘

核心思路:服务器只做「门」和「轻量 API」,重的东西放家里。

第一步:用 frp 打通家庭网络

NAS 在家里 NAT 后面,没有公网 IP,公网 IP 还是动态的。传统做法是把路由器端口映射到公网 + DDNS,但这样等于把 NAS 直接暴露在外网,风险和收益不成正比。

frp 的方案是反过来:让 NAS 主动连服务器,由服务器对外提供入口。

服务端配置 /etc/frp/frps.toml

bindPort = 7000

# Dashboard 管理界面
webServer.addr = "0.0.0.0"
webServer.port = 7500
webServer.user = "admin"
webServer.password = "替换成强密码"

auth.method = "token"
auth.token = "替换成强随机串"

# 只允许特定端口穿透,收紧攻击面
allowPorts = [
  { start = 7000, end = 7010 },
  { start = 8090, end = 8090 },
  { start = 2222, end = 2222 }
]
systemctl enable --now frps

客户端(NAS 上用飞牛的应用商店版 frpc)

关键是隧道配置:

serverAddr = "118.31.226.61"
serverPort = 7000
auth.token = "与服务端一致的 token"

[[proxies]]
name = "halo-web"
type = "tcp"
localIP = "172.17.0.1"
localPort = 8090
remotePort = 8090

踩坑记录 #1:localIP 到底填什么?

一开始填了 NAS 的局域网 IP 192.168.1.66,日志一直报:
connect to local service [192.168.1.66:8090] error: i/o timeout

因为 frpc 跑在容器里,容器的网络视图和宿主机不一样。改成本机的 Docker 网桥网关 172.17.0.1 后立刻通了。

结论:容器里的 frpc,localIP 要填 172.17.0.1(或实际网关),不是局域网 IP,也不是 127.0.0.1。

验证:

# 服务器上执行,通了就说明整条链路 OK
curl -s http://127.0.0.1:8090/actuator/health/readiness
# {"status":"UP"}

第二步:把 Halo 迁到 NAS

在 NAS 的 1Panel 应用商店里安装 Halo,端口保持 8090 —— 这样 frp 隧道和 Nginx 反代都不用改。

装完发现一个隐藏问题:1Panel 默认把 HALO_EXTERNAL_URL 设成了 http://localhost:8090。

# /vol1/@appdata/1Panel/1panel/apps/halo/halo/.env
HALO_EXTERNAL_URL="https://www.52hq.com"   # ← 必须改

不改的话,后台所有链接、图片地址、邮件里的链接都会指向 localhost:8090,是个很难发现的坑。

改完重启容器即可。

第三步:Nginx 双轨兜底

NAS 是家用的,随时可能关机、断电、休眠。博客不能因为这个就挂掉。

思路是:NAS 在线时走真实站点,离线时自动降级到静态页。

location / {
    proxy_pass http://127.0.0.1:8090;
    # ... 代理头
    proxy_intercept_errors on;
    error_page 502 503 504 = @static_fallback;
}

location @static_fallback {
    root /www/sites/www.52hq.com/static;
    try_files $uri /index.html =503;
}

静态兜底页里放了:

  • 小程序二维码(微信扫一扫直接进「随礼记」)

  • 「刘工技术贴」入口

  • 联系方式

踩坑记录 #2:try_files /index.html 会劫持所有静态资源

最初写的是 try_files /index.html =503;,结果兜底页里的二维码图片请求 /qr-suili.png 返回的仍是 index.html(Content-Type: text/html),图片根本显示不出来。

正确写法是 try_files $uri /index.html =503; —— 先按 URI 找文件,找不到才回退到首页。

实测结果:

NAS 在线 → <title>刘工技术栈</title>          (真实 Halo)
NAS 离线 → <title>刘工技术栈 · 暂时离线</title> (兜底页)
二维码   → HTTP 200 | 108949 字节 | image/png  ✅

第四步:三系统物理隔离

这是这次重构里最重要的一条原则:自研系统和随礼记小程序必须互不影响。

隔离维度

随礼记小程序

自研博客

Halo

目录

/opt/suiliji

/opt/oldblog

NAS 独立存储

容器

suiliji-api

oldblog-api

1Panel-halo-0Ee1

端口

127.0.0.1:3000

127.0.0.1:3300

隧道 8090

数据库

wxa 库

blog 库

halo_m8wdn2

数据库用户

wxa_app

blog_app

halo_xxx

数据库层面的隔离是硬隔离,实测:

-- blog_app 访问自己的库
SELECT 'OK';   -- ✅ 正常

-- blog_app 试图访问随礼记的库
USE wxa;
-- ERROR 1044 (42000): Access denied for user 'blog_app'@'%' to database 'wxa'  ← 被拒绝

只这一条 GRANT 的限制,就保证了自研博客的代码即使写出天大的 bug,也动不了随礼记的一条记录。

自研博客的改造思路

原来的自研博客有自己的后端 API(/api/articles、/api/categories 等),但后端代码已经没了。前端倒是完好。

改造方案是写一个适配层:

旧前端 (index/article/category/admin.html)
        ↓ 同源请求
oldblog-api (port 3300)
        ↓
   ┌────────┴────────┐
   ▼                 ▼
blog 库           Halo 公开 API
(自己的数据)    (同步来源)

关键点:适配层只读 Halo 的公开接口,把数据同步进自己的 blog 库。两边都不依赖对方的写入。

踩坑记录 #3:Halo 公开 API 的 sort 参数会 500

curl 'http://127.0.0.1:8090/apis/api.content.halo.run/v1alpha1/posts?sort=publishTime,desc'
# {"detail":"服务器内部发生错误...","status":500}

去掉 sort 就正常返回。排序改在本地做,别指望 Halo 的排序参数。

踩坑记录 #4:Halo 的正文在 content.content,不在 status.content

我一开始按直觉取了 status.content,永远是空字符串。正确的是:

full = halo_post(name)
html = full["content"]["content"]   # HTML
md   = full["content"]["raw"]       # Markdown

踩坑合集:那些浪费了半小时的地方

1. PowerShell 会吃掉 nginx 配置里的 $

# 这样写会把 nginx 变量搞坏
ssh root@server "cat > /etc/nginx/site.conf << 'EOF'
  proxy_set_header Host $host;
EOF"

PowerShell 会把 $host、$(date) 当成自己的变量展开,结果服务器上的配置变成了:

proxy_set_header Host System.Management.Automation.Internal.Host.InternalHost

正确做法:本地写好文件,用 scp 传上去。 永远不要在 SSH 命令字符串里写含 $ 的内容。

2. ssl_stapling 不能写在 location 里

location / {
    ssl_stapling on;   # ❌ nginx: [emerg] "ssl_stapling" is not allowed here
}

它属于 server 或 http 级别。顺带一提:

nginx: [warn] "ssl_stapling" ignored, no OCSP responder URL in the certificate

出现这条 warn 说明证书本身不含 OCSP 地址(阿里云免费 DV 证书就是这样),stapling 根本无法启用,不是配置写错了。

3. Halo 2.x 的数据全在 extensions 一张表里

以为会有 users、posts 这些表,结果:

SHOW TABLES FROM halo;
-- extensions
-- migrations

Halo 2.x 是 EAV 设计,所有资源都以 JSON 存在 extensions 表的 data 列,主键是 /registry/users/liugong 这样的路径。

所以想改数据,得:

-- 读取(用 HEX 避开引号和换行问题)
SELECT HEX(data) FROM extensions WHERE name='/registry/users/liugong';

-- 写回(用 UNHEX,彻底规避转义)
UPDATE extensions SET data=CONVERT(UNHEX('...') USING utf8mb4)
  WHERE name='/registry/users/liugong';

这个 HEX / UNHEX 的技巧救了我好几次——凡是手工改带 JSON 的数据库字段,一律走十六进制,能避开所有 shell 转义和编码的坑。

4. Halo 的密码是 Argon2,不是 bcrypt

{"spec":{"password":"{argon2@SpringSecurity_v5_8}$argon2id$v=19$m=16384,t=2,p=1$..."}}

写 bcrypt 的哈希进去,Halo 直接当验证失败。参数要对齐 Spring Security 的默认值:

from argon2 import PasswordHasher
from argon2.low_level import Type

ph = PasswordHasher(
    time_cost=2, memory_cost=16384, parallelism=1,
    hash_len=32, salt_len=16, type=Type.ID
)
hash = "{argon2@SpringSecurity_v5_8}" + ph.hash(password)

5. 删路由器端口映射时,别把 SSH 通道一起删了

为了安全,我把路由器的 8090 端口映射删掉了(Halo 走隧道,不需要公网映射了)。

结果 NAS 的 frpc 正好在那一刻掉线,而 2222(NAS SSH)的映射也被一起删了 —— 顿时失去了所有远程进入 NAS 的通道。

教训:保留一条应急通道。 8090 可以删,但 SSH 的端口映射要留着,那是你唯一的后门。

6. PowerShell 的 Set-Content -Encoding UTF8 会毁掉中文

用来修改 JS 文件里的一个参数,结果整个文件的中文全变成了乱码,引号也断了:

title: s.title || '鏃犳爣棰?,

结论:跨平台改文件,用 write 工具写本地文件再 scp,或者干脆在代码里用 \u 转义中文。 不要用 PowerShell 的文本处理来碰源码。

成果

重构完成后,服务器内存占用:

现在:几乎只剩 Nginx + 两个轻量 Node 进程

三个系统的最终状态:

系统

域名

状态

Halo 博客

www.52hq.com

✅ 200,NAS 上运行

自研博客

blog.52hq.com

✅ 200,独立数据库

随礼记小程序

api.52hq.com

✅ 200,独立数据库

兜底静态页

www.52hq.com(NAS 离线时)

✅ 自动切换

备份策略

三层备份,任一环出问题都还有退路:

  1. NAS 本地每日备份 —— 每天 03:00 自动打包 Halo 数据目录 + 数据库,保留 14 天

  2. 服务器侧备份 —— Nginx 配置、站点文件的历史版本

  3. 本地离线备份 —— 关键数据同步到自己的电脑

备份脚本的核心就是别硬编码密码,从容器环境变量里读:

DB_PASS=$(docker exec halodb env | grep -E '^MARIADB_PASSWORD=' | cut -d= -f2-)
docker exec -e MYSQL_PWD="$DB_PASS" halodb mariadb-dump -u"$DB_USER" \
  --single-transaction --routines --triggers halo | gzip > halo-db-$(date +%Y%m%d).sql.gz

写在最后

这次重构最大的收获不是某个具体技术点,而是三个原则:

  1. 重的东西放家里,轻的东西放云上。 机器硬扛 Java 应用是自找麻烦。

  2. 容器里的网络视图和宿主机不一样。 遇到 i/o timeout,先怀疑地址填错了。

  3. 能上硬隔离就上硬隔离。 数据库权限那一行 GRANT,比任何代码里的 try-catch 都可靠。

以及最重要的一条:每次改动前先备份。 今天我删过一次 Halo 用户记录、改坏过两次 nginx 配置、写坏过一次 JS 文件,都是靠备份救回来的。


本文记录于 2026-10-31,架构仍在持续演进中。