Jenkins 安全清理孤立工作区(workspace)的 Shell 脚本:原理、实现与实战
前言:如果你是 Jenkins 运维或开发人员,大概率遇到过这样的窘境:
某天监控突然告警“Jenkins 服务器磁盘使用率超 90%”,登录服务器排查,却发现 workspace
目录下堆积了上百个陌生文件夹——它们对应的任务早就被删除,却像“隐形垃圾”一样占据着几十甚至几百 GB 空间。
更让人头疼的是,很多人误以为“Jenkins 配置‘只保留最近 N 次构建’就能自动清理 workspace”,但实际上,这个策略仅会删除构建历史记录和制品文件
,对存储构建中间产物、依赖缓存的 workspace
目录“视而不见”。
手动清理?风险太高——一旦误删正在运行的任务 workspace,可能导致构建失败;逐一审核目录是否“有用”?效率低下,尤其对有上百个任务的 Jenkins 集群来说,简直是“体力活”。
正是为了解决这个“痛点”,我们开发了这套 Jenkins 孤立工作区安全清理脚本
。它不只是一个简单的删除工具,更像是为 Jenkins 量身定制的“磁盘管家”:默认“预演不执行”,通过多层安全校验(进程占用、目录老化、Jenkins 活跃构建检测)避免误删,还能生成可追溯的计划与日志,让清理工作“安全、可控、可审计”。
接下来,我们从问题背景、脚本功能、使用方法到原理拆解,一步步带你掌握这套清理方案,彻底告别 Jenkins 磁盘“臃肿”难题。
适用环境:Linux 上的 Jenkins Controller 或 Agent
目标:自动发现并清理“孤立”的 workspace 目录(对应的 Jenkins Job 已删除或不再存在)
- Jenkins 设置“只保留最近 N 次构建”不会自动清理
workspace
。 - 这篇文章提供一个安全优先的 Shell 脚本:默认 Dry-Run,多重安全检查(年龄、占用、白/黑名单、Jenkins API 活动构建检查),二次确认,生成计划与日志,才会执行删除。
- 覆盖 Folder/Multibranch 的命名(
%2F
)和@tmp/@2
等后缀的处理。
⚠️ 重要提示:操作前务必对目标工作区目录进行完整备份
由于脚本涉及文件系统的删除操作,为避免意外情况(如误删仍在使用的工作区、环境差异导致的非预期行为等),请在执行任何清理动作前,通过 tar
、rsync
等工具对 Jenkins 工作区根目录(或相关子目录)创建完整归档备份,确保数据具备可恢复性。
一、背景与问题
- Jenkins 的构建保留策略(
numToKeepStr
等)只会清理 构建历史和制品(artifacts),不会自动清理 workspace。 - workspace 目录通常在:
或者在 Agent 节点相应路径。$JENKINS_HOME/workspace/<job-full-name-with-%2F>
- 如果大量 Job 被删除或迁移,可能遗留很多孤立工作区目录(包含
@tmp
、@2
、@script
等后缀目录),占用大量磁盘。 - 团队需要一个安全可审计的自动清理工具。
二、脚本能做什么(功能清单)
- 发现现存 Job 的“期望工作区名集合”(递归扫描
$JENKINS_HOME/jobs
,处理多层 Folder)。 - 与
$WORKSPACE_ROOT
的实际目录对比,找出疑似孤立的工作区目录。 - 默认 Dry-run:只生成清理计划,不做删除。
- 安全保障:
- 老化阈值(例如只删“最近 3 天以外”的目录)。
- 进程占用检测(
lsof
或fuser
)。 - Jenkins API 检查是否有正在构建(可选)。
- 白/黑名单正则过滤。
- 删除需二次确认(输入候选数量)。
- 审计输出:计划文件 + 删除日志文件。
- 仅在对应 Job 已不存在时,才考虑清理
*@tmp
(可选开关)。
三、安全设计(为什么它安全)
- 默认预演(Dry-run),不碰任何文件。
- 老化检查:避免清理刚产生/仍在使用的目录(默认 3 天)。
- 占用检查:检测是否有进程占用目录,避免误删。
- Jenkins API 检测:发现活动构建就退出(可选)。
- 白/黑名单:逐步放开,防止“一刀切”。
- 交互确认:必须手动输入候选数量才会删除。
- 计划与日志:可审计、可回溯。
四、前置条件与环境
- 操作系统:Linux(Bash 环境)
- 执行用户:建议
jenkins
或具备删除权限的用户(谨慎使用 root) - 命令工具:
bash
、find
、stat
、date
、sed
、awk
等基础工具 - 可选工具:
lsof
或fuser
(占用检查,不装也可用,但安全性降低) - 可选 Jenkins API:
curl
+JENKINS_URL/JENKINS_USER/JENKINS_API_TOKEN
五、脚本源码
文件名建议:
safe-clean-orphan-workspaces.sh
#!/usr/bin/env bash
# safe-clean-orphan-workspaces.sh
# Safely detect and (optionally) clean Jenkins orphaned workspaces.
# Author: You & Copilot
# License: MITset -Eeuo pipefail# -------- Defaults (override via CLI flags) --------
WORKSPACE_ROOT="${WORKSPACE_ROOT:-/data1/var/lib/jenkins/workspace}"
JENKINS_HOME="${JENKINS_HOME:-/data1/var/lib/jenkins}"
OLDER_THAN_DAYS="${OLDER_THAN_DAYS:-3}" # only delete if dir mtime older than this many days
APPLY=false # dry-run by default
INCLUDE_TMP=false # also delete *@tmp when base is orphan
CHECK_OPEN_FILES=true # use lsof/fuser if available
VERBOSE=false
EXCLUDE_PATTERN="" # e.g. "^(keep-this|dont-touch-.*)$" (regex on dirname)
INCLUDE_PATTERN="" # optional regex to narrow candidates
PLAN_DIR="${PLAN_DIR:-./}" # where to write plan & logs# Optional: if set, we try to detect active builds and abort if any
JENKINS_URL="${JENKINS_URL:-}" # e.g. http://127.0.0.1:8080
JENKINS_USER="${JENKINS_USER:-}" # user for API
JENKINS_API_TOKEN="${JENKINS_API_TOKEN:-}" # API token# --------- Helpers ----------
log() { printf '%s\n' "$*" >&2; }
vlog() { $VERBOSE && printf '[verbose] %s\n' "$*" >&2 || true; }usage() {cat <<'EOF'
Usage:safe-clean-orphan-workspaces.sh [options]Options:--workspace-root PATH Workspace root (default: /data1/var/lib/jenkins/workspace)--jenkins-home PATH Jenkins home (default: /data1/var/lib/jenkins)--older-than DAYS Only delete dirs older than DAYS (default: 3)--include-tmp Also delete *@tmp when base job is orphan (default: off)--no-open-files-check Skip lsof/fuser check (default: on)--include-regex REGEX Only consider dirnames matching this regex--exclude-regex REGEX Skip dirnames matching this regex--plan-dir PATH Where to write the plan & logs (default: ./)--verbose More logs--apply Actually delete (requires interactive confirm)-h, --help Show this helpOptional safety integration with Jenkins API (to avoid cleaning during active builds):env JENKINS_URL, JENKINS_USER, JENKINS_API_TOKENExamples:Dry-run (preview):./safe-clean-orphan-workspaces.sh --workspace-root /data1/var/lib/jenkins/workspace \--jenkins-home /data1/var/lib/jenkins --verboseApply (after preview looks good):./safe-clean-orphan-workspaces.sh --applyEOF
}while [[ $# -gt 0 ]]; docase "$1" in--workspace-root) WORKSPACE_ROOT="$2"; shift 2;;--jenkins-home) JENKINS_HOME="$2"; shift 2;;--older-than) OLDER_THAN_DAYS="$2"; shift 2;;--include-tmp) INCLUDE_TMP=true; shift;;--no-open-files-check) CHECK_OPEN_FILES=false; shift;;--include-regex) INCLUDE_PATTERN="$2"; shift 2;;--exclude-regex) EXCLUDE_PATTERN="$2"; shift 2;;--plan-dir) PLAN_DIR="$2"; shift 2;;--verbose) VERBOSE=true; shift;;--apply) APPLY=true; shift;;-h|--help) usage; exit 0;;*) log "Unknown arg: $1"; usage; exit 1;;esac
donetimestamp="$(date +'%Y%m%d-%H%M%S')"
PLAN_FILE="${PLAN_DIR%/}/orphan-workspaces-plan-${timestamp}.txt"
LOG_FILE="${PLAN_DIR%/}/orphan-workspaces-delete-${timestamp}.log"# ---- Preflight checks ----
[[ -d "$WORKSPACE_ROOT" ]] || { log "Workspace root not found: $WORKSPACE_ROOT"; exit 1; }
[[ -d "$JENKINS_HOME/jobs" ]] || { log "Jenkins jobs dir not found: $JENKINS_HOME/jobs"; exit 1; }if [[ -n "$JENKINS_URL" && -n "$JENKINS_USER" && -n "$JENKINS_API_TOKEN" ]]; thenlog "Checking Jenkins executors for active builds via API..."# crumb not required for GET; we only look for '"building":true'if curl -fsS -u "${JENKINS_USER}:${JENKINS_API_TOKEN}" \"$JENKINS_URL/computer/api/json?depth=1" | grep -q '"building":true'; thenlog "Detected active builds. For safety, aborting now. (Unset JENKINS_* to skip this check.)"exit 1elsevlog "No active builds reported by Jenkins API."fi
fi# ---- Build expected workspace basenames set from $JENKINS_HOME/jobs ----
# For any config.xml under jobs/..../jobs/<name>/..., we extract each <name> after '/jobs/' and join with '%2F'.
declare -A EXPECTED
vlog "Discovering existing job full names under: $JENKINS_HOME/jobs"# We intentionally avoid parsing XML; we rely on folder structure.
while IFS= read -r -d '' cfg; dorel="${cfg#$JENKINS_HOME/jobs/}" # strip prefix# Extract every component that follows '/jobs/' (e.g., jobs/f1/jobs/f2/jobs/jobA/config.xml => f1 f2 jobA)# shellcheck disable=SC2001names=$(sed -E 's#(^|/)jobs/#\n#g' <<<"$rel" | tr '\n' ' ' | awk '{for(i=1;i<=NF;i++) print $i}' | sed -E 's#/.*##')# join with %2Ffull=""for n in $names; do[[ -z "$n" ]] && continueif [[ -z "$full" ]]; then full="$n"; else full="${full}%2F${n}"; fidone[[ -n "$full" ]] && EXPECTED["$full"]=1
done < <(find "$JENKINS_HOME/jobs" -type f -name 'config.xml' -print0)vlog "Total expected workspace basenames discovered: ${#EXPECTED[@]}"# ---- Enumerate actual workspace dirs ----
log "Scanning workspace root: $WORKSPACE_ROOT"
mapfile -d '' DIRS < <(find "$WORKSPACE_ROOT" -mindepth 1 -maxdepth 1 -type d -print0)# Prepare plan & header
{echo "# Orphan workspace deletion plan @ ${timestamp}"echo "# WORKSPACE_ROOT=$WORKSPACE_ROOT"echo "# JENKINS_HOME=$JENKINS_HOME"echo "# OLDER_THAN_DAYS=$OLDER_THAN_DAYS INCLUDE_TMP=$INCLUDE_TMP APPLY=$APPLY"echo "# INCLUDE_PATTERN=${INCLUDE_PATTERN:-<none>} EXCLUDE_PATTERN=${EXCLUDE_PATTERN:-<none>}"echoprintf "%-8s | %-50s | %-19s | %s\n" "CANDID." "DIRNAME" "LAST_MODIFIED" "FULL_PATH"echo "---------+----------------------------------------------------+---------------------+----------------------------------------"
} > "$PLAN_FILE"candidates=0
skipped=0
considered=0is_in_use() {local d="$1"if ! $CHECK_OPEN_FILES; then return 1; fiif command -v lsof >/dev/null 2>&1; thentimeout 5s lsof +D "$d" >/dev/null 2>&1 && return 0 || return 1elif command -v fuser >/dev/null 2>&1; thenfuser -s "$d" 2>/dev/null && return 0 || return 1elsereturn 1fi
}for d in "${DIRS[@]}"; do((considered++)) || truename="$(basename "$d")"base="${name%%@*}" # strip '@...' suffix (covers @tmp, @2, @script, etc.)# Include/Exclude filtersif [[ -n "$INCLUDE_PATTERN" ]]; thenif ! [[ "$name" =~ $INCLUDE_PATTERN ]]; then((skipped++)) || true; $VERBOSE && vlog "Skip by include-regex: $name"; continuefifiif [[ -n "$EXCLUDE_PATTERN" ]]; thenif [[ "$name" =~ $EXCLUDE_PATTERN ]]; then((skipped++)) || true; $VERBOSE && vlog "Skip by exclude-regex: $name"; continuefifi# If base is expected -> not orphan; skip (and do not touch its @tmp)if [[ -n "${EXPECTED[$base]:-}" ]]; then((skipped++)) || truevlog "Keeps (expected job): $name"continuefi# If it's an @tmp (or @N) but base exists in workspace (rare), still treat via base rule above.# Now base is NOT expected -> consider orphan# Age checkif ! find "$d" -prune -mtime +"$OLDER_THAN_DAYS" | grep -q .; then((skipped++)) || truevlog "Skip (too new, <= ${OLDER_THAN_DAYS}d): $name"continuefi# If this is a pure '@tmp' of an existing base? Already excluded by EXPECTED check.# For extra safety: if name ends with '@tmp' and INCLUDE_TMP=false, skip.if [[ "$name" == *"@tmp" && "$INCLUDE_TMP" != true ]]; then((skipped++)) || truevlog "Skip @tmp (INCLUDE_TMP=false): $name"continuefi# Check if directory is being used by some process (best-effort)if is_in_use "$d"; then((skipped++)) || truevlog "Skip (in use by process): $name"continuefi# Record candidatelm="$(date -d "@$(stat -c %Y "$d")" +'%F %T' 2>/dev/null || stat -f "%Sm" -t "%Y-%m-%d %H:%M:%S" "$d")"printf "%-8s | %-50s | %-19s | %s\n" "YES" "$name" "$lm" "$d" >> "$PLAN_FILE"((candidates++)) || true
done# Report summary
{echoecho "# Summary:"echo "# Considered: $considered"echo "# Skipped: $skipped"echo "# Candidates: $candidates"
} >> "$PLAN_FILE"log "Plan written to: $PLAN_FILE"
log "Candidates found: $candidates"if ! $APPLY; thenlog "Dry-run only. Review the plan above. Re-run with --apply to actually delete."exit 0
fi# Apply mode: ask for interactive confirmation
echo
echo "🚨 You are about to DELETE $candidates directories listed in:"
echo " $PLAN_FILE"
read -r -p "Type exactly the number of candidates ($candidates) to confirm: " confirm
if [[ "$confirm" != "$candidates" ]]; thenlog "Confirmation failed. Aborting."exit 1
fi# Execute deletions according to plan file
deleted=0
failed=0
echo "# Delete log @ $timestamp" > "$LOG_FILE"while IFS= read -r line; do# Only lines marked as candidate ("YES | ... | ... | /full/path")[[ "$line" =~ ^YES\ \ \ \ \ \ \ \ \ \|\ ]] || continuepath="$(awk -F '|' '{print $4}' <<<"$line" | sed -E 's#^[[:space:]]+##')"if [[ -d "$path" ]]; thenif rm -rf --one-file-system -- "$path"; thenecho "[OK] $(date +'%F %T') Deleted: $path" | tee -a "$LOG_FILE"((deleted++)) || trueelseecho "[ERR] $(date +'%F %T') FAILED: $path" | tee -a "$LOG_FILE"((failed++)) || truefielseecho "[SKIP] $(date +'%F %T') Missing (already gone): $path" | tee -a "$LOG_FILE"fi
done < "$PLAN_FILE"echo
log "Deletion finished. Deleted=$deleted, Failed=$failed"
log "Log written to: $LOG_FILE"
六、工作原理详解
1)构建“期望存在的工作区名集合”
- Jenkins 在
$JENKINS_HOME/jobs
下按 Folder/Job 分层保存:jobs/<folder>/jobs/<sub-folder>/jobs/<job>/config.xml
。 - 脚本不解析 XML,而是利用目录结构:遇到每个
config.xml
,就把路径中的层级名提取出来并用%2F
连接(Jenkins 会把 Job 全名中的斜杠替换为%2F
作为 workspace 名称的一部分)。 - 例如:
jobs/TeamA/jobs/Service/jobs/build-api/config.xml
→TeamA%2FService%2Fbuild-api
。
2)枚举实际的工作区目录
- 枚举
$WORKSPACE_ROOT
(如/data1/var/lib/jenkins/workspace
)下一层目录,每个目录名可能是:- 标准 workspace 名:
TeamA%2FService%2Fbuild-api
- 临时/派生目录:
xxx@tmp
、xxx@2
、xxx@script
等
- 标准 workspace 名:
- 将目录名按
@
切分取“基础名”(base="${name%%@*}"
),用于与“期望集合”匹配。
3)筛选与安全过滤
- 包含/排除正则:你可以只针对特定目录处理(
--include-regex
)或排除一些目录(--exclude-regex
)。 - 年龄:只清理修改时间超过 N 天的目录(
--older-than
)。 - @tmp 策略:默认不清理
*@tmp
;若加--include-tmp
,仅在对应基础名不在期望集合里(即 Job 真正不存在)时才会清理。
4)占用检测
- 如果安装了
lsof
或fuser
,脚本会检查目录是否被进程使用。被占用 → 跳过。
5)Jenkins API 检测(可选)
- 如果配置了
JENKINS_URL/JENKINS_USER/JENKINS_API_TOKEN
,脚本会访问:
若发现/computer/api/json?depth=1
"building": true
(有活跃构建),直接退出,避免在构建高峰清理。
6)计划文件与汇总
- Dry-run 阶段会生成一个 plan 文件(
orphan-workspaces-plan-*.txt
),列出候选目录、最后修改时间、完整路径及汇总信息。
7)执行与日志
- 加
--apply
会进入执行模式,需要二次确认(输入候选数量)。 - 删除动作与结果写入 删除日志(
orphan-workspaces-delete-*.log
)。
七、使用指南(完整范例)
1)创建与赋权
nano safe-clean-orphan-workspaces.sh # 粘贴脚本
chmod +x safe-clean-orphan-workspaces.sh
2)Dry-run(默认仅预览)
./safe-clean-orphan-workspaces.sh \--workspace-root /data1/var/lib/jenkins/workspace \--jenkins-home /data1/var/lib/jenkins \--verbose
输出中会提示计划文件位置,打开查看候选列表。
3)加过滤器(逐步放开)
- 只清理包含关键词的目录:
./safe-clean-orphan-workspaces.sh --include-regex 'Webhook|Timer'
- 排除某类:
./safe-clean-orphan-workspaces.sh --exclude-regex '^(keep-.*|do-not-touch)$'
4)清理 *@tmp
(可选)
./safe-clean-orphan-workspaces.sh --include-tmp
只有当对应基础工作区名不在期望集合里(即 Job 已删除)时才会考虑清理
@tmp
。
5)调整“老化天数”
./safe-clean-orphan-workspaces.sh --older-than 7
6)启用 Jenkins API 安全检测(推荐)
export JENKINS_URL="http://127.0.0.1:8080"
export JENKINS_USER="jenkins-admin"
export JENKINS_API_TOKEN="********"
./safe-clean-orphan-workspaces.sh
一旦检测到
"building": true
,脚本会退出。
7)真正执行删除
./safe-clean-orphan-workspaces.sh --apply
会要求你输入候选数量以确认。执行结果会写入日志 orphan-workspaces-delete-*.log
。
8)自动化(建议仍然保留 Dry-run + 人工审核)
- 生成计划(每天凌晨 2 点):
# /etc/crontab
0 2 * * * jenkins /path/safe-clean-orphan-workspaces.sh --plan-dir /var/log/jenkins-clean-plans > /var/log/jenkins-clean-cron.log 2>&1
- 由管理员审核计划文件后,手动执行
--apply
。
八、常见场景与建议
- 多分支/PR(Multibranch):每个分支/PR 都是一个“Job 全名”,脚本会正确处理
%2F
命名映射。 - 自定义 workspace:如果某些 Job 使用了“自定义 workspace”(不在标准路径/命名),脚本可能无法关联。建议对这类路径手工确认或通过
--include/--exclude-regex
精准控制。 - 多节点(Agent):每个 Agent 有自己的 workspace 根目录,需要在各节点分别运行脚本。
- 缓存策略:
node_modules/.m2/.gradle
等缓存很大。若你想保留以加速构建,不建议“每次构建后清理 workspace”。可以只定期清理孤儿目录。 - 与 WS Cleanup 插件配合:日常构建层面用
cleanWs()
处理临时文件;存量垃圾、孤儿目录用本文脚本处理。 - 权限与安全:尽量以
jenkins
用户执行,避免 root;生产环境先 Dry-run,确认后再 Apply。 - 回滚与审计:删除本质不可逆;通过计划与日志实现事前/事后审计。关键服务器建议配合快照或备份策略。
九、故障排查(FAQ)
- 计划里一个候选都没有?
- 说明没有孤儿目录,或
--older-than
太大,或正则过滤过于严格。
- 说明没有孤儿目录,或
- 全都提示 “too new”?
- 调小
--older-than
,或再等几天。
- 调小
lsof
/fuser
不可用?- 占用检查会自动跳过(安全性降低),建议安装
lsof
或改为--no-open-files-check
明确跳过。
- 占用检查会自动跳过(安全性降低),建议安装
- 目录名有空格/特殊字符?
- 脚本对路径做了适当引用和
-print0
读取,通常安全。仍建议避免手工改名。
- 脚本对路径做了适当引用和
- 没有生成计划文件?
- 检查
--plan-dir
路径是否存在/可写;查看标准输出中的报错信息。
- 检查
- Jenkins API 认证失败?
- 确认 URL 正确、用户与 Token 有权限访问
/computer/api/json
,网络可达。
- 确认 URL 正确、用户与 Token 有权限访问
十、扩展
A)只清理“孤儿 @tmp”的极简命令
在确认 Job 已删除的前提下清理所有
@tmp
残留(仍建议先 Dry-run):
find /data1/var/lib/jenkins/workspace -maxdepth 1 -type d -name '*@tmp' -mtime +1 -print
# 确认后:
find /data1/var/lib/jenkins/workspace -maxdepth 1 -type d -name '*@tmp' -mtime +1 -exec rm -rf {} +
这个方法不检查对应 Job 是否存在,不如本文脚本安全,除非你已在 Jenkins 中确认 Job 确实删除。
B)Groovy 版本(思路)
- 在 Jenkins Script Console 中用 Groovy 调用 Jenkins 内部 API,可以直接获取 Job 与 workspace 的真实映射,精度更高,能跨控制器/节点执行。但需要管理员权限,且必须非常谨慎。
- 建议 Groovy 版本仅用于打印候选清单,删除仍通过你完全理解的 Shell 脚本执行。
十一、完整使用流程建议(SOP)
- Dry-run 生成计划文件 → 人工审核。
- (可选)重复 Dry-run,添加
--include-regex
/--exclude-regex
精准控制。 - 在非工作时段、确保无构建(或启用 API 检查)时,执行
--apply
。 - 归档计划与日志以便审计。
- 周期性重复:结合 cron 实现“先计划后审批”的流程。
十二、结语
上面这套方案,既解决了 Jenkins 长期运行后 workspace 垃圾堆积 的痛点,又通过多层安全策略确保稳、准、狠:
- 先发现,再确认;
- 不在高峰时段下手;
- 有日志可查;
- 避免影响仍在使用的目录。