内核都卸载了,DKMS 却还在为它们编译 110M 模块:揪出 i915-sriov-dkms 的“幽灵内核”
先说结论
给 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 里躺着一堆"查无此人"的内核
先把问题全景画出来:
图 2:问题全景。系统里实际安装的内核镜像只有 7.0.14-16-pve 一个,DKMS 却交付了 5 份编译产物。
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/ 目录下有哪些子目录——每个内核版本安装时会在这里拥有一个以版本号命名的目录,相当于小区的住户名单。列一下这份名单:
图 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:
图 5:真实命令输出(删去列)。内核镜像只剩 7.0 系列,但 4 套 6.17 的 headers 包原封不动地躺在系统里。
至此,对照组凑齐了,结论也就出来了:
- 6.17 × 4:名单上有目录(升级后残留)+ 图纸柜里有 headers(一直没卸)→ DKMS 视为"合法住户",照单编译;
- 6.14 × 3:名单上有空目录,但 headers 早已清掉 → DKMS 直接跳过,
dkms status里连一条记录都没有。
同样都是幽灵,待遇却天差地别,差就差在那一套"图纸"上。这也解释了图 3 里为什么偏偏是 4 个 6.17 而没有 6.14——DKMS 给幽灵编译的前提条件,是幽灵的 headers 还在系统里。
四、问题根因:一行 export 打开的"全量编译"开关
4.1 驱动包自带脚本里的那一行
名单和图纸只是"客观条件",还得有人下令开工。翻出这个 deb 包自带的安装脚本(postinst,包在安装/配置阶段执行的钩子):
图 6:真实文件内容。安装脚本在第一件事就把 autoinstall_all_kernels 设为 1。
这个变量是 DKMS 官方维护脚本(/usr/lib/dkms/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 来让所有内核都获得模块:

图 8:真实网页截图。Launchpad Bug 报告从反方向印证了这个开关的存在与默认值。
4.2 把整个决策链条画出来
图 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 是小区物业的装修队,工作守则写着:照着「住户名单 + 图纸柜」,给每个有图纸的住户定制一套家具;
/lib/modules是住户名单:内核装上时登记,卸载时本该划掉,但残留在所难免;- headers 是图纸柜:装了 headers = 图纸还在;镜像卸了图纸没卸 = 人走了图纸还压在柜子里;
autoinstall_all_kernels=1是加班开关:正常物业只服务当前住着的一两户,打开开关后"名单上每一户都要做";- 编译产物是定制家具:6.17.13-2 这栋楼都拆了,家具照样按图纸做好,然后堆进物业仓库(磁盘)。
整个事件里没有谁是"坏蛋":上游想让所有已装内核启动时都能用上显卡虚拟化,逻辑自洽;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 只剩当前内核一条 installed、linux-version list 只剩一行、/var/lib/dkms 从 110M 瘦到 39M——三条全中,修复完成。当前内核的模块安然无恙,显卡虚拟化功能不受任何影响。
六、一键自动体检脚本
手不想碰键盘的读者,我把"体检 → 分类 → 清理 → 复核"整个流程封装成了三平台脚本。设计上遵循三条安全底线:
- 默认演练模式:不带参数只输出"将会做什么"的报告,落一份到
/tmp,人看过再动手; - 绝不碰运行中内核:
uname -r命中的目录无条件 KEEP,另设dpkg -S占用检查,任何仍被已安装软件包占有的目录一律跳过; - 元包保护:
proxmox-headers-7.0这类版本号不完整的元包不自动处理,宁可漏杀不可误杀。
图 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 时代的内核卫生守则
这次事件的教训可以浓缩成几条守则:
- 删内核要"镜像 + headers"成对删。headers 是 DKMS 误编译的土壤:只要图纸还在柜子里,下一次重装或升级 DKMS 驱动时,幽灵就会被再次唤醒。
apt autoremove之后,顺手dpkg -l | grep headers复查一眼。 - 装 DKMS 驱动前,想清楚要不要"全量编译"。如果你的机器常年只保留一个内核,
autoinstall_all_kernels=1纯属浪费;如果是新旧内核并存、等待重启验证的过渡期,这个设计反而是保障——重启回退旧内核时模块依然可用。 dkms status要会读"沉默的浪费"。它只报告成功与失败,不报告"值不值得"。每台 DKMS 驱动的机器都值得每月跑一次本文的体检脚本。- 判断"内核是否存在"要以
/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 add 和 dkms install -m i915-sriov-dkms -v <版本> -k $(uname -r),只给当前内核编译。但要注意:后续每次内核升级后需要自己补编译(这正是 deb 包 + 全量编译帮你自动做的事)。取舍归你自己。
参考资料

图 13:真实网页截图。Debian Wiki 的 DkmsPackaging 页面记录了 DKMS 打包与 common.postinst 的官方约定。
- strongtz/i915-sriov-dkms(GitHub)——本文主角,i915/xe 驱动的 SR-IOV 社区移植版
- Launchpad Bug #2120641:DKMS only builds modules for the currently booted kernel version——
autoinstall_all_kernels开关的官方"病历" - Debian Wiki:DkmsPackaging——DKMS 的 Debian 打包规范与 common.postinst 约定
- dkms(8) 手册页(Debian)——
autoinstall与相关参数的权威说明 - dkms-project/dkms(GitHub)——DKMS 上游源码仓库