Day 11:AI 业务端到端 —— chainlit + GitOps + 跨 WAN vLLM
Day 1-10 把基础设施一层一层垒起来:CNI、存储、Ingress、cert-manager、Operator、Harbor、ArgoCD、Gitea/Jenkins、GPU、MIG、vLLM。Day 11 第一次把这些全部用起来 —— 一个真实的 chainlit chat UI 应用,从 git push 开始,经过完整 GitOps 流水线,跨 WAN 调到 GPU 集群上的 vLLM endpoint。
整篇按 A → F 6 个阶段走:写业务代码 → 容器化 → CI 推 Harbor → ArgoCD 拉到主集群 → 跨 WAN 接 vLLM → 业务指标可观测。每个阶段都有一个真坑。
端到端拓扑
git push → Gitea(notes-app) → Jenkins(Kaniko build) → Harbor(chat-ui:<sha>)
│
Gitea(notes-deploy) ◄── 改 image tag ───────────┘
│
▼ ArgoCD watch + sync
主集群 chat ns: chat-ui Deploy + vllm-upstream Svc/Endpoints
│
▼ cross-WAN (m1:30800 SSH tunnel → gpu1)
GPU 集群 k3s: vllm-3b → Qwen2.5-3B on A800 MIG 2g.10gb
Day 1-10 能力首次全部「用起来」:Cilium 跨节点 Service + Hubble L7(D1)、Kyverno + PSA baseline(D5)、kube-prometheus-stack ServiceMonitor(D6)、Harbor + ArgoCD(D7)、Gitea + Jenkins(D8 番外)、vLLM(D8 主线)、DCGM cross-WAN(D9)、A800 MIG 2g.10gb slice(D10)。Longhorn(D4)这次留作可选 —— chainlit 用 in-memory session 简化。
A. chainlit 业务代码
chainlit 是给 LLM 应用做 chat UI 最快的方案 —— 一个装饰器 @cl.on_message + 异步 streaming,几十行代码就有 WebSocket + 流式打字效果 + 多轮 session 管理。OpenAI-compat 接口让它直接对接 vLLM。
A.1 app.py
import os, time, chainlit as cl
from openai import AsyncOpenAI
from prometheus_client import Counter, Histogram, start_http_server
CHAT_REQUESTS = Counter("chat_requests_total", "chat completions", ["model", "status"])
CHAT_TOKENS = Counter("chat_tokens_total", "tokens", ["model", "kind"])
CHAT_LATENCY = Histogram("chat_first_token_latency_seconds", "TTFT", ["model"],
buckets=[0.1, 0.25, 0.5, 1, 2, 5, 10])
CHAT_E2E = Histogram("chat_e2e_latency_seconds", "E2E", ["model"],
buckets=[0.5, 1, 2, 5, 10, 30, 60])
start_http_server(9100)
client = AsyncOpenAI(base_url=os.getenv("VLLM_URL"), api_key="not-needed")
MODEL = os.getenv("MODEL", "qwen2.5-3b")
@cl.on_message
async def on_message(msg: cl.Message):
response = cl.Message(content="")
first_token, t0, status = None, time.time(), "success"
prompt_tokens = completion_tokens = 0
history = (cl.user_session.get("history") or []) + \
[{"role": "user", "content": msg.content}]
try:
stream = await client.chat.completions.create(
model=MODEL, messages=history, stream=True,
stream_options={"include_usage": True},
max_tokens=512, temperature=0.7,
)
async for chunk in stream:
if chunk.choices and chunk.choices[0].delta.content:
if first_token is None:
first_token = time.time() - t0
CHAT_LATENCY.labels(model=MODEL).observe(first_token)
await response.stream_token(chunk.choices[0].delta.content)
if chunk.usage:
prompt_tokens = chunk.usage.prompt_tokens
completion_tokens = chunk.usage.completion_tokens
except Exception as e:
status = "error"
response.content = f"error: {e}"
finally:
CHAT_REQUESTS.labels(model=MODEL, status=status).inc()
CHAT_TOKENS.labels(model=MODEL, kind="prompt").inc(prompt_tokens)
CHAT_TOKENS.labels(model=MODEL, kind="completion").inc(completion_tokens)
CHAT_E2E.labels(model=MODEL).observe(time.time() - t0)
cl.user_session.set("history", history + [{"role": "assistant", "content": response.content}])
await response.update()
LLM 业务必埋的 4 个 metric:TTFT(用户感知最强,p95 > 2s 用户就跑)、E2E latency(整轮对话,反映 generation throughput)、tokens by kind(prompt/completion 分开计费 + 容量规划)、requests by status(QPS + 错误率)。
stream_options={"include_usage": True} 是 vLLM 流式取 token 数的官方姿势 —— 流式默认不返回 usage,必须显式开启,否则 chunk.usage 永远是 None。
A.2 Dockerfile(multi-stage,~140 MB final)
FROM python:3.11-slim AS builder
WORKDIR /app
RUN pip config set global.index-url https://mirrors.aliyun.com/pypi/simple/
COPY requirements.txt .
RUN pip install --no-cache-dir --user -r requirements.txt
FROM python:3.11-slim
WORKDIR /app
COPY /root/.local /root/.local
ENV PATH=/root/.local/bin:$PATH
COPY app.py .
EXPOSE 8000 9100
CMD ["chainlit", "run", "app.py", "--host", "0.0.0.0", "--port", "8000", "--headless"]
--headless 关掉首次启动的浏览器打开和遥测,是生产姿势。
B. CI:Gitea push → Jenkins → Kaniko → Harbor
Jenkins agent 是 K8s Pod,每次 build 拉起一个 Kaniko 容器构建镜像并推 Harbor,build 完销毁。无需 Docker daemon,K8s 内安全构建。
B.1 Jenkinsfile
pipeline {
agent {
kubernetes {
yaml '''
apiVersion: v1
kind: Pod
spec:
containers:
- name: kaniko
image: gcr.io/kaniko-project/executor:debug
command: [/busybox/cat]
tty: true
volumeMounts: [{name: docker-config, mountPath: /kaniko/.docker}]
volumes:
- name: docker-config
secret: {secretName: harbor-auth, items: [{key: .dockerconfigjson, path: config.json}]}
'''
}
}
environment { REGISTRY = "10.0.24.28:30002"; PROJECT = "bootcamp"; IMAGE = "chat-ui" }
stages {
stage('Build') {
steps { container('kaniko') { sh '''
GIT_SHA=$(git rev-parse --short HEAD 2>/dev/null || echo dev)
/kaniko/executor --dockerfile=Dockerfile --context=$(pwd) \
--destination=${REGISTRY}/${PROJECT}/${IMAGE}:${GIT_SHA} \
--destination=${REGISTRY}/${PROJECT}/${IMAGE}:latest \
--insecure --skip-tls-verify
''' } }
}
}
}
--insecure --skip-tls-verify 是因为 Harbor 这次是 HTTP NodePort(学习集群简化)。生产必须走 HTTPS + cert-manager 证书,不要把这两个 flag 带进 production pipeline。
B.2 触发与产出
git push origin main # Jenkins SCM 每分钟 poll 一次,自动触发 Build
# Build #2 SUCCESS (6 min),Harbor 收到 bootcamp/chat-ui:<sha> + :latest
为什么同时打 :<git-sha> 和 :latest:sha tag 用于精确部署 + 回滚审计,:latest 用于人肉调试。生产 ArgoCD manifest 一定 pin sha tag,否则 image 内容变了但 manifest 没变,ArgoCD 检测不到 drift。
C. CD:ArgoCD watch deploy repo
GitOps 的核心是「期望状态写在 git 里」。源码 repo(notes-app)和部署 repo(notes-deploy)分两个 —— 源码变动触发 CI 构建镜像,部署 repo 改 image tag 触发 CD。两者解耦。
C.1 注册 Gitea Repository
apiVersion: v1
kind: Secret
metadata:
name: gitea-repo
namespace: argocd
labels: {argocd.argoproj.io/secret-type: repository}
stringData:
type: git
url: http://gitea-http.gitea.svc:3000/bootcamp/notes-deploy.git
username: bootcamp
password: bootcamp
insecure: "true"
C.2 真坑:ArgoCD 默认不接 HTTP repo
第一次 sync 直接报 transport: authentication handshake failed: context deadline exceeded。
ArgoCD 默认假设 git remote 是 HTTPS + valid TLS。学习集群的 Gitea 是 ClusterIP HTTP,必须三处同时改:Secret 里 insecure: "true"、URL 写 http:// scheme(不是默认 git+SSH)、kubectl rollout restart -n argocd deploy/argocd-repo-server 刷连接池缓存。生产用 Ingress + cert-manager TLS 就没这事。
C.3 Application
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata: {name: chat-ui, namespace: argocd}
spec:
project: default
source:
repoURL: http://gitea-http.gitea.svc:3000/bootcamp/notes-deploy.git
targetRevision: HEAD
path: chat-ui
destination: {server: https://kubernetes.default.svc, namespace: chat}
syncPolicy:
automated: {prune: true, selfHeal: true}
syncOptions: [CreateNamespace=true, ServerSideApply=true]
三个关键 flag:prune 删 git 里去掉的资源(防集群只增不减脏掉);selfHeal 把 kubectl edit 改的状态自动改回 git 版本(GitOps 必开);ServerSideApply 避免 client-side merge 误删 controller 写入的 status 字段。
D. 部署 manifest:namespace + Deployment + 跨 WAN Service
部署文件全部放在 notes-deploy/chat-ui/ 目录。下面这一坨是 ArgoCD 实际拉到的全部 yaml。
D.1 namespace + chat-ui
apiVersion: v1
kind: Namespace
metadata:
name: chat
labels: {pod-security.kubernetes.io/enforce: baseline}
---
apiVersion: apps/v1
kind: Deployment
metadata: {name: chat-ui, namespace: chat}
spec:
replicas: 1
selector: {matchLabels: {app: chat-ui}}
template:
metadata: {labels: {app: chat-ui}}
spec:
imagePullSecrets: [{name: harbor-pull}]
containers:
- name: chat-ui
image: 10.0.24.28:30002/bootcamp/chat-ui:latest
imagePullPolicy: Always
ports: [{containerPort: 8000, name: http}, {containerPort: 9100, name: metrics}]
env:
- {name: VLLM_URL, value: "http://vllm-upstream.chat.svc:8000/v1"}
- {name: MODEL, value: "qwen2.5-3b"}
resources:
requests: {cpu: 100m, memory: 256Mi}
limits: {cpu: 1, memory: 1Gi}
readinessProbe: {httpGet: {path: /, port: 8000}, initialDelaySeconds: 20}
---
apiVersion: v1
kind: Service
metadata: {name: chat-ui, namespace: chat, labels: {app: chat-ui}}
spec:
type: NodePort
selector: {app: chat-ui}
ports:
- {port: 80, targetPort: 8000, nodePort: 30810, name: http}
- {port: 9100, targetPort: 9100, nodePort: 30811, name: metrics}
---
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata: {name: chat-ui, namespace: chat, labels: {release: kps}}
spec:
selector: {matchLabels: {app: chat-ui}}
endpoints: [{port: metrics, interval: 10s, path: /metrics}]
D.2 真坑:image pull secret 跨 namespace 不会自动带过来
Harbor 是私有 registry,需要 imagePullSecret。Day 7 在 harbor ns 创的 harbor-pull Secret 在 chat ns 看不到,chat-ui Pod 报 Failed to pull image: unauthorized —— Secret 不跨 namespace。
# 方式 1:每个业务 ns 各创一份
kubectl -n chat create secret docker-registry harbor-pull \
--docker-server=10.0.24.28:30002 \
--docker-username='robot$bootcamp' --docker-password='<token>'
# 方式 2:Kyverno generate policy 自动同步到所有 ns(Day 5 装好的可直接用)
学习集群用方式 1,生产多业务 ns 用方式 2 集中管理。Day 5 的 Kyverno generate rule 这次第一次派上用场。
E. 跨 WAN 接 vLLM:Service + 手动 Endpoints
vLLM 跑在 GPU 集群(独立 k3s on gpu1),主集群没有 GPU。跨 WAN 的方案选 SSH reverse tunnel:
# m1 上跑(systemd 持久化),把 gpu1 的 vllm-3b Service 8000 端口
# 反向暴露到 m1:30800
ssh -fN -p 15128 -L 0.0.0.0:30800:<vllm-svc-cluster-ip>:8000 root@gpu1
主集群里要让 chat-ui 通过 K8s 原生 Service DNS 调到 vllm —— 用 ClusterIP Service + 手动 Endpoints 把这个外部端点伪装成集群内 Service。
---
apiVersion: v1
kind: Service
metadata: {name: vllm-upstream, namespace: chat}
spec:
type: ClusterIP
ports:
- {port: 8000, targetPort: 30800, name: http, protocol: TCP}
---
apiVersion: v1
kind: Endpoints
metadata: {name: vllm-upstream, namespace: chat}
subsets:
- addresses: [{ip: 10.0.24.31}] # m1 hostIP(SSH tunnel 落点)
ports: [{port: 30800, name: http, protocol: TCP}]
chat-ui 里 VLLM_URL=http://vllm-upstream.chat.svc.cluster.local:8000/v1 —— 业务代码完全看不出后端跨 WAN,迁移到 mesh 或同集群只改 Endpoints。
E.1 真坑:Headless Service 不做 port redirect
第一版想省 kube-proxy 一层用了 clusterIP: None(headless):chat-ui resolve vllm-upstream:8000 → DNS 直接返回 m1 IP 10.0.24.31 → chat-ui 直连 10.0.24.31:8000 → 连错端口(tunnel 在 :30800 不在 :8000)。
排查半小时才意识到:headless Service 只做 DNS round-robin,不做 port 映射,targetPort 字段被忽略。改回默认 ClusterIP,让 kube-proxy 做 8000 → 30800 的 DNAT 才对。
E.2 真坑:Service 和 Endpoints 的 port name 必须一致
K8s 用 port name 把两边对齐:
Service: ports: [{port: 8000, targetPort: 30800, name: http}]
Endpoints: ports: [{port: 30800, name: http}] # 必须匹配
漏写 name 或两边名字不一样 → kube-proxy 不知道转到 Endpoints 的哪个端口 → Service 看着 healthy 但全 timeout。
E.3 真坑:ArgoCD 默认 exclude Endpoints
ArgoCD 默认认为 Endpoints 是 Service 自动生成的从属资源,sync 时报 ExcludedResourceWarning: /Endpoints vllm-upstream is excluded。实际行为:首次 apply 会创建,但 ArgoCD 不 track。手动 kubectl apply 一次后续 sync 不会删它。要让 ArgoCD 完全管理,改 argocd-cm 把 Endpoints 从 resource.exclusions 去掉。
E.4 真坑:SSH tunnel 死了 chat-ui 全 timeout
SSH tunnel 不是 K8s 一等公民,无健康检查无自动重连。tunnel 被 OOM kill 或网络抖动断开时,Endpoints 10.0.24.31:30800 还在,K8s 认为 Service healthy,但所有请求 Connection refused —— K8s 层面完全看不出问题。
学习集群对策:systemd + autossh 替代裸 ssh:
# /etc/systemd/system/vllm-tunnel.service
[Service]
ExecStart=/usr/bin/autossh -M 0 -N -p 15128 \
-o ServerAliveInterval=15 -o ServerAliveCountMax=3 \
-L 0.0.0.0:30800:<vllm-svc-ip>:8000 root@gpu1
Restart=always
RestartSec=5
生产对策:换 WireGuard / Tailscale(L3 隧道 + CNI 直接路由对端 Pod CIDR)或 Cilium ClusterMesh(K8s 原生多集群 Service 发现)。SSH tunnel 学习场景能用,不要带进生产。
F. 端到端验证 + 业务可观测
F.1 链路 9 项验证
按顺序逐项验,任一项 fail 都得回到对应 day 修复,不要跳过往下走:
| 验证项 | 期望 |
|---|---|
git push 后 Gitea 看到 commit | commit hash 在 web |
| Jenkins Build History | SUCCESS,约 6 min |
Harbor /v2/bootcamp/chat-ui/tags/list | tag 含新 sha |
argocd app get chat-ui | Synced / Healthy |
kubectl -n chat get pod | 1/1 Running |
Pod 内 curl vllm-upstream:8000/v1/models | 返回 qwen2.5-3b |
浏览器开 <node-ip>:30810 | chainlit UI 流式回复 |
Prometheus /targets | chat-ui target up |
curl <pod-ip>:9100/metrics | grep chat_ | 4 个 metric 在 |
F.2 真坑:metric 不增长
ServiceMonitor up、metric 端点 200 OK,但 chat_requests_total 一直 0。根因:chainlit 是 WebSocket 应用,只有 @cl.on_message handler 真正跑了 metric 才 +1。curl HTTP 路径绕过了 WebSocket,metric 不触发。
正确触发:浏览器开 <node-ip>:30810 发消息,或用 chainlit-client Python 库连 WebSocket 压测。业务 metric 验证一定要走真实业务路径,绕开业务逻辑的 curl 是自欺欺人。
F.3 业务面板的 4 个核心查询
Grafana LLM 业务面板的最小集:
sum(rate(chat_requests_total[1m])) by (status) # QPS
histogram_quantile(0.95, sum(rate(chat_first_token_latency_seconds_bucket[5m])) by (le, model)) # TTFT p95
sum(rate(chat_tokens_total{kind="completion"}[1m])) by (model) # token throughput
sum(rate(chat_requests_total{status="error"}[5m])) / sum(rate(chat_requests_total[5m])) # 错误率
加上 Day 9 的 DCGM DCGM_FI_DEV_GPU_UTIL 和 DCGM_FI_DEV_FB_USED,业务 + GPU 第一次能在同一个面板里看 —— 业务 QPS 涨的时候 GPU util 跟不跟得上一目了然。
整体回顾
走完这一天,chat ns 里:chat-ui Deployment 1/1 + NodePort Service + vllm-upstream ClusterIP Service + 手动 Endpoints + ServiceMonitor;argocd ns 里 chat-ui Application Synced / Healthy。
从 git push 到生产服务可用 < 10 分钟:commit → Gitea → Jenkins 拉 Kaniko build → push Harbor → 改 deploy repo image tag → ArgoCD 检测 drift 自动 sync → 主集群 chat-ui 滚动更新 → 流量走 Service/Endpoints 跨 WAN 到 GPU 集群 vLLM。
面试常见题
Q1:从 git commit 到生产 Pod 跑起来的完整 GitOps 链路?
8 跳:push 到源码 repo → Jenkins SCM watch 触发 build → K8s agent 拉 Kaniko Pod → push Harbor(:git-sha + :latest)→ 改 deploy repo 的 image tag commit → ArgoCD 检测 drift → ServerSideApply 到目标集群 → Deployment 滚动更新 + readinessProbe 过后接流量。
深问点:源码 repo 和部署 repo 为什么要拆开?—— 源码改动频繁需要 CI 测试;部署 repo 是声明式期望状态,只有 tag bump 或 config 变动,可以加 PR review 卡点。合一会让每次 push 都触发 ArgoCD sync,破坏 CD 的可审计性。
Q2:主集群没 GPU,调远程 GPU 集群的 LLM endpoint 工程方案怎么选?
| 方案 | 优点 | 缺点 | 适合 |
|---|---|---|---|
| SSH reverse tunnel + 手动 Endpoints | 0 网络改造 | 进程级、无 K8s 健康检查 | PoC / 学习 |
| WireGuard / Tailscale | L3 隧道,CNI 直接路由对端 Pod CIDR | 需运维密钥 | 中小生产 |
| Cilium ClusterMesh | K8s 原生多集群 Service 发现 | 两边都要 Cilium | 多区 / 多云 |
| 公网 LB + mTLS | 简单粗暴 | 公网暴露,需 WAF | 跨组织调用 |
实战教训:SSH tunnel 死了 K8s 看不出问题(Endpoints 还在但请求全 timeout),生产至少 WireGuard 起步。
Q3:chainlit / Streamlit / Gradio 给 LLM 做 UI 怎么选?
chainlit 是 chat 专用(@cl.on_message + WebSocket + 流式 token 原生 + cl.user_session 多轮),chat 产品首选;Streamlit 是 dashboard 通用(rerun 模型对 streaming 不友好,要手动 placeholder 刷新),适合内部数据看板;Gradio 是 demo 专用(HTTP-only),适合模型 demo / HF Spaces。LLM 业务产品级首选 chainlit。
Q4:LLM 业务的 timeout / retry / streaming 怎么处理?
3 个原则:
- timeout 分两段 —— connect timeout 5s 必须握上;overall timeout 不要设,让 stream 自然结束或客户端取消。LLM generation 时间不可预测,固定 30s overall 会切断长回答
- retry 只在 connect 层 —— 连接失败 / 5xx / 429 可重试;已开始 stream 中途断开不要重试,会让用户看到回答重复或前后矛盾
- client cancel 必须传到 vLLM —— WebSocket 断开时
asyncio.CancelledError一路传到client.chat.completions.create,让 vLLM 释放 KV cache 槽位。否则一个 32k context 占着 slot 到max_tokens跑完,TPS 暴跌
深问:流式怎么取 prompt/completion tokens?OpenAI compat 默认流式不返回 usage,必须 stream_options={"include_usage": True},最后一个 chunk 才带 usage(vLLM 0.6+ 支持)。
Q5:生产 AI 应用的容量规划怎么做?
3 维度叠加:GPU 并发上限(单 vLLM max concurrent ≈ available_gpu_mem / kv_cache_per_seq,Qwen2.5-3B + A800 2g.10gb 约 30-50 并发,超了 TTFT 飙升)+ token 预算(p95 input + p95 output × 目标 QPS,对照 vLLM TPS 1500-2500 tokens/s 算实例数)+ WebSocket 连接(chainlit 每用户一条长连接,100+ 并发需 HPA + Redis session store 替代 in-memory)。
扩容信号优先级:TTFT p95 > queueing time > GPU util。GPU util 50% 看着很闲但 TTFT 已被 KV cache 排队拖垮的场景常见,别只看利用率。
下一步
Day 1-11 把单业务从基础设施到 GitOps 走通。Day 12 进入多业务 / 多租户场景:namespace 配额(ResourceQuota + LimitRange)、租户隔离(NetworkPolicy + RBAC + image scan gate)、Argo Rollouts canary / blue-green、SLO 驱动的发布门禁(Flagger + Prometheus query)。