给服务器配置加测试:从静态检查到冒烟脚本

一、改一行配置,谁来看住它

个人站长的服务器上,很多事故不是被攻击,而是自己改的:为了修一个 404 顺手改了 Nginx 的 try_files,为了调一个超时改了 PHP 的 max_execution_time,为了试一个新插件动了 wp-config.php。改的时候心里没底,改完凭「刷新一下看着正常」就收工。第二天流量上来,或者某个只在特定路径下走的分支被触发,问题才爆出来。

大公司靠 CI/CD 和一堆集成测试兜这种风险,小站显然没这个条件。但「没有 CI」不等于「只能靠运气」——在服务器上放一份最小化的测试与静态检查,把最容易错的那几个点自动看住,成本其实很低。这篇文章讲的就是这套东西:不引入任何框架,只用 bash、shellcheck、几个 curl 断言和 Cron,就能拦住大部分「改完就坏」的情况。

二、先分清三类检查,别一上来就想写单测

给服务器配置写测试,第一件事是承认它不是业务代码。对一台 Nginx + PHP + 少量自写脚本的机器,真正有价值的检查只有三类:

  • 静态检查:配置文件的语法对不对、shell 脚本有没有明显的地雷。特点是毫秒级、零依赖、能在改动瞬间给出结果。
  • 冒烟测试:服务起来之后,关键 URL 是不是还能返回 200、关键端口是不是还在听、证书是不是还没过期。特点是一秒钟内能跑完。
  • 回归断言:把「上次修好过的那个 bug」变成一个可重复执行的检查,防止它复发。

真正需要「单元测试」这种重武器的场景很少——如果你自己写了 PHP 插件或 Go 服务,那确实应该写;但对「调配置」这件事,把 nginx -t 和二十条 curl 断言跑起来,收益远大于强行搭一套 PHPUnit。

三、静态检查:四条命令覆盖 90% 的配置事故

机器上需要检查的配置就那么几类,每一类都有官方的语法检查器,别自己写正则去猜:

# 1) Nginx:改完必须 -t,且要在 reload 之前
nginx -t -c /etc/nginx/nginx.conf

# 2) PHP-FPM:没有 -t,但 -tt 会把解析后的完整配置打出来,能暴露 include 错位
php-fpm8.2 -tt 2>&1 | tail -5

# 3) MySQL / MariaDB:检查 my.cnf 里有没有写错的指令
mysqld --validate-config

# 4) systemd 单元:改了 .service 之后
systemd-analyze verify /etc/systemd/system/myapp.service

这四个命令里,systemd-analyze verify 最容易被忽略。它的输出里有一类 Unknown key name 警告是真正会致命的:单元文件里写了一个拼错的 Restart=always1,systemd 会忽略这一行而不报错,于是你以为配了自动重启,实际没有。这种「静默忽略」是运维配置里最阴的一类失败。

另外两个不起眼但很有效的检查:

# 配置里引用的路径是否真实存在(证书、日志目录、include 文件)
grep -ohE '/etc/[a-zA-Z0-9._/-]+' /etc/nginx/conf.d/*.conf \
  | sort -u | while read -r p; do [ -e "$p" ] || echo "MISSING $p"; done

# 磁盘上还剩多少 inode(小文件多的站点最容易被 inode 而不是空间打满)
df -i / | awk 'NR==2 {print "inode 使用率", $5}'

四、shellcheck:把「删错文件」挡在提交之前

运维脚本的事故比配置事故更狠,因为脚本能执行 rm。shellcheck 是个纯静态分析器,不执行脚本,装完直接跑:

apt-get install -y shellcheck
shellcheck -S warning /usr/local/bin/*.sh

-S warning 的意思是「只报 warning 及以上」,因为默认的 info 级别会报出一堆 Quote this to prevent word splitting,噪音太大容易让人放弃。第一次跑的结果通常是这样:一大片 SC2086(没加引号)、几个 SC2046(命令替换没加引号)、一两个 SC2181(用 $? 判断命令结果)。这些绝大多数是真问题,改起来也快。

真正救命的告警是这几个:

  • SC2115:rm -rf "$DIR"/ 里 $DIR 为空时会变成 rm -rf /。shellcheck 会明确提示「use ${var:?} to prevent this」。
  • SC2181:先执行再用 if [ $? -eq 0 ] 判断,中间插了任何命令就失效。
  • SC2064:trap "rm -f $tmp" EXIT 用双引号,变量在设置 trap 的瞬间就被展开了,实际上是「立刻删」或「删错文件」;应该用单引号。

把这条命令挂进 crontab,每天跑一次并把输出发到自己邮箱,比事后翻日志强得多:

SHELL=/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
30 4 * * * shellcheck -S warning /usr/local/bin/*.sh 2>&1 | grep -E '^In ' || true

注意 PATH 那一行。cron 里的 PATH 跟你 SSH 登录时的完全不一样,很多「crontab 手动能跑、定时不执行」的问题都出在这里——这是本系列文章里反复出现的第一名原因。

五、冒烟测试:二十条断言看住整站

下面这个脚本是整篇文章的核心,放在 /usr/local/bin/smoke.sh,每次改完配置手动跑一遍,也挂到每小时一次。

#!/bin/bash
set -uo pipefail

HOST=https://www.example.com
FAIL=0
ok()   { echo "PASS $1"; }
bad()  { echo "FAIL $1"; FAIL=$((FAIL+1)); }

chk_code() {  # 名称 URL 期望码
  local code
  code=$(curl -s -o /dev/null -w '%{http_code}' -m 10 --retry 1 "$2")
  [ "$code" = "$3" ] && ok "$1 $code" || bad "$1 期望$3 实际$code"
}

chk_code "首页"        "$HOST/"                 200
chk_code "文章页"      "$HOST/1915.html"        200
chk_code "不存在的页"  "$HOST/no-such-page-xx"  404
chk_code "后台登录"    "$HOST/admin/login.php"  200
chk_code "sitemap"     "$HOST/sitemap.xml"      200
chk_code "robots"      "$HOST/robots.txt"       200

# 1) 首页正文里必须有关键串(防止白屏/维护页)
body=$(curl -s -m 10 "$HOST/")
grep -q 'article' <<<"$body" && ok "首页有内容" || bad "首页无内容"

# 2) HTTPS 证书剩余天数
end=$(echo | openssl s_client -connect www.example.com:443 -servername www.example.com 2>/dev/null \
      | openssl x509 -noout -enddate | cut -d= -f2)
days=$(( ( $(date -d "$end" +%s) - $(date +%s) ) / 86400 ))
[ "$days" -gt 14 ] && ok "证书剩余${days}天" || bad "证书只剩${days}天"

# 3) 负载与磁盘
load1=$(cut -d' ' -f1 /proc/loadavg)
awk -v l="$load1" 'BEGIN{exit !(l < 8)}' && ok "负载$load1" || bad "负载$load1"
use=$(df --output=pcent / | tail -1 | tr -dc '0-9')
[ "$use" -lt 85 ] && ok "磁盘${use}%" || bad "磁盘${use}%"

# 4) 关键服务在听
for p in 80 443 3306; do
  ss -lnt | grep -q ":$p " && ok "端口$p" || bad "端口$p 未监听"
done

echo "--- 失败 $FAIL 项"
exit $((FAIL > 0))

脚本里有几个刻意的写法,值得单独说明。set -uo pipefail 而不是 set -e:因为冒烟测试必须跑完全部断言才知道到底坏了几处,set -e 会在第一条失败处直接退出。但 pipefail 保留,能抓出管道中间的失败。grep -q ... <<<"$body" 而不是 echo "$body" | grep:herestring 不产生子进程,也避免 echo 对 -n 之类内容的解释。

六、把「修过的 bug」变成回归断言

这才是测试真正增值的地方。举三个真实例子,每个都是曾经挂过一整天的问题:

例一,搜索页被爬虫打挂。 之前有一次百度蜘蛛毫无节制地爬 ?s= 搜索页,每次请求都要跑一遍全表 LIKE,机器直接飙到 100%。修复是给搜索页加了限速、并让搜索页返回 noindex。回归断言就两条:

# 搜索页必须带 noindex
curl -s "$HOST/?s=test" | grep -qi 'noindex' && ok "搜索页 noindex" || bad "搜索页缺 noindex"
# 连续 20 次请求,必须出现 429 或者至少全部 200,不能出现 502
for i in $(seq 20); do
  curl -s -o /dev/null -w '%{http_code}\n' "$HOST/?s=load$i"
done | sort | uniq -c

例二,附件下载 404。 换服务器时忘了把 /usr/uploads 软链接带过去,日志里 404 一片。回归断言:

# 从数据库里随机抽 3 个附件路径,逐个查 200
mysql -N -B -e "SELECT text FROM typecho_contents ORDER BY RAND() LIMIT 3" zz1984 \
  | grep -oE '/usr/uploads/[^")]+' | while read -r u; do
      chk_code "附件 $u" "$HOST$u" 200
  done

例三,缓存把登录页也缓存了。 CDN 规则贪心地匹配了 /*,结果后台登录页被缓存 7 天,所有人都拿到同一个 CSRF token。回归断言:拿两次登录页,比较 Set-Cookie 或页面里的 token 是否不同。

a=$(curl -s "$HOST/admin/login.php" | md5sum)
sleep 2
b=$(curl -s "$HOST/admin/login.php" | md5sum)
[ "$a" != "$b" ] && ok "登录页未缓存" || bad "登录页被缓存了!"

七、发布闸门:把检查放进改配置的动作里

检查写好了,但人不会记得跑。唯一的办法是把它变成动作的一部分。最简单的落地是一个 deploy-nginx.sh,所有人都必须通过它改配置:

#!/bin/bash
set -euo pipefail
CONF=/etc/nginx/conf.d/site.conf
cp -a "$CONF" "/root/backup/site.conf.$(date +%Y%m%d%H%M%S)"

nginx -t                                  # 第一道:语法
systemctl reload nginx                    # 平滑重载,不断连接
sleep 2
/usr/local/bin/smoke.sh || {
  echo "冒烟失败,自动回滚"
  cp -a "$(ls -t /root/backup/site.conf.* | head -1)" "$CONF"
  systemctl reload nginx
  exit 1
}
echo "发布成功"

三个细节决定了这个脚本在关键时刻能不能救场。备份文件名带秒级时间戳,用 ls -t | head -1 取回滚点,比维护「latest」软链接可靠;systemctl reload 而不是 restart,前者是平滑的,后者会掐断正在处理的请求;冒烟失败自动回滚并且返回非零,这样在 crontab 或其他脚本里调用时会立刻被发现。

顺带说一个反面经验:不要写「回滚后不再重载」的版本。曾经有一次脚本回滚了文件但忘了 reload,配置在磁盘上是对的、内存里还是错的,排查花了二十分钟。任何一次 cp 覆盖配置,后面都必须跟一次 reload。

八、这些检查做不到什么

必须承认边界,否则会过度信任它。冒烟测试只能覆盖你想到的 URL,它发现不了「某个只在移动端触发的 JS 报错」「某个老用户浏览器缓存了旧 CSS」「某条只在月末跑的统计 SQL 超时」。静态检查也发现不了「逻辑是对的但业务规则错了」。

所以这份清单的定位是网底,不是质量保证。它拦的是那 70% 的「低级错误导致整站挂」——配置语法、端口没听、证书过期、磁盘满了、附件 404、登录页被缓存。剩下的 30% 仍然需要人去看页面、看日志、看真实用户的反馈。上这套东西的收益不是「不用管了」,而是「不用再为低级的错误熬夜」。

九、小结

给一台小服务器加上最小化的测试,一共五件事:改配置前用官方检查器过一遍(nginx -t / systemd-analyze verify)、运维脚本过一遍 shellcheck、写一个二十条断言的冒烟脚本、把修过的 bug 变成可重复的回归检查、再用一个带自动回滚的发布脚本把前面这些串成一道闸门。整个成本大概是两个下午,之后每次改配置都能在三秒内知道有没有搞坏东西。

如果只能做一件事,那就先写冒烟脚本的第 1、2 条:首页 200、证书未过期。这两条能挡掉我见过最多的事故。

Last modification:October 29th, 2026 at 07:24 pm

Leave a Comment