公司的Harbor镜像仓是个无比古早的版本:v1.7.1-c3806525,算是无比笨重

现在的话,肯定用 zot 来搭建了

Harbor 由于用了近 10 年了,有很多无效的镜像存在,0字节,删不掉,单个仓都有1400多个了

image-20261105131538112

想了很久如何把这个破事给了结,终极大法就是扫描出这些损坏的镜像,然后干脆用 busybox 的最小镜像推上去顶替掉,然后再删掉。

说老实话,过程一点也不简单!!!!

首先要安装 skopeo,用这个进行推送,因为新版本Docker的格式已经变了,现在直接拉busybox的镜像,docker push 推不上去,格式不兼容!!!先拿一个TAG来测试:

apt-get install -y skopeo

docker pull busybox:1.37-musl

docker save busybox:1.37-musl -o /tmp/busybox.tar

export HARBOR_USER=admin
export HARBOR_PASSWORD=xxxxxxx
export BAD_TAG=tag-release-202511261018.20251126102001

# 转换格式,推一个试试
skopeo copy   --format v2s2   docker-archive:/tmp/busybox.tar   "docker://harbor.rendoumi.com/svc/order-service:${BAD_TAG}"

# 然后删除一个试试
curl -sS -i -X DELETE   -u "$HARBOR_USER:$HARBOR_PASSWORD"  "https://harbor.rendoumi.com/api/repositories/svc/order-service/tags/${BAD_TAG}"

# 回复是200就是没问题了
HTTP/1.1 200 OK
Server: nginx
Date: Mon, 26 Oct 2026 12:22:23 GMT
Content-Type: text/plain; charset=utf-8
Content-Length: 0
Connection: keep-alive
Set-Cookie: sid=xxxxxxxx; Path=/; HttpOnly

然后写个Python3的程序来自动清理吧:

注意参数,需要从harbor里获得:

# Harbor的参数
HARBOR_URL = os.getenv("HARBOR_URL", "https://harbor.rendoumi.com").rstrip("/")
HARBOR_USER = "admin"
HARBOR_PASSWORD = "xxxxxxx"


# 仅清理指定项目和仓库
PROJECT_ID = 2
REPOSITORY = "svc/order-service"

运行方法:

# 先预跑一遍,看看
./clean_harbor.py

# 来吧,真的动手删除
./clean_harbor.py --delete --confirm

clear_harbor.py的内容如下:

#!/usr/bin/env python3

import os
import sys
import argparse
import subprocess
import shutil
from urllib.parse import quote

import requests
from requests.auth import HTTPBasicAuth

HARBOR_URL = os.getenv("HARBOR_URL", "https://harbor.rendoumi.com").rstrip("/")
HARBOR_USER = "admin"
HARBOR_PASSWORD = "xxxxxxx"


# 仅清理指定项目和仓库
PROJECT_ID = 2
REPOSITORY = "svc/order-service"

# 用于修复异常 Tag 的本地镜像归档
BUSYBOX_ARCHIVE = "/tmp/busybox.tar"

# 异常 Tag 的三个特征必须同时满足
ZERO_CREATED_TIME = "0001-01-01T00:00:00Z"


def create_session():
    if not HARBOR_USER or not HARBOR_PASSWORD:
        sys.exit(
            "错误:请设置 HARBOR_USER 和 HARBOR_PASSWORD 环境变量"
        )

    session = requests.Session()
    session.auth = HTTPBasicAuth(HARBOR_USER, HARBOR_PASSWORD)
    session.verify = True
    session.headers.update({"Accept": "application/json"})

    return session

def get_tags(session):
    """获取仓库全部标签;超时或临时错误时自动重试。"""
    repo_path = quote(REPOSITORY, safe="")
    url = f"{HARBOR_URL}/api/repositories/{repo_path}/tags"

    max_retries = 5
    timeout = (10, 180)  # 连接超时 10 秒,读取超时 180 秒

    for attempt in range(1, max_retries + 1):
        try:
            print(
                f"正在读取标签(第 {attempt}/{max_retries} 次尝试)……"
            )

            response = session.get(url, timeout=timeout)

            if response.status_code != 200:
                if response.status_code in (429, 500, 502, 503, 504):
                    print(
                        f"Harbor 暂时返回 HTTP {response.status_code}"
                    )
                else:
                    raise RuntimeError(
                        f"获取标签失败:HTTP {response.status_code}\n"
                        f"{response.text[:1000]}"
                    )
            else:
                data = response.json()

                if not isinstance(data, list):
                    raise RuntimeError(
                        f"标签 API 返回了非列表数据:{data!r}"
                    )

                print(f"成功读取 {len(data)} 个标签")
                return data

        except (requests.Timeout, requests.ConnectionError) as exc:
            print(f"请求暂时失败:{exc}")

        if attempt < max_retries:
            wait_seconds = min(attempt * 5, 20)
            print(f"{wait_seconds} 秒后重试……")
            time.sleep(wait_seconds)

    raise RuntimeError(
        f"连续 {max_retries} 次尝试后仍无法获取标签,请检查 Harbor 响应情况"
    )

def is_malformed(tag):
    """严格筛选符合全部三个异常条件的标签。"""
    return (
        tag.get("size") == 0
        and tag.get("digest") == ""
        and tag.get("created") == ZERO_CREATED_TIME
    )


def repair_tag_with_skopeo(tag_name):
    """
    先将 Docker Schema 2 格式的 BusyBox 镜像推送到目标 Tag。

    只有 skopeo 返回成功,才允许继续执行 DELETE。
    """
    destination = (
        f"docker://harbor.bybon.cn/"
        f"{REPOSITORY}:{tag_name}"
    )

    command = [
        "skopeo",
        "copy",
        "--format",
        "v2s2",
        f"docker-archive:{BUSYBOX_ARCHIVE}",
        destination,
    ]

    print(f"[修复] 正在向 Harbor 推送有效 manifest:{tag_name}")

    try:
        result = subprocess.run(
            command,
            check=False,
            text=True,
        )
    except OSError as exc:
        raise RuntimeError(
            f"无法执行 skopeo:{exc}"
        ) from exc

    if result.returncode != 0:
        raise RuntimeError(
            f"skopeo 推送失败,退出码:{result.returncode}"
        )

    print(f"[修复成功] {tag_name}")


def delete_tag(session, tag_name):
    """调用 Harbor 1.7.1 旧版 API 删除单个 Tag。"""
    repo_path = quote(REPOSITORY, safe="")
    encoded_tag = quote(tag_name, safe="")

    url = (
        f"{HARBOR_URL}/api/repositories/{repo_path}"
        f"/tags/{encoded_tag}"
    )

    return session.delete(url, timeout=30)


def verify_tag_deleted(session, tag_name):
    """查询 Tag,确认删除结果。"""
    repo_path = quote(REPOSITORY, safe="")
    encoded_tag = quote(tag_name, safe="")

    url = (
        f"{HARBOR_URL}/api/repositories/{repo_path}"
        f"/tags/{encoded_tag}"
    )

    response = session.get(url, timeout=30)

    if response.status_code == 404:
        return True

    if response.status_code == 200:
        return False

    raise RuntimeError(
        f"验证删除结果失败:HTTP {response.status_code}\n"
        f"{response.text[:300]}"
    )


def main():
    parser = argparse.ArgumentParser(
        description=(
            "清理 Harbor 1.7.1 异常标签:"
            "先用 skopeo 修复 manifest,再调用 API 删除"
        )
    )

    parser.add_argument(
        "--delete",
        action="store_true",
        help="执行实际修复和删除;默认仅扫描预览",
    )

    parser.add_argument(
        "--confirm",
        action="store_true",
        help="执行前确认项目和仓库范围",
    )

    args = parser.parse_args()

    if args.confirm and not args.delete:
        parser.error("--confirm 必须与 --delete 一起使用")

    if not shutil.which("skopeo"):
        sys.exit("错误:系统中未找到 skopeo,请先安装")

    session = create_session()

    print(f"Harbor:     {HARBOR_URL}")
    print(f"Project ID: {PROJECT_ID}")
    print(f"Repository: {REPOSITORY}")
    print(f"镜像归档:   {BUSYBOX_ARCHIVE}")
    print()

    print("正在读取标签……")
    tags = get_tags(session)

    malformed = [
        tag for tag in tags
        if is_malformed(tag)
    ]

    print(f"\n标签总数:{len(tags)}")
    print(f"符合异常条件的标签数:{len(malformed)}")

    if not malformed:
        print("没有发现符合全部三个条件的异常标签。")
        return

    print("\n待处理标签:")

    for tag in malformed:
        print(
            f"  {tag.get('name', '<unknown>')} | "
            f"size={tag.get('size')} | "
            f"digest={tag.get('digest')!r} | "
            f"created={tag.get('created')}"
        )

    if not args.delete:
        print("\n当前为预览模式,没有修改或删除任何标签。")
        print("确认列表无误后,使用 --delete --confirm 执行。")
        return

    # 实际操作前检查本地镜像归档
    if not os.path.isfile(BUSYBOX_ARCHIVE):
        sys.exit(
            f"错误:找不到镜像归档文件 {BUSYBOX_ARCHIVE}"
        )

    if args.confirm:
        expected = f"{PROJECT_ID}/{REPOSITORY}"
        answer = input(
            f"\n输入 {expected} 确认修复并删除上述标签:"
        ).strip()

        if answer != expected:
            print("确认内容不匹配,取消操作。")
            return

    success = 0
    failed = 0

    for index, tag in enumerate(malformed, 1):
        name = tag.get("name")

        if not isinstance(name, str) or not name:
            print(f"[跳过] 第 {index} 个标签没有有效名称")
            failed += 1
            continue

        print(f"\n[{index}/{len(malformed)}] 处理 Tag:{name}")

        # 第一步:用 skopeo 修复 manifest
        try:
            repair_tag_with_skopeo(name)
        except RuntimeError as exc:
            print(f"[失败] {name}:{exc}")
            print("[安全措施] 推送失败,不执行 DELETE")
            failed += 1
            continue

        # 第二步:调用 Harbor API 删除
        try:
            response = delete_tag(session, name)

            if response.status_code not in (200, 202, 204):
                print(
                    f"[删除失败] {name} "
                    f"(HTTP {response.status_code}): "
                    f"{response.text[:300]}"
                )
                failed += 1
                continue

            print(
                f"[DELETE 已接受] {name} "
                f"(HTTP {response.status_code})"
            )

        except requests.RequestException as exc:
            print(f"[删除异常] {name}: {exc}")
            failed += 1
            continue

        # 第三步:重新查询,验证 Tag 确实消失
        try:
            if verify_tag_deleted(session, name):
                print(f"[成功] {name} 已确认删除")
                success += 1
            else:
                print(
                    f"[验证失败] {name}:"
                    "DELETE 已返回成功,但 Tag 仍然存在"
                )
                failed += 1

        except (requests.RequestException, RuntimeError) as exc:
            print(f"[验证异常] {name}: {exc}")
            failed += 1

    print("\n清理结束")
    print(f"确认删除成功:{success}")
    print(f"失败或未能确认:{failed}")


if __name__ == "__main__":
    main()