配置文件是网络服务运行的基石,nginx的转发规则、SSH的登录策略、数据库的连接参数,都存放在一个个文本文件里。当服务突然异常时,运维人员最常问的三个问题是:谁改的、什么时候改的、改了什么。如果没有任何审计机制,这些问题几乎无法回答。用Ruby搭建一套配置变更审计系统并不复杂,本文将从方案选型、核心实现、差异记录到查询统计,完整讲解整个落地过程。

一、四种审计方案的选型对比
实现配置变更审计,常见思路有四种。第一种是定时快照对比,每隔一段时间对配置目录做一次完整拷贝,对比前后差异。这种方式实现最简单,但如果变更发生在两次快照之间又被快速改回,就会漏记;而且快照只能看到变了什么,很难回答是谁改的。
第二种是借助Git做版本管理,把配置目录初始化成仓库,每次变更自动commit。Git天然擅长记录差异,配合hook还能强制留下操作者信息。缺点是Git不会自动感知文件变化,仍需要额外的触发机制,而且仓库历史一旦被恶意清理就失去了证据效力。
第三种是使用inotify等内核级文件监听,Ruby通过rb-inotify这个gem可以实时感知文件的写入事件。它的优点是真正实时,不依赖人工流程;但监听只能告诉你文件变了,无法直接拿到操作者身份和变更内容,需要配合内容快照补齐信息。
第四种是包装脚本拦截,也就是把vim、sed这类编辑命令替换成一个Ruby包装器,编辑前后自动记录。这种方式能精确拿到操作人和操作时间,是最符合审计要求的方案,但覆盖不了直接用API或程序写入的情况。实践中推荐组合使用:以inotify监听兜底,以包装脚本提供身份信息,二者互相印证。
二、核心实现:拦截变更并记录关键信息
先实现审计记录的核心类。审计日志最重要的字段包括:操作人(通过ENV['USER']或Etc.getlogin获取)、操作时间(精确到毫秒)、目标文件路径、变更前后的内容摘要。为了让日志可追溯,我们把每次记录写入JSON格式的追加文件中,每行一条记录,方便后续用Ruby直接解析。
require 'etc'
require 'digest'
require 'json'
require 'time'
class ConfigAuditor
def initialize(log_path: '/var/log/config_audit.jsonl')
@log_path = log_path
end
# 记录一次变更事件
def record(file_path, old_content, new_content, action: 'modify')
entry = {
timestamp: Time.now.iso8601(3),
user: Etc.getlogin rescue ENV['USER'] || 'unknown',
pid: Process.pid,
action: action, # create / modify / delete
file: File.expand_path(file_path),
old_md5: old_content ? Digest::MD5.hexdigest(old_content) : nil,
new_md5: new_content ? Digest::MD5.hexdigest(new_content) : nil,
diff: (old_content && new_content) ? unified_diff(old_content, new_content) : nil
}
File.open(@log_path, 'a') { |f| f.puts(JSON.generate(entry)) }
entry
end
private
# 生成简易的unified diff摘要
def unified_diff(old_str, new_str, context = 1)
require 'diffy'
Diffy::Diff.new(old_str, new_str, context: context).to_s
end
end代码中的Etc.getlogin拿到的是真实的登录用户名,比环境变量更可靠,因为环境变量可以被伪造。iso8601(3)生成带毫秒精度的标准时间格式,排序和检索都很方便。差异计算使用了diffy这个gem,执行gem install diffy即可安装,它能生成标准的unified diff格式,和diff -u命令的输出一致。
有了核心类,下一步是把它接到实际的写入入口。以包装脚本方案为例,可以写一个safe-edit命令,编辑前先快照内容,编辑后对比:
#!/usr/bin/env ruby
require_relative 'config_auditor'
file = ARGV[0]
abort '用法: safe-edit <文件路径>' unless file
abort "文件不存在: #{file}" unless File.exist?(file)
auditor = ConfigAuditor.new
old_content = File.read(file)
editor = ENV['EDITOR'] || 'vim'
# 调用编辑器,等待用户完成编辑
system("#{editor} #{Shellwords.escape(file)}")
new_content = File.exist?(file) ? File.read(file) : ''
if new_content != old_content
action = File.exist?(file) ? 'modify' : 'delete'
auditor.record(file, old_content, new_content, action: action)
puts "[审计] 已记录对 #{file} 的#{action == 'delete' ? '删除' : '修改'}操作"
else
puts '[审计] 内容无变化,未记录'
end把这个脚本放到/usr/local/bin/safe-edit并加上执行权限,团队约定所有配置修改必须通过它进行。由于脚本以当前用户身份运行,Etc.getlogin记录下来的就是真正的操作者,这是单纯文件监听做不到的。
三、实时监听兜底:用rb-inotify捕获绕过流程的变更
包装脚本依赖人的自觉,总有人会直接用sed -i或程序写入。这时需要一个后台守护进程,用inotify监听配置目录的写入事件。Ruby中通过rb-inotify gem实现:
require 'rb-inotify'
require 'digest'
require 'json'
require 'time'
WATCH_DIR = '/etc/nginx'
SNAPSHOT = '/var/lib/config_audit/snapshots.json'
# 维护文件内容快照表,用于计算变更前内容
def load_snapshots
File.exist?(SNAPSHOT) ? JSON.parse(File.read(SNAPSHOT)) : {}
end
notifier = INotify::Notifier.new
snapshots = load_snapshots
notifier.watch(WATCH_DIR, :modify, :create, :delete, :recursive) do |event|
path = File.join(WATCH_DIR, event.name)
new_content = File.file?(path) ? File.read(path) rescue nil
old_content = snapshots[path]
entry = {
timestamp: Time.now.iso8601(3),
source: 'inotify',
file: path,
flags: event.flags.map(&:to_s),
old_md5: old_content && Digest::MD5.hexdigest(old_content),
new_md5: new_content && Digest::MD5.hexdigest(new_content)
}
File.open('/var/log/config_audit.jsonl', 'a') { |f| f.puts(JSON.generate(entry)) }
# 更新快照
snapshots[path] = new_content
File.write(SNAPSHOT, JSON.generate(snapshots))
end
puts "开始监听 #{WATCH_DIR} ..."
notifier.run监听进程拿不到操作者身份(内核通知里没有这个信息),但entry中的source: 'inotify'字段标记了来源。排查时可以拿事件时间戳和登录日志(如/var/log/auth.log或last命令输出)做交叉比对:某个时刻只有一个人在服务器上,那变更大概率就是他做的。这也是审计日志与系统日志互相印证的典型做法。
需要注意,inotify在文件被写入的瞬间触发,此时文件可能还没写完。生产环境建议对高频写事件的文件加一个短延迟去抖,比如收到事件后先sleep半秒再读内容,避免读到半截数据。
四、日志归档与查询:让审计数据真正可用
记录只是第一步,审计的价值在于能快速查询。JSONL格式的日志用Ruby解析非常方便,下面是一个按文件、按时间范围、按用户过滤的查询脚本:
#!/usr/bin/env ruby
require 'json'
require 'time'
# 用法: audit-query.rb --file nginx.conf --user deploy --since 2024-01-01
options = { file: nil, user: nil, since: nil }
ARGV.each_slice(2) do |key, value|
options[key.sub('--', '').to_sym] = value
end
since_time = options[:since] ? Time.parse(options[:since]) : nil
IO.foreach('/var/log/config_audit.jsonl') do |line|
e = JSON.parse(line) rescue next
next if options[:file] && !e['file'].include?(options[:file])
next if options[:user] && e['user'] != options[:user]
next if since_time && Time.parse(e['timestamp']) < since_time
puts "#{e['timestamp']} | #{(e['user'] || e['source']).to_s.ljust(10)} | #{e['action'] || e['flags'].join(',')} | #{e['file']}"
puts e['diff'] if e['diff'] && e['diff'].length < 2000
end如果日志量大,建议定期把JSONL导入SQLite,借助索引可以做到秒级检索。Ruby标准库自带sqlite3的接口(需gem install sqlite3),建表时给file、user、timestamp三个字段都建上索引,按任意维度查询都很流畅。
归档方面,JSONL日志建议按天切割,配合logrotate做压缩保留,例如保留180天以满足常见的合规要求。同时把日志目录权限设为chattr +a只允许追加,防止被篡改,这也是审计日志具备证据效力的前提。
五、几个容易被忽略的实践细节
首先是摘要与全文分开存。MD5摘要用于快速判断是否变化,完整diff用于回溯细节,全文快照可以只保留最近N份,避免磁盘无限膨胀。其次是区分计划内与计划外变更,可以在包装脚本中加一个--ticket参数要求填写工单号,没有工单号的变更在查询时会被标红,这对运维规范落地很有帮助。
另外,sudo提权后的用户身份要特殊处理:通过sudo执行时Etc.getlogin可能返回root,应该优先读取SUDO_USER环境变量,把原始提权人记录下来。最后,所有时间统一用UTC加毫秒存储,展示时再转本地时区,可以避免服务器时区不一致带来的排序混乱。把这些细节处理好,这套Ruby审计方案就足够应对中小规模团队的日常运维和合规检查需求了。