etckeeper 配置漂移监控实战:每天一次 /etc 差异快照,把「谁改了我的服务器」变成可追溯的记录

做个人站长这些年,我踩过的最难查的一类故障,不是「服务起不来」,而是「服务好好的,某天开始不对劲」。Nginx 的某个 location 突然不生效了,PHP 的 upload_max_filesize 又变回 2M 了,ssh 端口莫名被改成了 22,fail2ban 的规则被人加了一条。这些问题的共同点是:没人记得自己改过,而改动本身又确实发生过。

这类问题在单机时代还好说,直觉上总有印象;一旦手上同时运维三四台机器,或者站点跑在两年没重装过的老 VPS 上,记忆就完全不可靠了。解决它的唯一办法,是把「配置」当作代码来对待:有基线、有版本、有差异、有告警。这篇文章讲的是我自己在用的那套最小方案,核心工具只有一个——etckeeper,加上一个每天跑一次的 diff 脚本。

一、先把问题问对:什么才算「配置漂移」

配置漂移(configuration drift)指的是一台机器的实际状态,与它「应该有的状态」之间的偏差。注意这个定义里有个默认前提:你得先知道「应该有的状态」是什么。很多站长从来没定义过它,所以漂移这个词对他们没有意义——因为无从比较。

配置漂移大致分三类,处理方式完全不同:

第一类是有意的、有记录的变化。比如你为了支持上传 20MB 的图片,把 nginx 的 client_max_body_size 从 1m 改到 20m。这是正常运维,不算事故。但它必须留下记录,否则三个月后你不知道为什么是 20m,也不敢改回去。

第二类是有意的、无记录的变化。比如你半夜排查故障,随手在 sshd_config 里加了一句 PermitRootLogin yes 想先登进去看看,然后忘了删。

第三类是无意的、甚至不是你改的。包管理器升级时覆盖了你的配置;某个一键脚本(面板、LNMP 安装包、Docker 镜像的 entrypoint)擅自改写了 /etc 下的文件;被入侵后攻击者植入的 cron 任务和 ssh 公钥。

第三类最危险,也最难发现,因为它的症状往往不是「报错」,而是「行为变了」。我见过一个真实案例:某台机器上的 certbot 定时任务被人加了一条 curl 到外部地址的命令,机器负载没有任何异常,只有人偶尔看一眼 crontab 才会发现。

所以我们的目标不是「禁止变化」——那不可能,运维本来就是在改配置——而是让每一次变化都可见。可见之后,才有判断的可能。

二、为什么 /etc 下面总在偷偷变

先理解变化的来源,才能设计出合理的监控频率和告警策略。在典型的 Debian 服务器上,/etc 的变化主要来自这几个方向:

包管理器的升级。apt upgrade 时,如果某个包的配置文件有新版,dpkg 会问你「保留本地版本 / 安装维护者版本 / 看差异」。这个交互在无人值守的 unattended-upgrades 下会自动选择保留本地版本(.dpkg-dist 出现),但你下次手动改配置时,很可能没注意到旁边多了一个 nginx.conf.dpkg-dist。

这类变化的特征是:文件被替换,可能伴随备份文件后缀(.dpkg-old、.dpkg-dist、.ucf-old,其中 ucf 系列来自 Debian 的配置文件同步工具)。

服务的自我修改。这一点很多人不知道:有些服务在运行时就会改写自己的配置或状态文件。典型例子是 fail2ban 在 jail.local 里写日志路径、certbot 在 renewal 目录里更新时间戳、Systemd 的 machine-id 与随机种子文件。这类变化是「正常噪音」,如果不用白名单过滤,你的 diff 每天都会是一堆同样的行。

运维脚本与面板。宝塔、aaPanel、OneinStack 这类面板会持续接管 /etc 下的部分文件。更隐蔽的是各类「一键优化脚本」,它们往往直接 sed -i 改 sysctl.conf、limits.conf、sshd_config 而不留任何说明。

入侵与供应链攻击。攻击者最喜欢动的位置排序很稳定:/etc/crontab、/etc/cron.d/、/etc/ssh/sshd_config、authorized_keys(在 /root/.ssh 下,不在 /etc,但同理)、/etc/ld.so.preload(隐藏恶意 so)、/etc/passwd 里新增的 UID 0 账号。

理解这四类来源后,一个合理的结论浮现出来:监控 /etc 的价值,一半在「找回我改了什么」,另一半在「发现别人改了什么」。前者提高效率,后者是安全底线。对个人站长来说,后者才是不可替代的部分。

三、基线:用 etckeeper 把 /etc 纳入版本管理

etckeeper 是老牌工具,思路非常简单:在 /etc 里初始化一个 Git(或 Mercurial、Bazaar)仓库,用 apt 的 hook 在每次包操作前后自动提交,再加一个 daily 定时任务提交非包操作带来的变化。

安装非常轻量:

apt-get install -y etckeeper git
# 关键:让 etckeeper 把 /etc 的 .git 目录纳入运行时忽略,避免权限混乱
cat > /etc/etckeeper/etckeeper.conf <<'EOF'
VCS="git"
AVOID_DAILY_AUTOCOMMITS=0
AVOID_COMMIT_BEFORE_INSTALL=0
HIGHLEVEL_PACKAGE_MANAGER=apt
LOWLEVEL_PACKAGE_MANAGER=dpkg
GIT_COMMIT_OPTIONS=""
EOF
etckeeper init
etckeeper commit "initial baseline"

安装完成后,/etc 就变成了一个 Git 仓库。你可以随时 cd /etc && git log --oneline -20 看最近发生了什么,也可以 git diff HEAD~5 -- nginx/ 看某个目录这几天的变化。

这里有几个实战注意点,是文档里不太提的:

权限问题。/etc 里有大量敏感文件(shadow、ssl 私钥、各种密码配置),而 /etc/.git 默认是 root 可读、其他用户不可读,这一点 etckeeper 已经处理好了。但如果你的 /etc 被某个 www-data 用户的脚本写入了文件,Git 会以 root 身份操作它们,可能改变属主。建议在 etckeeper.conf 里确认 GIT_COMMIT_OPTIONS 与用户身份设置正确。

大文件与二进制。如果把整个 /etc 提交进 Git,偶尔会有几 MB 的证书、GeoIP 数据库之类混进来,仓库体积增长很快。可以用 git config core.bigFileThreshold 1m 让 Git 对大文件存成 blob 而不做 delta 压缩,节省 CPU;更根本的做法是在 /etc/.gitignore 里排除这类明显不需要版本化的路径,比如某些面板生成的缓存目录。

不要用 etckeeper 管 /etc 之外的路径。这点必须强调。很多人会想着「那我把 /var/www 的 nginx 配置也纳进来吧」,但 /var/www 里通常有几个 G 的网站数据,纳入 Git 会直接拖垮机器。网站代码用独立的 Git 仓库,配置文件才归 etckeeper。职责要分清。

初始化之后,你就有了「基线」这个概念:git log 里的每一条提交,都是某一时刻 /etc 的快照。这是整套方案的地基,没有它,后面的 diff 就只是两个随机时间点的对比,没有历史纵深。

四、差异:每天一次 diff,把变化压缩成可读的清单

有了仓库,每天要做的事情就一句话:把当前 /etc 与昨天的快照比一下,把变化输出成人类能读的清单。

这里有个重要的设计选择:不要用 git diff 直接输出全文。/etc 一次升级可能涉及几十个文件,全量 diff 几百行,没人看。我的做法是分两步:先出一份「变化文件清单」,再对其中敏感的文件单独看内容。

#!/bin/bash
# /usr/local/bin/etc-drift-report.sh
set -euo pipefail
REPORT=/var/log/etc-drift/$(date +%F).txt
mkdir -p /var/log/etc-drift
cd /etc

# 1) 未提交的变化(= 今天的改动还没进 Git)
CHANGED=$(git status --porcelain || true)
# 2) 昨天那笔提交与你当前 HEAD 的差异文件列表
FILES=$(git diff --name-status HEAD~1 HEAD 2>/dev/null || true)

{
  echo "===== 未提交变化 $(date '+%F %T') ====="
  echo "$CHANGED"
  echo
  echo "===== 最近一次提交涉及的文件 ====="
  echo "$FILES"
  echo
  echo "===== 敏感路径明细 ====="
  for f in etc/ssh/sshd_config etc/crontab etc/passwd etc/sudoers \
           etc/ld.so.preload etc/fail2ban/jail.local; do
    if echo "$CHANGED $FILES" | grep -q "$(basename "$f")"; then
      echo "---- $f ----"
      git diff HEAD -- "/$f" 2>/dev/null | head -60 || true
    fi
  done
} > "$REPORT" 2>&1

# 只保留 30 天
find /var/log/etc-drift -name '*.txt' -mtime +30 -delete

把这个脚本挂到 systemd timer 上,每天凌晨三点跑一次:

# /etc/systemd/system/etc-drift.service
[Unit]
Description=etckeeper drift report
[Service]
Type=oneshot
ExecStart=/usr/local/bin/etc-drift-report.sh

# /etc/systemd/system/etc-drift.timer
[Unit]
Description=Daily etckeeper drift report
[Timer]
OnCalendar=*-*-* 03:20:00
Persistent=true
[Install]
WantedBy=timers.target

注意 Persistent=true 这一行:如果你的机器在凌晨三点关机或者重启了,下次开机时它会补跑一次。这一点在「任务没执行」类故障里非常常见——很多站长写完 timer 从来不 care 机器是否在线,结果一周后才发现在那台不常开的机器上任务一次都没跑过。

第二天早上,你只需要 cat /var/log/etc-drift/$(date +%F).txt,变化是什么一目了然。

五、仓库本身怎么不撑爆磁盘

这是 etckeeper 用久了必然遇到的问题:一年之后 /etc/.git 涨到几百 MB,甚至出现过把根分区塞满的情况。原因有几个:

高频变化的文件。某些服务每次启动都会改写配置文件里的时间戳或 PID,导致每天都有 commit。如果这类文件有几十个,Git 的对象数量会迅速膨胀。

大二进制文件。GeoIP 数据库、字体缓存、证书打包文件,每次更新都是一份新的完整对象。

历史从不清理。默认 Git 不做 gc,etckeeper 的 daily 任务也不会主动压缩对象。

我的处理办法是三步:

第一步,在 /etc/.gitignore 里排除明确不需要版本化的路径。常见的有:面板的运行时缓存、/etc/letsencrypt/archive 下的证书私钥备份(renewal 目录保留即可)、各种 *.pid 与 *.cache。排除之后 git rm --cached 让它们退出索引。

第二步,每周做一次 gc 加 prune:

cd /etc
git reflog expire --expire=90.days --all
git gc --prune=now --aggressive
du -sh /etc/.git

第三步,也是最有效的:接受「不需要无限历史」。配置漂移排查通常关心最近三个月,一年前的 sshd_config 内容对你几乎没有价值。可以每隔半年做一次历史压缩——创建一个新的初始提交,把之前的历史打包归档到 /root 的备份里,然后重建仓库。这听起来粗暴,但它把仓库体积从几百 MB 压回几 MB,而且保留了真正需要的信息。

顺带一提:git gc --aggressive 在 /etc 这种小仓库上很快,但如果你的机器是 512MB 内存的小 VPS,建议改成普通的 git gc --prune=now,避免 gc 期间内存吃紧影响业务。

六、把 diff 变成判断:哪些变化该告警

每天看报告这件事,人类坚持不过两周。所以必须让机器帮你筛:哪些变化必须立刻看,哪些可以周末扫一眼。

我把变化分成三级:

红色——立即看,走告警通道。涉及这几个文件的任何变化:/etc/ssh/sshd_config、/etc/ssh/ssh_config、/etc/passwd、/etc/shadow、/etc/sudoers 及其 .d 目录、/etc/ld.so.preload、/etc/crontab 与 /etc/cron.d/*、/etc/systemd/system/ 下新增的 .service。这些是入侵者最常动的位置,正常运维也很少碰。

黄色——每天扫一眼。Nginx、PHP、MySQL、Redis 的主配置文件,防火墙规则(nftables.conf、ufw 的 rules),fail2ban 的 jail.local。

灰色——自动忽略。机器 ID、随机种子、动态 DNS 缓存、日志相关的路径配置。这类可以写进过滤脚本直接不输出。

告警通道我推荐 ntfy 或者一个简单的 Telegram bot,原因很简单:邮件在手机上很容易被忽略,而推送会响。具体实现就是在 diff 脚本末尾加一段判断:

if echo "$CHANGED $FILES" | grep -qE 'sshd_config|/passwd|/shadow|sudoers|ld\.so\.preload|cron'; then
  curl -s -H "Title: [RED] 配置漂移告警 $(hostname)" \
       -d "$(echo "$CHANGED" | head -20)" \
       https://ntfy.sh/your-secret-topic >/dev/null
fi

这里有个坑要注意:ntfy 的公共服务器(ntfy.sh)是明文的,topic 名就是全部凭据。别用 zz1984-alert 这种可猜的名字,也不要在消息里贴密码和私钥内容。真要稳妥就自建一个 ntfy,Docker 一行命令的事情。

七、漂移之后:回滚的正确姿势与它的边界

发现了一个不该有的变化,第一反应是 git checkout 覆盖回去。这里必须提醒:etckeeper 管的是文件,不管服务的状态。

举个例子:你把 sshd_config 回滚到昨天的版本了,但 sshd 进程还在用今天加载的配置运行。如果今天的配置是 PermitRootLogin yes、Port 22,你回滚了文件但没重启 sshd,攻击窗口依然是开着的。所以标准动作是:

cd /etc
git diff HEAD -- ssh/sshd_config      # 先看清楚改了什么
git checkout HEAD -- ssh/sshd_config  # 再回滚文件
sshd -t                               # 语法校验,必须有这一步
systemctl reload sshd                 # 生效

sshd -t 这一步不能省。配置文件的语法错误会让 sshd 重启失败,而你恰好又依赖 ssh 登录——这是最经典的把自己锁在门外的方式。同理,Nginx 用 nginx -t,PHP-FPM 用 php-fpm -t,MySQL 的配置错误则直接导致起不来。

另一个边界是:回滚文件不等于回滚系统状态。如果漂移的原因是某个包升级,那么回滚配置文件会让新版本的二进制程序读到旧格式的配置,可能直接崩溃。这种情况应该做的不是 checkout,而是把 .dpkg-dist 拿来对比,重新合并一份配置。判断依据是:git log 里这次变化的提交信息如果来自 etckeeper 的 pre-install hook,那就说明是包操作引起的,要走「合并」而不是「回滚」。

八、常见故障排查清单

症状一:etckeeper commit 报错「Author identity unknown」。Git 没有配置用户名邮箱。解决:cd /etc && git config user.email root@localhost && git config user.name root。注意这是仓库级配置,不会污染全局。

症状二:git status 显示几千个未跟踪文件。说明 .gitignore 没生效或者被某个脚本覆盖了。检查 /etc/.gitignore 是否还存在,以及是否有文件被 git add -f 强制加入过。

症状三:/etc 里出现大量 .dpkg-old、.ucf-dist 文件。这是包升级时保留了本地配置。这不是漂移,是正常现象,但需要定期清理,否则 etckeeper 每天都会报告它们。建议在 .gitignore 里忽略 *.dpkg-old、*.dpkg-dist、*.ucf-*。但注意:忽略之前先确认里面没有你需要的旧配置。

症状四:daily autocommit 任务没跑。Debian 新版使用 systemd timer 而不是 cron,检查 systemctl list-timers | grep etckeeper,再看 journalctl -u etckeeper.timer -n 50。老版本在 /etc/cron.daily/etckeeper,检查 cron 服务本身是否活着。

症状五:diff 报告里天天出现同一个文件。大概率是服务的运行时写入。找到那个文件,判断它是否真的需要版本化,不需要就 ignore,需要就考虑把变化的部分拆到单独的 conf.d 里,主配置保持稳定。

症状六:git 报 index.lock 存在。说明上一次操作被中断(多半是 OOM 或者磁盘满)。确认没有 git 进程在跑后,直接删除 /etc/.git/index.lock 即可,不要整个仓库删掉重建。

九、个人站长的最小可行方案

说了这么多,如果你只想要一个能立刻落地的版本,按这个顺序做就行:

1. 装 etckeeper,init 并做一次 initial commit。十分钟。

2. 写好 /etc/.gitignore,排除证书归档、面板缓存、包管理备份文件。五分钟。

3. 把上面的 drift report 脚本挂到 systemd timer,每天三点跑。十五分钟。

4. 给自己的手机配一个 ntfy topic,red 级变化推过去。五分钟。

5. 每周花十分钟看一次 /var/log/etc-drift 的汇总,顺便 du -sh /etc/.git 看一眼体积。

整套下来不到一小时,之后它就在后台默默工作了。它的价值不会在第一个月显现,而是在某一天——当你的站点突然行为异常,或者某台两年没管的机器被人摸进来——你会庆幸自己有一份可以倒查三个月的配置变更记录。

运维这件事,说到底不是把系统配得多么完美,而是让每一次意外都留在可以追溯的痕迹里。配置漂移监控,就是最低成本的那道痕迹。

Last modification:October 19th, 2026 at 12:23 pm

Leave a Comment