把博客搬回家:云服务器 + 家庭 NAS 的三系统隔离重构
把博客搬回家:一次 NAS + frp + 三系统隔离的架构重构
一台阿里云 ECS,一台放在家里的飞牛 NAS,两个域名,三套完全隔离的系统。
记录这次重构的完整过程,包括踩过的每一个坑。
起因:之前所有服务都挤在那台 ECS 上:
真正的转折点是:我买了 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 ✅
第四步:三系统物理隔离
这是这次重构里最重要的一条原则:自研系统和随礼记小程序必须互不影响。
数据库层面的隔离是硬隔离,实测:
-- 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参数会 500curl '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 进程三个系统的最终状态:
备份策略
三层备份,任一环出问题都还有退路:
NAS 本地每日备份 —— 每天 03:00 自动打包 Halo 数据目录 + 数据库,保留 14 天
服务器侧备份 —— Nginx 配置、站点文件的历史版本
本地离线备份 —— 关键数据同步到自己的电脑
备份脚本的核心就是别硬编码密码,从容器环境变量里读:
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
写在最后
这次重构最大的收获不是某个具体技术点,而是三个原则:
重的东西放家里,轻的东西放云上。 机器硬扛 Java 应用是自找麻烦。
容器里的网络视图和宿主机不一样。 遇到
i/o timeout,先怀疑地址填错了。能上硬隔离就上硬隔离。 数据库权限那一行
GRANT,比任何代码里的try-catch都可靠。
以及最重要的一条:每次改动前先备份。 今天我删过一次 Halo 用户记录、改坏过两次 nginx 配置、写坏过一次 JS 文件,都是靠备份救回来的。
本文记录于 2026-10-31,架构仍在持续演进中。