无意间发现现有Jenkins部署脚本有几处严重性能问题,果断进行了优化

779 阅读7分钟

背景

最近在对项目的CI/CD流水线进行改造,将Gitlab-CI迁移到Jenkins,为什么要进行迁移呢?因为Jenkins在构建流程控制,构建触发方式,多项目依赖构建,Job UI 管理,构建节点调度,插件生态,权限管理这7个方面均有一定优势,另外公司使用的Gitlab-CI功能是收费的,而Jenkins可以在本地安装部署,是免费的。

功能JenkinsGitLab CI
构建流程控制Groovy 脚本灵活YAML 声明式,受限
构建触发方式多种方式受限于 GitLab 系统事件
多项目依赖构建灵活支持需要手动管理
Job UI 管理图形化仅配置文件
构建节点调度Agent 自定义分配基于 GitLab Runner
插件生态非常丰富较少,需脚本手动扩展
权限管理细粒度控制项目级别

这些都不是重点,重点是将CI/CD工具升级到Jenkins的过程中,无意间发现一个菜鸟运维, 刚开始看到他写的部署脚本,有明显重复的代码,就觉得很菜,随着深入了解,发现了部署流程中四处比较明显的性能问题,而这些部署流程都运行了一年多了,一年的时间都没见去修正。让我对运维同事的技术水平大跌眼镜。

无意间的发现

原来的GitLab CI流水线主要有四个步骤:

  1. 从远程代码仓库拉取指定分支的代码;
  2. 安装项目依赖,根据分支和部署环境的对应关系, 构建对应环境的部署包
  3. 登录docker,将第二步打包的文件,上传到docker构建上下文,生成docker 镜像tag,并上传到镜像仓库
  4. 登录k8s, 拉取需要部署的docker tag, 部署deploy.yaml定义的资源

在第4步,调用了下面这段部署脚本。先不看具体内容,第一眼看过去,会发现有三段明显的重复。单凭这一点,我就感觉写这个部署脚本的运维是个菜鸟,稍微有点专业修养的运维,都会对重复的代码片段进行封装。

#!/bin/sh
##总体逻辑是:生产环境发版,如果已经有deploy,则直接update image tag,如果没有利用deploy.yaml模板创建deploy,ing,svc等。
NEW_CI_PROJECT_NAMESPACE=$(echo "$CI_PROJECT_NAMESPACE" | awk -F / '{print $NF}')
if [ $CI_COMMIT_BRANCH == "master" ]; then
    ##判断prod是否有ingress,如果没有就创建。
    kubectl -n $NS --kubeconfig=/etc/deploy/kube2prod get deploy | egrep -i ^$NEW_CI_PROJECT_NAMESPACE-$CI_PROJECT_NAME
    if [ $? -eq 0 ]; then
        ###将文件导入全局变量生产的kube2prod到/etc/deploy/kube2prod
        kubectl -n $NS --kubeconfig=/etc/deploy/kube2prod set image deployment/$NEW_CI_PROJECT_NAMESPACE-$CI_PROJECT_NAME $NEW_CI_PROJECT_NAMESPACE-$CI_PROJECT_NAME=reg.example.com:9088/$NEW_CI_PROJECT_NAMESPACE/$CI_PROJECT_NAME:$CI_PIPELINE_ID
        sleep 90s
        kubectl -n $NS --kubeconfig=/etc/deploy/kube2prod get pods | grep $NEW_CI_PROJECT_NAMESPACE-$CI_PROJECT_NAME | awk '{print $3}' | egrep -i 'Running|Containercreating|Terminating'
        ##判断是否更新tag成功,如果成功,则echo succcess

        if [ $? == 0 ]; then
            echo "deploy is success"
        else
            echo "deploy failed"
            exit 1
        fi
        ##如果没有deployment,则用deploy模板创建。
    else
        sed -i s/{{PROJECT_NAME}}/$CI_PROJECT_NAME/g deploy.yaml
        sed -i s/{{NAMESPACE}}/$NS/g deploy.yaml
        sed -i s/{{PROJECT_NAMESPACE}}/$NEW_CI_PROJECT_NAMESPACE/ deploy.yaml
        sed -i s/{{IMAGE_TAG}}/$CI_PIPELINE_ID/ deploy.yaml
        sed -i s/{{SERVICE_NS}}/$NS/g deploy.yaml
        cat deploy.yaml
        kubectl -n $NS --kubeconfig=/etc/deploy/kube2prod apply -f deploy.yaml
    fi

##canary环境发版
elif [ $CI_COMMIT_BRANCH == "canary" ]; then
    ##判断canary是否有deploy,如果没有就创建。
    kubectl -n $CI_COMMIT_BRANCH --kubeconfig=/etc/deploy/kube2canary get deploy | egrep -i ^$NEW_CI_PROJECT_NAMESPACE-$CI_PROJECT_NAME
    if [ $? -eq 0 ]; then
        ###将文件导入全局变量生产的kube2canary到/etc/deploy/kube2canary
        kubectl -n $CI_COMMIT_BRANCH --kubeconfig=/etc/deploy/kube2canary set image deployment/$NEW_CI_PROJECT_NAMESPACE-$CI_PROJECT_NAME $NEW_CI_PROJECT_NAMESPACE-$CI_PROJECT_NAME=reg.example.com:9088/$NEW_CI_PROJECT_NAMESPACE/$CI_PROJECT_NAME:$CI_PIPELINE_ID
        sleep 60s
        kubectl -n $CI_COMMIT_BRANCH --kubeconfig=/etc/deploy/kube2canary get pods | grep $NEW_CI_PROJECT_NAMESPACE-$CI_PROJECT_NAME | awk '{print $3}' | egrep -i 'Running|Containercreating|Terminating'
        ##判断是否更新tag成功,如果成功,则echo succcess

        if [ $? == 0 ]; then
            echo "deploy is success"
        else
            echo "deploy failed"
            exit 1
        fi
        ##如果没有deployment,则用deploy模板创建。
    else
        sed -i s/{{PROJECT_NAME}}/$CI_PROJECT_NAME/g deploy.yaml
        sed -i s/{{NAMESPACE}}/$CI_COMMIT_BRANCH/g deploy.yaml
        sed -i s/{{PROJECT_NAMESPACE}}/$NEW_CI_PROJECT_NAMESPACE/ deploy.yaml
        sed -i s/{{IMAGE_TAG}}/$CI_PIPELINE_ID/ deploy.yaml
        sed -i s/{{SERVICE_NS}}/$CI_COMMIT_BRANCH/g deploy.yaml
        cat deploy.yaml
        kubectl -n $CI_COMMIT_BRANCH --kubeconfig=/etc/deploy/kube2canary apply -f deploy.yaml
    fi

    ##非canary/prod发版
else
    ##判断其他ns是否有deploy,如果没有就创建。
    kubectl -n $CI_COMMIT_BRANCH --kubeconfig=/etc/deploy/config get deploy | egrep -i ^$NEW_CI_PROJECT_NAMESPACE-$CI_PROJECT_NAME
    if [ $? -eq 0 ]; then
        ##将文件导入全局变量测试环境的kubeconfig_test_dev
        kubectl -n $CI_COMMIT_BRANCH --kubeconfig=/etc/deploy/config set image deployment/$NEW_CI_PROJECT_NAMESPACE-$CI_PROJECT_NAME $NEW_CI_PROJECT_NAMESPACE-$CI_PROJECT_NAME=reg.example.com:9088/$NEW_CI_PROJECT_NAMESPACE/$CI_PROJECT_NAME:$CI_PIPELINE_ID
        sleep 150s
        kubectl -n $CI_COMMIT_BRANCH --kubeconfig=/etc/deploy/config get pods | grep $NEW_CI_PROJECT_NAMESPACE-$CI_PROJECT_NAME | awk '{print $3}' | egrep -v 'Running|Containercreating|Terminating|Pending'
        if [ $? == 1 ]; then
            echo "deploy is success"
        else
            echo "deploy failed"
            exit 1
        fi
    else
        sed -i s/{{PROJECT_NAME}}/$CI_PROJECT_NAME/g deploy.yaml
        sed -i s/{{NAMESPACE}}/$CI_COMMIT_BRANCH/g deploy.yaml
        sed -i s/{{PROJECT_NAMESPACE}}/$NEW_CI_PROJECT_NAMESPACE/ deploy.yaml
        sed -i s/{{IMAGE_TAG}}/$CI_PIPELINE_ID/ deploy.yaml
        sed -i s/{{SERVICE_NS}}/$CI_COMMIT_BRANCH/g deploy.yaml
        cat deploy.yaml
        kubectl -n $CI_COMMIT_BRANCH --kubeconfig=/etc/deploy/config apply -f deploy.yaml
    fi
fi

接着再看看主体执行过程:

  1. 先判断分支, master,canary,test和dev这三组分支执行不同的流程(流程的内容近乎一致,不同之处主要是不同环境使用的k8s登录配置不同)
  2. 检查当前空间是否已有对应的 Deployment 存在,如果存在 Deployment(已部署过),更新镜像,等待若干秒让 Pod 启动完成,检测 Pod 是否处于正常状态,输出部署结果
  3. 如果不存在 Deployment(首次部署),根据分支和部署环境的不同,动态替换deploy.yaml中的变量,替换变量包括:
变量说明
{{PROJECT_NAME}}项目名
{{NAMESPACE}}K8s 命名空间
{{PROJECT_NAMESPACE}}GitLab 项目路径最后一级
{{IMAGE_TAG}}CI流水线 ID
{{SERVICE_NS}}服务命名空间

,替换完成之后执行初始化部署。

看懂了现有部署脚本的逻辑功能,我对原来的部署脚本的重复片段进行了一番封装。第一次优化之后的脚本如下所示:

#!/bin/sh
set -e
# 部署环境
DEPLOY_ENV=$1
# docker tag名称
IMAGE_TAG=$2

# 更新 Deployment(如果存在)或创建新 Deployment
update_deploy() {
    local namespace=$1  # 目标命名空间(环境,如 prod/canary/dev)
    local kubeconfig=$2 # 对应的 kubeconfig 配置文件
    local deploy_name="${CI_PROJECT_NAMESPACE}-${CI_PROJECT_NAME}"
    local image_full="reg.example.com:9088/${CI_PROJECT_NAMESPACE}/${CI_PROJECT_NAME}:${IMAGE_TAG}"

    # 检查 Deployment 是否存在
    if kubectl -n "$namespace" --kubeconfig="$kubeconfig" get deploy "$deploy_name" >/dev/null 2>&1; then
        echo "Deployment [$deploy_name] exists, updating image..."

        # 去镜像仓库拉取镜像
        kubectl -n "$namespace" --kubeconfig="$kubeconfig" set image deployment/"$deploy_name" \
            "$deploy_name"="$image_full"

        sleep 150s
        kubectl -n "$namespace" --kubeconfig="$kubeconfig" get pods | grep "$deploy_name" | awk '{print $3}' | egrep -v 'Running|Containercreating|Terminating|Pending'
        if [ $? == 1 ]; then
            echo "deploy is success"
        else
            echo "deploy failed"
            exit 1
        fi
    else
        echo "Deployment [$deploy_name] not found, creating..."
        create_deploy "$namespace" "$kubeconfig"
    fi
}

# 使用 deploy.yaml 模板创建新的 Deployment
create_deploy() {
    local namespace=$1
    local kubeconfig=$2

    echo "Deployment does not exist, creating new deployment..."

    # 通过 sed 替换 deploy.yaml 模板中的占位符
    sed -i "s/{{PROJECT_NAME}}/$CI_PROJECT_NAME/g" deploy.yaml
    sed -i "s/{{NAMESPACE}}/$namespace/g" deploy.yaml
    sed -i "s/{{PROJECT_NAMESPACE}}/$CI_PROJECT_NAMESPACE/g" deploy.yaml
    sed -i "s/{{IMAGE_TAG}}/$IMAGE_TAG/g" deploy.yaml
    sed -i "s/{{SERVICE_NS}}/$namespace/g" deploy.yaml

    # 输出 deploy.yaml 以供调试
    cat deploy.yaml

    # 应用 deployment 资源
    kubectl -n "$namespace" --kubeconfig="$kubeconfig" apply -f deploy.yaml
}

# 生产环境(prod)部署 , 使用的是release/*的分支,手动部署
if [ "$DEPLOY_ENV" == "prod" ]; then
    update_deploy "prod" "/etc/deploy/kube2prod"

# 灰度环境(canary)部署, , 使用的是release/*的分支,手动部署
elif [ "$DEPLOY_ENV" == "canary" ]; then
    update_deploy "canary" "/etc/deploy/kube2canary"

# 测试环境(test)部署, 使用的是release/*的分支,自动部署
elif [ "$DEPLOY_ENV" == "test" ]; then
    update_deploy "test" "/etc/deploy/config"

# dev 部署,使用的是dev分支,自动部署
else
    update_deploy "dev" "/etc/deploy/config"
fi

虽然封装前后代码行数差不多,但是层次感,可读性有了明显提升。现在形式看起来比较符合程序员的审美了。

第一个坑

再深挖一下执行过程, 看看是否都合理。当我看到sleep 150s的时候,做为外行的我,感到比较好奇,为什么要等待150s? 等待的时间有点太长了吧。

        sleep 150s
        kubectl -n "$namespace" --kubeconfig="$kubeconfig" get pods | grep "$deploy_name" | awk '{print $3}' | egrep -v 'Running|Containercreating|Terminating|Pending'
        if [ $? == 1 ]; then
            echo "deploy is success"
        else
            echo "deploy failed"
            exit 1
        fi

问了一下大模型老师,大模型告诉我,这里不用傻等,如果多次运行脚本,每次都 sleep 150 秒,效率太低。可优化成如下的语句:

        # 等待 Deployment 更新完成(最多等待150秒)
        echo "Waiting for deployment to rollout..."
        if kubectl rollout status deployment/"$deploy_name" -n "$namespace" --timeout=150s --kubeconfig="$kubeconfig"; then
            echo "✅ Deployment [$deploy_name] rollout successful"
        else
            echo "❌ Deployment [$deploy_name] rollout failed"
            kubectl -n "$namespace" --kubeconfig="$kubeconfig" describe deployment "$deploy_name"
            exit 1
        fi

相较于原来的写法优点是:

  • kubectl rollout status自动轮询判断是否 rollout 成功,一旦完成就立即返回(不用等满 150s),超时就报错。

  • 原生支持 Deployment 状态检查,可靠性高。不依赖 grepawkegrep 这些命令行解析,简洁且语义明确。- 会自动跟踪 Deployment 对应的 ReplicaSetPod 的创建和就绪状态。

  • 会在失败时输出明确的错误(如某个 Pod 启动失败、镜像拉取失败等)。原来的写法如果失败了,你只能看到“deploy failed”,不知道是哪个 Pod 出错了、为什么失败。

  • 不会被 Pending 状态或短暂状态变化误判。Pod 状态字段(如 PendingRunningCrashLoopBackOff)有时变化快或者输出不统一,容易误判。

就这一句改进,实测下来就将每次部署的时间缩短了2分钟。原来构建需要7分钟

image.png

优化之后,缩短到4分钟多(有抖动。有时候需要5分钟)

image.png

第二个坑

也是在调试其它功能,无意间看到Jenkins流水线构建日志输出如下这么一句:sending build context to docker daemon 1.039GB, 好家伙,docker客户端发送到docker守护进程的构建上下文高达1.039GB!项目打包之后的业务文件体积根本不可能这么大,肯定有问题。

image.png

因为我是外行,不知道问题出在哪里,再次请教了一下大模型老师, 大模型老师告诉我,docker build . 的执行过程是:

graph TD 
1[将当前目录的所有文件打包为构建上下文, &#40 除非 .dockerignore 排除 &#41] 
--> 2[上传构建上下文到 Docker 引擎 &#40 本地或远程 &#41] 
--> 3[Docker 引擎读取 Dockerfile, 按步骤构建镜像]
--> 4[只有你在COPY,ADD 等指令中用到的文件才写入镜像层]

默认情况下,docker build 会将构建上下文(通常是你运行命令的当前目录 .)中的所有文件(包括子目录)打包并发送给 Docker 引擎,用于构建镜像。将文件发送给 Docker 引擎,影响构建时间,尤其远程网络传输文件时(CI/CD一般都是网络传输)。原来是没有正确设置docker构建上下文目录引起的。为减少构建上下文大小,提高效率,可以使用两种方式,过滤掉无关文件。

  1. 使用 .dockerignore文件设置过滤掉不需要上传的内容。
  2. 指定一个docker构建上下文目录,把真正要用到的文件拷贝进去。

采用.dockerignore设置忽略文件,要设置的文件类型有点多,所以采用了第二种方式。docker构建上下文一下子从1.039GB下降到不到10M, 构建时间也缩短了50s左右。

image.png

image.png

第三个坑

这是一个Python项目,我调通了Jenkins部署流程之后,发现docker每次生成镜像时,都会重装项目的依赖包。明显不合理,应该和pnpm安装node依赖包一样,项目的node依赖包发生变更时,才去安装。看了一下Python项目Dockerfile定义:

FROM reg.example.com:9088/library/python-3.12-ubuntu20.04:v2
ENV TZ=UTC
ENV LANG=C.UTF-8
ADD . /app/
WORKDIR /app/
RUN pip3 install --no-cache-dir --upgrade pip
RUN pip3 install -i https://mirrors.aliyun.com/pypi/simple/ --no-cache-dir -r requirements.txt
EXPOSE 8080
ENTRYPOINT ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8080", "--workers", "4", "--loop", "uvloop", "--http", "httptools", "--access-log"]

很快发现了问题所在:

  1. Docker 构建镜像时会按“每一行命令生成一个缓存层”。
  2. ADD . /app/ 这一行会把整个代码目录拷贝进镜像,包括 requirements.txt
  3. 只要代码目录中任何文件改动(哪怕是 .py 文件) ,Docker 就认为 ADD . /app/ 对应的层发生变化,于是后面的所有命令都会重新执行,包括:RUN pip3 install -r requirements.txt

哪怕你没有改动 requirements.txt,只是改了代码文件,也会触发重新安装依赖。正确的做法是:先复制 requirements.txt,单独一层( Docker 只有在 requirements.txt 本身发生变化时,才会重新执行这一层和后续安装命令)。然后才 ADD . /app/,这样你修改 .py 文件也不会影响依赖安装层的缓存。优化之后的Dockerfile文件内容为:

FROM reg.example.com:9088/library/python-3.12-ubuntu20.04:v2
ENV TZ=UTC
ENV LANG=C.UTF-8
WORKDIR /app

# 先复制 requirements.txt,提高缓存复用效率
COPY requirements.txt .
# 升级 pip
RUN pip3 install --no-cache-dir --upgrade pip
# 安装依赖
RUN pip3 install -i https://mirrors.aliyun.com/pypi/simple/ --no-cache-dir -r requirements.txt
# 再复制项目文件(修改代码不会导致依赖重装)
ADD . /app/

EXPOSE 8080

再看Jenkins构建日志,果然不再每次重装依赖了。 image.png

第四个坑

Harbor中的docker tag长期不清理,造成镜像文件已经高达2.41TB, 影响了服务器上运行服务的稳定性,某一天测试在工作群里多次直呼内存又爆了。 image.png

长期不清理 Harbor 中的镜像 tag 虽然不会直接导致服务“爆显存”,但会带来一系列间接风险。例如,部署工具在每次部署时可能会拉取全部镜像,导致网络和存储压力骤增;当 Harbor 启用了 Webhook 或镜像扫描等功能时,过多的 tag 会显著拖慢系统响应,造成调度混乱和资源分配异常。如果此时缺乏资源限制措施,极端情况下可能引发运行时内存或显存占用异常甚至溢出,尤其是在涉及大型模型或 GPU 应用的场景下,这种风险更加突出。

image.png

如何清理无用的Harbor仓库tag呢?有两种方式:一是在UI界面设置保留规则,二是通过写脚本清理(推荐第二种,省人省事)

第一种 通过UI界面操作

先介绍一下Harbor, Harbor 是一个开源的云原生镜像仓库项目,最初由 VMware 发起。它主要用于存储和分发 Docker 镜像,也支持 Helm Chart 等云原生制品。Harbor 在 Docker Registry 的基础上,增加了企业级的功能,包括:

  • 用户和权限管理:支持基于角色的访问控制(RBAC),可以细粒度地管理用户和项目权限。
  • 镜像安全扫描:集成了漏洞扫描工具(如 Trivy),自动检测镜像中的安全漏洞。
  • 镜像签名与内容信任:支持 Notary,实现镜像签名和验证,保障镜像来源可信。
  • 多租户和项目隔离:支持多个项目和命名空间,适合企业多团队协作。
  • 镜像复制:支持与其他 Harbor 实例或第三方仓库之间的镜像同步和复制,方便多数据中心部署。
  • 审计日志:记录用户操作,便于安全审计和问题追踪。
  • Web UI 和 API:提供友好的 Web 管理界面和丰富的 RESTful API,方便集成和自动化。

Harbor 兼容 OCI 标准,常用于 Kubernetes、DevOps、CI/CD 等场景,是企业级容器镜像管理的主流选择之一。Harbor 提供了自定义保留规则,能实现自动清理不需要的镜像功能。

在Harbor页面中设置保留规则的步骤具体如下:

1、选择一个项目,当前账户至少具有该项目的管理员权限

2、进入项目界面,选择“策略”,然后选择“TAG保留”

image.png

3、点击”添加规则”,开始设置保留规则,共分为三个部分,顺序分先后:

image.png

3.1 应用到仓库:可以选择“匹配”或者“排除”,选择或者排除具有特定名称的仓库或者使用通配符标记名称符合特定规则的仓库;被选中的仓库将应用该条保留规则,并且在此阶段,不会对未选中的存储库执行任何操作。

image.png

3.2 以数量或时间为条件:通过指定最大tags数(按最近pull/push的顺序)或指定保留tags的最长时间(最近pull/push的时间)来设置要保留的tags

image.png

3.3 Tags:可以选择“匹配”或者“排除”,选择或者排除具有特定名称的仓库或者使用通配符标记名称符合特定规则的tags。同时,可以选择是否应将无tag的镜像捕获为符合标记保留规则的一部分。

image.png

第二种 通过脚本设置

下面的脚本虽然比较长,可是逻辑比较简单。逻辑是:

  • 遍历查询Harbor仓库下的每个项目, 每个项目每次查询100条记录(为什么每次查询100条,这是Harbor API的限制,查多了查询接口会报错)。对查询出来的这100条记录按时间进行倒序排列。
  • 保留规则是 (1)如果项目是包含以release或dev开头的镜像,说明是按新的命名规范生成的镜像,保留规则是:release开头的镜像保留最新的10条,dev开头的镜像保留最新的5条;(2)如果不包含以release或dev开头的镜像,说明是按Jenkins流水线的序号递增命名的镜像(不规范命名),保留最新的30个
  • 对整个Harbor仓库各个项目前100条轮询清理一次之后,调用垃圾清理接口执行一下垃圾回收,释放磁盘空间。

清理无用Docker tag的shell脚本clean-docker-tag.sh的内容如下:

#!/bin/bash
set -euo pipefail

USERNAME="${1:-username}"
PASSWORD="${2:-password}"
HARBOR_URL="https://reg.example.com:9088"
PAGE_SIZE=100
# 普通tag保留数量,release tag保留数量,dev tag保留数量
RETAINED_COUNT=30
RELEASE_RETAINED_COUNT=10
DEV_RETAINED_COUNT=5

if ! command -v jq &>/dev/null && [ ! -x /tmp/jq ]; then
  echo "⚠ jq 未安装,正在下载..."
  curl -L -o /tmp/jq https://github.com/stedolan/jq/releases/latest/download/jq-linux64
  chmod +x /tmp/jq
fi

export PATH=/tmp:$PATH

echo "📦 正在获取所有项目..."
projects=$(curl -s --connect-timeout 30 --max-time 120 -u "$USERNAME:$PASSWORD" "$HARBOR_URL/api/v2.0/projects?page=1&page_size=${PAGE_SIZE}" | jq -r '.[].name')
readarray -t projects_array <<<"$projects"

for project in "${projects_array[@]}"; do
  project=$(echo "$project" | tr -d '\r')
  echo -e "\n🔍 项目: $project"

  repos=$(curl -s --connect-timeout 30 --max-time 120 -u "$USERNAME:$PASSWORD" "$HARBOR_URL/api/v2.0/projects/$project/repositories?page=1&page_size=${PAGE_SIZE}")
  repo_names=$(echo "$repos" | jq -r '.[].name')

  if [[ -z "$repo_names" ]]; then
    echo "  ⚠ 仓库目录下无项目,跳过"
    continue
  fi

  while read -r repo_name; do
    # 清除回车符(防止 Windows 格式破坏)
    repo_name=$(echo "$repo_name" | tr -d '\r')
    echo "  📁 仓库: $repo_name"

    tags_response=$(curl -s --connect-timeout 30 --max-time 120 -u "$USERNAME:$PASSWORD" \
      "$HARBOR_URL/api/v2.0/projects/$project/repositories/${repo_name##*/}/artifacts?page=1&page_size=${PAGE_SIZE}")

    if [[ "$(echo "$tags_response" | jq length)" -eq 0 ]]; then
      echo "    ⚠ 项目下无 tag,跳过"
      continue
    fi

    tags_info=$(echo "$tags_response" | jq -c '.[] | select(.tags != null) | {digest: .digest, tags: .tags}')

    all_tags=()
    declare -A tag_to_digest
    while IFS= read -r entry; do
      digest=$(echo "$entry" | jq -r '.digest')
      tags=$(echo "$entry" | jq -r '.tags[]?.name // empty')

      for tag in $tags; do
        all_tags+=("$tag")
        tag_to_digest["$tag"]="$digest"
      done
    done <<<"$tags_info"

    #仓库中是有 artifact 的,但是所有 artifact 都是 未打 tag(untagged) 的镜像
    if [[ ${#all_tags[@]} -eq 0 ]]; then
      echo "    ⚠ 无 tag,跳过"
      continue
    fi

    # 按时间倒序排序 tags(根据 tag 名字假设为数字或时间戳格式)
    sorted_tags=($(printf "%s\n" "${all_tags[@]}" | sort -r))

    release_tags=($(printf "%s\n" "${sorted_tags[@]}" | grep -E '^release' || true))
    dev_tags=($(printf "%s\n" "${sorted_tags[@]}" | grep -E '^dev' || true))

    delete_tags=()

    if [[ ${#release_tags[@]} -gt $RELEASE_RETAINED_COUNT ]]; then
      delete_tags+=("${release_tags[@]:$RELEASE_RETAINED_COUNT}")
    fi
    if [[ ${#dev_tags[@]} -gt $DEV_RETAINED_COUNT ]]; then
      delete_tags+=("${dev_tags[@]:$DEV_RETAINED_COUNT}")
    fi

    if [[ ${#release_tags[@]} -eq 0 && ${#dev_tags[@]} -eq 0 && ${#sorted_tags[@]} -gt $RETAINED_COUNT ]]; then
      delete_tags+=("${sorted_tags[@]:$RETAINED_COUNT}")
    fi

    if [[ ${#delete_tags[@]} -eq 0 ]]; then
      echo "    ✅ 无需删除 tag"
    else
      echo "    🗑 正在删除多余 tag:${#delete_tags[@]} 个"
      for tag in "${delete_tags[@]}"; do
        digest="${tag_to_digest[$tag]}"
        echo "      🔸 删除 $tag"
        # echo "$HARBOR_URL/api/v2.0/projects/$project/repositories/${repo_name##*/}/artifacts/$digest/tags/$tag"
        curl -s -u "$USERNAME:$PASSWORD" -X DELETE \
          "$HARBOR_URL/api/v2.0/projects/$project/repositories/${repo_name##*/}/artifacts/$digest/tags/$tag" -o /dev/null
      done

    fi

  done <<<"$repo_names"

done

# === 触发垃圾回收 ===
echo "🚀 正在触发 Harbor 垃圾回收任务..."

response_and_code=$(curl -s -u "$USERNAME:$PASSWORD" -X POST "$HARBOR_URL/api/v2.0/system/gc/schedule" \
  -H "Content-Type: application/json" \
  -d '{
    "parameters": {
      "delete_untagged": true,
      "dry_run": false
    },
    "schedule": {
      "type": "Manual"
    }
  }' \
  -w "\n%{http_code}")

http_code=$(echo "$response_and_code" | tail -n1)
response_body=$(echo "$response_and_code" | sed '$d')

if [ "$http_code" = "201" ]; then
  echo "✅ 垃圾回收任务提交成功!"
else
  echo "⚠️ 提交失败,响应信息如下:"
  echo "$response_body"
  echo "HTTP 状态码: $http_code"
fi


使用方法:

chmod +x clean-docker-tags.sh
./clean-docker-tags.sh

这个清理无用Docker的shell脚本,需要每天运行。自然而然会让人想到创建定时任务,执行清理。在Jenkins中创建一个定时任务,每天6点钟执行一次清理任务的配置方法如下:

pipeline {
  agent any

  triggers {
    cron('H 6 * * *') // 每天早上 6 点执行一次
  }

  stages {
    stage('Run Cleanup Script') {
      steps {
        withCredentials([
            usernamePassword(
                credentialsId: 'REGISTRY',
                usernameVariable: 'REGISTRY_USERNAME',
                passwordVariable: 'REGISTRY_PASSWORD'
            )
        ]) {
          sh """
            chmod +x ./common-tools/clean-docker/clean-docker-tag.sh
            # 禁用命令打印,防止泄露密码
            set +x
            ./common-tools/clean-docker/clean-docker-tag.sh  "$REGISTRY_USERNAME" "$REGISTRY_PASSWORD"
          """
        }
      }
    }
  }
}

运行了一周之后,删的只剩下1.03TB, 释放了1400多GB的空间。如果不加这个脚本,Harbor仓库占用的空间还会随着时间的推移不断膨胀。

image.png

结语

随着大模型的普及和能力的不断增强,让一些原本只能由内行处理的问题,外行现在可以通过问询大模型解决。大模型让跨界的门槛一下子降低了很多,外行在大模型的能力加持之下逆袭菜鸟内行成为可能。一个善于发现问题,善于提问,善于动手实践的外行,合理使用大模型的话,涉足某一领域,水平会很快到达入门级。在这个人人都能问,有问必有答的时代,主动提问、持续实践的外行,正在变成带着大模型“外挂”的新型内行。也许距离专家还有较长的路要走,但大模型已经让通往那条路的起点变得触手可及。外行逆袭,不再是幻想,而是趋势。