把大模型能力接入业务,通常只需要一个 HTTP 客户端和少量提示词;但当代码进入持续迭代阶段,真正容易出问题的往往不是请求本身,而是发布过程:开发者手工登录服务器替换文件,环境变量被写进脚本,构建完成后没有明确的健康检查,失败时也缺少可重复的回滚入口。
这类服务还存在一个额外风险:模型调用涉及外部网络、访问令牌、请求内容和费用控制。一次普通的代码发布,如果同时改变了请求格式、超时策略或模型配置,可能在应用已经上线后才暴露问题。因此,持续交付的目标不只是“自动执行命令”,而是把构建、配置、验证和恢复组织成一条可追踪的链路。
本文使用 Jenkins 接收代码仓库的 Webhook,构建 Docker 镜像,将镜像部署到目标主机,并在发布后执行健康检查。示例服务只演示接入边界,不绑定某个具体模型供应商。若选择 HaerAPI 作为上游接口,应以其当前文档确认接口格式、鉴权方式、可用模型和数据处理条款;示例中的上游地址仅作为环境配置,不代表任何能力承诺。
设计原理
一条可维护的流水线可以拆为五个阶段:
- 触发:代码仓库在受保护分支发生变更后,通过带签名或随机令牌的 Webhook 通知 Jenkins。
- 构建:Jenkins 在干净工作区执行依赖安装、单元测试和镜像构建,避免使用开发机上的隐式状态。
- 交付:镜像推送到镜像仓库,目标主机只拉取指定版本,不直接接收源代码。
- 验证:容器启动后先检查本地健康端点,再执行不产生业务副作用的接口验证。
- 恢复:部署前保存旧版本标识;新版本验证失败时重新启动旧镜像,并保留流水线日志供审计。
模型密钥和 Webhook 凭据不应写进仓库、Dockerfile 或 Jenkinsfile。Jenkins 凭据系统只负责在流水线运行期间注入秘密,目标主机则通过受限权限的环境文件或专用密钥管理系统读取它们。生产环境是否允许把令牌放入环境变量,要根据主机权限、日志策略和组织合规要求判断;至少要避免把环境变量完整打印到构建日志中。
最小服务
下面是一个使用 Python 标准库实现的简化服务。它只保留请求边界,实际项目应补充鉴权、限流、输入长度限制、结构化日志和错误分类。
# app.py
import json
import os
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.request import Request, urlopen
from urllib.error import HTTPError, URLError
UPSTREAM_URL = os.environ["UPSTREAM_URL"]
UPSTREAM_TOKEN = os.environ["UPSTREAM_TOKEN"]
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
if self.path == "/healthz":
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(b'{"status":"ok"}')
return
self.send_error(404)
def do_POST(self):
if self.path != "/ask":
self.send_error(404)
return
try:
size = int(self.headers.get("Content-Length", "0"))
if size <= 0 or size > 32768:
self.send_error(413, "request too large")
return
body = json.loads(self.rfile.read(size))
prompt = body.get("prompt")
if not isinstance(prompt, str) or not prompt.strip():
self.send_error(400, "prompt is required")
return
payload = json.dumps({
"input": prompt}).encode()
request = Request(
UPSTREAM_URL,
data=payload,
headers={
"Content-Type": "application/json",
"Authorization": "Bearer " + UPSTREAM_TOKEN,
},
method="POST",
)
with urlopen(request, timeout=30) as response:
result = response.read()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(result)
except (ValueError, KeyError):
self.send_error(400, "invalid json")
except (HTTPError, URLError, TimeoutError):
self.send_error(502, "upstream unavailable")
def log_message(self, fmt, *args):
# 不记录请求体,避免提示词或响应内容进入默认日志。
super().log_message(fmt, *args)
if __name__ == "__main__":
ThreadingHTTPServer(("0.0.0.0", 8080), Handler).serve_forever()
这里的 /healthz 只能证明进程能够响应,不能证明上游模型可用。发布验证应分层:进程健康是第一层,网络和凭据可用性是第二层,业务响应格式符合契约是第三层。第三层测试应使用不含敏感信息的固定输入,并控制调用频率和成本。
容器与流水线
# Dockerfile
FROM python:3.12-slim
WORKDIR /app
COPY app.py .
USER nobody
EXPOSE 8080
CMD ["python", "app.py"]
Jenkins 节点需要具备 Git、Docker 和访问镜像仓库的权限。下面的 Jenkinsfile 假定流水线凭据中存在 registry-login,目标主机通过 SSH 凭据 deploy-ssh 登录。凭据 ID 只是示例名称,实际项目应按组织规则创建。
pipeline {
agent any
options {
timestamps(); disableConcurrentBuilds() }
environment {
IMAGE = "registry.example.com/team/model-gateway:${BUILD_NUMBER}"
CONTAINER = "model-gateway"
}
stages {
stage('Test') {
steps {
sh 'python -m py_compile app.py'
}
}
stage('Build and Push') {
steps {
withCredentials([usernamePassword(
credentialsId: 'registry-login',
usernameVariable: 'REG_USER',
passwordVariable: 'REG_PASS')]) {
sh '''set +x
printf '%s' "$REG_PASS" | docker login registry.example.com \\
--username "$REG_USER" --password-stdin
docker build --pull -t "$IMAGE" .
docker push "$IMAGE"
docker logout registry.example.com
'''
}
}
}
stage('Deploy') {
steps {
sshagent(credentials: ['deploy-ssh']) {
sh '''ssh -o StrictHostKeyChecking=yes deploy@app-host \\
"IMAGE='$IMAGE' CONTAINER='$CONTAINER' /opt/model-gateway/deploy.sh"'''
}
}
}
}
post {
always {
archiveArtifacts artifacts: 'app.py,Dockerfile', fingerprint: true }
}
}
deploy.sh 应放在目标主机并设置为仅管理员可修改。脚本不接受任意镜像地址,生产环境还应校验镜像仓库域名、标签格式或镜像摘要,避免命令注入和错误部署。
#!/bin/sh
set -eu
: "${IMAGE:?IMAGE is required}"
case "$IMAGE" in
registry.example.com/team/model-gateway:[0-9]*) ;;
*) echo "invalid image" >&2; exit 2 ;;
esac
old="$(docker inspect --format '{
{.Config.Image}}' "$CONTAINER" 2>/dev/null || true)"
docker pull "$IMAGE"
docker rm -f "$CONTAINER" 2>/dev/null || true
docker run -d --name "$CONTAINER" --restart unless-stopped \\
--env-file /etc/model-gateway/runtime.env \\
-p 127.0.0.1:8080:8080 "$IMAGE"
ok=0
i=0
while [ "$i" -lt 12 ]; do
if wget -qO- http://127.0.0.1:8080/healthz >/dev/null; then ok=1; break; fi
i=$((i + 1)); sleep 2
done
if [ "$ok" -ne 1 ]; then
docker logs --tail 80 "$CONTAINER" >&2 || true
docker rm -f "$CONTAINER" 2>/dev/null || true
if [ -n "$old" ]; then
docker run -d --name "$CONTAINER" --restart unless-stopped \\
--env-file /etc/model-gateway/runtime.env \\
-p 127.0.0.1:8080:8080 "$old"
fi
exit 1
fi
目标主机的 /etc/model-gateway/runtime.env 至少应包含以下变量,权限可设置为 0600,所有者设为运行部署流程的受限账户:
UPSTREAM_URL=https://api.example.invalid/v1/infer
UPSTREAM_TOKEN=从密钥管理系统注入的令牌
不要把上述文件复制进镜像,也不要在失败时输出其内容。对于正式环境,建议用镜像摘要替代可变标签,并把旧版本摘要写入发布记录。
Webhook 安全
Jenkins 的 Webhook 地址不应裸奔在公网。可采用反向代理限制来源网段、使用仓库平台支持的签名校验,并在 Jenkins 侧关闭匿名构建权限。若只能使用令牌,令牌应放在 URL 参数之外的受保护凭据中,并定期轮换。Webhook 触发后,流水线还应检查分支、提交者权限和变更范围,避免任意分支直接发布生产版本。
网络层可以只暴露反向代理的 443 端口,Jenkins 管理端口放在内网;目标服务绑定 127.0.0.1,由 Nginx 或其他网关负责 TLS、访问控制和请求大小限制。外部 API 的域名也应加入明确的出口策略,避免应用被利用为任意地址请求器。
常见问题
为什么构建成功仍然发布失败?
构建成功只说明镜像可以生成,并不等于目标主机能拉取镜像、能读取配置或能访问上游。应分别检查镜像仓库认证、主机磁盘、容器日志、DNS、出口防火墙和上游返回状态。把这些检查拆成独立阶段,定位速度会更快。
健康检查是否应该直接调用模型?
不一定。模型调用可能产生费用、受到限流,或者因为供应商短暂故障导致整次发布被误判。常规发布先执行本地健康检查;是否增加上游探针,应根据接口计费、幂等性、数据敏感度和故障策略决定。
如何避免同一提交重复部署?
Jenkins 可以使用 disableConcurrentBuilds(),流水线开始时记录提交哈希,并在部署脚本中检查当前运行版本。对于多节点部署,还需要分批发布或使用编排平台的滚动更新能力。不能只依赖镜像标签判断版本,因为可变标签可能被覆盖。
回滚后配置是否也要回滚?
应用镜像和运行配置应分别版本化。若新代码要求新的环境变量或请求协议,单纯恢复旧镜像可能仍然失败。发布记录应同时保存镜像摘要、配置版本、数据库变更和上游接口配置;数据库变更则应优先设计为向后兼容,并明确回滚边界。
总结
把模型调用服务接入 Jenkins,不是把一条 docker run 命令搬进流水线,而是建立一组可验证的交付约束:Webhook 负责受控触发,构建阶段保证输入可重复,凭据系统隔离秘密,部署脚本保存旧版本,健康检查验证运行状态,失败路径负责恢复。
当服务开始承载真实业务后,还应继续补齐请求审计的最小字段、超时与重试预算、上游错误分类、成本告警以及人工审批闸门。这样,模型供应商或接口地址发生变化时,应用代码、发布流程和合规检查仍然保持清晰的边界。