← 返回首页

一次双机静态站部署,踩了六个坑

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、服务正常启动,问题在别处等着。

所以排查时多问一句「这个操作依赖什么前提」,比反复检查配置有没有写错更有效。

运维部署踩坑Caddy