凌晨三点被电话叫醒,机房里一台 CentOS 7.9 机器起不来——屏幕停在 grub>,键盘能敲,鼠标不动,风扇呼呼转。这台机器上周刚做过内核小版本升级,顺手 reboot 就卡在这儿了。手敲 ls 看分区,(hd0,msdos1) 还在;set 看前缀,prefix=(hd0,msdos1)/boot/grub2,貌似一切正常。结果敲 linux /vmlinuz-XXX root=UUID=... 回车,原地打转。说实话那会儿脑子有点懵:分区认,文件在,但就是起不来。

这种单点事故最磨人:你明知道问题不大,却得在夜里跟它耗半小时。

老套路:进 dracut 救援,chroot /mnt/sysimage,重跑一遍 grub2-install /dev/sda,再把配置文件 grub.cfg 重新生成一下。那天我还特意比对了 /boot/grub2/grubenv,发现 kernelopts 被写成旧内核路径,难怪起不来。改完 exit,reboot,灯亮,登录成功,前后三十分钟。

第二天巡检,我拿 Ansible 跑了遍 facter,过滤出相同机型、相同磁盘控制器、相同内核版本的节点,居然还有七台带着同样症状:grub2-mkconfig 里的 DEFAULT_KERNEL 指向了不存在的 path。这批机器都跑着 Anolis 8.2,镜像源里默认 kernel-core 比本地 /boot 里多一个小版本号,导致 dracut 生成 initramfs 时把参数写错。这不是运气差,而是批量运维的必然。一次看似普通的升级,背后藏着模板与实际环境不一致的老毛病。等你真正面对几十上百台机器时,你会发现“单机故障”只是冰山露出水面的那一角。

GRUB 挂了,逃不出这几类剧本

这些年处理引导故障,十次里有八次绕不开三类坑:内核更新时 grubby 脚本半路撂挑子,分区表调完 UUID 变了但 /boot/grub2/grub.cfg 还死守着老值,更常见的是 grub.conf 被人手一抖改成了个根本不存在的内核路径。阿里云那个诊断工具我试过几回,它报的「grub 格式检查异常」,翻译成人话就是配置文件跟磁盘实际布局对不上——要么 grub-install 没跑完,要么 initramfs 里塞了个错的 root 设备。

手动三板斧说穿了也就三件事。第一斧 grub2-install 写到 MBR 或 EFI 分区,这一步基本不失败,除非硬盘控制器驱动丢了。第二斧 grub2-mkconfig -o /boot/grub2/grub.cfg 重新生成菜单,但很多人忽略了一个前提——你得先确认 /boot 里确实有 vmlinuz 和 initramfs。我在 Anolis 8.2 上遇到过镜像源推送了 kernel-core 新版本,但 yum update 只更新了包没同步删除旧内核,结果 grub2-mkconfig 把 DEFAULT_KERNEL 写成了最新那个不存在的路径。第三斧是 chroot 救援:从 dracut 模式进去,mount --bind /dev /mnt/sysimage/dev && mount --bind /proc /mnt/sysimage/proc && mount --bind /sys /mnt/sysimage/sys,然后 chroot /mnt/sysimage 重跑前两斧。

这三斧头敲完,一台机器确实能救回来。但每台机器至少要敲十行命令,中间还得瞪大眼睛看输出有没有 error。更烦的是,有些故障根本不在 grub 层:比如 UEFI 下 Boot0000 指向了 grubx64.efi 但那个文件被更新程序覆盖成空文件了,你手敲多少行 grub-install 都没用,得用 efibootmgr -c -d /dev/sda -p 1 -L "CentOS" -l \\EFI\\centos\\shimx64.efi 重建启动项。单机修一遍十五到三十分钟,五十台机器就是一个人一天的工作量。而且人眼最容易漏掉——grub2-mkconfig 执行时输出了一行 Found linux image: /boot/vmlinuz-5.10.134-17.el7.x86_64,你扫一眼以为正常,实际上那行下面跟着 error: ... 才对,但你早就切到下一台机器了。

单机故障只是冰山尖儿,水面下的系统脆弱性才是真问题。我那次修完一台后,用 Ansible 的 setup 模块收集了所有节点的 ansible_kernel,再配合 stat 模块查看 /boot/grub2/grub.cfg 里引用的内核文件是否存在,结果筛出一批机器 grub.cfg 里写了 vmlinuz-5.10.134-17,但 /boot 下只有 vmlinuz-5.10.134-16 的内核,initramfs 文件后缀名多了个 .old。这种差异,你手动一台一台 boot 进系统根本不会去注意,直到下次重启。

更隐蔽的场景是分区调整。有人用 growpart 扩容了根分区,blkid 显示的 PARTUUID 没变,但 /etc/default/grub 里写死了 GRUB_CMDLINE_LINUX="root=UUID=xxxx",而你扩容后文件系统 UUID 被 tune2fs 改过。下次 reboot,grub 找到内核了,但 root 设备根本挂不上,直接崩到 dracut:/#。这种坑,没有批量巡检时根本不会暴露——直到你某天重启整个集群。

手动修 GRUB 这件事,熟练工可以做到不踩雷,但规模一上来,人的注意力就是最大的短板。而自动化要做的,不是让人变得更厉害,是把这些固定步骤写成代码,让机器替人去一遍遍检查、一遍遍重写。

server boot failure grub prompt

把修复步骤塞进 Playbook

既然手工修复再熟练也挡不住规模带来的遗漏,那就把决策点和操作步骤都装进 Ansible Playbook 里,让机器去重复“核对→修正→回写”的循环。我们只做两件事:先把能救活的节点一次性拉回可启动状态,再把潜在的配置漂移标记出来交给后续补丁。

Playbook 的入口不是“运行命令”,而是“先确认急救通道可用”。我们用 ansible.builtin.shell 触发一次 dracut --force --no-hostonly 重建 initramfs,确保即使当前 /boot/grub2/grub.cfg 碎掉,也能在下次重启时走 rescue 模式捞回来;随后再调用 grub2-install /dev/sda 把 stage1/stage2 写回 MBR 或 EFI 分区。为了避免误伤,这些步骤默认只在 inventory 里标了 boot_issue=true 的主机上执行。

- name: force rebuild initramfs for rescue readiness
  ansible.builtin.shell: |
    dracut --force --no-hostonly $(uname -r)
  args:
    creates: /boot/initramfs-$(uname -r).img
  become: true

- name: reinstall grub2 to MBR/EFI
  ansible.builtin.shell: grub2-install /dev/sda
  become: true
  when: ansible_facts['pkg_mgr'] is defined

真正麻烦的不是重装,而是配置文件漂移:CentOS 7 叫 grub.conf,CentOS 8/Stream 与 Anolis 用的是 /boot/grub2/grub.cfg,由 grub2-mkconfig -o 生成;而 UEFI 机器还要保证 shimx64.efi 指向正确。我会用 ansible.builtin.stat 检查 /boot 下列出的内核与 initramfs 是否真的存在,若发现条目缺失就跳过 mkconfig,直接把旧文件备份并报警,而不是盲目覆盖。

- name: backup and regenerate grub config if kernel list matches disk files
  ansible.builtin.shell: |
    cp /boot/grub2/grub.cfg /boot/grub2/grub.cfg.bak.$(date +%s)
    grub2-mkconfig -o /boot/grub2/grub.cfg
  args:
    creates: /tmp/grub_regen_done
  become: true
  when: ansible_facts['firmware'] == 'uefi' or ansible_facts['bios'] is defined

跑完这一套后别急着庆功,先看一眼每台机器的 serial console 输出有没有卡在 dracut:/#,那是根分区挂载参数被改过的典型信号;遇到这种情况我会把 root=UUID=... 从 /etc/default/grub 里清理成 LABEL=/,再重新跑一遍 mkconfig,省得下一次大版本更新又把整批节点带崩。

Ansible playbook automation server repair

巡检:不等告警,主动翻配置文件

修复完了,不代表下次不崩。真正让运维睡踏实的不是那套急救 playbook,而是一个能每天、每周自动告诉你「哪台机器的 GRUB 可能快不行了」的巡检机制。

我自己吃过亏:某次大版本更新后,CentOS 7 升到 8,20 台机器里 3 台卡在 grub> 提示符,排查才发现它们的 /boot/grub2/grub.cfg 里内核版本号没更新。从那之后,我就给 Ansible 加了一套巡检——不是等故障告警,而是主动去翻配置文件。

第一,检查 /boot/grub2/grub.cfg 是否存在且非空。可能你觉得这太基础,但有一次我就碰上文件被某安全脚本误删成 0 字节的情况。用 ansible.builtin.stat 拿 size,小于 256 字节的直接标红。

- name: check grub.cfg size
  ansible.builtin.stat:
    path: /boot/grub2/grub.cfg
  register: grub_cfg_stat

- name: alert if grub.cfg is too small
  ansible.builtin.debug:
    msg: "WARNING: grub.cfg on {{ inventory_hostname }} is only {{ grub_cfg_stat.stat.size }} bytes"
  when: grub_cfg_stat.stat.size is defined and grub_cfg_stat.stat.size < 256

第二,核对内核与 GRUB 版本是否匹配。这步得靠 setup 模块里拿的 ansible_kernel,然后 grep 一下 grub.cfg 里有没有这个版本串。

- name: verify kernel version appears in grub.cfg
  ansible.builtin.shell: |
    grep -q "{{ ansible_kernel }}" /boot/grub2/grub.cfg && echo "MATCH" || echo "MISMATCH"
  register: kernel_check
  changed_when: false

- name: flag mismatch
  ansible.builtin.fail:
    msg: "GRUB config does not reference running kernel {{ ansible_kernel }}"
  when: kernel_check.stdout != "MATCH"

这个检查之前帮我逮住过一个坑:某次内核热补丁升级后,uname -r 显示 5.10.134-17,但 grub.cfg 里还在引用 5.10.134-16。如果没有巡检,下次重启就会直接调起旧内核,甚至因为模块不兼容卡死在 dracut 阶段。

第三,把结果汇成一张表。我用 ansible.builtin.copy 模块在管控机上写一个 CSV,每条记录带上主机名、时间戳、GRUB 文件大小、内核匹配状态。然后 cron 跑起来,每天凌晨 2 点扫一趟。

邮件很容易被淹没,尤其半夜的告警。我更推荐走 Webhook,把巡检报告直接丢到钉钉、飞书或者 Slack 群。Ansible 本身没有内置 Webhook 模块,但用 ansible.builtin.uri 模块发 POST 请求很顺手。

- name: send alert to feishu webhook
  ansible.builtin.uri:
    url: "https://open.feishu.cn/open-apis/bot/v2/hook/xxxxxx"
    method: POST
    body_format: json
    body:
      msg_type: "interactive"
      card:
        header:
          title:
            tag: "plain_text"
            content: "GRUB 巡检异常"
        elements:
          - tag: "markdown"
            content: "主机 {{ inventory_hostname }} 内核版本 {{ ansible_kernel }} 未匹配 GRUB 配置"
  when: kernel_check.stdout != "MATCH"
  ignore_errors: true

如果巡检全绿,我就只写一条静默日志,不打扰任何人。只有出现 MISMATCH 或者文件异常小的机器才会触发通知。这样既不会麻木人的神经,也能保证问题不被漏掉。

巡检本身也要防死锁。有的机器可能正处在内核升级过程中,GRUB 还没来得及更新——这时候把巡检结果暂存,等下一次 cron 再比对,不要一棒子打死。自动化巡检不是要抢在运维前面做决定,而是帮你在症状出现之前就拿到 x 光片。

server monitoring dashboard alert notification

让机器自己修自己

内核更新最容易被忽略的一步,就是 grub.cfg 没跟着变。与其人肉记,不如把判断塞进流水线。我在 Jenkins 里加了个 Ansible 任务,包安装完成后只做两件事:核对 /boot/grub2/grub.cfg 里 default=0 是否指向新索引,再用 ansible.builtin.stat 拿取文件 size、mtime,和上一次巡检留下的基线比对。

- name: ensure GRUB default points to newest kernel
  ansible.builtin.lineinfile:
    path: /boot/grub2/grub.cfg
    line: 'set default="0"'
    state: present
  when: upgrade_result.changed

- name: collect grub config metadata
  ansible.builtin.stat:
    path: /boot/grub2/grub.cfg
  register: grub_stat

变更一旦发生,就把这台主机标为“待二次核验”,第二天凌晨的巡检自然会把它拉回绿灯区;若发现默认仍是旧项,直接标记异常并走修复分支。

检测出问题只是开始,能修才是本事。我把修复写成独立 playbook,只在 MISMATCH 或文件过小时调用,并且严格限制影响面:只动 grub.cfg 与内核条目,不动分区表。

- name: rebuild GRUB menu for CentOS/Anolis
  ansible.builtin.command:
    cmd: grub2-mkconfig -o /boot/grub2/grub.cfg
  args:
    creates: /tmp/grub.rebuilt
  become: true

- name: verify new menu entries
  ansible.builtin.shell:
    cmd: |
      grep -c 'menuentry .*{{ ansible_kernel }}' /boot/grub2/grub.cfg || exit 1
  changed_when: false
  register: rebuilt_check

有些故障并不在菜单生成,而在 dracut 阶段卡住,比如模块缺失或 initramfs 与内核版本不对位。遇到这类情况,我会先尝试重建 initramfs,再配合 systemd-nspawn 进入容器式环境做最小修复,尽量不让节点重启。

自动化最怕“修坏了回不去”。我要求所有节点至少保留两个可启动项,并在每次升级前用 ansible.builtin.copy 把 /boot/grub2/grub.cfg 备份到 /boot/grub2/grub.cfg.bak.YYYYMMDD,同时记录内核版本号。

  • 恢复路径A:grub2-editenv list 找到救急条目,单次启动选择旧内核即可
  • 恢复路径B:若默认菜单仍错误,使用 grub2-install 重写引导扇区,再导入备份配置

回滚脚本同样由巡检触发,只是多一道确认:只有在连续两次检测都失败时才真正切换。这样一来,偶发的瞬时抖动不会误杀刚刚还好好的服务。这套流程跑稳后,值班电话少了,群里的截图也从黑屏堆栈变成了绿色对勾。

dracut 救援模式下,Ansible 连不上

前面那套巡检+修复跑得顺风顺水,直到某个深夜,一台机器在GRUB更新后半死不活——卡进了dracut的救援shell。按说救个引导嘛,ssh进去跑一遍上面的playbook就行。问题在于,救援模式下的网络压根没起来。

那个节点网络层只给了最小内核模块,eth0没IP,甚至systemd-networkd都没启动。ansible的ssh连接直接超时,连ping都静默。我当时的表情大概跟那台机器的shell一样黑。

后来只能搬出IPMI的SOL(Serial Over LAN)串口,手动敲了三条命令:ip addr add 192.168.1.100/24 dev eth0、检查路由、确认DNS。这才把ansible的控制通道搭起来。但回头想,如果机房在异地、带外管理没开,那就是两眼一抹黑。

后来我索性把前置检查嵌进了巡检playbook——在pre_task里塞了一个,轮询三次网络存活。三次全挂?直接跳过修复、甩一条告警出去,让人带外介入。同时要求每台机器的GRUB条目里都得留一个fallback内核,不带任何修复补丁,只保证能进完整的systemd环境。这招挺土的,但管用:就算新的initramfs真炸了,重启后还能rollback回来,走IPMI或者带外通道再做二次修复。

自动化最怕的不是脚本写错,而是脚本在极端场景下把自己锁在门外。留一手后门、多写一个fallback,比任何炫技都管用。这套东西折腾了大半年,从单机敲命令到批量自愈,中间踩的坑比GRUB菜单条目还密。不过每次看到巡检报告里全是绿勾、值班手机安静如鸡,就觉得那些凌晨修引导的夜晚,总算没白熬。