为 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指向同一个 digestsha256: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:
text
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 之外,先用同样的账号在本机验证一次登录,确认账号密码和仓库地址都没问题:
bash
(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,在里面执行:
groovypipeline { 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)。
groovy
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需要至少有一个可用的执行器才能开始。一般的配置位置是:
textManage 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 仓库地址和对应凭据:
texthttps://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 次构建的末段):
text
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),得到 digestsha256: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 拉取一遍:
bash
(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 流水线的完整路径:
text
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。 - 安全扫描留到下一步:先跑通"构建 + 推送"闭环,再叠加漏洞扫描。