DocsByRcDocsByRc
系统清理脚本
工具脚本

系统清理脚本

PVE 宿主机交互式清理:APT 缓存、无用依赖、旧内核、系统日志、残留配置,菜单式操作。

适用PVE 宿主机
难度简单
阅读—

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 运行时会自动退出,不会无限循环刷屏。