配置漂移:每月一次的基线检查怎么做

一、为什么「线上和文档不一致」是运维最大的隐形债

很多站长接手一台跑了两三年的服务器,第一件事是翻文档,结果文档写的还是三年前的架构:文档上说 Nginx 在 /etc/nginx,实际上有人为了加模块编译到了 /usr/local/nginx;文档上说 PHP 7.4,实际上 php -v 出来是 8.1;文档上说备份脚本在 crontab 里,实际上每天早上是某个同事手动跑的。这种状态我叫它「配置漂移」——机器的真实状态和任何人认知中的状态逐渐分离,而且分离过程是静默的、没有告警的。

它之所以危险,是因为它平时完全不疼。业务跑着,访问正常,监控全绿。只有在需要通过「重建」来恢复的时候(换机器、迁移机房、误删后还原、灾备演练),漂移才一次性爆发:你按文档重建出来的机器,和原来那台根本不是同一台,而原来的那台已经不在了。这时候你会发现,救命的那些细节——一个写在 /etc/security/limits.d/ 里的文件描述符上限、一条手工加的 iptables 规则、一个改动过的 php.ini 参数——全都不在任何文档里。

这篇文章讲的是把「配置漂移」量出来、收回去的具体做法:先做一次基线盘点,再把配置纳入版本控制,然后建立每个月的漂移检查,最后写一份真正能用来重建机器的文档。全部是我在一台跑了六年、被四五个人轮流改过的小站服务器上实际做过的,不是理论。

二、第一步:做一次「重建清单」盘点,而不是「配置清单」

常规的配置盘点会列出「装了哪些软件、开了哪些端口」,这个清单对迁移几乎没用。真正有用的是「如果明天这台机器没了,我要在哪些地方动手才能让它恢复成现在这样」。所以盘点要从「重建动作」倒推,而不是从「已安装」正推。

我习惯用一张表,五列:项目、实际位置、当前值、来源(手工/包管理/配置管理)、是否在版本控制里。第一轮填表不要追求全,先覆盖六类最容易漂的东西:

  • Web 与运行时配置:Nginx 主配置与 vhost 目录、PHP-FPM 池配置、php.ini 的覆盖参数、MySQL 的 my.cnf 位置与 include 目录。
  • 系统级限制:/etc/security/limits.conf 与 limits.d/、sysctl.d/ 里的内核参数、systemd 的 unit 覆盖(systemctl cat 看到的 *.d/*.conf)。
  • 定时任务:crontab -l(每个用户都要看)、/etc/cron.*、systemd timer 的列表。
  • 网络与防火墙:iptables/nftables 的 actual ruleset(不是配置文件,是 -S 导出的内容)、监听端口与对应进程。
  • 目录布局:站点根目录真实路径、日志目录、备份目录、数据盘挂载点与 fstab 条目。
  • 证书与密钥位置:证书文件路径、私钥权限、是否有软链接指向某个会自动续期的目录。

「实际位置」这一列是关键。不能只看默认路径,必须用命令确认真相:

# Nginx 真实加载的是哪份配置(编译参数决定)
nginx -V 2>&1 | tr ' ' '\n' | grep -E 'conf-path|prefix'

# 当前进程用的配置文件(不信文档,信进程)
ps -eo pid,args | grep -E 'nginx: master|php-fpm: master|mysqld' | grep -v grep

# 所有用户与系统定时任务
for u in $(cut -d: -f1 /etc/passwd); do echo "== $u"; crontab -l -u "$u" 2>/dev/null; done
systemctl list-timers --all --no-pager

# 实际生效的防火墙规则(不是文件内容)
nft list ruleset 2>/dev/null | head -40
iptables-save 2>/dev/null | head -40

# 实际生效的内核参数(不是文件内容)
sysctl -a 2>/dev/null | grep -E 'net.core.somaxconn|fs.file-max|vm.swappiness'

这一步花掉我大概两个小时,产出的表直接决定了后面所有工作的边界。特别注意 nginx -V 和 ps 这两个命令:一台被多人改过的机器上,「服务实际在用哪个配置」是最常见的认知错误来源。

三、第二步:把配置纳入版本控制,但别把整台机器都塞进去

很多人一听配置管理就想到 Ansible、Puppet,或者干脆把整个 /etc 丢进 Git。前者对小站是过度工程,后者会把密码、私钥、随时变动的缓存文件全卷进去,最后仓库里 90% 的 diff 是噪声,没人愿意看,等于没管。

我的做法是「白名单 + 单一仓库」:只把真正需要重建的配置纳入 Git,放在一个专门的目录里,用软链接或 include 让服务读它,或者干脆只做「快照 + 部署脚本」。对已经跑起来的机器,最小侵入的方案是后者——不改服务读取路径,只做定期导出。

#!/bin/bash
# /usr/local/bin/cfg-snapshot.sh —— 导出关键配置到版本控制目录
set -Eeuo pipefail
DEST=/srv/config-src/$(hostname -s)
mkdir -p "$DEST"/{nginx,php,mysql,system,cron,net}

# Nginx:只导 vhost 与主配置,跳过 cache 与 logs
rsync -a --delete /etc/nginx/nginx.conf "$DEST/nginx/"
rsync -a --delete --exclude '*.pid' /etc/nginx/conf.d/ "$DEST/nginx/conf.d/"
[ -d /etc/nginx/sites-enabled ] && rsync -a --delete /etc/nginx/sites-enabled/ "$DEST/nginx/sites-enabled/"

# PHP:池配置 + 被改过的 ini 参数(不是整份 ini)
cp -a /etc/php/*/fpm/pool.d/ "$DEST/php/" 2>/dev/null || true
php --ini | sed -n 's/^Loaded Configuration File: *//p' > "$DEST/php/ini-path.txt"
grep -vE '^\s*(;|$)' "$(cat "$DEST/php/ini-path.txt")" > "$DEST/php/ini-effective.txt" || true

# MySQL:只留 my.cnf,不含数据目录与 binlog
cp -a /etc/my.cnf "$DEST/mysql/" 2>/dev/null || cp -a /etc/mysql/my.cnf "$DEST/mysql/" 2>/dev/null || true

# 系统层:sysctl / limits / systemd 覆盖文件
sysctl -a 2>/dev/null | grep -vE '^\s*#' > "$DEST/system/sysctl-effective.txt"
cp -a /etc/security/limits.d/ "$DEST/system/" 2>/dev/null || true
systemctl list-unit-files --state=enabled --no-pager > "$DEST/system/enabled-units.txt"
systemctl cat nginx php-fpm mysqld > "$DEST/system/unit-cat.txt" 2>/dev/null || true

# 定时任务与网络现状
crontab -l > "$DEST/cron/root.crontab" 2>/dev/null || true
systemctl list-timers --all --no-pager > "$DEST/cron/timers.txt"
nft list ruleset > "$DEST/net/nftables.txt" 2>/dev/null || true
ss -lntup > "$DEST/net/listen.txt"

cd /srv/config-src
git add -A
git -c user.name=cfg -c user.email=cfg@localhost commit -q -m "snapshot $(date +%F_%H%M)" || echo "no change"

用 systemd timer 每天跑一次,产生的 diff 就是「昨天到今天机器被改了什么」。这个 diff 的价值极高:某天首页突然 502,而你看到 git log -p 里下午三点有人改了 pool.d/www.conf 的 pm.max_children,排查时间从两小时变成两分钟。

三个必须注意的点:

  • 导出「生效值」而不是「配置文件」。上面同时导了 sysctl-effective.txt、ini-effective.txt、unit-cat.txt,因为 systemctl cat 会把主 unit 和所有 *.d 覆盖文件拼在一起,这才是真实生效的内容。
  • 排除 volatile 内容:不要导 *.pid、cache、session 文件、日志,否则每天都有几十个文件的噪声 diff,没人看。
  • 敏感信息单独处理:私钥、数据库密码不要进 Git 仓库。要么用占位符替换后提交,要么在仓库里放 .gitignore 并另用加密方式保存。一个泄露的 Git 仓库比没有仓库更糟。

四、第三步:每月一次漂移检查,三个数字看出问题

有了快照,配置就变成了可比较的东西。每月花十分钟做一次检查,只看三个数字:

数字一:上次快照提交距今天数。如果超过两天没提交,说明 timer 挂了或者 git commit 一直在报错。这个数字是最基础的活性检查,低于三天的值都算异常。

数字二:最近三十天的提交次数。正常稳定运行的机器一个月应该只有几次提交(系统包升级、证书轮换)。如果一个月有三十几次提交,说明有人在频繁手工改动,这是漂移的高风险信号。要去看每次提交都改了什么,然后问清楚:这个改动是临时的还是永久的?临时的要写到期时间。

数字三:当前配置与「上一版重建脚本」的差异行数。重建脚本是下一节要讲的产物。如果 git diff 显示某份配置有几十行变化,那重建脚本大概率已经对不上了。

cd /srv/config-src
echo "天数: $(( ( $(date +%s) - $(git log -1 --format=%ct) ) / 86400 ))"
echo "三十天提交: $(git rev-list --since='30 days ago' --count HEAD)"
echo "未提交改动:"; git status --porcelain | head -20

漂移检查真正要做的事不是「发现差异」,而是「决定每个差异的归宿」。我把它归成三类,每类处理方式不同:

  • 应该成为基线:临时调大 client_max_body_size 之后没改回去,而效果确实更好 → 更新基线文档与重建脚本,写清楚为什么是这个值。
  • 应该被撤回:为了排查问题加的调试日志、临时关掉的缓存、为某次活动改短的 TTL → 立刻恢复,或者加一个明确的到期日期并设为到期告警。
  • 应该被记录但不动:业务决定了某个非常规配置(比如为了兼容老客户端必须保留的低版本 TLS)→ 写进「已知偏离」清单,注明原因与复查日期。这份清单才是接手者最需要看的。

五、第四步:写一份能真正重建机器的文档

大部分运维文档写成了「说明书」,读者看完不知道从哪下手。有用的文档应该写成「开工单」:从头到尾照着敲,每条命令都有预期输出,每一步失败都有下一步怎么办。

我把重建文档切成五段,每段一页以内:

  1. 基础环境:系统版本、内核版本、时区、hostname、SSH 端口与密钥登录要求、必要的基础包清单。
  2. 目录与磁盘:挂载点表(设备、挂载点、文件系统、是否自动挂载)、站点根目录路径、日志目录路径、权限与属主要求。
  3. 服务安装与基础配置:每个服务的「来源」(发行版仓库/官方源/编译)与版本、配置文件的来源路径(Git 仓库里的哪个目录)、必须的编译参数或扩展。
  4. 数据恢复:数据库从哪份备份恢复、恢复后要跑哪条校验(行数、最大 ID、某张关键表的校验和)、附件从哪恢复、恢复后的权限修复命令。
  5. 验收:五到十条可执行判断。例如「curl -I 返回 200 且 Server 头与旧机一致」「数据库关键表行数在 ±1% 内」「队列积压为 0」「证书链完整」「定时任务列表与旧机差集为空」。

关键是验收段:没有验收标准的重建文档等于没有重建文档。判断方式要可执行、可对比,而不是「检查网站是否正常」这种废话。我习惯把旧机的这些数字先抓下来存进仓库,重建后直接 diff:

# 重建前在旧机上抓基线,存进仓库
mkdir -p /srv/config-src/baseline
ss -lntup | awk '{print $1, $5}' | sort > /srv/config-src/baseline/ports.txt
mysql -N -e "SELECT table_name, table_rows FROM information_schema.tables
             WHERE table_schema='blog' ORDER BY table_name" > /srv/config-src/baseline/rows.txt
find /var/www/blog/usr/uploads -type f | wc -l > /srv/config-src/baseline/uploads-count.txt
du -sh /var/www/blog/usr/uploads > /srv/config-src/baseline/uploads-size.txt
nginx -V 2>&1 | tr ' ' '\n' | sort > /srv/config-src/baseline/nginx-build.txt

重建完成后,同样几条命令再跑一遍,diff 出结果。端口列表一致、表行数差异在可解释范围内、附件数与体积一致、Nginx 编译参数一致——这四个全对,才算重建完成。任何一个对不上,就把那一条补进重建文档,这样文档每演练一次就更准一分。

六、第五步:把「重建」变成可演练的动作

文档写完不算完,必须演练。最省钱的方式不是买一台新服务器,而是在现有机器上开一个容器或一台轻量 VM,按文档走一遍「只做前四步、不接流量」的重建,跑一遍验收,然后删掉。整个过程一个下午,成本几乎为零,暴露的问题却非常真实:

  • 某条命令依赖了一个你没写进文档的环境变量;
  • 某个配置文件的权限在新建机器上是 0644,而服务要求 0640,导致启动失败;
  • 数据库恢复后 lower_case_table_names 与线上不一致,导致某些查询突然报找不到表;
  • 附件恢复后属主是 root,PHP 进程没有写权限,上传功能静默失败。

这四类问题在真实故障里都会出现,而在演练里遇到时成本极低。演练频次建议是:重大变更(换 PHP 大版本、换数据库版本、换主题框架)之后必须演练一次,其余每季度一次。

七、这套做法在小站上的时间成本

很多站长看到「基线盘点 + 版本控制 + 月度检查 + 重建文档 + 季度演练」会觉得太重。实际的时间投入是这样的:第一轮盘点两小时,写快照脚本半小时,写重建文档两到三小时,之后每月检查十分钟、每季度演练一个下午。总投入不到一天,换来的是「机器没了也能在几小时内恢复」这个能力。

对比一下不做的代价:某天硬盘坏了、服务商封了机器、误操作删了配置,你手上只有一份三个月前的数据库备份和一堆回忆。恢复过程会变成一场持续十几个小时的、没有方向的考古。这个风险对小站尤其不划算——小站没有专职运维,出问题的时候往往只有你一个人,而且往往是深夜。

八、四个最容易踩的坑

坑一:把「装了配置管理工具」当成「消掉了漂移」。Ansible playbook 能重造一台机器,但如果有人在机器上手工改了一个 sysctl 而没写回 playbook,下次 ansible-playbook 跑完,这个改动会消失——或者更糟,它不会消失,因为 playbook 根本不管理这一项,于是漂移继续存在。工具不解决问题,覆盖范围才解决问题。用快照 diff 来验证「playbook 覆盖的配置」是否真的是全部。

坑二:导出了配置文件,但没导出「生效值」。我踩过这个坑:一台机器的 limits.d/ 里有两份文件,一份把 nofile 设成 65535,另一份(后来加的)设成 1024,而 systemd 服务的实际限制又被 unit 文件里的 LimitNOFILE 覆盖成 8192。三份文件,三个值,真实生效的是第三个。cat /proc/$(pgrep -f php-fpm | head -1)/limits | grep 'open files' 才能看到真相。

坑三:把密码和私钥一起提交。小站最容易犯这个错,因为「反正仓库是私有的」。但仓库会被 clone 到本地、会被备份、会被人分享。稳妥做法是 .gitignore 里排除所有含 secret 的文件,仓库里只放 *.example 模板,真实值在部署时由环境变量或本地文件注入。

坑四:文档写完就归档,再也没人打开。判断一份重建文档是否还有效,最简单的方法是:每季度演练时把它当成唯一的参考资料,不许打开旧机器查。只要演练时你需要「凭记忆补一条」,就把这条立刻写进文档。

九、小结

配置漂移的本质是「认知与事实的分离」,它不会报错,只在最需要精确的时候爆发。把这件管起来不需要复杂的工具,需要的是三个习惯:每次改动后机器状态能被导出成可比较的文本;每天有一个安静的 diff 告诉你昨天发生了什么;文档写的是「怎么重建 + 怎么验收」,而不是「我们有哪些配置」。

这套东西做到位之后,你会得到两个额外收益。一是排查故障的速度会明显变快,因为 git log 就是一个免费的操作审计记录;二是心理上的轻松——你不再需要「记住」服务器上的一切,因为服务器自己会记住。

Last modification:November 5th, 2026 at 08:27 pm

Leave a Comment