记得那是个周二的凌晨两点,我是某知名互联网大厂的基础架构团队的一员。就在四个小时前,安全团队发来了紧急通告:Linux内核存在高危漏洞(CVE-2024-XXXX),要求所有生产环境服务器在24小时内完成补丁修复。
这时候,摆在我们面前的选择题很简单:要么手动登录上千台服务器逐个执行 yum update,要么写个靠谱的自动化脚本一劳永逸。
前者的结果我不用多说——估计明天早上大家都能收到来自CTO的“关爱邮件”。于是,我们团队通宵达旦地折腾出了这套批量补丁修复方案。今天就把这个实战案例掰开揉碎讲给你听,无论你是刚入行的运维小白,还是想优化现有流程的老手,相信都能从中找到有价值的东西。
为什么“简单粗暴”的SSH循环会害死你?
在分享最终方案之前,我得先泼盆冷水。很多人第一反应是:SSH连上去跑命令不就行了吗?于是写出了这样的代码:
#!/bin/bash
# ❌ 错误示范: naive-ssh-patch.sh
servers=("192.168.1.10" "192.168.1.11" "192.168.1.12" ...)
for ip in "${servers[@]}"; do
ssh root@$ip "yum update -y kernel"
done
看起来很美,对吧?但实际运行起来,你会发现几个致命问题:
第一个坑:并发灾难。虽然循环是串行的,但如果你改成并行执行,上千台服务器同时发起YUM事务,你的内网带宽和包管理器元数据服务器会瞬间爆炸。我们第一次尝试时,内部YUM源直接报了503,导致所有服务器都无法获取补丁列表。
第二个坑:状态不可控。如果某台服务器网络抖动,SSH连接超时,你的脚本是继续跑还是报错退出?默认情况下,很多脚本会静默跳过失败的主机,等你发现时,已经有几百台服务器没打上补丁,而你还以为全部成功了。
第三个坑:依赖地狱。生产环境服务器通常不能随便装新包,尤其是内核。补丁更新可能引发依赖冲突,导致系统关键组件被意外升级或降级。我们没有考虑到这一点,差点让三台核心数据库服务器因为依赖冲突启动失败。
这些问题,都是我们在血泪教训中总结出来的。所以,一个生产级的批量补丁脚本,必须包含错误处理、状态追踪、并发控制和回滚机制。
我们的终极方案架构
经过反复打磨,我们设计了这样一个分层架构的补丁修复系统:
- 主机分组管理:不是所有服务器都一起上,而是按业务重要性分为P0(核心数据库)、P1(关键业务)、P2(边缘服务)三个梯队。
- 预检查机制:在真正打补丁前,先检查系统状态、磁盘空间、依赖关系。
- 灰度发布:先在一台非生产机器上测试,确认无误后再推广到小批量,最后全量。
- 原子性事务:每个补丁包的安装都是原子操作,失败则自动回滚。
- 全量日志审计:每台服务器的每个操作都有详细日志,方便事后追溯。
下面,我把核心代码段拆解开来讲。
第一步:主机清单与分组管理
我们使用了一个JSON格式的主机清单,这样既方便阅读,也便于程序解析:
// host_inventory.json
{
"groups": {
"P0_databases": {
"servers": [
{"ip": "10.0.1.10", "hostname": "db-master-01", "priority": 1},
{"ip": "10.0.1.11", "hostname": "db-slave-01", "priority": 2}
],
"maintenance_window": "02:00-04:00",
"rollback_enabled": true
},
"P1_app_servers": {
"servers": [
{"ip": "10.0.2.20", "hostname": "app-node-01", "priority": 1},
{"ip": "10.0.2.21", "hostname": "app-node-02", "priority": 2}
],
"maintenance_window": "03:00-05:00",
"rollback_enabled": true
},
"P2_edge_services": {
"servers": [
{"ip": "10.0.3.30", "hostname": "edge-01", "priority": 1}
],
"maintenance_window": "04:00-06:00",
"rollback_enabled": false
}
},
"global_settings": {
"max_parallel": 50,
"timeout_seconds": 300,
"log_dir": "/var/log/patch_audit"
}
}
这个清单的关键在于maintenance_window(维护窗口)和rollback_enabled(是否启用回滚)。P0级服务器只能在凌晨2-4点操作,而且必须启用回滚,因为数据库一旦出问题,影响的是整个业务。而P2级边缘服务虽然风险低,但我们选择不启用回滚,因为它们本来就是无状态服务,重装比回滚更快。
第二步:预检查函数——别等出事了再后悔
在真正执行补丁前,我们写了一个强大的预检查函数:
#!/bin/bash
# preflight_check.sh —— 补丁前健康检查
check_disk_space() {
local target_ip=$1
local required_mb=2048 # 需要2GB可用空间
# 通过SSH检查目标服务器根分区使用情况
available_space=$(ssh -o ConnectTimeout=10 -o StrictHostKeyChecking=no root@$target_ip \
"df -m / | awk 'NR==2 {print \$4}'")
if [ "$available_space" -lt "$required_mb" ]; then
echo "ERROR: $target_ip has only ${available_space}MB free, need ${required_mb}MB"
return 1
fi
echo "OK: $target_ip has ${available_space}MB free space"
return 0
}
check_running_services() {
local target_ip=$1
local critical_services=("mysqld" "nginx" "redis")
# 检查关键服务是否在运行,并记录状态
ssh -o ConnectTimeout=10 -o StrictHostKeyChecking=no root@$target_ip \
"for svc in ${critical_services[*]}; do
systemctl is-active \$svc;
done" | paste -d',' -s > /tmp/services_${target_ip}.csv
echo "Services status for $target_ip saved to /tmp/services_${target_ip}.csv"
}
check_package_conflicts() {
local target_ip=$1
local patch_list="kernel-3.10.0-1160.el7 kernel-devel-3.10.0-1160.el7"
# 模拟安装,不实际执行,检查依赖冲突
ssh -o ConnectTimeout=10 -o StrictHostKeyChecking=no root@$target_ip \
"yum check-update $patch_list 2>&1 | grep -E 'Error|Conflict' || echo 'No conflicts'" \
> /tmp/conflicts_${target_ip}.log
if grep -q "Error\|Conflict" /tmp/conflicts_${target_ip}.log; then
echo "WARNING: Conflicts detected on $target_ip, see /tmp/conflicts_${target_ip}.log"
return 1
fi
return 0
}
# 主检查流程
preflight_check() {
local server=$1
echo "=== Starting preflight checks for $server ==="
check_disk_space "$server" && \
check_running_services "$server" && \
check_package_conflicts "$server"
local result=$?
echo "=== Preflight checks $([[ $result -eq 0 ]] && echo 'PASSED' || echo 'FAILED') for $server ==="
return $result
}
这段代码的核心思想是防御性编程。磁盘空间不足会导致YUM无法下载补丁包;关键服务状态记录是为了在补丁导致系统异常时能够快速定位问题;而依赖冲突检查则避免了因第三方仓库污染导致的安装失败。
第三步:并行执行引擎——快而不乱
这是整个脚本最精妙的部分。我们用了xargs配合--max-procs来实现可控的并发:
#!/bin/bash
# parallel_patch_executor.sh —— 并行补丁执行引擎
LOG_DIR="/var/log/patch_audit"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
SUCCESS_FILE="$LOG_DIR/success_${TIMESTAMP}.txt"
FAIL_FILE="$LOG_DIR/failure_${TIMESTAMP}.txt"
> "$SUCCESS_FILE"
> "$FAIL_FILE"
# 构建SSH命令,每个服务器一个后台任务
patch_server() {
local ip=$1
local server_name=$2
local log_file="$LOG_DIR/${server_name}_${TIMESTAMP}.log"
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Starting patch on $server_name ($ip)" | tee -a "$log_file"
# 执行补丁安装,捕获所有输出
ssh_output=$(ssh -o ConnectTimeout=60 \
-o ServerAliveInterval=30 \
-o StrictHostKeyChecking=no \
root@$ip \
"set -e; \
yum update -y kernel kernel-devel kernel-tools \
> /tmp/yum_update.log 2>&1; \
echo 'PATCH_SUCCESS' >> /tmp/patch_status; \
systemctl restart sshd" 2>&1)
ssh_exit_code=$?
# 记录详细日志
{
echo "=== Patch execution log for $server_name ($ip) ==="
echo "Exit code: $ssh_exit_code"
echo "SSH output:"
echo "$ssh_output"
echo "Remote yum log:"
ssh -o ConnectTimeout=10 root@$ip "cat /tmp/yum_update.log" 2>/dev/null || echo "Could not retrieve remote log"
} > "$log_file"
# 根据退出码分类记录
if [ $ssh_exit_code -eq 0 ]; then
echo "$server_name ($ip)" >> "$SUCCESS_FILE"
echo "[$(date '+%Y-%m-%d %H:%M:%S')] SUCCESS: $server_name ($ip)" | tee -a "$log_file"
else
echo "$server_name ($ip)" >> "$FAIL_FILE"
echo "[$(date '+%Y-%m-%d %H:%M:%S')] FAILED: $server_name ($ip)" | tee -a "$log_file"
# 失败时自动尝试回滚(如果启用)
if [ "$rollback_enabled" = "true" ]; then
echo "Attempting rollback for $server_name..."
ssh root@$ip "yum history undo last && systemctl revert sshd" 2>/dev/null || \
echo "Rollback failed for $server_name, manual intervention required"
fi
fi
}
export -f patch_server
export LOG_DIR TIMESTAMP SUCCESS_FILE FAIL_FILE rollback_enabled
# 从JSON中提取服务器列表,使用xargs并行执行
cat host_inventory.json | \
jq -r '.groups.P1_app_servers.servers[] | "\(.ip) \(.hostname)"' | \
xargs -P 20 -I {} bash -c 'patch_server "$@"' _ {}
echo "=== Batch execution completed ==="
echo "Successful patches: $(wc -l < "$SUCCESS_FILE")"
echo "Failed patches: $(wc -l < "$FAIL_FILE")"
echo "Logs stored in: $LOG_DIR"
这里有几个关键细节值得注意:
-o ServerAliveInterval=30:这个参数非常重要。默认的SSH连接在长时间无数据交互时会断开,而YUM更新可能需要几分钟。设置这个参数后,SSH会每30秒发送一个keepalive包,防止连接被防火墙或路由器掐断。set -e:在远程服务器上执行命令时加上这个,确保任何命令失败都会立即终止整个脚本,而不是继续执行后续步骤。远程日志捕获:我们不仅在本地记录SSH的输出,还要求服务器将YUM的详细日志也记录下来,然后通过SSH拉取回来。这样即使本地网络出问题,我们也能通过远程日志排查原因。
失败自动回滚:对于启用了回滚的服务器组,一旦补丁安装失败,脚本会自动尝试
yum history undo来回滚到之前的状态。当然,这招不是万能的,如果补丁已经修改了系统配置,可能还需要手动干预。
第四步:灰度发布策略——小步快跑,稳健前行
千台服务器不可能一次性全上,我们采用了三级灰度策略:
第一级:金丝雀测试(1台)
- 选择一台与非生产环境完全一致的测试服务器
- 执行完整补丁流程
- 运行兼容性测试套件(包括应用冒烟测试)
- 观察24小时,确认无异常
第二级:小批量验证(10台)
- 从P2级服务器中随机选取10台
- 执行补丁,但不重启
- 监控系统指标(CPU、内存、IO、网络)
- 检查应用日志,确认无报错
- 观察2小时,确认稳定性
第三级:分批全量(按组执行)
- 先执行P2级所有服务器(风险最低)
- 等待1小时,确认无大规模问题
- 再执行P1级服务器
- 最后执行P0级服务器(核心数据库)
- 每批之间间隔30分钟,方便观察和回滚
这个策略的核心思想是风险隔离。如果第一批服务器出现问题,我们可以在问题扩散到整个集群之前停下来,排查原因,甚至回滚。
第五步:结果验证与审计追踪
补丁打完了,怎么知道真的成功了呢?我们写了一个验证脚本:
#!/bin/bash
# post_patch_verification.sh —— 补丁后验证
verify_kernel_version() {
local ip=$1
local expected_version="3.10.0-1160.el7"
actual_version=$(ssh -o ConnectTimeout=10 root@$ip "uname -r" 2>/dev/null)
if [ "$actual_version" = "$expected_version" ]; then
echo "OK: $ip kernel version is $actual_version"
return 0
else
echo "ERROR: $ip kernel version is $actual_version, expected $expected_version"
return 1
fi
}
verify_service_status() {
local ip=$1
local service=$2
status=$(ssh -o ConnectTimeout=10 root@$ip "systemctl is-active $service" 2>/dev/null)
if [ "$status" = "active" ]; then
echo "OK: $ip $service is running"
return 0
else
echo "WARNING: $ip $service is $status"
return 1
fi
}
generate_report() {
local success_file=$1
local fail_file=$2
echo "# Patch Execution Report" > report_${TIMESTAMP}.md
echo "" >> report_${TIMESTAMP}.md
echo "Generated: $(date)" >> report_${TIMESTAMP}.md
echo "" >> report_${TIMESTAMP}.md
echo "## Successful Servers" >> report_${TIMESTAMP}.md
cat "$success_file" | while read line; do
ip=$(echo "$line" | awk '{print $2}')
verify_kernel_version "$ip" >> report_${TIMESTAMP}.md 2>&1
done >> /dev/null
echo "" >> report_${TIMESTAMP}.md
echo "## Failed Servers" >> report_${TIMESTAMP}.md
cat "$fail_file" >> report_${TIMESTAMP}.md
echo "" >> report_${TIMESTAMP}.md
echo "## Summary" >> report_${TIMESTAMP}.md
echo "- Total successful: $(wc -l < "$success_file")" >> report_${TIMESTAMP}.md
echo "- Total failed: $(wc -l < "$fail_file")" >> report_${TIMESTAMP}.md
echo "- Success rate: $(echo "scale=2; $(wc -l < "$success_file") * 100 / $(($(wc -l < "$success_file") + $(wc -l < "$fail_file"))) " | bc)%" >> report_${TIMESTAMP}.md
}
这个验证脚本做了三件事:检查内核版本是否正确更新、验证关键服务是否正常运行、生成一份Markdown格式的报告。报告生成后,我们会自动邮件发送给相关责任人,包括CTO、运维总监和安全团队。
实战中的数据:3分钟真的够了吗?
回到标题,3分钟搞定千台服务器,这个说法有点夸张,但我可以给你实际数据:
- 准备阶段(主机清单确认、测试环境验证):约15分钟
- 灰度测试(1台测试机+10台小批量):约20分钟(含观察时间)
- 批量执行(989台服务器,20并发):约8分钟
- 验证与报告:约5分钟
所以,从开始执行到全部完成,实际时间大约在50分钟左右。但这50分钟里,我们不需要手动登录任何一台服务器,所有操作都是自动化的。相比之下,如果手动操作,即使两个人轮班,至少也要8-10小时。
那些差点让我们翻车的细节
最后,分享几个我们在实战中遇到的坑,希望能帮你避开:
坑一:时间不同步导致补丁冲突 有一台服务器的系统时间与NTP服务器偏差了5分钟,导致YUM认为补丁已经安装,跳过了实际更新。解决办法是在预检查阶段增加时间同步检查: “`bash ntp_offset=\((ssh root@\)ip “ntpstat 2>/dev/null | awk ‘{print
