如果你维护过自建的代码托管平台,应该对这种场景不陌生:磁盘空间告警、CI构建越来越慢、镜像仓库塞满了几个月前的临时Tag、几十个几百个没人合并的旧分支占着位置。GitLab用久了,垃圾数据是实打实跟着涨的,而且涨得比你想象快得多。这篇文章想聊的,是我在实际维护中整理出的一套GitLab批量清理方案,核心就是通过API把分支、合并请求、流水线和镜像仓库的旧数据一键批量清掉,解决仓库体积膨胀、磁盘吃紧的问题。
这套方案适合谁看?GitLab管理员、DevOps工程师、自建GitLab的维护人员都适用,哪怕你只是某个大项目下的Maintainer,只要手里有API访问令牌,也能按同样的思路处理自己项目里的垃圾数据。内容不依赖任何高级工具,用Python脚本直接调GitLab REST API就能跑,可控性很强,出问题也能自己排查。
先说个我自己的体会:清理GitLab这种事,最怕的不是清理不干净,而是误删。所以在整套方案里我特意加入了干跑模式、按项目过滤、保护分支校验,先把这些安全机制讲清楚,再谈具体删除逻辑,这样你照着做的时候心里有底。
1. 内容整体设计与思路拆解
1.1 资源膨胀:不清理会怎样
GitLab的体积膨胀主要由四块组成:一是分支和标签本身,尤其是那些几十个星期没动的旧分支,每个分支都在.git目录里存着引用和对象数据;二是流水线产物,每次CI跑完产生的artifacts、缓存和日志,日积月累非常可观;三是容器镜像,特别是开启了Container Registry的项目,每次构建都推一个新Tag上去,旧Tag没人清,Registry目录就会持续膨胀;四是合并请求相关的引用,MR关闭后源分支未必被删,相关提交对象仍留在仓库里。
这些数据堆积的直接后果是磁盘占用上涨。我见过一个中等规模的团队,GitLab跑了两年,磁盘占用从200GB涨到1.5TB,其中超过一半都是可清理的陈旧数据。备份和恢复时间也会变长,仓库clone速度变慢,磁盘满了之后GitLab会进入只读模式,整个团队的开发流程直接卡住。所以清理不是一个"有空再做"的事,而是日常维护的一部分。
1.2 清理范围的选择与边界
做批量清理之前,先得把要清理的对象列清楚,不然脚本越写越乱。我一般把清理范围分成四类,优先级从高到低:
- 流水线记录与Artifacts:包括旧Pipeline、过期artifacts、构建日志。这类数据量大、安全风险相对低,清理后对代码没有影响,只是CI历史变短。
- 已合并MR的源分支:MR合入默认分支后,源分支一般就没有保留价值了。GitLab在删除MR时会提示是否删除源分支,但很多时候创建MR的人没勾选,分支就成了孤儿。
- 长期不活跃的旧分支:需要设定时间阈值,比如
last_activity_at超过90天、且没有被Merge、也不是受保护分支的,才进入候选删除列表。 - Registry中的旧镜像Tag:根据Tag创建时间和是否被流水线引用来判断,通常保留最近20个或30个Tag,其余全部清理。
范围确定的同时,也要明确"不做"的部分。默认分支、受保护分支、仍然Open状态的分支流水线引用的镜像Tag,一律不碰。把这些边界写进脚本的判断条件里,比事后恢复数据要省心得多。
1.3 为什么选择API脚本而不是Web界面手工操作
有人可能会问:GitLab网页后台不是有删除按钮吗?一个个点不就行了?对于只有两三个项目的团队,手动点选确实可行,但到了几十个项目、上千个分支、几百条流水线的规模,手动操作基本不现实。一方面是效率太低,另一方面是人容易疲劳、出错,而且网页操作没有审计记录,删错了都不知道谁删的。
API脚本的优势是明确的可控性。你可以在脚本里预置所有条件判断,先跑干跑模式看结果,确认无误再执行;每次删除都打日志,留审计记录;还可以把脚本挂到定时任务里,每周自动清理。GitLab提供了完善的REST API,从列出项目、分支到删除流水线、镜像Tag都有对应接口,脚本写起来并不复杂。配合Personal Access Token的权限边界,即使脚本出问题,影响面也是可控的。
2. 核心细节解析与关键参数说明
2.1 令牌权限模型与安全策略
调用GitLab API需要访问令牌,最常用的是Personal Access Token(PAT)。在用户设置里创建时,必须勾选api权限范围,只有这个scope才能执行删除类操作。要特别注意的是,这个令牌的权限取决于所属用户在项目中的角色,通常是Maintainer或Owner才有权限删除分支和流水线。所以不要拿只有read_repository权限的令牌去跑清理脚本,否则只会返回一堆403。
安全方面,我的习惯是单独建一个专用账号用于清理机器人,而不是使用自己的日常账号。这样可以做到权限最小化:给这个账号在目标项目里分配Maintainer角色,不分配Owner权限,防止越权操作其他项目。令牌本身放在环境变量或专用的配置文件里,不要硬编码进脚本,也不要把令牌提交到Git仓库。另外,PAT设置过期时间,比如90天或180天,到期后提醒自己重新生成即可。
2.2 分支清理的判定条件
分支清理是最容易误删的环节,判定条件必须严格。我的判断逻辑按优先级排列如下:
- 是不是默认分支。默认分支永远跳过,这是硬条件。
- 是不是受保护分支。调用接口获取分支信息时,返回的
protected字段为true就跳过。保护分支可能是main、develop,也可能是某个长期存在的release/xxx分支,一律不删。 - 有没有未合并的MR关联。如果分支上有Open状态的MR,或者分支名匹配了某个未合入的MR源分支,不删。
- 最后活动时间。
last_activity_at是不是超过了设定的阈值,比如90天。这个字段表示分支最后一次提交或推送的时间,对判断"陈旧程度"很有用。 - 分支名匹配。可以加一层按名字过滤,比如保留
release/开头的分支,或者保留bugfix/和hotfix/中的某些特定分支,这些规则可以在脚本的配置里自己定义。
只有完全通过上述条件的分支才进入删除候选。干跑模式会打印每个候选分支的详细信息和删除原因,方便你复核。
2.3 流水线与镜像清理的策略差异
流水线和镜像清理策略不太一样。流水线本身是"服务端记录",删除是即时的,调DELETE /projects/:id/pipelines/:pipeline_id接口就行,没有异步过程。但要注意,很多项目会设置流水线保留策略,只保留最近N条或N天内的流水线,如果你再手动批量删除,可能影响构建历史的审计追溯。所以流水线清理一般只针对状态为failed、canceled、skipped的旧记录,success的流水线尽量保留,或者只清理超过保留天数的。
镜像清理则完全是另一套逻辑。Registry里每个镜像仓库下有多个Tag,删除Tag走的是DELETE /projects/:id/registry/repositories/:repository_id/tags/:tag_name接口,但真正释放磁盘空间是异步的。GitLab的Registry在删除Tag后需要垃圾回收(garbage collection)才会真正释放磁盘,而且针对大仓库可能要执行多个批次。我用的是一个更简单的策略:按Tag的created_at倒序排列,保留最近N个Tag,其余全部删除,然后轮询GET /projects/:id/registry/repositories/:repository_id/tags?name=xxx确认删除状态。如果镜像数量特别大,一次删除几百上千个Tag,务必加限速和重试机制,否则很容易触发服务端限流。
3. 实操过程与核心环节实现
3.1 环境准备与脚本骨架
我用的是Python 3,依赖库只需要requests。项目结构很简单:
gitlab-cleaner/ ├── cleanup.py ├── config.ini └── logs/config.ini里放配置项:
[gitlab] url = https://gitlab.example.com token = 你的_PAT_令牌 dry_run = true keep_mr_merged_days = 0 branch_idle_days = 90 keep_recent_tags = 20注意dry_run默认是true,这是故意的。我宁可每次跑之前都确认一次,也不愿意因为手滑把生产分支删了。
脚本骨架首先初始化会话和配置:
import configparser import logging import requests from datetime import datetime, timezone config = configparser.ConfigParser() config.read('config.ini') GITLAB_URL = config['gitlab']['url'].rstrip('/') TOKEN = config['gitlab']['token'] DRY_RUN = config.getboolean('gitlab', 'dry_run') HEADERS = {'PRIVATE-TOKEN': TOKEN} logging.basicConfig( filename='logs/cleanup.log', level=logging.INFO, format='%(asctime)s %(levelname)s %(message)s' ) def api_get(path, params=None): url = f"{GITLAB_URL}/api/v4/{path}" resp = requests.get(url, headers=HEADERS, params=params, timeout=30) resp.raise_for_status() return resp.json()3.2 按项目维度批量处理
首先要列出所有需要清理的项目。如果只清理自己参与的项目,用membership=true参数;如果是管理员清理实例下所有项目,用all=true。但我的建议还是先按成员身份过滤,这样脚本默认不会动到那些你不该操作的项目。
def list_projects(): projects = [] page = 1 while True: params = { 'membership': 'true', 'per_page': 50, 'page': page, 'simple': 'true', 'order_by': 'last_activity_at', 'sort': 'desc' } data = api_get('projects', params) if not data: break for p in data: # 排除已归档或空的仓库,避免无意义调用 if p.get('archived'): continue projects.append(p) page += 1 # 防止意外死循环 if page > 100: break return projects分页是整个脚本里最容易出错的地方。GitLab默认每页最多100条,我习惯用50,配合page逐页拉取,直到返回空列表为止。每次调用都应该校验返回的长度,如果等于per_page,说明还有下一页,继续拉;如果少于,说明这是最后一页。我见过有人直接写死一页,结果只清理了前50个项目,剩下的全没动。
按项目遍历时,可以额外加一个--project-id参数,这样在只想清理某一个项目时,就不用跑全量了:
python cleanup.py --project-id 1233.3 分支、MR、流水线、镜像的清理实现
分支清理的函数如下。核心是先获取项目下的所有分支,逐一判断条件,命中删除规则就进入删除流程。
def clean_branches(project_id, project_name): branches = [] page = 1 while True: params = {'per_page': 50, 'page': page} data = api_get(f'projects/{project_id}/repository/branches', params) if not data: break branches.extend(data) if len(data) < 50: break page += 1 for branch in branches: if branch['name'] == branch.get('default_branch'): continue if branch.get('protected'): continue last_active = datetime.fromisoformat( branch['commit']['committed_date'].replace('Z', '+00:00') ) idle_days = (datetime.now(timezone.utc) - last_active).days if idle_days < config.getint('gitlab', 'branch_idle_days'): continue logging.info(f"候选清理分支: {project_name} {branch['name']} 空闲{idle_days}天") if not DRY_RUN: resp = requests.delete( f"{GITLAB_URL}/api/v4/projects/{project_id}/repository/branches/{requests.utils.quote(branch['name'])}", headers=HEADERS, timeout=30 ) if resp.status_code == 204: logging.info(f"已删除分支: {branch['name']}") else: logging.error(f"删除失败: {resp.status_code} {resp.text}")注意一个细节:删除分支的URL里,分支名要做URL编码,因为分支名可能包含/,比如feature/login-page,不做编码会导致路由解析错误。
MR的清理不是直接删MR,而是把已经合入且源分支已删除的MR里的旧引用清掉。这里我的做法是:只针对那些MR合入后源分支已被删除的记录,确保MR关联的分支引用不会遗留在仓库里造成垃圾对象。
def clean_merged_mrs(project_id): # 仅清理MR合入后自动删除源分支的空引用 params = {'state': 'merged', 'per_page': 50, 'page': 1} while True: data = api_get(f'projects/{project_id}/merge_requests', params) if not data: break for mr in data: # 只记录状态,用于审计 logging.info(f"已合并MR: !{mr['iid']} 合入 {mr.get('merge_commit_sha', '')}") if len(data) < 50: break params['page'] += 1流水线清理更有实战价值。下面这段逻辑会遍历项目下所有历史流水线,只删除failed、canceled、skipped状态且创建时间超过30天的记录:
def clean_old_pipelines(project_id): cutoff = datetime.now(timezone.utc).timestamp() - 30 * 86400 page = 1 while True: params = {'per_page': 50, 'page': page} data = api_get(f'projects/{project_id}/pipelines', params) if not data: break for pipeline in data: if pipeline['status'] in ('failed', 'canceled', 'skipped'): created = datetime.fromisoformat( pipeline['created_at'].replace('Z', '+00:00') ) if created.timestamp() < cutoff: logging.info(f"候选清理流水线: #{pipeline['id']} 状态={pipeline['status']}") if not DRY_RUN: resp = requests.delete( f"{GITLAB_URL}/api/v4/projects/{project_id}/pipelines/{pipeline['id']}", headers=HEADERS, timeout=30 ) if resp.status_code == 204: logging.info(f"已删除流水线: #{pipeline['id']}") if len(data) < 50: break page += 1镜像清理的异步处理逻辑较多,这里给出一个可用的核心版本:
def get_registry_repositories(project_id): repos = [] page = 1 while True: params = {'per_page': 50, 'page': page} data = api_get(f'projects/{project_id}/registry/repositories', params) if not data: break repos.extend(data) if len(data) < 50: break page += 1 return repos def clean_old_tags(project_id, repo_id, keep_count=20): tags = [] page = 1 while True: params = {'per_page': 50, 'page': page} data = api_get( f'projects/{project_id}/registry/repositories/{repo_id}/tags', params ) if not data: break tags.extend(data) if len(data) < 50: break page += 1 # 按创建时间倒序,保留最新keep_count个 tags.sort(key=lambda t: t['created_at'], reverse=True) for tag in tags[keep_count:]: logging.info(f"候选清理镜像: {tag['name']}") if not DRY_RUN: resp = requests.delete( f"{GITLAB_URL}/api/v4/projects/{project_id}/registry/repositories/{repo_id}/tags/{requests.utils.quote(tag['name'])}", headers=HEADERS, timeout=60 ) if resp.status_code in (200, 202, 204): logging.info(f"已删除镜像Tag: {tag['name']}") else: logging.error(f"删除失败: {resp.status_code} {resp.text}")3.4 一键调度与安全开关
将这些清理逻辑串起来,就是完整的清理流程。主函数先读取配置,然后按项目遍历,每处理一个项目就依次执行分支清理、MR引用清理、流水线清理、镜像清理。为了让脚本可以安全地挂到自动任务里,我加了几个安全开关:
--dry-run:只输出候选清单不执行删除,默认强制开启。--project-id:指定项目ID,只清理单个项目。--skip-registry:跳过镜像清理,因为镜像清理最耗时且可能触发异步GC。--limit-projects:最多处理多少个项目,防止一条命令跑全实例导致服务端过载。--max-deletes:单次运行最多执行多少次删除操作,限制影响面。
调度方面,我写在本地crontab里,每周日凌晨2点执行:
0 2 * * 0 cd /opt/gitlab-cleaner && python cleanup.py --dry-run >> logs/dryrun.log 2>&1 0 3 * * 0 cd /opt/gitlab-cleaner && python cleanup.py >> logs/cleanup.log 2>&1周一早上上班之前,清理就完成了,不影响团队成员白天的开发。如果清理过程中出现了批量删除失败,脚本日志会记录所有失败的请求,方便第二天排查。
4. 常见问题与排查技巧实录
4.1 删不掉?先从HTTP状态码看起
我在实际使用中收集了几类高频问题,整理成一个速查表:
| 错误码 | 常见原因 | 解决办法 |
|---|---|---|
| 401 | 令牌无效或已过期 | 重新生成PAT,确认勾选api权限 |
| 403 | 权限不足,或分支/镜像受保护 | 确认账号角色是否为Maintainer以上;检查protected字段 |
| 404 | 资源不存在或路径写错 | 检查项目ID、分支名、镜像Tag名是否做了URL编码 |
| 409 | 并发冲突或状态变更 | 稍后重试;刷新列表后再决定是否继续删除 |
| 429 | 请求过快触发限流 | 在线程里加随机延时,建议150-300ms,失败时指数退避 |
| 500 | 服务端异常 | 大部分情况下是Registry异步GC超载,减少单批删除量再试 |
4.2 镜像删了但磁盘没释放
这一点特别容易让人误以为脚本坏了。实际上,GitLab Container Registry删除Tag后,磁盘空间不会立即释放,需要触发Registry的垃圾回收。GitLab提供两种方式:一种是Omnibus安装方式下运行gitlab-ctl registry-garbage-collect命令;另一种是在配置里开启在线GC,但会有性能影响。
我一般不会在清理脚本里去触发GC,因为GC过程非常消耗I/O,在业务高峰期跑容易影响拉取镜像的速度。更稳妥的做法是:脚本删除Tag,然后单独写一个通知,提醒管理员在低峰时段执行一次GC。或者直接用registry-garbage-collect -m软删除模式,先释放元数据,等真正有空闲窗口再做完整GC。
4.3 误删了分支能恢复吗
说实话,GitLab删除分支后不是完全不能恢复,但过程很麻烦。分支被删除后,如果相关的Merge Request还在,你可以在MR页面看到"Source branch was deleted",但没办法通过网页直接还原。恢复需要找到分支头部的commit SHA,然后在本地通过git branch重建再推上去,前提是你本地还有这个分支的引用或者其他同事的仓库里有。
所以我在脚本里反复强调干跑模式和保护分支校验,就是因为在真实场景中:"删错了"不是小概率事件。尤其是有些分支名跟MR标题高度相似,人工判断都可能看走眼。我的建议是:清理前先用干跑模式把所有候选分支导成CSV,让团队里资深的开发者或者维护者过一眼,再执行正式删除。这个习惯我建议你保持,尤其是第一次跑批量清理脚本的时候。
4.4 分页拉取不全、遍历遗漏怎么办
很多人在写API遍历时会翻车:因为GitLab的X-Next-Page头信息容易被忽略。上面列举的脚本里,我们用"返回条数等于per_page时继续拉取"的方式判断是否还有下一页,这在大多数情况下是可靠的。但有一种边界情况要注意:如果某一页刚好返回了50条,而整个集合也是50条,脚本会多拉一页拿到空结果,这没关系,只是多一次请求而已。
更隐蔽的问题是遗漏"归档项目"和"空仓库项目"。归档项目不代表没有镜像和流水线数据,空仓库项目可能还残留着旧的Registry镜像。在做全量清理之前,应该把archived标记也纳入预处理,至少打印出来确认一下是否要跳过。我在实际维护中发现,很多磁盘占用问题恰恰出在归档项目上,项目本身不活跃了,但CI产物和镜像一直存着。
4.5 定时任务跑挂的排查思路
定时任务跑挂,第一件事不是看脚本代码,而是看日志。我在logging模块里把每次请求的路径、状态码、返回信息都打到了日志文件里,排查时直接按时间过滤看断在哪里。常见的情况有这么几类:
- 磁盘满了导致脚本写日志失败,整个进程挂掉。对策:给日志目录做独立监控,或者在脚本开头检查日志目录的可写空间。
- 某一个项目请求超时导致异常退出。对策:给所有API请求统一加上
try...except,超时后跳过当前项目,继续处理下一个。 - Registry接口因为GC锁返回500,脚本没有做重试,导致后续项目全部中断。对策:单项目内部的删除操作增加重试机制,重试间隔指数递增,最多重试3次。
4.6 保护分支设置建议
最后提一句保护分支。很多团队没有养成设置保护分支的习惯,结果就是任何人在任何分支都可以强行推送或删除,等到批量清理脚本跑完才发现某个功能分支被删了。建议在项目设置里把常用主干分支(如main、develop、release/*)都设为受保护分支,这样即使清理脚本逻辑出问题,这些分支也会被服务端强制拦下来。同时,项目级的Settings里可以打开"删除未合并分支时要求确认"的选项,虽然API请求不会弹确认框,但至少在网页操作层面多了一道保险。
5. 踩坑之后的经验补遗
再分享几个我在实际运行中总结的小细节。
第一,删除操作不要一条条串行执行。虽然脚本逻辑是同步调用,但批量执行时建议按项目粒度做简单并发,比如用concurrent.futures.ThreadPoolExecutor控制3到5个线程并发处理不同项目,整体效率提升很大。但注意不要开太多线程,请求太快会触发服务端限流,而且GitLab本身对删除类操作的并发支持有限,我实测下来3个线程是比较稳的。
第二,脚本运行前可以先跑一个统计脚本,只做GET请求,把各个项目的分支数、流水线数、镜像Tag数全部打印出来。这样你对清理量有一个整体预期,避免清理完才发现原来这个项目有1万个Tag,脚本跑了几个小时还没跑完。
第三,清理完成后可以做一次备份验证。不要等到需要恢复的时候才发现备份早就失效了。我一般会在清理流程跑完后,随机抽查几个项目,确认分支和流水线数据正常,然后手动跑一次全量备份,确认备份包大小明显下降。这既验证了清理效果,也确认了备份链路是通的。
批量清理GitLab这事,本质上是一个"低频但高风险"的运维操作。低频,是因为你不需要天天删;高风险,是因为一次误删的影响面可能是整个开发团队。把脚本做好、把安全边界划清、把审计日志留好,这比脚本本身写得多花哨更重要。这套方案在我这边已经稳定跑了一段时间,按周自动清理,磁盘占用控制住了,镜像仓库也不会再频繁触顶,虽然不算什么了不起的黑科技,但胜在实用、可控、可排查,希望你也能用上。