一次双机静态站部署,踩了六个坑
2026-10-08 · 12 分钟阅读
需求很简单:两台 VPS 各挂一个静态网页,域名不同、内容一致、都要 443 直连不带端口,而且不能影响已有的代理服务。
听起来不难,实际踩了六个坑。记下来。
坑一:装不上东西,先查内存
在其中一台 VPS 上装 Caddy,命令这样写的:
$ dnf install -y caddy
# 没有任何输出,直接返回
检查一下——没装上。rsync 也一样装不上,同样是静默失败。
查到内存才明白:
$ free -m
total used free
Mem: 765 358 404
总共 765M。安装过程被 OOM 杀掉了,但错误被重定向吞了。
改用官方预编译二进制,秒装:
$ curl -sL -o caddy.tar.gz https://github.com/caddyserver/caddy/releases/download/v2.8.4/caddy_2.8.4_linux_amd64.tar.gz
$ tar xzf caddy.tar.gz caddy
$ install -m 755 caddy /usr/local/bin/caddy
$ caddy version
v2.8.4
教训:小内存机器上包管理器不可靠,尤其是包管理器的依赖解析很吃内存。优先静态二进制。另外,把安装命令的 stderr 重定向到 /dev/null 会让你完全看不到失败原因。
坑二:服务读不到证书,因为 /root 是 0700
Caddy 启动直接报错:
Error: loading certificates: open /root/.acme.sh/xxx/fullchain.cer: permission denied
证书明明在,权限也对。问题在于:Caddy 以 caddy 用户运行,而 /root 目录是 0700——非 root 用户连目录都进不去,文件权限再对也没用。
做法是把证书复制到服务自己管得着的地方,而不是去放宽 /root 的权限:
$ mkdir -p /etc/caddy/certs
$ cp /root/.acme.sh/xxx/fullchain.cer /etc/caddy/certs/
$ cp /root/.acme.sh/xxx/xxx.key /etc/caddy/certs/
$ chown -R caddy:caddy /etc/caddy/certs
$ chmod 644 /etc/caddy/certs/*
教训:不要去 chmod 755 /root。把资源搬到服务能访问的位置,保持最小暴露。
坑三:续期钩子指向了错误的旧服务
部署完顺手查了证书续期配置,发现:
Le_ReloadCmd='systemctl restart hysteria-server'
这台机器上确实跑着 hysteria,所以这个钩子不算错——但它只重启 hysteria,完全不管 Caddy。
后果是:证书到期自动续期成功,Caddy 手里却还是旧证书,网页直接报证书错误。而且这个故障要等到 90 天后才出现,极容易被忽略。
用 --install-cert 把 Caddy 接进续期链:
$ acme.sh --install-cert -d example.com \
--key-file /etc/caddy/certs/example.com.key \
--fullchain-file /etc/caddy/certs/fullchain.cer \
--reloadcmd "chown caddy:caddy /etc/caddy/certs/* && \
chmod 644 /etc/caddy/certs/* && \
systemctl restart caddy; \
systemctl restart hysteria-server"
注意末尾保留了原来的 hysteria 重载——别把已有逻辑覆盖掉。
教训:--install-cert 会用新命令**替换**整个 Le_ReloadCmd,所以要先看清楚原来的内容,合并而不是替换。
坑四:同步脚本硬编码文件列表
两台机器要保持内容一致,方案是一台做源、另一台定时拉取。第一版脚本写了个列表:
PAGES="/ /archive/ /about/ /style.css"
后来加了个 links.js——忘了往列表里加。
结果:源站正常,目标站那个文件 404。因为脚本用了"任一文件失败就整体放弃"的保护,它甚至没报错,只是安静地什么都没同步。
改成从首页自动发现资源:
# 抓首页,抽出所有站内路径
grep -oE '(href|src)="(/[^"#?]*)"' index.html | sed -E 's/.*"(\/[^"]*)"/\1/' | sort -u
后来删掉 links.js 时又暴露了同一问题的另一面:列表里还写着它,拉取失败导致同步再次放弃。
教训:同步脚本里不要维护文件列表,让它自己发现。而且"整体放弃"的保护策略要配合日志告警,否则失败是无声的。
坑五:验证要逐 URL 真跑,不能只看退出码
上面那个 404 是怎么发现的?靠逐页对比,不是靠脚本说"成功":
for u in / /links.js /style.css /archive/; do
h=$(curl -s -o /tmp/h -w '%{http_code}' --resolve src:443:1.2.3.4 "https://src$u")
v=$(curl -s -o /tmp/v -w '%{http_code}' --resolve dst:443:5.6.7.8 "https://dst$u")
hm=$(md5sum /tmp/h|cut -c1-8); vm=$(md5sum /tmp/v|cut -c1-8)
[ "$hm" = "$vm" ] && echo "$u ✓" || echo "$u ✗ $h/$hm vs $v/$vm"
done
脚本退出码 0,逐页对比才发现问题。
教训:退出码只表示"脚本跑完了",不表示"结果是对的"。验收要查最终状态。
坑六:对外暴露监控前,先看看里面有什么
当时想把监控面板也挂到公网,动手前先查了一下数据:
52 个监控项
├─ 13 项含内网地址(网关、NAS、各服务)
├─ 9 项内网设备名 + IP(路由器、插座、传感器)
├─ 7 项 SSH 主机名和端口
└─ 18 项纯内网端口探测
这些一旦公开,等于把内网拓扑和攻击面清单贴在网上。虽然私有地址公网访问不到,但攻击者拿到这份地图后,一旦有任何进入内网的途径,探测成本直接归零。
最终决定:监控留在内网,页面上不放任何内网入口。
教训:暴露任何东西之前,先 dump 一遍它的实际内容。 面板的"表面功能"和"实际泄露的信息"经常不是一回事。
总结一下
| 坑 | 根因 | 正确做法 |
|---|---|---|
| 装不上包 | 内存不足被 OOM,错误被吞 | 查 free -m,改用静态二进制 |
| 读不到证书 | /root 是 0700 | 复制到服务可访问目录 |
| 续期后服务没换证 | reload 钩子没包含新服务 | 用 --install-cert 合并钩子 |
| 同步漏文件 | 硬编码列表 | 自动发现 + 逐文件校验 |
| 以为同步成功 | 只看退出码 | 逐 URL 比对状态码和哈希 |
| 差点泄露内网 | 没看实际内容就暴露 | 先 dump 数据再决定 |
配置语法错会直接报错,反而好修;行为理解错是静默的——命令返回 0、服务正常启动,问题在别处等着。
所以排查时多问一句「这个操作依赖什么前提」,比反复检查配置有没有写错更有效。