中文 English

内核都卸载了,DKMS 却还在为它们编译 110M 模块:揪出 i915-sriov-dkms 的“幽灵内核”

发布时间: 2026-09-14 · 阅读量 --
ProxmoxVE pve DKMS 故障排查 运维 Troubleshooting Linux 自动化 Windows 11 macOS Ubuntu 26.04

先说结论

给 PVE 节点安装显卡虚拟化驱动 i915-sriov-dkms 时,dkms status 里冒出了 4 个"从没装过的内核版本"——每个都被完整编译了一份模块,磁盘上多吃了约 70MB,编译时间白白翻了五倍。而这些内核的镜像包,早在装驱动之前就被卸载干净了。

追凶的结论让人哭笑不得:这个驱动包自带的安装脚本里,赫然写着一行 export autoinstall_all_kernels=1——把 DKMS 的"给系统里所有内核各编译一份"开关拧到了最大。而 DKMS 判断"内核是否存在"的依据,不是 dpkg 数据库,而是 /lib/modules/ 下的目录名单加上对应 headers(内核头文件)是否还在。旧内核的镜像卸了,四套 headers 却原封不动地留在系统里,于是幽灵们拿到了"开工许可"。

修复只要三步:卸掉孤儿 headers → 清掉为幽灵内核编译的 DKMS 条目 → 删掉残留目录。本文完整复盘排查过程,并给出 Windows 11 / Ubuntu 26.04 / macOS 26 三个平台的一键体检脚本(默认演练模式),以及「人工执行」和「交给 Agent 自动配置」两种用法。

原创封面:谁在给「幽灵内核」派活?

图 1:原创封面图。PVE 主机装上一个驱动,DKMS 流水线却连夜给 4 个"幽灵内核"各交付了一套编译产物。

一、问题背景:给核显插上"一卡多虚"的翅膀

我的家庭实验室里有一台 PVE 物理节点,CPU 自带的 Intel 核显除了给控制台输出画面,大部分时间都在吃灰。为了让这颗核显物尽其用,我打算用 SR-IOV(单根 I/O 虚拟化)把它切成多个虚拟显卡(VF),直通给几台虚拟机分别用——硬件只有一份,算力大家分,这正是 All in Boom 家庭服务器的经典玩法。

PVE 官方内核自带的 i915 驱动并不支持在消费级核显上开 SR-IOV,社区方案就是 strongtz/i915-sriov-dkms:把主线内核的 i915 驱动打上 SR-IOV 补丁,做成 DKMS 模块发布。当前的发布版支持 6.17.x ~ 7.0.x 内核,我的节点正好跑在 7.0.14-16-pve 上,版本完全匹配。

DKMS(Dynamic Kernel Module Support)简单说就是"内核模块的自动装修队":每次内核更新,它都会自动把第三方模块重新编译一遍,让模块始终匹配新内核。安装这类驱动通常就是 dpkg -i 一个 deb 包,装完 dkms status 里能看到 installed 就算万事大吉。

装完之后我习惯性瞄了一眼状态,然后愣住了。

二、问题表现:dkms status 里躺着一堆"查无此人"的内核

先把问题全景画出来:

问题全景:装 1 个驱动,DKMS 却交出 5 份编译产物

图 2:问题全景。系统里实际安装的内核镜像只有 7.0.14-16-pve 一个,DKMS 却交付了 5 份编译产物。

dkms status 的真实输出是这样的:

dkms status 里的四个幽灵内核

图 3:真实命令输出。四条 6.17.x 的条目状态都是 installed——编译成功、安装成功,但对应内核根本不存在。

疑点非常直白:这台机器上运行且仅安装了一个内核镜像 7.0.14-16-pve,DKMS 却为 4 个 6.17.x 版本也各编译了一份模块。更诡异的是,这几个版本的内核镜像早在装驱动之前就被我卸载了——升级到 7.0 系列后清理旧内核是标准操作,apt 里连一个 6.17 的 proxmox-kernel-* 包都不剩。

也就是说,DKMS 在给 4 个"幽灵内核"打工:编译它们永远用不上的模块。这既不是报错也不是警告,一切"看起来都很成功",只是磁盘在悄悄变胖(/var/lib/dkms 里躺着 110M,其中约 70M 属于幽灵),编译时间也翻了五倍。

三、问题分析:名单上有名字,图纸柜里有图纸

3.1 先排除人为因素

按照惯例,先把自己疑心的部分排除掉:

猜测 验证方式 结论
驱动包装错了版本? 核对 deb 包版本与 GitHub Release 排除,就是最新版
手动编译过残留? /var/lib/dkms 只有本次安装的时间戳 排除
有人动过 dkms 配置? /etc/dkms/framework.conf 干干净净 排除
DKMS 自己抽风? 同样操作在干净虚拟机里复现,照样编出 4 份 实锤:行为是确定性的

最后一个验证很关键:这不是灵异事件,是确定性逻辑。那么 DKMS 到底依据什么决定"要给哪些内核编译"?

3.2 DKMS 的"住户名单":/lib/modules

DKMS 是个用户态框架,它不查 dpkg 数据库,它眼中的"系统里有哪些内核",就是 /lib/modules/ 目录下有哪些子目录——每个内核版本安装时会在这里拥有一个以版本号命名的目录,相当于小区的住户名单。列一下这份名单:

/lib/modules 名单与 headers 检查

图 4:真实命令输出。名单上有 8 个版本:4 个 6.17 带 headers(黄色),3 个 6.14 是连 headers 都没有的空目录,外加运行中的 7.0。

名单比想象的要长——除了运行中的 7.0.14-16-pve,还有 4 个 6.17 和 3 个 6.14。那 3 个 6.14 目录是历次升级留下的"空壳"(ls -A 数出来是 0 个文件),内核镜像卸载时没能把它们一并带走。

3.3 DKMS 的"图纸柜":headers

但"名单上有名字"只是必要条件。编译内核模块需要内核头文件(headers)——相当于按原图纸施工,没有图纸,装修队只能跳过这一户。检查方法就是看 /lib/modules/<版本>/build 链接是否存在(这个链接由 headers 包创建)。而 headers 是不是还装着,得问 dpkg:

四套旧内核的 headers 还在系统里

图 5:真实命令输出(删去列)。内核镜像只剩 7.0 系列,但 4 套 6.17 的 headers 包原封不动地躺在系统里。

至此,对照组凑齐了,结论也就出来了:

同样都是幽灵,待遇却天差地别,差就差在那一套"图纸"上。这也解释了图 3 里为什么偏偏是 4 个 6.17 而没有 6.14——DKMS 给幽灵编译的前提条件,是幽灵的 headers 还在系统里

四、问题根因:一行 export 打开的"全量编译"开关

4.1 驱动包自带脚本里的那一行

名单和图纸只是"客观条件",还得有人下令开工。翻出这个 deb 包自带的安装脚本(postinst,包在安装/配置阶段执行的钩子):

postinst 里的 autoinstall_all_kernels=1

图 6:真实文件内容。安装脚本在第一件事就把 autoinstall_all_kernels 设为 1。

这个变量是 DKMS 官方维护脚本(/usr/lib/dkms/common.postinst)识别的开关。看看官方代码怎么解读它:

common.postinst 的判断逻辑

图 7:真实文件内容(节选)。变量一被导出,“只编当前内核"的默认分支整个被跳过。

语义翻译过来就是:不设这个变量时,DKMS 默认只给"当前运行内核 + 最新内核"编译;变量一旦被导出,改为遍历系统里的全部内核。而这正是 i915-sriov-dkms 打包者的选择——上游在 debian 打包脚本里显式加上了这行 export。

这个行为甚至有官方"病历"可查:Launchpad 上有一份针对 dkms 包的 Bug 报告,标题就叫"DKMS only builds modules for the currently booted kernel version”(DKMS 只为当前引导的内核编译),报告里明确写道:默认行为源自 autoinstall_all_kernels 未设置,希望设为 yes 来让所有内核都获得模块:

Launchpad Bug #2120641 的原始描述

图 8:真实网页截图。Launchpad Bug 报告从反方向印证了这个开关的存在与默认值。

4.2 把整个决策链条画出来

DKMS 选择内核的决策流程

图 9:DKMS 在包安装阶段的决策流程。headers 是否在场,是幽灵能否被编译的分水岭。

根因一句话总结:i915-sriov-dkms 的安装脚本导出 autoinstall_all_kernels=1 → DKMS 遍历 /lib/modules/ 全部名单 → 4 个 6.17 幽灵"名单在册 + 图纸(headers)齐全" → 每人获得一份专属编译产物;6.14 幽灵因图纸缺失幸免。

4.3 打个比方:小区物业的装修队

如果觉得 DKMS、headers 这些词太抽象,用一个小区的比喻讲,小学生也能听懂:

小区物业装修队的比喻

图 10:小区比喻。名单 + 图纸缺一不可,而"楼还在不在"根本不在装修队的检查清单里。

整个事件里没有谁是"坏蛋":上游想让所有已装内核启动时都能用上显卡虚拟化,逻辑自洽;DKMS 忠实执行名单,恪尽职守;真正的坑在于——装修队检查名单和图纸,却从不确认"这栋楼还存不存在"

五、如何解决:把幽灵从名单上划掉

思路顺着根因倒推就行,三步:

# 1) 卸掉孤儿 headers(镜像已卸载、headers 还在的,就是孤儿)
apt-get purge -y proxmox-headers-6.17.13-2-pve proxmox-headers-6.17.13-21-pve \
                 proxmox-headers-6.17.13-4-pve proxmox-headers-6.17.4-2-pve

# 2) 清掉为幽灵内核编译的 DKMS 条目
for k in 6.17.13-2-pve 6.17.13-21-pve 6.17.13-4-pve 6.17.4-2-pve; do
  dkms remove i915-sriov-dkms/2026.08.12.1 -k $k
done

# 3) 删掉 /lib/modules 下的残留空目录(确认对应内核包已不存在)
rm -rf /lib/modules/6.17.13-2-pve /lib/modules/6.17.13-21-pve \
       /lib/modules/6.17.13-4-pve /lib/modules/6.17.4-2-pve

做完之后跑一遍"复检三连":

清理完成后的复检三连

图 11:真实命令输出。dkms status 从 5 条变 1 条,linux-version list 只剩运行内核,DKMS 缓存从 110M 瘦身到 39M。

dkms status 只剩当前内核一条 installedlinux-version list 只剩一行、/var/lib/dkms 从 110M 瘦到 39M——三条全中,修复完成。当前内核的模块安然无恙,显卡虚拟化功能不受任何影响。

六、一键自动体检脚本

手不想碰键盘的读者,我把"体检 → 分类 → 清理 → 复核"整个流程封装成了三平台脚本。设计上遵循三条安全底线:

脚本演练输出实录

图 12:真实演练输出(dry-run)。四个 6.17 幽灵被标记为 GHOST,运行内核被保护,元包被跳过,复核段一目了然。

方法一:人工一键执行

Ubuntu 26.04 / PVE 节点(fix-dkms-ghost-kernels.sh——本机直接跑,或传 root@目标节点 远程体检另一台:

#!/usr/bin/env bash
# fix-dkms-ghost-kernels.sh — 清理"幽灵内核":为已卸载内核残留的 DKMS 构建、孤儿 headers、残留目录
# 本机体检:   sudo bash fix-dkms-ghost-kernels.sh
# 本机清理:   sudo bash fix-dkms-ghost-kernels.sh --apply
# 远程体检:   bash fix-dkms-ghost-kernels.sh root@<目标节点IP>
# 远程清理:   bash fix-dkms-ghost-kernels.sh root@<目标节点IP> --apply
set -euo pipefail
TARGET=""
APPLY_ARG=""
for a in "$@"; do
  case "$a" in
    --apply) APPLY_ARG="--apply" ;;
    *) TARGET="$a" ;;
  esac
done

PAYLOAD=$(cat <<'EOS'
set -euo pipefail
APPLY=0
[ "${1:-}" = "--apply" ] && APPLY=1
REPORT="${TMPDIR:-/tmp}/dkms-ghost-kernels-$(date +%Y%m%d-%H%M%S).txt"
log(){ printf '%s\n' "$*" | tee -a "$REPORT"; }

RUNNING="$(uname -r)"
log "== [1/6] DKMS 幽灵内核体检 $(date -Is) =="
log "运行中内核: $RUNNING"
INSTALLED_KERNELS="$(cd /boot 2>/dev/null && ls vmlinuz-* 2>/dev/null | sed 's/^vmlinuz-//' || true)"
log "已安装内核镜像 (/boot/vmlinuz-*):"
printf '%s\n' "$INSTALLED_KERNELS" | sed 's/^/  - /' | while read -r l; do log "$l"; done

log "== [2/6] dkms status 现状 =="
dkms status 2>/dev/null | while IFS= read -r l; do log "$l"; done
[ -z "$(dkms status 2>/dev/null)" ] && log "(无任何 DKMS 条目)" || true

log "== [3/6] 逐个分类 /lib/modules 下的内核目录 =="
GHOST_DIRS=()
for d in /lib/modules/*/; do
  v="$(basename "$d")"
  [ "$v" = "$RUNNING" ] && { log "KEEP  $v  (正在运行,绝不动它)"; continue; }
  if printf '%s\n' "$INSTALLED_KERNELS" | grep -qxF "$v"; then
    log "KEEP  $v  (内核镜像仍已安装)"; continue
  fi
  if dpkg -S "/lib/modules/$v" >/dev/null 2>&1; then
    log "KEEP  $v  (仍有已安装的软件包占用,跳过)"; continue
  fi
  GHOST_DIRS+=("$v")
  log "GHOST $v  (内核镜像已卸载,残留目录;文件数: $(find "$d" -type f 2>/dev/null | wc -l))"
done

log "== [4/6] 需要清理的 DKMS 构建条目 =="
STALE_COUNT=0
while IFS= read -r line; do
  [ -z "$line" ] && continue
  kv="$(printf '%s' "$line" | awk -F', ' '/,/{print $2}' | cut -d, -f1)"
  [ -z "$kv" ] && continue
  [ "$kv" = "$RUNNING" ] && continue
  printf '%s\n' "$INSTALLED_KERNELS" | grep -qxF "$kv" && continue
  mod="$(printf '%s' "$line" | cut -d/ -f1)"
  mv="$(printf '%s' "$line" | cut -d/ -f2 | cut -d, -f1)"
  STALE_COUNT=$((STALE_COUNT+1))
  if [ "$APPLY" = 1 ]; then
    log "CLEAN dkms remove $mod/$mv -k $kv"
    dkms remove "$mod/$mv" -k "$kv" || log "  (移除失败,请手动检查)"
  else
    log "STALE $mod/$mv  for kernel $kv  (将执行: dkms remove $mod/$mv -k $kv)"
  fi
done < <(dkms status 2>/dev/null)
[ "$STALE_COUNT" = 0 ] && [ "$APPLY" = 0 ] && log "(没有为幽灵内核编译的 DKMS 条目,干净)" || true

log "== [5/6] 需要卸载的孤儿内核头文件包 =="
ORPHANS=()
for pkg in $(dpkg --list 2>/dev/null | awk '/^ii  (linux-headers|proxmox-headers|pve-headers)-[0-9]/{print $2}'); do
  v="$(printf '%s' "$pkg" | sed -E 's/^(linux|proxmox|pve)-headers-//')"
  printf '%s' "$v" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+' || { log "SKIP  $pkg  (元包/版本号不完整,不自动处理)"; continue; }
  [ "$v" = "$RUNNING" ] && { log "SKIP  $pkg  (属于运行中内核)"; continue; }
  [ -e "/boot/vmlinuz-$v" ] && { log "SKIP  $pkg  (内核镜像仍已安装)"; continue; }
  ORPHANS+=("$pkg")
  log "ORPHAN $pkg  (对应内核 $v 已卸载)"
done
if [ "${#ORPHANS[@]}" -gt 0 ]; then
  if [ "$APPLY" = 1 ]; then
    log "PURGE: apt-get -y purge ${ORPHANS[*]}"
    DEBIAN_FRONTEND=noninteractive apt-get -y purge "${ORPHANS[@]}" || log "  (卸载失败,请手动检查)"
  else
    log "演练: apt-get -s purge ${ORPHANS[*]}"
    apt-get -s purge "${ORPHANS[@]}" 2>/dev/null | grep -E '^(Remv|Purg)' | while IFS= read -r l; do log "  $l"; done || true
  fi
else
  log "(没有孤儿头文件包,干净)"
fi

log "== [6/6] 清理幽灵残留目录 =="
if [ "${#GHOST_DIRS[@]}" -gt 0 ]; then
  for v in "${GHOST_DIRS[@]}"; do
    if [ "$APPLY" = 1 ]; then
      log "RMDIR /lib/modules/$v"
      rm -rf "/lib/modules/$v"
    else
      log "演练: rm -rf /lib/modules/$v"
    fi
  done
else
  log "(没有幽灵残留目录,干净)"
fi

log "== 复核 =="
log "dkms status:"; dkms status 2>/dev/null | sed 's/^/  /' | while IFS= read -r l; do log "$l"; done
log "/lib/modules:"; ls /lib/modules | sed 's/^/  /' | while IFS= read -r l; do log "$l"; done
log "DKMS 缓存占用: $(du -sh /var/lib/dkms 2>/dev/null | cut -f1)"
if [ "$APPLY" = 1 ]; then
  log "完成。清理报告: $REPORT"
else
  log "以上为演练结果(未做任何修改)。确认无误后加 --apply 重新执行。报告: $REPORT"
fi
EOS
)

if [ -n "$TARGET" ]; then
  ssh "$TARGET" "bash -s $APPLY_ARG" <<<"$PAYLOAD"
else
  [ "$(id -u)" -eq 0 ] || { echo "本机清理需要 root 权限,请用 sudo 运行。"; exit 1; }
  bash -s $APPLY_ARG <<<"$PAYLOAD"
fi

Windows 11(Fix-DkmsGhostKernels.ps1——用系统自带 OpenSSH 客户端远程体检/清理,PowerShell 里执行 .\Fix-DkmsGhostKernels.ps1 -Target "root@<目标节点IP>"(加 -Apply 才真正清理)。特别说明:脚本刻意做成纯 ASCII——Windows PowerShell 5.1 会把无 BOM 的 UTF-8 脚本按本地代码页误读,中文字符串轻则乱码、重则解析报错,纯 ASCII 是最稳的选择:

# Fix-DkmsGhostKernels.ps1 - Check & clean "ghost kernels" on a remote Ubuntu/Debian/PVE node.
# Dry run : .\Fix-DkmsGhostKernels.ps1 -Target "root@<server-ip>"
# Apply   : .\Fix-DkmsGhostKernels.ps1 -Target "root@<server-ip>" -Apply
# Uses only the built-in OpenSSH client. (ASCII-only on purpose: Windows PowerShell 5.1
# mis-decodes BOM-less UTF-8 scripts and may fail to parse non-ASCII strings.)
param(
  [Parameter(Mandatory = $true)][string]$Target,
  [int]$Port = 22,
  [switch]$Apply
)

$payload = @'
set -euo pipefail
APPLY=0
[ "${1:-}" = "--apply" ] && APPLY=1
REPORT="${TMPDIR:-/tmp}/dkms-ghost-kernels-$(date +%Y%m%d-%H%M%S).txt"
log(){ printf '%s\n' "$*" | tee -a "$REPORT"; }

RUNNING="$(uname -r)"
log "== [1/6] DKMS ghost-kernel health check $(date -Is) =="
log "Running kernel: $RUNNING"
INSTALLED_KERNELS="$(cd /boot 2>/dev/null && ls vmlinuz-* 2>/dev/null | sed 's/^vmlinuz-//' || true)"
log "Installed kernel images (/boot/vmlinuz-*):"
printf '%s\n' "$INSTALLED_KERNELS" | sed 's/^/  - /' | while read -r l; do log "$l"; done

log "== [2/6] dkms status =="
dkms status 2>/dev/null | while IFS= read -r l; do log "$l"; done
[ -z "$(dkms status 2>/dev/null)" ] && log "(no DKMS entries)" || true

log "== [3/6] Classifying /lib/modules entries =="
GHOST_DIRS=()
for d in /lib/modules/*/; do
  v="$(basename "$d")"
  [ "$v" = "$RUNNING" ] && { log "KEEP  $v  (running kernel, never touched)"; continue; }
  if printf '%s\n' "$INSTALLED_KERNELS" | grep -qxF "$v"; then
    log "KEEP  $v  (kernel image still installed)"; continue
  fi
  if dpkg -S "/lib/modules/$v" >/dev/null 2>&1; then
    log "KEEP  $v  (still owned by installed packages, skipped)"; continue
  fi
  GHOST_DIRS+=("$v")
  log "GHOST $v  (image uninstalled, leftover dir; files: $(find "$d" -type f 2>/dev/null | wc -l))"
done

log "== [4/6] DKMS builds for ghost kernels =="
STALE_COUNT=0
while IFS= read -r line; do
  [ -z "$line" ] && continue
  kv="$(printf '%s' "$line" | awk -F', ' '/,/{print $2}' | cut -d, -f1)"
  [ -z "$kv" ] && continue
  [ "$kv" = "$RUNNING" ] && continue
  printf '%s\n' "$INSTALLED_KERNELS" | grep -qxF "$kv" && continue
  mod="$(printf '%s' "$line" | cut -d/ -f1)"
  mv="$(printf '%s' "$line" | cut -d/ -f2 | cut -d, -f1)"
  STALE_COUNT=$((STALE_COUNT+1))
  if [ "$APPLY" = 1 ]; then
    log "CLEAN dkms remove $mod/$mv -k $kv"
    dkms remove "$mod/$mv" -k "$kv" || log "  (remove failed, please check manually)"
  else
    log "STALE $mod/$mv  for kernel $kv  (will run: dkms remove $mod/$mv -k $kv)"
  fi
done < <(dkms status 2>/dev/null)
[ "$STALE_COUNT" = 0 ] && [ "$APPLY" = 0 ] && log "(no DKMS builds for ghost kernels, clean)" || true

log "== [5/6] Orphan kernel header packages =="
ORPHANS=()
for pkg in $(dpkg --list 2>/dev/null | awk '/^ii  (linux-headers|proxmox-headers|pve-headers)-[0-9]/{print $2}'); do
  v="$(printf '%s' "$pkg" | sed -E 's/^(linux|proxmox|pve)-headers-//')"
  printf '%s' "$v" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+' || { log "SKIP  $pkg  (meta package, not handled automatically)"; continue; }
  [ "$v" = "$RUNNING" ] && { log "SKIP  $pkg  (belongs to the running kernel)"; continue; }
  [ -e "/boot/vmlinuz-$v" ] && { log "SKIP  $pkg  (kernel image still installed)"; continue; }
  ORPHANS+=("$pkg")
  log "ORPHAN $pkg  (kernel $v no longer installed)"
done
if [ "${#ORPHANS[@]}" -gt 0 ]; then
  if [ "$APPLY" = 1 ]; then
    log "PURGE: apt-get -y purge ${ORPHANS[*]}"
    DEBIAN_FRONTEND=noninteractive apt-get -y purge "${ORPHANS[@]}" || log "  (purge failed, please check manually)"
  else
    log "Dry-run: apt-get -s purge ${ORPHANS[*]}"
    apt-get -s purge "${ORPHANS[@]}" 2>/dev/null | grep -E '^(Remv|Purg)' | while IFS= read -r l; do log "  $l"; done || true
  fi
else
  log "(no orphan header packages, clean)"
fi

log "== [6/6] Removing ghost leftover dirs =="
if [ "${#GHOST_DIRS[@]}" -gt 0 ]; then
  for v in "${GHOST_DIRS[@]}"; do
    if [ "$APPLY" = 1 ]; then
      log "RMDIR /lib/modules/$v"
      rm -rf "/lib/modules/$v"
    else
      log "Dry-run: rm -rf /lib/modules/$v"
    fi
  done
else
  log "(no ghost leftover dirs, clean)"
fi

log "== Verification =="
log "dkms status:"; dkms status 2>/dev/null | sed 's/^/  /' | while IFS= read -r l; do log "$l"; done
log "/lib/modules:"; ls /lib/modules | sed 's/^/  /' | while IFS= read -r l; do log "$l"; done
log "DKMS cache usage: $(du -sh /var/lib/dkms 2>/dev/null | cut -f1)"
if [ "$APPLY" = 1 ]; then
  log "Done. Report: $REPORT"
else
  log "This was a DRY RUN (nothing changed). Re-run with -Apply to clean. Report: $REPORT"
fi
'@

$arg = ''
if ($Apply) { $arg = '--apply' }
$payload | ssh -p $Port $Target "bash -s $arg"
if ($LASTEXITCODE -eq 0) {
  Write-Host "Done. Please review the final 'Verification' block above." -ForegroundColor Green
} else {
  Write-Host "Failed. Check the SSH connection and the output above." -ForegroundColor Red
}

macOS 26(fix-dkms-ghost-kernels-macos.zsh——终端里执行 bash fix-dkms-ghost-kernels-macos.zsh root@<目标节点IP>(加 --apply 才真正清理):

#!/usr/bin/env zsh
# fix-dkms-ghost-kernels-macos.zsh — macOS 26 一键体检/清理远程节点的"幽灵内核"
# 体检: bash fix-dkms-ghost-kernels-macos.zsh root@<目标节点IP>
# 清理: bash fix-dkms-ghost-kernels-macos.zsh root@<目标节点IP> --apply
# 只依赖系统自带的 ssh,目标机需为 Ubuntu / Debian / Proxmox VE。
set -euo pipefail
TARGET="${1:?用法: bash fix-dkms-ghost-kernels-macos.zsh root@<目标节点IP> [--apply]}"
APPLY_ARG="${2:-}"

ssh "$TARGET" "bash -s $APPLY_ARG" <<'EOS'
set -euo pipefail
APPLY=0
[ "${1:-}" = "--apply" ] && APPLY=1
REPORT="${TMPDIR:-/tmp}/dkms-ghost-kernels-$(date +%Y%m%d-%H%M%S).txt"
log(){ printf '%s\n' "$*" | tee -a "$REPORT"; }

RUNNING="$(uname -r)"
log "== [1/6] DKMS 幽灵内核体检 $(date -Is) =="
log "运行中内核: $RUNNING"
INSTALLED_KERNELS="$(cd /boot 2>/dev/null && ls vmlinuz-* 2>/dev/null | sed 's/^vmlinuz-//' || true)"
log "已安装内核镜像 (/boot/vmlinuz-*):"
printf '%s\n' "$INSTALLED_KERNELS" | sed 's/^/  - /' | while read -r l; do log "$l"; done

log "== [2/6] dkms status 现状 =="
dkms status 2>/dev/null | while IFS= read -r l; do log "$l"; done
[ -z "$(dkms status 2>/dev/null)" ] && log "(无任何 DKMS 条目)" || true

log "== [3/6] 逐个分类 /lib/modules 下的内核目录 =="
GHOST_DIRS=()
for d in /lib/modules/*/; do
  v="$(basename "$d")"
  [ "$v" = "$RUNNING" ] && { log "KEEP  $v  (正在运行,绝不动它)"; continue; }
  if printf '%s\n' "$INSTALLED_KERNELS" | grep -qxF "$v"; then
    log "KEEP  $v  (内核镜像仍已安装)"; continue
  fi
  if dpkg -S "/lib/modules/$v" >/dev/null 2>&1; then
    log "KEEP  $v  (仍有已安装的软件包占用,跳过)"; continue
  fi
  GHOST_DIRS+=("$v")
  log "GHOST $v  (内核镜像已卸载,残留目录;文件数: $(find "$d" -type f 2>/dev/null | wc -l))"
done

log "== [4/6] 需要清理的 DKMS 构建条目 =="
STALE_COUNT=0
while IFS= read -r line; do
  [ -z "$line" ] && continue
  kv="$(printf '%s' "$line" | awk -F', ' '/,/{print $2}' | cut -d, -f1)"
  [ -z "$kv" ] && continue
  [ "$kv" = "$RUNNING" ] && continue
  printf '%s\n' "$INSTALLED_KERNELS" | grep -qxF "$kv" && continue
  mod="$(printf '%s' "$line" | cut -d/ -f1)"
  mv="$(printf '%s' "$line" | cut -d/ -f2 | cut -d, -f1)"
  STALE_COUNT=$((STALE_COUNT+1))
  if [ "$APPLY" = 1 ]; then
    log "CLEAN dkms remove $mod/$mv -k $kv"
    dkms remove "$mod/$mv" -k "$kv" || log "  (移除失败,请手动检查)"
  else
    log "STALE $mod/$mv  for kernel $kv  (将执行: dkms remove $mod/$mv -k $kv)"
  fi
done < <(dkms status 2>/dev/null)
[ "$STALE_COUNT" = 0 ] && [ "$APPLY" = 0 ] && log "(没有为幽灵内核编译的 DKMS 条目,干净)" || true

log "== [5/6] 需要卸载的孤儿内核头文件包 =="
ORPHANS=()
for pkg in $(dpkg --list 2>/dev/null | awk '/^ii  (linux-headers|proxmox-headers|pve-headers)-[0-9]/{print $2}'); do
  v="$(printf '%s' "$pkg" | sed -E 's/^(linux|proxmox|pve)-headers-//')"
  printf '%s' "$v" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+' || { log "SKIP  $pkg  (元包/版本号不完整,不自动处理)"; continue; }
  [ "$v" = "$RUNNING" ] && { log "SKIP  $pkg  (属于运行中内核)"; continue; }
  [ -e "/boot/vmlinuz-$v" ] && { log "SKIP  $pkg  (内核镜像仍已安装)"; continue; }
  ORPHANS+=("$pkg")
  log "ORPHAN $pkg  (对应内核 $v 已卸载)"
done
if [ "${#ORPHANS[@]}" -gt 0 ]; then
  if [ "$APPLY" = 1 ]; then
    log "PURGE: apt-get -y purge ${ORPHANS[*]}"
    DEBIAN_FRONTEND=noninteractive apt-get -y purge "${ORPHANS[@]}" || log "  (卸载失败,请手动检查)"
  else
    log "演练: apt-get -s purge ${ORPHANS[*]}"
    apt-get -s purge "${ORPHANS[@]}" 2>/dev/null | grep -E '^(Remv|Purg)' | while IFS= read -r l; do log "  $l"; done || true
  fi
else
  log "(没有孤儿头文件包,干净)"
fi

log "== [6/6] 清理幽灵残留目录 =="
if [ "${#GHOST_DIRS[@]}" -gt 0 ]; then
  for v in "${GHOST_DIRS[@]}"; do
    if [ "$APPLY" = 1 ]; then
      log "RMDIR /lib/modules/$v"
      rm -rf "/lib/modules/$v"
    else
      log "演练: rm -rf /lib/modules/$v"
    fi
  done
else
  log "(没有幽灵残留目录,干净)"
fi

log "== 复核 =="
log "dkms status:"; dkms status 2>/dev/null | sed 's/^/  /' | while IFS= read -r l; do log "$l"; done
log "/lib/modules:"; ls /lib/modules | sed 's/^/  /' | while IFS= read -r l; do log "$l"; done
log "DKMS 缓存占用: $(du -sh /var/lib/dkms 2>/dev/null | cut -f1)"
if [ "$APPLY" = 1 ]; then
  log "完成。清理报告: $REPORT"
else
  log "以上为演练结果(未做任何修改)。确认无误后加 --apply 重新执行。报告: $REPORT"
fi
EOS

三个脚本执行完,核对输出末尾的「复核」段:dkms status 只剩运行内核、/lib/modules 只剩真实存在的内核,即为体检通过;确认无误后加 --apply / -Apply 重新执行一遍,就完成了清理。

方法二:Agent 自动配置

更省事的做法是把问题直接丢给 AI Agent(ZCode、Claude Code 等均可),你只需要提供 SSH 连接方式。下面这段提示词可以直接复制:

请帮我检查并清理一台 Proxmox VE / Ubuntu 服务器上的 DKMS"幽灵内核"——即内核镜像
已卸载、但系统里仍残留其 DKMS 编译产物、孤儿 headers 包和 /lib/modules 残留目录。
请严格按以下步骤操作:

1. 通过 SSH 登录该节点(连接方式见会话上下文/我会另行提供);
2. 记录现状:uname -r、dkms status、ls /lib/modules、
   dpkg -l | grep -E 'linux-headers|proxmox-headers|pve-headers';
3. 先以演练模式运行一次体检脚本(先 dry-run,不改任何东西):
   - 分类规则:uname -r 命中的目录无条件保留;/boot/vmlinuz-<版本> 仍存在的保留;
     dpkg -S 显示仍被已安装包占用的目录跳过;其余判定为幽灵;
   - 孤儿 headers:包名对应的内核版本无 vmlinuz 且非运行内核,且版本号含三段数字
     (形如 proxmox-headers-7.0 的元包不得列入);
4. 把演练报告给我看,明确列出:将 dkms remove 的条目、将 apt purge 的包、
   将 rm -rf 的目录;等我确认后加 --apply 重新执行;
5. 清理后复核:dkms status 应只剩已安装内核的条目;ls /lib/modules 应与
   /boot/vmlinuz-* 及运行内核一致;du -sh /var/lib/dkms 记录瘦身效果。

约束:绝不触碰运行中内核(uname -r);不执行 dist-upgrade/full-upgrade;不重启;
不动当前内核的 headers 与模块;rm -rf 仅限 /lib/modules/<幽灵版本> 这一层目录。

Agent 跑完后,重点核对它给出的三样证据:演练报告里的 GHOST/ORPHAN 清单、清理后的 dkms status、以及 /lib/modules/boot/vmlinuz-* 的一致性。

七、举一反三:DKMS 时代的内核卫生守则

这次事件的教训可以浓缩成几条守则:

  1. 删内核要"镜像 + headers"成对删。headers 是 DKMS 误编译的土壤:只要图纸还在柜子里,下一次重装或升级 DKMS 驱动时,幽灵就会被再次唤醒。apt autoremove 之后,顺手 dpkg -l | grep headers 复查一眼。
  2. 装 DKMS 驱动前,想清楚要不要"全量编译"。如果你的机器常年只保留一个内核,autoinstall_all_kernels=1 纯属浪费;如果是新旧内核并存、等待重启验证的过渡期,这个设计反而是保障——重启回退旧内核时模块依然可用。
  3. dkms status 要会读"沉默的浪费"。它只报告成功与失败,不报告"值不值得"。每台 DKMS 驱动的机器都值得每月跑一次本文的体检脚本。
  4. 判断"内核是否存在"要以 /boot/vmlinuz-* 和 dpkg 为准,而不是 /lib/modules 的目录——后者是"名单",会有残页;前者是"门牌",拆楼必摘牌。

八、Q&A

Q1:为幽灵内核编译出来的模块,有什么实际危害?

A:功能上零危害——幽灵内核永远不会启动,模块永远不会被加载。危害都在资源侧:安装时多等几趟编译(update-initramfs -u -k all 也会为每个内核目录跑一遍)、磁盘多占几十 MB、dkms status 里多一堆干扰视线的条目。属于"不致命但膈应人"的慢性浪费。

Q2:为什么 6.14 的空目录没被编译?

A:因为编译需要 headers(/lib/modules/<版本>/build 链接)。6.14 的 headers 当年清干净了,只剩目录空壳,DKMS 想编也无从下手。这恰好从反面验证了根因:名单 + 图纸,缺一不可。

Q3:这些幽灵模块会拖慢开机吗?

A:不会。开机只加载运行内核的模块,幽灵的模块在它自己的目录里睡大觉。唯一的间接影响是 initramfs 更新变慢——那也只发生在安装/卸载包的那几十秒。

Q4:上游为什么要设计成"给所有内核编译"?这不是 bug 吗?

A:不是 bug,是取舍。i915-sriov 这类驱动属于"启动即必需":如果新旧内核并存、你重启回退到旧内核,没有模块显卡虚拟化就直接失效。全量编译保证了任何已安装内核启动时都可用。Debian 默认(只编当前 + 最新内核)是另一种取舍,Launchpad 上同样有人提需求希望默认全量。两种策略没有对错,只有场景。

Q5:以后升级内核还会复发吗?

A:只要"删镜像留 headers"的习惯还在,就会复发——下次重装或升级 i915-sriov-dkms 时,残留 headers 的幽灵会再次被全量编译。根治方法是让"镜像 + headers"同进同出,然后定期跑一遍本文的体检脚本,一分钟解决。

Q6:我就是不想要全量编译,能从根上关掉吗?

A:可以绕过 deb 包的 postinst,手动注册 DKMS:把源码目录放到 /usr/src/i915-sriov-dkms-<版本> 后执行 dkms adddkms install -m i915-sriov-dkms -v <版本> -k $(uname -r),只给当前内核编译。但要注意:后续每次内核升级后需要自己补编译(这正是 deb 包 + 全量编译帮你自动做的事)。取舍归你自己。

参考资料

Debian Wiki 的 DkmsPackaging 页面

图 13:真实网页截图。Debian Wiki 的 DkmsPackaging 页面记录了 DKMS 打包与 common.postinst 的官方约定。

本文阅读量 --