欢迎光临
我们一直在努力

04 | 用 Jenkins 打通 CI自动构建镜像并推送到本地 Harbor

为 llm-serving 项目搭一条最小可用的 CI 流水线:Jenkins 从 GitHub 拉取代码,构建两个镜像(FastAPI 的 llm-serving、llama.cpp 推理的 llm-serving-llama),打上构建号和 latest 两个标签,推送到本地 Harbor 的 library 项目。

备注:构建号BUILD_NUMBER 是 Jenkins 自动提供的环境变量,无须手动定义。

流程

GitHub (lglgl/llm-serving, main)
│ Pipeline script from SCM
▼
Jenkins Pipeline: llm-serving-ci
│ docker build
▼
本地镜像 llm-serving / llm-serving-llama
│ docker login (harbor-credentials) + docker push
▼
Harbor harbor.local:30080/library/
├─ llm-serving:<构建号> / latest
└─ llm-serving-llama:<构建号> / latest

过程概览

步骤操作关键内容
1 确认 Harbor 可访问 http://harbor.local:30080,默认用户 admin
2 在 Jenkins 配置 Harbor 凭据 Username with password,ID 为 harbor-credentials
3 确认 Jenkins 能执行 Docker 构建 流水线里能调用 docker 命令
4 创建 Jenkinsfile 构建、登录、推送两个镜像
5 配置 Jenkins 执行器 保证有可用的执行器来跑构建
6 在 Jenkins 创建任务 Pipeline script from SCM,Git 仓库 + */main + Jenkinsfile
7 构建并查看日志 Build Now,Console Output 显示 SUCCESS
8 验收 从 Harbor 拉取镜像,digest 与推送日志一致

验证结果

  • 第 6 次构建(Build Number 6)的最终结果为 SUCCESS,日志末尾输出 所有镜像已推送到 Harbor。
  • 两个镜像都已推送到 harbor.local:30080/library/:llm-serving-llama 的 6 与 latest 指向同一个 digest sha256:dea4451c…;llm-serving:latest 的 digest 是 sha256:71f0b338…。
  • 验收时 docker pull 两个 latest 镜像,得到的 digest 与 Jenkins 推送日志里的完全一致。

环境信息(来自记录)

  • 宿主机:MacBook,项目目录 devops-platform
  • 镜像仓库:Harbor,http://harbor.local:30080,项目 library
  • Jenkins 任务:llm-serving-ci(Pipeline 类型)
  • 代码仓库:https://github.com/lglgl/llm-serving.git,分支 */main,脚本路径 Jenkinsfile
  • 凭据 ID:harbor-credentials
  • 流水线结束时间:Mon Oct 12 01:17:25 CST 2026

一、确认 Harbor 可访问

先确认 Harbor 本身是可用的


二、在 Jenkins 配置 Harbor 凭据

2.1 添加凭据

进入 Jenkins:

Manage Jenkins → Credentials → System → Global credentials → Add Credentials

填写:

字段值
Kind Username with password
Username admin
Password <Harbor 密码>
ID harbor-credentials

要点:

  • ID 要和 Jenkinsfile 里引用的一致(harbor-credentials),流水线通过这个 ID 取出用户名和密码,不会把密码写进脚本。
  • 本文把真实密码替换成了占位符 <Harbor 密码>,不要把密码写进博客或 Git 仓库。

2.2 在本机验证 Harbor 登录

在 Jenkins 之外,先用同样的账号在本机验证一次登录,确认账号密码和仓库地址都没问题:

(base) igwanglyang@MacbookProdahouzicn devops-platform % docker logout harbor.local:30080
docker login harbor.local:30080
Removing login credentials for harbor.local:30080
Username: admin
Password:
Login Succeeded
(base) igwanglyang@MacbookProdahouzicn devops-platform %

先 docker logout 再 docker login,可以排除"之前保存的旧登录状态"带来的干扰;输出 Login Succeeded 说明凭据有效。


三、确认 Jenkins 能执行 Docker 构建

流水线的核心动作是 docker build 和 docker push,所以要先确认 Jenkins 的执行环境里能调用 Docker。

补充:原始记录这一步只有标题。一个常用的检查办法是先建一个临时 Pipeline,在里面执行:

pipeline {
agent any
stages {
stage('Check Docker') {
steps {
sh 'docker version'
sh 'docker info | head -n 20'
}
}
}
}

如果输出里能看到 Client 和 Server 两部分版本信息,说明 Jenkins 能访问 Docker daemon。如果报 docker: command not found 或无法连接 daemon,需要为 Jenkins 的运行环境安装 Docker CLI 并让它能访问 Docker daemon(具体做法取决于 Jenkins 是怎么部署的)。同时要确认 Jenkins 的执行环境能解析 harbor.local。


四、创建 Jenkinsfile

在代码仓库根目录创建 Jenkinsfile(对应任务里的 Script Path)。

pipeline {
agent any

options {
// 禁止并发构建,避免同时覆盖镜像标签
disableConcurrentBuilds()

// 整个流水线最多运行 60 分钟
timeout(time: 60, unit: 'MINUTES')
}

environment {
// Harbor 镜像仓库地址,当前使用 HTTP
HARBOR = 'harbor.local:30080'

// Harbor 项目名称
HARBOR_PROJECT = 'library'

// FastAPI 服务镜像
APP_IMAGE = 'llm-serving'

// llama.cpp + Qwen 服务镜像
LLAMA_IMAGE = 'llm-serving-llama'

// Docker Desktop Kubernetes 的本机入口
HARBOR_IP = '127.0.0.1'
}

stages {
// ============================================================
// 阶段 1:检出 GitHub 代码
// ============================================================
stage('Checkout') {
steps {
echo '========== 阶段 1:检出 GitHub 代码 =========='

// 使用 Jenkins 任务中配置的 SCM
checkout scm

sh '''
set -eu

echo "当前工作目录:$(pwd)"
echo "当前 Git 提交:$(git rev-parse –short HEAD)"
echo "当前分支:$(git branch –show-current)"
'''
}
}

// ============================================================
// 阶段 2:检查构建环境
// ============================================================
stage('Check Environment') {
steps {
echo '========== 阶段 2:检查构建环境 =========='

sh '''
set -eu

# Jenkins 在 macOS 上运行时,补充 Homebrew 路径
export PATH="/opt/homebrew/bin:/usr/local/bin:$PATH"

echo "操作系统:$(uname -s)"
echo "CPU 架构:$(uname -m)"
echo "当前用户:$(whoami)"
echo "PATH=$PATH"

# 检查 Docker CLI 是否存在
if ! command -v docker >/dev/null 2>&1; then
echo "ERROR:找不到 Docker CLI"
exit 1
fi

echo "Docker CLI 路径:$(command -v docker)"

# 同时检查 Docker Client 和 Docker Server
docker version

echo "Docker 环境检查通过"
'''
}
}

// ============================================================
// 阶段 3:检查 Harbor
// ============================================================
stage('Check Harbor') {
steps {
echo '========== 阶段 3:检查 Harbor =========='

sh '''
set -eu

HARBOR_URL="http://$HARBOR"

echo "Harbor 地址:$HARBOR_URL"
echo "连接 IP:$HARBOR_IP"

# 使用 –resolve 绕过系统 DNS 查询。
# HTTP Host 仍然是 harbor.local:30080,
# 因此 NGINX Ingress 可以匹配 Harbor 路由。

echo "检查 Harbor Portal…"

portal_status=$(curl –silent –show-error \\
–connect-timeout 5 \\
–max-time 10 \\
–resolve "harbor.local:30080:$HARBOR_IP" \\
-o /dev/null \\
-w '%{http_code}' \\
"$HARBOR_URL/")

if [ "$portal_status" != "200" ]; then
echo "ERROR:Harbor Portal 检查失败"
echo "HTTP 状态码:$portal_status"
echo "请检查 Ingress Host 和 Harbor Portal 后端"
exit 1
fi

echo "Harbor Portal 检查通过"

# 检查 Docker Registry API
echo "检查 Harbor Registry API…"

registry_status=$(curl –silent –show-error \\
–connect-timeout 5 \\
–max-time 10 \\
–resolve "harbor.local:30080:$HARBOR_IP" \\
-o /dev/null \\
-w '%{http_code}' \\
"$HARBOR_URL/v2/")

# 未登录时,401 通常表示 Registry 已响应认证挑战
case "$registry_status" in
200|401)
echo "Registry API 检查通过"
echo "HTTP 状态码:$registry_status"
;;
*)
echo "ERROR:Registry API 检查失败"
echo "HTTP 状态码:$registry_status"
exit 1
;;
esac

echo "Harbor 连通性检查通过"
'''
}
}

// ============================================================
// 阶段 4:检查项目文件
// ============================================================
stage('Validate Files') {
steps {
echo '========== 阶段 4:检查项目文件 =========='

sh '''
set -eu

# 检查 FastAPI 和 llama.cpp 镜像的构建文件
test -f Dockerfile
test -f requirements.txt
test -f llama/Dockerfile

echo "构建文件检查通过"

# 确保大型 GGUF 模型没有被提交到 Git
MODEL_FILE="llama/models/qwen2-0_5b-instruct-q4_k_m.gguf"

if git ls-files –error-unmatch "$MODEL_FILE" \\
>/dev/null 2>&1; then
echo "ERROR:GGUF 模型文件不能提交到 Git"
exit 1
fi

echo "GGUF 模型未被 Git 跟踪"
'''
}
}

// ============================================================
// 阶段 5:构建 FastAPI 镜像
// ============================================================
stage('Build llm-serving') {
options {
timeout(time: 10, unit: 'MINUTES')
}

steps {
echo '========== 阶段 5:构建 llm-serving 镜像 =========='

sh '''
set -eu
export PATH="/opt/homebrew/bin:/usr/local/bin:$PATH"

IMAGE="$HARBOR/$HARBOR_PROJECT/$APP_IMAGE"

echo "目标镜像:$IMAGE:$BUILD_NUMBER"
echo "开始时间:$(date)"

# 构建 FastAPI 服务镜像
docker build –progress=plain –pull \\
-t "$IMAGE:$BUILD_NUMBER" \\
-t "$IMAGE:latest" \\
-f Dockerfile .

echo "llm-serving 镜像构建成功"
echo "结束时间:$(date)"
'''
}
}

// ============================================================
// 阶段 6:构建 llama.cpp + Qwen 镜像
// ============================================================
stage('Build llama + Qwen') {
options {
// 模型下载可能耗时较长
timeout(time: 20, unit: 'MINUTES')
}

steps {
echo '========== 阶段 6:构建 llama + Qwen 镜像 =========='

sh '''
set -eu
export PATH="/opt/homebrew/bin:/usr/local/bin:$PATH"

IMAGE="$HARBOR/$HARBOR_PROJECT/$LLAMA_IMAGE"

echo "目标镜像:$IMAGE:$BUILD_NUMBER"
echo "开始时间:$(date)"

# 模型文件不提交到 Git,由 llama/Dockerfile 在构建时下载
docker build –progress=plain –pull \\
-t "$IMAGE:$BUILD_NUMBER" \\
-t "$IMAGE:latest" \\
-f llama/Dockerfile llama

echo "llama + Qwen 镜像构建成功"
echo "结束时间:$(date)"
'''
}
}

// ============================================================
// 阶段 7:验证本地镜像
// ============================================================
stage('Verify Images') {
steps {
echo '========== 阶段 7:验证本地镜像 =========='

sh '''
set -eu
export PATH="/opt/homebrew/bin:/usr/local/bin:$PATH"

# 检查两个镜像的版本标签是否存在
docker image inspect \\
"$HARBOR/$HARBOR_PROJECT/$APP_IMAGE:$BUILD_NUMBER" \\
>/dev/null

docker image inspect \\
"$HARBOR/$HARBOR_PROJECT/$LLAMA_IMAGE:$BUILD_NUMBER" \\
>/dev/null

echo "两个镜像均已成功生成"
'''
}
}

// ============================================================
// 阶段 8:推送镜像到 Harbor
// ============================================================
stage('Push to Harbor') {
options {
timeout(time: 10, unit: 'MINUTES')
}

steps {
echo '========== 阶段 8:推送镜像到 Harbor =========='

// 从 Jenkins Credentials 获取 Harbor 账号和密码
withCredentials([
usernamePassword(
credentialsId: 'harbor-credentials',
usernameVariable: 'HARBOR_USER',
passwordVariable: 'HARBOR_PASSWORD'
)
]) {
sh '''
set -eu
set +x

export PATH="/opt/homebrew/bin:/usr/local/bin:$PATH"

# 推送结束后尝试清理 Docker 登录状态
trap 'docker logout "$HARBOR" >/dev/null 2>&1 || true' EXIT

echo "登录 Harbor:$HARBOR"

# 使用标准输入传递密码,避免将密码写入命令参数
printf '%s' "$HARBOR_PASSWORD" |
docker login "$HARBOR" \\
-u "$HARBOR_USER" \\
–password-stdin

# 推送 FastAPI 镜像的版本标签
echo "推送 $APP_IMAGE:$BUILD_NUMBER"

docker push \\
"$HARBOR/$HARBOR_PROJECT/$APP_IMAGE:$BUILD_NUMBER"

# 推送 FastAPI 镜像的 latest 标签
echo "推送 $APP_IMAGE:latest"

docker push \\
"$HARBOR/$HARBOR_PROJECT/$APP_IMAGE:latest"

# 推送 llama.cpp + Qwen 镜像的版本标签
echo "推送 $LLAMA_IMAGE:$BUILD_NUMBER"

docker push \\
"$HARBOR/$HARBOR_PROJECT/$LLAMA_IMAGE:$BUILD_NUMBER"

# 推送 llama.cpp + Qwen 镜像的 latest 标签
echo "推送 $LLAMA_IMAGE:latest"

docker push \\
"$HARBOR/$HARBOR_PROJECT/$LLAMA_IMAGE:latest"

echo "所有镜像已推送到 Harbor"
'''
}
}
}
}

// ================================================================
// 流水线结束处理
// ================================================================
post {
success {
echo '''
========================================
CI 成功
llm-serving 和 llama + Qwen 镜像已推送。
请在 Harbor 中检查镜像版本。
========================================
'''

}

failure {
echo '''
========================================
CI 失败
请根据最后一个失败阶段定位问题。
优先检查 Docker、网络、模型下载和 Harbor 认证。
========================================
'''

}

always {
echo "Pipeline 结束时间:${new Date()}"
echo "Jenkins Build Number:${env.BUILD_NUMBER}"
echo "最终构建结果:${currentBuild.currentResult}"
}
}
}

设计要点:

  • 双标签:每个镜像推送两次,一个是构建号(如 6,可追溯到具体某次构建),一个是 latest(方便默认拉取)。
  • 凭据不落盘:用 withCredentials 取出用户名和密码,再通过 –password-stdin 登录,密码不会出现在命令行参数里。
  • timeout 兜底:避免某次推送卡住导致流水线一直占着执行器。
  • post { always { … } }:无论成功失败都输出结束时间、构建号、最终结果。
  • 注意:构建 llama/Dockerfile 时需要 llama/models/qwen2-0_5b-instruct-q4_k_m.gguf 在工作区里存在(约 390MB),要保证它能随代码被 Jenkins 取到(例如提交进仓库、使用 Git LFS,或在流水线里提前下载)。

五、配置 Jenkins 执行器

补充:原始记录这一步只有一个标题"Jenkins 配置执行器",没有内容。执行器(executor)决定了 Jenkins 同时能跑几个构建,流水线 agent any 需要至少有一个可用的执行器才能开始。

一般的配置位置是:

Manage Jenkins → Nodes → Built-In Node → Configure → Number of executors

对这样的单人本地环境,把执行器数量设为 1 或 2 即可;设置完成后保存。请把你实际设置的数量和位置补充到这里。


六、在 Jenkins 创建任务

  • 进入 Jenkins 首页,选择 New Item。

  • 名称填写 llm-serving-ci,类型选择 Pipeline。

  • 在 Pipeline 配置中选择 Pipeline script from SCM。

  • SCM 选择 Git,填写 GitHub 仓库地址和对应凭据:

    https://github.com/lglgl/llm-serving.git

  • 分支填写 */main,Script Path 填写 Jenkinsfile。

  • 保存后点击 Build Now,查看 Console Output。

  • 要点:

    • 选 Pipeline script from SCM 后,流水线定义随代码一起版本管理,改 Jenkinsfile 就是改 CI,不用到 Jenkins 页面里改脚本。
    • 分支 */main 要和仓库实际的默认分支一致;Script Path 必须与仓库中 Jenkinsfile 的相对路径一致。
    • 仓库如果是私有的,需要为 Jenkins 另外配置能拉取 GitHub 的凭据(与 Harbor 凭据是两回事)。

    七、构建并查看 Console Output

    点击 Build Now 后,从 Console Output 里可以看到推送阶段的日志(此处为第 6 次构建的末段):

    84dd5a4db43e: Layer already exists

    373ce1c1778b: Layer already exists
    11d89b6e886f: Layer already exists
    258ba1f62b51: Layer already exists

    latest: digest: sha256:71f0b338f88c7fdcc97f947d79560bf224d4f642bcdfeb46bbb93f32fae6d7f8 size: 1992
    推送 llm-serving-llama:6

    The push refers to repository [harbor.local:30080/library/llm-serving-llama]

    1d76195dc42f: Preparing
    5f70bf18a086: Preparing
    bbd7bf824dec: Preparing
    6038fa08e79b: Preparing
    14887e27a87e: Preparing
    1e9178dc9005: Preparing
    1e9178dc9005: Waiting

    bbd7bf824dec: Pushed
    6038fa08e79b: Pushed
    5f70bf18a086: Pushed

    1d76195dc42f: Pushed

    14887e27a87e: Pushed

    1e9178dc9005: Pushed

    6: digest: sha256:dea4451c2031a02617c5cc45c78fda418436b510cd7d155abf09b0c2435fa7e0 size: 1582
    推送 llm-serving-llama:latest

    The push refers to repository [harbor.local:30080/library/llm-serving-llama]

    1d76195dc42f: Preparing
    5f70bf18a086: Preparing
    bbd7bf824dec: Preparing
    6038fa08e79b: Preparing
    14887e27a87e: Preparing
    1e9178dc9005: Preparing
    1e9178dc9005: Waiting

    6038fa08e79b: Layer already exists
    14887e27a87e: Layer already exists
    5f70bf18a086: Layer already exists
    bbd7bf824dec: Layer already exists
    1d76195dc42f: Layer already exists

    1e9178dc9005: Layer already exists

    latest: digest: sha256:dea4451c2031a02617c5cc45c78fda418436b510cd7d155abf09b0c2435fa7e0 size: 1582
    所有镜像已推送到 Harbor

    [Pipeline] }
    [Pipeline] // withCredentials
    [Pipeline] }
    [Pipeline] // timeout
    [Pipeline] }
    [Pipeline] // stage

    [Pipeline] stage
    [Pipeline] { (Declarative: Post Actions)
    [Pipeline] echo
    Pipeline 结束时间:Mon Oct 12 01:17:25 CST 2026
    [Pipeline] echo
    Jenkins Build Number:6
    [Pipeline] echo
    最终构建结果:SUCCESS

    日志解读:

    • 第一段(llm-serving:latest):四层全部是 Layer already exists,说明同样的层已经随 llm-serving:6 推送过了;latest 只是给同一个 digest 再打了一个标签,最后一行 digest 为 sha256:71f0b338…。
    • 推送 llm-serving-llama:6:先推 6 标签,这次有真实的层上传(Pushed),得到 digest sha256:dea4451c…、size 1582。
    • 推送 llm-serving-llama:latest:这次所有层都是 Layer already exists,digest 仍是 sha256:dea4451c…。两个标签指向同一份内容,所以只多了一次元数据写入,几乎不花时间。
    • 结尾:withCredentials、timeout、stage 依次正常结束,Post Actions 输出结束时间 Mon Oct 12 01:17:25 CST 2026、构建号 6、最终结果 SUCCESS。

    八、验收

    构建成功后,把两个镜像从 Harbor 拉取一遍:

    (base) igwanglyang@MacbookProdahouzicn devops-platform % docker pull harbor.local:30080/library/llm-serving:latest
    docker pull harbor.local:30080/library/llm-serving-llama:latest
    latest: Pulling from library/llm-serving
    Digest: sha256:71f0b338f88c7fdcc97f947d79560bf224d4f642bcdfeb46bbb93f32fae6d7f8
    Status: Image is up to date for harbor.local:30080/library/llm-serving:latest
    harbor.local:30080/library/llm-serving:latest
    latest: Pulling from library/llm-serving-llama
    Digest: sha256:dea4451c2031a02617c5cc45c78fda418436b510cd7d155abf09b0c2435fa7e0
    Status: Image is up to date for harbor.local:30080/library/llm-serving-llama:latest
    harbor.local:30080/library/llm-serving-llama:latest

    核对点:

    镜像拉取得到的 digest与 Jenkins 推送日志
    llm-serving:latest sha256:71f0b338f88c…d7f8 一致
    llm-serving-llama:latest sha256:dea4451c2031…fa7e0 一致(同 6 标签)
    • digest 一致,说明 Harbor 里的 latest 就是这次流水线刚推送的内容。
    • Image is up to date 表示本机已经有同一 digest 的镜像,无需再下载。

    补充:也可以登录 Harbor 网页 http://harbor.local:30080,进入 library 项目,检查 llm-serving 与 llm-serving-llama 两个仓库里是否有 6 和 latest 两个标签,并确认它们的 digest 与上面一致。


    后续计划

    当前流水线只做"构建 + 推送"。后续再集成安全扫描:

    常见做法有两种——一是在 Harbor 里对 library 项目开启"推送时自动扫描"(Harbor 通常自带 Trivy 扫描器);二是在 Jenkinsfile 里增加一个扫描阶段,在推送前后用 Trivy 等工具检查镜像漏洞,并设定严重级别阈值,不达标就让流水线失败。


    总结

    这条 CI 流水线的完整路径:

    1. 确认 Harbor 可访问:http://harbor.local:30080(admin)
    2. Jenkins 添加凭据:Username with password,ID = harbor-credentials
    3. 确认 Jenkins 能执行 docker 命令
    4. 仓库根目录创建 Jenkinsfile:构建 → 登录 → 推送(构建号 + latest)
    5. 配置 Jenkins 执行器
    6. 新建 Pipeline 任务 llm-serving-ci:Pipeline script from SCM
    仓库 https://github.com/lglgl/llm-serving.git,分支 */main,Script Path = Jenkinsfile
    7. Build Now → Console Output 显示 SUCCESS
    8. docker pull harbor.local:30080/library/<镜像>:latest,核对 digest

    经验点:

  • 先在 Jenkins 之外验证 Harbor 登录:docker logout + docker login 排除账号、地址、HTTP 仓库配置的问题,再去配 Jenkins 凭据。
  • 凭据用 ID 引用,不写进脚本:withCredentials + –password-stdin,密码既不进 Jenkinsfile,也不出现在命令行里。
  • 双标签策略:构建号用来追溯,latest 用来默认拉取;两个标签指向同一 digest,第二次推送几乎零成本。
  • 流水线即代码:Pipeline script from SCM 让 Jenkinsfile 跟随代码版本管理。
  • 用 digest 验收:推送日志里的 digest 与 docker pull 的 digest 一致,才算真正确认镜像进了 Harbor。
  • 安全扫描留到下一步:先跑通"构建 + 推送"闭环,再叠加漏洞扫描。
  • 赞(0)
    未经允许不得转载:171主机测评 » 04 | 用 Jenkins 打通 CI自动构建镜像并推送到本地 Harbor
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址