工具脚本
系统清理脚本
PVE 宿主机交互式清理:APT 缓存、无用依赖、旧内核、系统日志、残留配置,菜单式操作。
clean.sh 是 PVE 宿主机的交互式清理工具(v2):菜单式操作,一键清理 APT 缓存、无用依赖、旧内核、系统日志和残留配置文件,附带完整的安全保护机制。
菜单功能
- APT 缓存——清理安装包缓存
- 无用依赖——autoremove 清理
- 系统日志——journal 与轮转日志
- 旧内核 + headers——保留当前与固定内核
- 残留配置——清理 rc 状态包
- 一键清理——以上全清(不含内核)
- 状态查看——磁盘/内核/虚拟机状态
在 PVE 宿主机上以 root 运行:
BASH
# 下载脚本 curl -fsSL https://docs.232777.xyz/assets/clean.sh -o clean.sh chmod +x clean.sh sudo bash clean.sh
按菜单提示输入序号即可。删除内核等危险操作需要二次确认(完整输入 yes)。
完整脚本内容如下:
BASH
#!/bin/bash # # clean —— PVE 交互式清理菜单 # 用法: sudo bash clean.sh # set -uo pipefail # ---------------- 前置检查 ---------------- need_root() { if [[ ${EUID:-$(id -u)} -ne 0 ]]; then echo "请用 root 权限运行: sudo bash $0" exit 1 fi } need_root # ---------------- 日志 ---------------- # 日志放在独立目录、只保留最近 10 份;磁盘满时 tee 会报错,这里一并处理。 LOG_DIR="/var/log/pve-clean" mkdir -p "$LOG_DIR" 2>/dev/null || true LOG_FILE="$LOG_DIR/clean_$(date +%Y%m%d_%H%M%S).log" if ! : >>"$LOG_FILE" 2>/dev/null; then LOG_FILE="/tmp/pve_clean_$(date +%Y%m%d_%H%M%S).log" if ! : >>"$LOG_FILE" 2>/dev/null; then LOG_FILE="/dev/null" echo "警告: 无法写入日志文件(磁盘可能已满),本次不记录日志。" fi fi # 自身日志只保留最近 10 份 if [[ -d "$LOG_DIR" ]]; then ls -1t "$LOG_DIR"/clean_*.log 2>/dev/null | tail -n +11 | xargs -r rm -f -- 2>/dev/null || true fi log() { echo "[$(date '+%H:%M:%S')] $*" | tee -a "$LOG_FILE"; } run() { "$@" 2>&1 | tee -a "$LOG_FILE"; return "${PIPESTATUS[0]}"; } # ---------------- 输入helpers ---------------- # read 遇到 EOF 会立刻返回导致无限循环;非交互终端(curl|bash、cron)直接退出。 # 这里统一走 ask()。 ANS="" ask() { # ask <提示> [默认值] -> 结果写入全局 $ANS local prompt="$1" default="${2-}" if ! IFS= read -rp "$prompt" ANS; then echo "" log "stdin 已结束(非交互运行),退出。本脚本需要交互式终端。" exit 0 fi ANS="${ANS:-$default}" } ask_int() { # ask_int <提示> <默认> <最小> <最大> local prompt="$1" default="$2" min="$3" max="$4" while true; do ask "$prompt" "$default" if [[ "$ANS" =~ ^[0-9]+$ ]] && (( ANS >= min && ANS <= max )); then return 0 fi echo " >> 请输入 $min 到 $max 之间的整数(直接回车 = $default)" done } confirm_yes() { # 必须完整输入 yes ask "$1" "" [[ "$ANS" == "yes" ]] } pause() { ask "按回车键返回菜单..." ""; } in_list() { # in_list <值> <值...> local needle="$1"; shift local x for x in "$@"; do [[ "$x" == "$needle" ]] && return 0; done return 1 } human() { numfmt --to=iec --suffix=B "${1:-0}" 2>/dev/null || echo "${1:-0}B"; } # 从内核/headers 包名里剥出版本号: proxmox-kernel-6.8.12-9-pve-signed -> 6.8.12-9 pkg_version() { local p="$1" p="${p#pve-kernel-}"; p="${p#proxmox-kernel-}" p="${p#pve-headers-}"; p="${p#proxmox-headers-}" p="${p%-signed}"; p="${p%-pve}" printf '%s' "$p" } # ---------------- 功能: APT 缓存 ---------------- do_apt_cache() { log "===== 清理 APT 安装包缓存 =====" local before after before=$(du -sb /var/cache/apt/archives 2>/dev/null | awk '{print $1}'); before=${before:-0} run apt-get clean after=$(du -sb /var/cache/apt/archives 2>/dev/null | awk '{print $1}'); after=${after:-0} log "完成。/var/cache/apt/archives 由 $(human "$before") 降到 $(human "$after")。" } # ---------------- 功能: autoremove ---------------- do_autoremove() { log "===== 清理无用依赖包 (autoremove --purge) =====" # 先预演给用户看清楚要删什么,避免 -y 盲删 local sim sim=$(LC_ALL=C apt-get -s autoremove --purge 2>&1) || true local victims victims=$(printf '%s\n' "$sim" | awk '/^(Remv|Purg) /{print " " $2}') if [[ -z "$victims" ]]; then log "没有需要清理的无用依赖包。" return fi echo "将要移除的包:" printf '%s\n' "$victims" # 预演里出现核心包就中止(正常不该发生,但兜住) if guard_core_packages "$sim"; then run apt-get autoremove -y --purge log "完成。" fi } # ---------------- 安全闸: 检查 apt 预演结果里有没有核心包 ---------------- # 安全闸:直接检查 apt 预演结果里有没有核心包。 # apt 是【连带】卸载核心包的,核心包本身并不在删除列表里, # 只有看预演结果才能发现连带效应。 guard_core_packages() { # guard_core_packages <apt -s 的输出>;安全返回 0 local sim="$1" danger danger=$(printf '%s\n' "$sim" \ | awk '/^(Remv|Purg) /{print $2}' \ | grep -E '^(proxmox-ve|pve-manager|pve-container|qemu-server|pve-cluster|proxmox-default-kernel|proxmox-kernel-helper|pve-kernel-helper|(pve|proxmox)-kernel-[0-9]+\.[0-9]+)$' \ || true) if [[ -n "$danger" ]]; then log "!!! 中止: apt 预演显示会连带移除以下【核心包】,绝不允许 !!!" printf ' %s\n' $danger | tee -a "$LOG_FILE" log "已放弃本次操作,系统未做任何改动。" log "这通常说明候选列表算错了,或者某个系列元包依赖了你想删的内核。" return 1 fi return 0 } # ---------------- 功能: 旧内核 ---------------- # 取出已安装的内核/headers 包,输出 "版本<TAB>包名",并按【版本号】排序。 # 关键点: 版本号是从包名里剥出来单独排序的,所以 pve-kernel-* 和 proxmox-kernel-* # 两代命名混装时也能正确比较大小。 list_kernel_entries() { # $1 = kernel | headers local kind="$1" re # 注意: 这里写 \\. 是因为经 awk -v 传参会吃掉一层反斜杠,awk 收到的才是 \. case "$kind" in kernel) re='^(pve|proxmox)-kernel-[0-9]+\\.[0-9]+\\.[0-9]+-[0-9]+-pve(-signed)?$' ;; headers) re='^(pve|proxmox)-headers-[0-9]+\\.[0-9]+\\.[0-9]+-[0-9]+-pve$' ;; *) return 1 ;; esac dpkg-query -W -f='${db:Status-Abbrev}\t${Package}\n' 2>/dev/null \ | awk -F'\t' -v re="$re" ' $1 ~ /^ii/ { pkg = $2 if (pkg ~ re) { ver = pkg sub(/^(pve|proxmox)-(kernel|headers)-/, "", ver) sub(/-pve(-signed)?$/, "", ver) print ver "\t" pkg } }' \ | sort -t$'\t' -k1,1 -V } do_old_kernels() { log "===== 清理旧内核(保留当前运行内核 + 固定内核 + 对应 headers)=====" local running_ver running_short running_ver=$(uname -r) log "当前运行内核: $running_ver" if [[ "$running_ver" != *-pve ]]; then log "当前运行的不是 PVE 内核 ($running_ver)。" log "可能你在 LXC 容器里运行本脚本(容器共用宿主机内核),此功能不适用,已跳过。" return fi running_short="${running_ver%-pve}" # 6.8.12-9-pve -> 6.8.12-9 # --- 收集已安装内核,按版本聚合 --- local -a entries=() vers_asc=() local -A pkgs_of_ver=() mapfile -t entries < <(list_kernel_entries kernel) local e v p for e in "${entries[@]}"; do v="${e%%$'\t'*}"; p="${e#*$'\t'}" if [[ -z "${pkgs_of_ver[$v]+x}" ]]; then vers_asc+=("$v"); pkgs_of_ver[$v]="$p" else pkgs_of_ver[$v]+=" $p" fi done if [[ ${#vers_asc[@]} -eq 0 ]]; then log "未检测到具体版本的 PVE 内核包,列出所有内核相关包供参考:" run dpkg -l | grep -Ei 'kernel|headers' || true return fi if [[ -z "${pkgs_of_ver[$running_short]+x}" ]]; then log "警告: 当前运行内核 $running_ver 对应的包已不在已安装列表中。" log "为安全起见不做任何删除。建议先 'apt-get install --reinstall proxmox-kernel-$running_ver' 或重启到已安装的内核后再清理。" return fi # --- 收集必须保护的版本: 固定内核 / hold 住的内核 --- local -a protected_vers=() local f pv for f in /etc/kernel/proxmox-boot-pin /etc/kernel/next-boot-pin; do if [[ -r "$f" ]]; then pv=$(tr -d '[:space:]' <"$f" 2>/dev/null || true) pv="${pv%-pve}" [[ -n "$pv" ]] && protected_vers+=("$pv") && log "检测到固定内核 ($f): $pv" fi done # apt-mark hold 住的内核也不能动 local held while read -r held; do [[ -z "$held" ]] && continue if [[ "$held" =~ ^(pve|proxmox)-kernel-([0-9]+\.[0-9]+\.[0-9]+-[0-9]+)-pve(-signed)?$ ]]; then protected_vers+=("${BASH_REMATCH[2]}") log "检测到被 apt-mark hold 的内核: ${BASH_REMATCH[2]}" fi done < <(apt-mark showhold 2>/dev/null || true) echo "" echo "已安装的内核版本(旧 -> 新,按版本号排序):" for v in "${vers_asc[@]}"; do local tag="" [[ "$v" == "$running_short" ]] && tag=" <== 当前运行" in_list "$v" ${protected_vers[@]+"${protected_vers[@]}"} && tag="$tag <== 已固定/hold" printf ' %-16s %s%s\n' "$v" "${pkgs_of_ver[$v]}" "$tag" done echo "" ask_int "要保留最近几个内核(含当前运行的)?[默认2]: " 2 1 20 local keep_n="$ANS" # --- 计算保留集合(纯版本号排序,正确) --- local -a vers_desc=() keep_vers=("$running_short") mapfile -t vers_desc < <(printf '%s\n' "${vers_asc[@]}" | sort -Vr) for v in ${protected_vers[@]+"${protected_vers[@]}"}; do [[ -n "${pkgs_of_ver[$v]+x}" ]] || continue in_list "$v" "${keep_vers[@]}" || keep_vers+=("$v") done for v in "${vers_desc[@]}"; do (( ${#keep_vers[@]} >= keep_n )) && break in_list "$v" "${keep_vers[@]}" && continue keep_vers+=("$v") done # --- 计算删除集合 --- local -a remove_pkgs=() remove_vers=() for v in "${vers_asc[@]}"; do in_list "$v" "${keep_vers[@]}" && continue remove_vers+=("$v") # shellcheck disable=SC2206 remove_pkgs+=(${pkgs_of_ver[$v]}) done # --- 连带处理 headers: 删除版本对应的 headers + 已没有内核的孤儿 headers --- local -a hdr_entries=() mapfile -t hdr_entries < <(list_kernel_entries headers) local hv hp for e in "${hdr_entries[@]}"; do hv="${e%%$'\t'*}"; hp="${e#*$'\t'}" if in_list "$hv" "${keep_vers[@]}"; then continue # 保留内核对应的 headers 也保留 fi remove_pkgs+=("$hp") if [[ -z "${pkgs_of_ver[$hv]+x}" ]]; then log "发现孤儿 headers(对应内核已不在): $hp" fi done if [[ ${#remove_pkgs[@]} -eq 0 ]]; then log "没有多余的旧内核/headers 需要删除。" return fi echo "" echo "将保留的内核版本:" for v in "${vers_desc[@]}"; do in_list "$v" "${keep_vers[@]}" && printf ' %s\n' "$v" done echo "将删除的包:" printf ' %s\n' "${remove_pkgs[@]}" # --- 安全闸 1: apt 预演 --- echo "" log "正在用 apt-get -s 预演,检查是否会连带影响核心包..." local sim if ! sim=$(LC_ALL=C apt-get -s purge "${remove_pkgs[@]}" 2>&1); then log "预演失败,放弃操作(系统未改动)。apt 输出:" printf '%s\n' "$sim" | tee -a "$LOG_FILE" log "常见原因: 依赖冲突未解决,可先跑 'apt-get install -f' 修复。" return fi guard_core_packages "$sim" || return # --- 安全闸 2: 断言当前运行内核和固定内核绝不在删除列表里 --- local rp rpv for rp in "${remove_pkgs[@]}"; do rpv=$(pkg_version "$rp") for v in "$running_short" ${protected_vers[@]+"${protected_vers[@]}"}; do if [[ "$rpv" == "$v" ]]; then log "!!! 内部断言失败: 受保护版本 $v 的包 $rp 进入了删除列表,已中止 !!!" log "系统未做任何改动。这是脚本 bug,请把上面这行贴出来。" return fi done done log "预演通过: 不影响任何核心包,当前运行内核与固定内核均被保留。" echo "" if ! confirm_yes "确认删除以上包?(必须完整输入 yes 确认): "; then log "已取消,系统未做任何改动。" return fi local rc run apt-get purge -y "${remove_pkgs[@]}" rc=$? if [[ $rc -ne 0 ]]; then log "!!! 删除失败 (退出码: $rc) !!!" log "内核实际上【没有被删除】。常见原因:" log " 1) 触发了 pve-apt-hook 保护 2) 依赖冲突 3) 包被 apt-mark hold 锁定" log "请看上方 apt 报错,或运行 'apt-get install -f' 修复后重试。" log "本次不会执行 update-initramfs / 引导刷新,避免在删除未成功时误操作引导。" return fi log "包删除成功,清理残留依赖..." run apt-get autoremove -y --purge || true log "更新 initramfs 和引导..." run update-initramfs -u -k all || log "警告: update-initramfs 返回非 0,请检查 /boot 空间。" if command -v proxmox-boot-tool >/dev/null 2>&1; then run proxmox-boot-tool refresh || log "警告: proxmox-boot-tool refresh 返回非 0,请手动检查引导。" else run update-grub || log "警告: update-grub 返回非 0,请手动检查引导。" fi # --- 二次核实 --- local -a still=() for rp in "${remove_pkgs[@]}"; do dpkg-query -W -f='${db:Status-Abbrev}' "$rp" 2>/dev/null | grep -q '^ii' && still+=("$rp") done if [[ ${#still[@]} -gt 0 ]]; then log "警告: 以下包仍显示已安装,请手动检查:" printf ' %s\n' "${still[@]}" | tee -a "$LOG_FILE" else log "已核实: 旧内核清理完成,引导已更新,包确实已移除。" fi echo "" echo "当前 /boot 空间:" df -h /boot 2>/dev/null | tee -a "$LOG_FILE" } # ---------------- 功能: 日志 ---------------- do_logs() { log "===== 清理系统日志 =====" ask_int "保留最近几天的日志?[默认30]: " 30 1 3650 local days="$ANS" echo "" echo "-- systemd journal --" run journalctl --vacuum-time="${days}d" || log "警告: journalctl vacuum 返回非 0。" # /var/log 下轮转出来的 .gz/.1 往往更占地方。 echo "" echo "-- /var/log 下的轮转日志 (*.gz / *.xz / *.old / *.数字) --" local -a rotated=() mapfile -t rotated < <(find /var/log -type f \ \( -name '*.gz' -o -name '*.xz' -o -name '*.old' -o -regex '.*\.[0-9]+$' \) \ -mtime +"$days" -print 2>/dev/null) if [[ ${#rotated[@]} -eq 0 ]]; then log "没有超过 ${days} 天的轮转日志。" else local sz sz=$(printf '%s\0' "${rotated[@]}" | du -scb --files0-from=- 2>/dev/null | tail -1 | awk '{print $1}') echo "找到 ${#rotated[@]} 个文件,合计 $(human "${sz:-0}"),例如:" printf ' %s\n' "${rotated[@]:0:10}" [[ ${#rotated[@]} -gt 10 ]] && echo " ... 以及另外 $(( ${#rotated[@]} - 10 )) 个" if confirm_yes "删除这些轮转日志?(输入 yes 确认): "; then printf '%s\0' "${rotated[@]}" | xargs -0 rm -f -- 2>/dev/null || true log "已删除 ${#rotated[@]} 个轮转日志文件。" else log "跳过轮转日志。" fi fi # PVE 自己的任务日志,攒久了也不小。删掉只影响 Web 界面的历史任务记录。 if [[ -d /var/log/pve/tasks ]]; then echo "" echo "-- PVE 任务日志 /var/log/pve/tasks --" local tsz tsz=$(du -sb /var/log/pve/tasks 2>/dev/null | awk '{print $1}') echo "当前占用: $(human "${tsz:-0}")(删除只会丢失 Web 界面里的历史任务记录,不影响运行)" if confirm_yes "删除超过 ${days} 天的 PVE 任务日志?(输入 yes 确认): "; then find /var/log/pve/tasks -type f -mtime +"$days" ! -name index -delete 2>/dev/null || true log "已清理 PVE 任务日志。" else log "跳过 PVE 任务日志。" fi fi log "日志清理完成。" } # ---------------- 功能: 残留配置包 ---------------- do_rc_purge() { log "===== 清理已卸载但残留配置的包 (rc 状态) =====" local -a rc_pkgs=() mapfile -t rc_pkgs < <(dpkg-query -W -f='${db:Status-Abbrev}\t${Package}\n' 2>/dev/null \ | awk -F'\t' '$1 ~ /^rc/ {print $2}') if [[ ${#rc_pkgs[@]} -eq 0 ]]; then log "没有 rc 状态的残留包。" return fi echo "以下包已卸载但配置文件仍残留:" printf ' %s\n' "${rc_pkgs[@]}" local sim sim=$(LC_ALL=C apt-get -s purge "${rc_pkgs[@]}" 2>&1) || { log "预演失败,放弃。"; return; } guard_core_packages "$sim" || return if confirm_yes "清除这些残留配置?(输入 yes 确认): "; then run apt-get purge -y "${rc_pkgs[@]}" || log "警告: purge 返回非 0。" log "完成。" else log "已取消。" fi } # ---------------- 功能: 一键 ---------------- do_all_safe() { log "===== 一键清理(缓存 + 依赖 + 日志 + 残留配置,不含内核)=====" do_apt_cache echo "" do_autoremove echo "" do_rc_purge echo "" do_logs log "一键清理完成(内核未动,如需清理请单独选 4)。" } # ---------------- 状态查看 ---------------- show_status() { echo "" echo "----- 当前系统状态(仅查看,不改动)-----" echo "磁盘空间:" df -h / /boot /var/log 2>/dev/null | sort -u echo "" echo "占地方的目录:" local d for d in /var/cache/apt/archives /var/log /var/log/pve /usr/src /var/lib/vz/dump; do [[ -d "$d" ]] && printf ' %-28s %s\n' "$d" "$(du -sh "$d" 2>/dev/null | awk '{print $1}')" done echo "" echo "已安装内核(按版本号排序,旧 -> 新):" local cur_short v p cur_short="$(uname -r)"; cur_short="${cur_short%-pve}" while IFS=$'\t' read -r v p; do [[ -z "${v:-}" ]] && continue if [[ "$v" == "$cur_short" ]]; then printf ' %-16s %s <== 当前运行\n' "$v" "$p" else printf ' %-16s %s\n' "$v" "$p" fi done < <(list_kernel_entries kernel) echo " (当前运行: $(uname -r))" if command -v proxmox-boot-tool >/dev/null 2>&1; then echo "" echo "引导内核状态:" proxmox-boot-tool kernel list 2>/dev/null | sed 's/^/ /' fi echo "" echo "运行中的虚拟机/容器:" command -v qm >/dev/null 2>&1 && qm list 2>/dev/null | sed 's/^/ /' command -v pct >/dev/null 2>&1 && pct list 2>/dev/null | sed 's/^/ /' echo " 本脚本任何选项都不会影响以上虚拟机/容器的运行。" } # ---------------- 菜单主循环 ---------------- while true; do clear echo "================================================" echo " PVE 清理工具 v2 (输入序号执行)" echo "================================================" echo " 1) 清理 APT 安装包缓存 (低风险)" echo " 2) 清理无用依赖包 autoremove (低风险)" echo " 3) 清理系统日志 journal/轮转/PVE任务 (低风险)" echo " 4) 清理旧内核 + 对应 headers (需二次确认)" echo " 5) 清理已卸载包的残留配置 (rc) (低风险)" echo " 6) 一键清理 1+2+3+5 (不含内核,推荐)" echo " 0) 查看磁盘/内核/虚拟机状态(不做改动)" echo " q) 退出" echo "------------------------------------------------" echo "日志文件: $LOG_FILE" echo "================================================" ask "请输入选项: " "" case "$ANS" in 1) do_apt_cache; pause ;; 2) do_autoremove; pause ;; 3) do_logs; pause ;; 4) do_old_kernels; pause ;; 5) do_rc_purge; pause ;; 6) do_all_safe; pause ;; 0) show_status; pause ;; q|Q) echo "退出。"; exit 0 ;; *) echo "无效选项"; sleep 1 ;; esac done
内核保护:清理旧内核时自动保护当前运行内核、proxmox-boot-tool 固定的内核、apt-mark hold 的内核,按版本号排序(不会误删)。
预演检查:删除前用
apt-get -s 预演,只要预演结果里出现核心包(proxmox-ve、pve-manager 等)就直接中止。日志:操作日志保存在
/var/log/pve-clean/,只保留最近 10 份,磁盘满时自动降级。非交互保护:通过管道(curl|bash)或 cron 运行时会自动退出,不会无限循环刷屏。