学习目标
学完本章你应该能够:
- 开发一个能"补位"的 SpotPool Operator:用
RequeueAfter周期巡检,把被云厂商随时释放的 GPU 竞价实例维持在一个声明的最小/最大实例数区间内,核心是接入腾讯云 CVM SDK 做创建/查询/终止,并通过 CRD 的status记录当前实例状态。 - 在实战一基础上深入一层,做一个基于多家网关负载均衡的大模型私有部署 Operator:用自定义虚拟机镜像让新实例启动即自带 Ollama 与模型,用 OpenAI 兼容的
/v1/chat/completions接口做推理,并借助 Kong 网关的 Admin API 动态同步 upstream(把新实例公网 IP 加进负载均衡表、把被释放实例剔除)。 - 开发一个基于大模型的日志流监测 Operator(log-pilot):周期性地从 Loki 按时间窗口取日志,命中关键字后交给 LLM 分析错误等级、给出建议,遇到严重问题自动把建议发到飞书。
- 开发一个基于运维专家知识库的故障排查 Operator(rag-log-pilot):直接从命名空间下的 Pod 取日志,转发给配置了内部专家知识库的 RAGFlow(而不是裸调大模型),用"基于你业务的知识解答"替换"模型自己泛泛而谈",再把答案发到飞书。
- 说清 Assets/竞价的成本账,理解为什么 AIOps 里"基于竞价实例做自托管大模型"是高性价比选型,以及它带来的"随时被回收"问题是如何被 Operator 自动闭环的。
前置知识:
- Operator 基本结构、
Reconcile机制、RequeueAfter(来自 Operator 基础章节) - Go + kubebuilder 的基本使用(
kubebuilder init / create api / make那套流程) - 基本 K8s 概念:CRD、Pod、namespace、RBAC、label selector
- 大模型的 OpenAI 兼容聊天接口、飞书机器人 webhook 的简单 POST 用法
本章你会动手做的事:
- 用 kubebuilder 脚手架初始化
spot-poolOperator,定义带"最小/最大实例数"的 CRD,接入腾讯云 CVM SDK,跑通"检测缺少实例 → 自动补开一台"的闭环。 - 制作用于 Ollama 的虚拟机镜像、用 docker compose 起一个监听公网的 Kong 网关,改造实战一的 Operator,让新开的 GPU 实例自动进入 Kong 的 upstream,实现多实例推理负载均衡。
- 初始化 Loki + Promtail + 一个会打印日志的示例应用,开发
log-pilot,观察它把命中关键字的日志发给 LLM 并推送到飞书。 - 在 RAGFlow 里建"运维专家知识库"、上传内部故障解决方案,开发
rag-log-pilot,观察它基于知识库回答 Pod 日志中的错误并推送到飞书。
从一个冲动开始:为什么自托管大模型离不开竞价实例
进入实战之前,先算一笔账。因为后面四个实战全都是围绕"GPU + 竞价实例 + 用 Operator 自动运维"这一件事展开的。
| 计费方式 | 小时价 | 逻辑 |
|---|---|---|
| 按量计费 | 约 8.68 元/小时 | CPU、内存、GPU 全按正价 |
| 竞价实例(Spot) | 约 1.71 元/小时 | CPU/内存/GPU 按折扣价,磁盘、带宽等其他部分正价 |
以某个 GN7 实例(T4 显卡、16G 显存、8 核 32G 内存)为例,总价几乎能便宜到 八分之一。如果你要做大模型自托管,或者训练/推理要长期占用一群 GPU,竞价实例基本是"成本最优解"。云厂商把暂时闲置的库存以一个极低价格租给你,本质是填它的空仓位——作为交换,你接受"随时可能被回收"。
这就是问题所在:竞价实例随时会被云厂商释放。当市场上有更高优先级(出价更高、库存竞争)的建机请求进来,云厂商就可能把你手上这台 GPU 收回去,导致你的推理服务或训练任务直接中断。手动盯着控制台去补机器是不现实的——所以第一件事,我们要写一个 Operator 来自动维护竞价实例资源池。
实战一:开发 Operator 调度 GPU 竞价实例资源池
整体架构
Operator 运行在 K8s 集群里,通过 watch 一个叫 SpotPool 的自定义资源来控制"竞价实例计算资源池"。SpotPool 的 spec 声明了允许的最小实例数和最大实例数,剩下的参数(地域、可用区、实例类型、子网、VPC、安全组、镜像、扣费类型等)其实是"开通腾讯云虚拟机"所需的参数。
当某台 GPU 竞价实例被云厂商自动释放后,Operator 会以一个固定周期监听资源池里的实例数量是否符合预期;不符合(也就是发现少了一台),它就调用腾讯云 SDK 重新创建一台补上,把资源池维持在三台左右。
flowchart LR
O[SpotPool Operator
运行在 K8s] -->|watch SpotPool CRD| C[(SpotPool CRD
声明 min/max 实例数)]
O -->|周期 RequeueAfter 巡检| Q[查询当前实例数]
Q --> N{在 min/max 区间?}
N -->|少于 min| K[调用腾讯云 SDK 创建实例]
N -->|多于 max| T[调用腾讯云 SDK 终止实例]
N -->|符合| W[等待下一轮]
K --> C
T --> C前置准备与脚手架
写这个 Operator 前需要:通过 kind / K3S 创建本地集群、安装 kubebuilder、准备 Go 环境。然后初始化工程(整体流程就是 kubebuilder 标准四步):
mkdir demo1 && cd demo1
go mod init spot-pool
kubebuilder init --domain example.com
# 创建一个 API:group = tcluster / iac,version = v1,kind = SpotPool
kubebuilder create api --group iac --version v1 --kind SpotPool \
--resource --controller
CRD 设计
SpotPool 的字段比较多,本质上就是"开通腾讯云虚拟机所需的参数"再加上"我们自己要用的最小/最大实例数"。
type SpotPoolSpec struct {
// 初始化腾讯云 SDK 的密钥
SecretID string `json:"secretId"`
SecretKey string `json:"secretKey"`
Region string `json:"region"` // 地域,如 ap-singapore
AvailableZone string `json:"availableZone"` // 可用区,要跟 VPC/子网匹配
InstanceType string `json:"instanceType"` // 实例类型,如 GN7 系带 T4 GPU
// 网络相关
SubnetId string `json:"subnetId"`
VPCId string `json:"vpcId"`
SecurityGroupId string `json:"securityGroupId"` // 注意复数 groups,敲错会报错
ImageId string `json:"imageId"` // 使用腾讯云官方公开镜像 ID
InstanceChargeType string `json:"instanceChargeType"` // 计费类型,这里用竞价实例 spot
// 我们自己关心的控制参数
MinInstances int32 `json:"minInstances"` // 最小实例数
MaxInstances int32 `json:"maxInstances"` // 最大实例数
}
type SpotPoolStatus struct {
Size int32 `json:"size"` // 当前实例数
Conditions []metav1.Condition `json:"conditions"`
Instances []InstanceInfo `json:"instances"` // 实例列表
}
type InstanceInfo struct {
InstanceId string `json:"instanceId"`
PublicIp string `json:"publicIp"`
}
写好之后跑 make generate && make manifests,把 CRD 和生成的代码都搞定。
接腾讯云 SDK:创建 CVM 客户端
后面创建/查询实例都依赖一个初始化好的 CVM 客户端。用 secretId / secretKey 做 credentials,指定 region 和签名方式(HmacSHA1)创建 cvm.NewClient:
func createCVMClient(sp v1.SpotPool) (*cvm.Client, error) {
credential := common.NewCredential(sp.Spec.SecretID, sp.Spec.SecretKey)
client, err := cvm.NewClient(credential, sp.Spec.Region,
&sdkprofile.ClientProfile{SignMethod: "HmacSHA1"})
if err != nil {
return nil, err
}
return client, nil
}
查询并统计运行中的实例
核心工具方法是 getRunningInstanceIds:发起 DescribeInstances 查询,只统计处于 running / pending 状态的实例,同时检查公网 IP 是否已经分配(公网 IP 是异步分配的,可能刚创建完还没有,需要靠重试)。
func getRunningInstanceIds(sp v1.SpotPool) ([]string, error) {
client, err := createCVMClient(sp)
if err != nil {
return nil, err
}
request := cvm.NewDescribeInstancesRequest()
// 镜像、可用区等过滤条件从这里带进去
response, err := client.DescribeInstances(request)
if err != nil {
return nil, err
}
var running []string
for _, instance := range response.Response.InstanceSet {
state := *instance.InstanceState
// 只看运行中 / 开通中 / 启动中的实例,跳过其它状态
if state != "RUNNING" && state != "PENDING" && state != "STARTING" {
continue
}
// 公网 IP 还没分配就返回错误,交给上层稍后重试
if *instance.PublicIpAddresses[0] == "" {
return nil, fmt.Errorf("public ip not ready, retry later")
}
running = append(running, *instance.InstanceId)
}
return running, nil
}
Reconcile 主逻辑:compare 出缺失或超员
类比:这不像是点名"张三在不在、李四在不在"的逐一核对,更像仓库盘库时只数"总共多少箱"——少了一箱就补,多了就退,只要总量落在合理区间,至于具体是哪一箱被换掉,没人关心。
对应到工程里就是:Reconcile 拿腾讯云当前的运行实例数 runningCount,和 CRD 声明的 min / max 比大小——runningCount < min 就补开差量,runningCount > max 就终止差量,在区间内就什么都不做。我们只做"数量对齐",不做"实例逐一对比",因为资源池的核心诉求是凑够数。
Reconcile 拿到腾讯云当前的运行实例数后,和 CRD 声明的 min / max 做对比,决定要创建还是终止。注意这里是"数量对齐"而非"逐一对比",因为我们只关心资源池凑够数。
func (r *SpotPoolReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
var sp iacv1.SpotPool
if err := r.Get(ctx, req.NamespacedName, &sp); err != nil {
return ctrl.Result{}, client.IgnoreNotFound(err)
}
runningIDs, err := getRunningInstanceIds(sp)
if err != nil {
// 查询失败(比如公网 IP 还没分配),10 秒后重试
return ctrl.Result{RequeueAfter: 10 * time.Second}, nil
}
runningCount := len(runningIDs)
switch {
case runningCount < int(sp.Spec.MinInstances):
delta := int(sp.Spec.MinInstances) - runningCount
log.FromContext(ctx).Info("need to create instances", "count", delta)
if err := runInstance(sp, int32(delta)); err != nil {
return ctrl.Result{RequeueAfter: 40 * time.Second}, nil
}
case runningCount > int(sp.Spec.MaxInstances):
delta := runningCount - int(sp.Spec.MaxInstances)
log.FromContext(ctx).Info("too many instances, terminate", "count", delta)
if err := terminateInstance(sp, int32(delta)); err != nil {
return ctrl.Result{RequeueAfter: 40 * time.Second}, nil
}
}
// 无论如何都要把最新实例状态写回 status,供观测
_ = r.Status().Update(ctx, &sp)
// 40 秒后再来一轮,这就是"周期巡检"的来源
return ctrl.Result{RequeueAfter: 40 * time.Second}, nil
}
创建实例:RunInstances
runInstance 构造 RunInstancesRequest,把 CRD 里的镜像、可用区、计费类型、实例数、实例名、公网带宽、登录密码、安全组、系统盘、VPC/子网全部填进去:
func runInstance(sp iacv1.SpotPool, count int32) error {
client, err := createCVMClient(sp)
if err != nil {
return err
}
request := cvm.NewRunInstancesRequest()
request.ImageId = &sp.Spec.ImageId
request.Placement = &cvm.Placement{Zone: &sp.Spec.AvailableZone}
request.InstanceChargeType = &sp.Spec.InstanceChargeType
request.InstanceCount = &count
// 实例名带时间戳,方便识别是新补出来的
name := fmt.Sprintf("spot-%d", time.Now().Unix())
request.InstanceName = &name
request.InstanceType = &sp.Spec.InstanceType
// 公网:按流量计费,带宽 100M,需要公网 IP
request.InternetAccessible = &cvm.InternetAccessible{
InternetChargeType: strPtr("TRAFFIC_POSTPAID_BY_HOUR"),
InternetMaxBandwidthOut: intPtr(100),
PublicIpAssigned: boolPtr(true),
}
// 登录与安全组
request.LoginSettings = &cvm.LoginSettings{Password: strPtr("password123")}
request.SecurityGroupIds = []*string{&sp.Spec.SecurityGroupId}
// 系统盘 100G 高性能 SSD
request.SystemDisk = &cvm.SystemDisk{
DiskType: strPtr("CLOUD_BSSD"), DiskSize: intPtr(100),
}
// 网络归属:VPC + 子网
request.VirtualPrivateCloud = &cvm.VirtualPrivateCloud{
VpcId: &sp.Spec.VPCId, SubnetId: &sp.Spec.SubnetId,
}
response, err := client.RunInstances(request)
if err != nil {
return err
}
for _, id := range response.Response.InstanceIdSet {
fmt.Println("created instance:", *id)
}
return nil
}
终止实例:TerminateInstances
terminateInstance 用来把超出的实例"退还",也就是先 query 出当前运行实例,再对其中的若干台执行终止:
func terminateInstance(sp iacv1.SpotPool, count int32) error {
client, err := createCVMClient(sp)
if err != nil {
return err
}
runningIDs, err := getRunningInstanceIds(sp)
if err != nil {
return err
}
// 只终止前 count 台
request := cvm.NewTerminateInstancesRequest()
request.InstanceIds = runningIDs[:count]
if _, err := client.TerminateInstances(request); err != nil {
return err
}
return nil
}
securityGroupId(复数)拼错成 securityGroupId / securityGroupsId 会在泛型渲染和部署时报错;② VPC 所在区域和实例所在可用区必须匹配,否则报"VPC 所在区域实例所在区不匹配"——这是排查半天最常见的原因,务必在腾讯云控制台确认子网的可用区跟 availableZone 一致;③ 竞价实例有配额限制,创建过多会报"竞价实例超限,请提交工单提升配额",说明请求是成功的,不代表逻辑错了。运行与验证
把样例 config/samples 填好真实参数后,用 make install 把 CRD 装进集群、kubectl apply -f config/samples/... 创建 SpotPool、最后 make run 在本地跑起 Operator。关键验证场景是手工模拟释放一台:
- 初始
min=2, max=2,Operator 发现没有实例,自动创建两台。 - 在腾讯云控制台手动销毁其中一台(模拟云厂商回收竞价实例)。
- 等一个周期(默认 40 秒的 RequeueAfter),Operator 通过查询发现只有 1 台、少于 min,便自动再补开一台。
整个"自动补位"闭环跑通,实战一就完成了。
实战二:Operator 实现大模型私有部署(Ollama + Kong 负载均衡)
为什么私有部署但又要多实例
实战一我们把 GPU 竞价实例维持在了一个稳定的资源池里,但这只是第一步——我们还没把大模型推理服务真正跑起来、利用起这一池 GPU。这一节要解决的是"怎么在私有环境里做高吞吐的推理"。
- 用 Ollama:它提供一个
/v1/chat/completions接口,兼容 OpenAI 的数据结构。这意味着你引入 openai / langchain 这类 SDK 后,只需把请求的base URL换成 Ollama 的地址,model、messages这些结构都不用改,就能直接做推理。 - 为什么要负载均衡:单实例推理慢,尤其在响应时延要求高的私有化场景。多开几台 GPU 实例,前置一个网关做加权轮询,把推理请求分发到多台机器,即可提升吞吐:请求 A 打到 GPU1、请求 B 打到 GPU2……不需要像单实例那样排队。
这时候竞价实例"随时可能被回收"的老问题又回来了:假设负载均衡表里挂着 GPU3,而 GPU3 恰好被云厂商释放了,那么网关把请求转发到它的 IP 时必然直接报错、拿不到正确结果。所以我们的 Operator 除了维持资源池数量,还要实时把 GPU 实例的公网 IP 和网关的 upstream 做自动同步——新开一台就加进负载均衡表,释放一台就把它剔除。
flowchart LR
C[外部调用 /v1/chat/completions] --> K[Kong 网关
加权轮询负载均衡]
K --> G1[(GPU1 Ollama:8080)]
K --> G2[(GPU2 Ollama:8080)]
K --> G3[(GPU3 Ollama:8080)]
O[Operator] -->|创建/释放实例| G3
O -->|同步 upstream:新增/剔除 target| K
G1 -.->|被释放?剔除 target| K两个"让新实例开箱即用"的准备
推理服务裸装会很慢:装 Ollama、下载模型(动辄几 GB)、启动服务,一个实例串下来要十几分钟。两个关键工程手段解决它:
1) 自定义虚拟机镜像让实例"启动即 ready"。 我们先用一台 GPU 实例装好 Ollama、配好自启动、预下载好要用的模型,再基于它做一个自定义虚拟机镜像。之后 Operator 补开新实例时直接指定这个镜像 ID,启动完自带 Ollama + 模型,且监听在 8080 端口,直接就能接后端流量。
2) Ollama 自启动。 Ollama 本身是个 CLI 程序,用官方推荐的 systemd service 方式实现开机自启:创建 service 文件、systemctl daemon-reload && systemctl enable ollama && systemctl start ollama,让它随系统启动并常驻监听推理端口。
我们可以把这两件事打包成一个 shell 脚本,一条命令跑完"安装 Ollama + 配置自启动 + 预加载模型(如 qwen2:0.5B 小模型测试用,生产可用 qwen2:7B)+ 制作自定义镜像",最后输出镜像 ID。示例脚本骨架:
#!/bin/bash
# 1. 用 Ollama 官方脚本安装
curl -fsSL https://ollama.com/install.sh | sh
# 2. 配置 systemd 实现自启动
cat > /etc/systemd/system/ollama.service <<'EOF'
[Unit]
Description=Ollama
After=network-online.target
[Service]
ExecStart=/usr/local/bin/ollama serve
Restart=always
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload && systemctl enable ollama && systemctl start ollama
# 3. 预加载模型(这一步会联网下载,比较耗时)
ollama pull qwen2:0.5b
ollama create qwen2:0.5b # 或直接使用已拉取模型
# 4. 把当前系统做成自定义镜像,输出 imageId
echo "完成:请把当前实例导出为自定义镜像,拿到 imageId"
部署 Kong 网关
选 Kong 的一个重要原因是它提供了 Admin API,可以动态控制 upstream / target。我们用一个 shell 脚本安装 docker、把 Kong 的 npm / docker-compose 配置拷过来、修改监听参数后 docker compose up 起来。
有一个关键必改项:要让 Operator 能通过公网控制 Kong,必须让 Kong Admin API 监听在 0.0.0.0,这样 Operator 才能用公网 IP 去调用它的控制接口。
CRD 增加 Kong 网关地址
在实战一的基础上,给 SpotPool 的 spec 加一个字段,让 Operator 知道往哪找 Kong:
KongGatewayIp string `json:"kongGatewayIp"` // Kong 网关的公网 IP
Kong 的端口约定:Admin API 在 8001(控制用),网关对外流量在 8000。
新增业务逻辑:同步 Kong 的 upstream
核心方法 syncKongUpstream,目标是让 Kong upstream(ollama-upstream)里的 target 列表与当前实例的公网 IP 列表保持一致。流程是:
1. 构造 Kong Admin API URL:http://<kongGatewayIp>:8001/upstreams/ollama-upstream/targets
2. GET 当前所有 target,解析出 {targetId, host(=实例IP:8080)}
3. 遍历实例列表,构造目标 IP 集
4. 对"在 target 里但不在实例里"的 target → DELETE 掉(实例被释放了)
5. 对"在实例里但不在 target 里"的实例 → POST 新增 target(新补的实例加进负载均衡表)
func syncKongUpstream(sp iacv1.SpotPool) error {
apiBase := "http://" + sp.Spec.KongGatewayIp + ":8001/upstreams/ollama-upstream/targets"
// 1. 取当前 targets
resp, err := http.Get(apiBase + "/list")
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("list targets: %d", resp.StatusCode)
}
body, _ := io.ReadAll(resp.Body)
var targetResp struct {
Data []struct {
ID string `json:"id"`
Host string `json:"host"`
} `json:"data"`
}
if err := json.Unmarshal(body, &targetResp); err != nil {
return err
}
// 2. 从实例状态构建目标 IP 集(Ollama 监听在 8080)
instanceSet := map[string]bool{}
instanceList := map[string]string{} // instance publicIp -> target host
for _, inst := range sp.Status.Instances {
host := fmt.Sprintf("%s:8080", inst.PublicIp)
instanceSet[host] = true
instanceList[inst.PublicIp] = host
}
// 3. 剔除:target 存在但实例已释放
for _, t := range targetResp.Data {
if !instanceSet[t.Host] {
delURL := fmt.Sprintf("%s/%s", apiBase, t.ID)
req, _ := http.NewRequest(http.MethodDelete, delURL, nil)
client := http.DefaultClient
if resp, err := client.Do(req); err == nil {
resp.Body.Close()
}
}
}
// 4. 新增:实例存在但 target 里没有
for _, inst := range sp.Status.Instances {
host := fmt.Sprintf("%s:8080", inst.PublicIp)
exist := false
for _, t := range targetResp.Data {
if t.Host == host {
exist = true
}
}
if !exist {
postData, _ := json.Marshal(map[string]string{"target": host})
http.Post(apiBase, "application/json",
strings.NewReader(string(postData)))
}
}
return nil
}
初始化 Kong 的 Service / Route / Upstream
首次运行时 Kong 里可能什么都没有。我们需要一个 checkKongProxy + createKongServiceAndRouteAndUpstream 方法做初始化:
- upstream:
ollama-upstream(前面同步 target 都是挂在它下面的)。 - service:
ollama-chat,host指向ollama-upstream,protocol = http,path = /v1/chat/completions。 - route:匹配某个
/ollama/chat路径,把外部请求路由进 service。
sequenceDiagram
participant O as Operator
participant K as Kong Admin API :8001
O->>K: GET /services 检查是否存在 ollama-chat
alt 不存在
O->>K: POST /upstreams 创建 ollama-upstream
O->>K: POST /services 创建 ollama-chat(host=ollama-upstream, path=/v1/chat/completions)
O->>K: POST /services/ollama-chat/routes 创建路由匹配 /ollama/chat
end
O->>K: POST/DELETE /upstreams/ollama-upstream/targets 同步实例 IPReconcile 里先做初始化检查(失败则 40 秒后重试),再做 upstream 同步。
运行与端到端验证
把 CRD 的 imageId 换成"带 Ollama + qwen2 模型"的自定义镜像,kongGatewayIp 填好,重新 make install / apply sample,然后 make run。打开 Kong 管理后台(Web UI)可看到它自动创建了 route;等 Operator 补开两台实例后,upstream 里会自动出现两个 target(对应两台 GPU 实例的 xxx.xxx:8080)。
用 curl 直接打网关的对外端口验证推理:
curl -X POST http://<kong-ip>:8000/ollama/chat \
-H "Content-Type: application/json" \
-d '{"model": "qwen2:0.5b",
"messages": [{"role": "user", "content": "你好,你是谁"}]}'
stream: true)。想直接拿完整结果,把 body 里的 stream 设成 false。然后手工释放一台实例,等 Operator 下一轮 reconcile 之后,刷新 Kong upstream 会发现被释放那台的 target 被自动剔除、并换成新补实例的 target——整个"资源池补位 + 负载均衡表同步"闭环跑通。有个可以观察到的现象:同步并不是即时的——实例被释放后,它的 target 可能要等 Operator 下一次 reconcile 才被剔掉,期间这个 IP 仍然挂在负载均衡表里。两个改进方向:① 把 RequeueAfter 调短;② 更优的做法是——一旦发现某个 IP 已不可达,就把它的 target 权重改为 0(weight=0),这样网关就不会再把流量打给它,等你确认再删除。
实战三:开发基于 LLM 的日志流监测 Operator
思路:日志 → LLM → 飞书
实战三做一个开箱跑路的 AIOps 报警器:周期性地从 Loki 取出日志,命中关键字后交给 LLM 分析,发现问题直接扔到飞书。它叫 log-pilot(日志助理)。整体链路:
Loki 日志系统 ──按时间窗口持续取日志──▶ 命中关键字(error/warning) ──▶ LLM 分析 ──▶ 严重问题 ──▶ 飞书通知
│
└── 不是所有日志都发给大模型,只按级别过滤 ──┘
CRD 设计
LogPilot 的 spec 里需要这些参数,status 里记录上一次取日志的时间戳(用于窗口续传):
type LogPilotSpec struct {
LokiUrl string `json:"lokiUrl"` // Loki 地址
LokiPromQL string `json:"lokiPromQL"` // 查日志的 LogQL 语句,如查 payment 服务
LLMEndpoint string `json:"llmEndpoint"` // LLM 的 OpenAI 兼容接口地址
LLMToken string `json:"llmToken"` // API Key(用自托管 Ollama 可留空)
LLMModel string `json:"llmModel"` // 模型名,如 gpt-4o
FeishuWebhook string `json:"feishuWebhook"` // 飞书机器人 webhook
}
type LogPilotStatus struct {
PreviousTimestamp string `json:"previousTimestamp"` // 上一次取日志的时间
}
Reconcile:按时间窗口去 Loki 取日志
类比:log-pilot 就像值班室的"闹钟巡检员"。它每隔 10 秒翻一次 Loki 的日志本,只把写着 error / warning 的页扯下来递给懂运维的专家(LLM),专家说"这条严重、该惊动人了",它就立刻 push 到你群里的飞书机器人;不严重的就记进本子(
previousTimestamp),下次从上次记到的地方接着翻,绝不重复也绝不漏页。
对应到工程里就是:Operator 用 RequeueAfter 周期性地跑 Reconcile,时间窗口的下界是 status.previousTimestamp(续传),命中关键字才交给 LLM 分析,模型判断严重时调 sendFeishuAlert 发到飞书 webhook。
这张图在讲 log-pilot 一个完整巡检周期的决策闭环:
flowchart LR
S[Reconcile 每10秒一轮] --> W[按时间窗口 previous,now
从 Loki 查日志]
W --> X{命中 error 关键字?}
X -->|无命中日志| NS[更新 previousTimestamp
等下一轮再查]
X -->|有日志| LLM[OpenAI 兼容接口分析
让模型判断严重程度]
LLM --> F{返回内容含"飞书"?}
F -->|是| FEI[POST 飞书 webhook 告警
推送修复建议]
F -->|否| NS
NS --> S每个 reconcile 周期计算一个时间窗口 [previous, now],用 previousTimestamp 续传(避免漏日志)。
now := time.Now()
previous := time.Now().Add(-5 * time.Second) // 默认最近 5 秒
if sp.Status.PreviousTimestamp != "" {
if ts, err := strconv.ParseInt(sp.Status.PreviousTimestamp, 10, 64); err == nil {
previous = time.Unix(0, ts*int64(time.Millisecond))
}
}
start := previous.UnixNano() // Loki 需要纳秒级时间戳
end := now.UnixNano()
if start > end {
// 时间反了,跳过本轮,10 秒后再试
return ctrl.Result{RequeueAfter: 10 * time.Second}, nil
}
然后用 LogQL 拼出 Loki 的 query_range 查询地址:
lokiURL := fmt.Sprintf("%s/loki/api/v1/query_range?query=%s&start=%d&end=%d",
sp.Spec.LokiUrl, url.QueryEscape(sp.Spec.LokiPromQL), start, end)
logs, err := queryLoki(lokiURL)
queryLoki 发一个 GET,解析返回的 data.result,再判断结果非空才进入后续分析:
func queryLoki(queryURL string) (string, error) {
resp, err := http.Get(queryURL)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("loki status: %d", resp.StatusCode)
}
body, _ := io.ReadAll(resp.Body)
var result struct {
Data struct {
Result []interface{} `json:"result"`
} `json:"data"`
}
if err := json.Unmarshal(body, &result); err != nil {
return "", err
}
if len(result.Data.Result) == 0 {
return "", nil // 没有命中日志,返回空
}
return string(body), nil
}
LLM 分析:让模型当"运维助手"
拿到日志后,调用 OpenAI 兼容接口(这里示例用 OpenAI Go SDK)分析。关键是把 prompt 写清楚:你是谁、日志是什么、要分析什么、什么情况算严重、返回格式。
func analyzeLogsWithLLM(endpoint, token, model, logs string) (analysedResult, error) {
conf := openai.DefaultConfig(token)
conf.BaseURL = endpoint // 重点:替换成我们的 LLM endpoint(可指向自托管 Ollama)
client := openai.NewClientWithConfig(conf)
resp, err := client.CreateChatCompletion(context.Background(),
openai.ChatCompletionRequest{
Model: model,
Messages: []openai.ChatCompletionMessage{
{
Role: "user",
Content: fmt.Sprintf(`你现在是一名运维专家,以下日志是从日志系统里获取到的日志,请分析日志错误等级。
如果遇到严重问题(例如:外部系统请求失败、外部系统故障、致命错误、数据库连接异常),请给出简短的建议。
并且对于你认为严重且需要通知运维人员的,请在内容里返回"飞书"标识。以下为日志:
%s`, logs),
},
},
})
if err != nil {
return analysedResult{}, err
}
content := resp.Choices[0].Message.Content
res := analysedResult{
Analysis: content,
HasError: strings.Contains(strings.ToLower(content), "飞书"),
}
return res, nil
}
注意 HasError 是靠模型返回内容里是否包含"飞书"关键字来判断的——是让 LLM 自己决定"这事要不要惊动运维",我们只做检查。
⚠️ 新手必踩的坑:用关键字判严重非常脆弱。① 这里用
strings.Contains(strings.ToLower(content), "飞书")判断,是因为模型可能写"飞书"也可能写"FS"、大小写还不一定,一旦换了个说法(比如只写"推送告警"不写"飞书")就检测不到、漏报;② 时间窗口也容易反了——若previousTimestamp比当前时间还大(时钟回拨或状态写入出错),start > end,Loki 会查不到或报错,所以代码里要显式判断"时间反了就跳过本轮、10 秒后再试"。这类"靠约定信号串起多系统"的方案,一定要留兜底。
发飞书通知
如果分析结果命中"飞书"标识,就构造一段 text 消息发到飞书机器人的 webhook:
func sendFeishuAlert(webhook, analysis string) error {
message := map[string]interface{}{
"msg_type": "text",
"content": map[string]string{"text": analysis},
}
body, _ := json.Marshal(message)
resp, err := http.Post(webhook, "application/json", bytes.NewReader(body))
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("feishu status: %d", resp.StatusCode)
}
return nil
}
收尾与部署
每次处理完后把它写回 status.previousTimestamp(当前时间),好让下一轮续传不重复不遗漏,最后 RequeueAfter: 10 * time.Second 再查一轮。
sp.Status.PreviousTimestamp = fmt.Sprintf("%d", currentTime)
_ = r.Status().Update(ctx)
return ctrl.Result{RequeueAfter: 10 * time.Second}, nil
部署前需要一个可查的 Loki + 一个会产生日志的示例应用。我们用自带的 IaC(Terraform/Promtail)脚本把 Loki + Promtail 起起来,再部署一个 payment 示例服务(默认几秒打印一条日志),在 Grafana 里确认能搜到业务日志(含 error 行)。然后 make install 装 CRD、apply config/samples 创建 LogPilot、make run 启动,观察它:
- 从 Loki 拿到含 error 的日志;
- 用 LLM 分析后给出建议(如"payment 服务存在错误");
- 把严重告警推送到飞书群。
llmEndpoint 当然可以填第三方的 GPT 服务;但如果你想完全私有化,就填实战二里 Kong 网关暴露的地址(它背后是 Ollama),并且可以不给 llmToken。这也是课后作业一要做的事——把实战二和实战三结合起来。实战四:实现基于运维专家知识库故障排查 Operator
为什么 Need RAGFlow:裸调大模型不够"懂你"
类比:你问一个刚入职的实习生"公司服务器挂了怎么办",他只会背教科书里的通用步骤;但你问一个在这个公司干了五年的老运维,他会直接告诉你"先找小王看内存、再联系小李查依赖"。差别不在双方智商,而在有没有一本你们公司自己的"排障手册"。RAG 就是让大模型在回答问题前去翻一遍这本手册。
对应到工程里就是:不裸调大模型,而是请求配置了"运维专家知识库"的 RAGFlow——把内部故障解决方案写成 markdown 后向量化进知识库,让大模型基于检索到的内部方案作答,把"泛泛而谈"变成"可落地执行"。
实战三里,LLM 是靠"自己训练时学到的知识"来回答的——它并不知道你公司内部的业务约定、负责人是谁、现网有什么历史坑。所以对它来说,“外部系统请求失败"这种回答常常很泛、没法落地执行。
实战四换个思路:不裸调大模型,而是请求配置了"运维专家知识库"的 RAGFlow。我们把内部故障解决方案写进一个知识库(markdown),RAGFlow 基于它做检索增强生成(RAG),把大模型"泛泛而谈"变成"基于你业务知识的具体可执行方案”。
flowchart LR
P[Operator
读取命名空间下 Pod 日志] --> R[RAGFlow API]
R -->|检索增强| K[(运维专家知识库
内部解决方案.md)]
K --> R
R -->|data.answer| O[Operator]
O --> F[飞书机器人]架构上和实战三有点像,但取日志的方式和问的对象都变了:
| 维度 | 实战三 | 实战四 |
|---|---|---|
| 日志来源 | Loki(按 LogQL 查) | 直接读 Pod 的容器日志 |
| 分析引擎 | 裸调 LLM | 调用 RAGFlow(背后接专家知识库) |
| 回答质量 | 依赖模型通用知识 | 基于你内部凭证化方案 |
准备一个"运维专家知识库"
RAGFlow 本身支持 OpenAI / Ollama 等多种模型后端。我们基于 “embedding” 把一份内部运维方案 ops.md 做向量化,上传进知识库。示例内容(用来自定义负责人与处置动作):
# 服务 500 错误
- 网关层 500 错误:先检查上游服务健康,联系小王。
- 服务异常:联系小李检查服务与依赖。
# 数据库连接异常
- 连接池耗尽:检查连接配置,提高 connection limit;联系小王提高内存限制。
# memory 相关
- 内存不足导致 OOM:联系小王提高内存限制。
CRD 设计
实战四的 CRD 叫 RagLogPilot,非常简单,只需要"去哪儿取日志 + 问谁"两组参数:
type RagLogPilotSpec struct {
WorkloadNamespace string `json:"workloadNamespace"` // 取日志的命名空间
RagflowEndpoint string `json:"ragflowEndpoint"` // RAGFlow API 地址(带 /v1/api 后缀)
RagflowToken string `json:"ragflowToken"` // RAGFlow API Key
}
(RAGFlow 用到的两个核心接口:创建对话 返回 data.id 作为 conversation id;获取回复 传 conversation_id + message,从返回的 data.answer 里拿答案。)
从 Pod 取日志并按关键字过滤
因为要读命名空间下所有 Pod 的容器日志,需要在 RBAC 里给 Pod 的 get / list 权限。核心方法 getAllPodLogs:
func getAllPodLogs(ctx context.Context, client client.Client, ns string) (string, error) {
// 1. 列出该 namespace 下所有 Pod
var pods corev1.PodList
if err := client.List(ctx, &pods, client.InNamespace(ns)); err != nil {
return "", err
}
// 2. 对每个 Pod 取容器日志(用 controller-runtime 的 client 直接取)
var sb strings.Builder
for _, p := range pods.Items {
req := client.GetLogs(ns, p.Name, &corev1.PodLogOptions{Container: p.Spec.Containers[0].Name})
rc, err := req.Stream(ctx)
if err != nil {
continue
}
data, _ := io.ReadAll(rc)
rc.Close()
sb.Write(data)
}
// 3. 只保留我们关心的行(含 error 等关键字)
var lines []string
for _, line := range strings.Split(sb.String(), "\n") {
if strings.Contains(strings.ToLower(line), "error") {
lines = append(lines, line)
}
}
return strings.Join(lines, "\n"), nil
}
请求 RAGFlow 做"知识库问答"
拿到日志后调用 queryRagSystem:先创建对话拿到 conversationId,再发消息(拼接日志 + 指示"请基于专家知识库解答,不知道就说不知道",并设置 stream=false 让它处理完再返回),最后取 data.answer:
func queryRagSystem(endpoint, token, logs string) (string, error) {
const kbPrompt = `以下是获取到的应用日志,请基于运维专家知识库进行解答:
%s
请基于我们内部的专家知识库给出解决方案。如果知识库中没有相关信息,请明确回答"不知道"。`
// 1. 创建对话,拿到 conversationId
convURL := endpoint + "/conversation"
convBody, _ := json.Marshal(map[string]string{"user": "aidesk"})
req, _ := http.NewRequest(http.MethodPost, convURL, bytes.NewReader(convBody))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
var cr struct {
Data struct{ ID string `json:"id"` } `json:"data"`
}
_ = json.Unmarshal(b, &cr)
conversationID := cr.Data.ID
// 2. 基于这个对话发消息,取 data.answer
chatBody, _ := json.Marshal(map[string]interface{}{
"conversation_id": conversationID,
"message": fmt.Sprintf(kbPrompt, logs),
"stream": false, // 非流式,等它回答完再取
})
req2, _ := http.NewRequest(http.MethodPost, endpoint+"/chat", bytes.NewReader(chatBody))
req2.Header.Set("Authorization", "Bearer "+token)
req2.Header.Set("Content-Type", "application/json")
resp2, err := http.DefaultClient.Do(req2)
if err != nil {
return "", err
}
defer resp2.Body.Close()
b2, _ := io.ReadAll(resp2.Body)
var ar struct {
Data struct{ Answer string `json:"answer"` } `json:"data"`
}
if err := json.Unmarshal(b2, &ar); err != nil {
return "", err
}
return ar.Data.Answer, nil
}
拿到 answer 后,复用实战三的 sendFeishuAlert 把答案推到飞书(这里为了演示也可以硬编码一个飞书机器人 webhook)。Reconcile 末尾 RequeueAfter: 30 * time.Second 周期性重查。
RAGFlow 侧的配置还原
- 用准备好的 shell 脚本开通一台腾讯云虚拟机,安装 docker、克隆 RAGFlow,用
docker compose启动(镜像较大,首次启动耗时较长)。 - 打开 RAGFlow 管理台 → 注册/登录 → 知识库里新建"运维专家知识库",选中文、团队。
- 必须先配置模型供应商(拿 API Key,如 openai 兼容端点),否则无法做 embedding;配置好后再上传
ops.md,点击启动 embedding,等待解析完成。 - 在"聊天"里新建助理(如"运维专家知识库"),选择该知识库,模型设置选择 OpenAI 兼容的模型(如 gpt-4o-mini)。
- 手动测一下:“服务 500 错误应该找谁?” 应能从知识库返回"联系小王"这类答案,确认知识库生效。
- 在 API 菜单里创建一个 API Key,把它填到
RagLogPilot.spec.ragflowToken(endpoint 记得带/v1/api后缀)。
运行与验证
make install 装 CRD,apply config/samples(workloadNamespace=default,endpoint / token 填好),make run 启动。部署一个会打印 error 的示例应用,观察 Operator:
- 从 default 命名空间的 Pod 拿到含 error 的日志;
- 请求 RAGFlow,基于专家知识库返回答案(如"xxx 错误,联系小李检查服务……"、“内存 OOM,联系小王提高内存限制”);
- 把答案推送到飞书。
/v1/api 之类后缀,否则 404;② 如果报 “message”(单词拼错成 mesage)解析报错,通常是 body JSON 的字段名敲错、解析空指针——先 json.Unmarshal 一格一层打印出来排查(比如 data.answer 的层级)。这些都是实战里真实踩过、靠打印 result 才定位到的。总结
这章用四个 Operator 串起来一条"用 Operator 把 AIOps 做落地"的主线,每节都在前一节基础上叠加一个能力:
- 实战一盘点基础资产:开发
SpotPoolOperator 维护 GPU 竞价实例资源池——用 CRD 声明最小/最大实例数,用RequeueAfter周期巡检,用腾讯云 CVM SDK 自动补位被释放的竞价格 GPU。竞价实例约 8 倍的价格差,是自托管大模型的高性价比选型,代价是"随时被回收",正好让 Operator 来闭环。 - 实战二补上推理能力:用 Ollama(OpenAI 兼容
/v1/chat/completions)做私有推理,用自定义虚拟机镜像让新实例"启动即 ready",用 Kong 网关做多 GPU 加权轮询负载均衡,并让 Operator 实时把实例 IP 同步到 Kong 的 upstream(新实例加入、被释放实例剔除)。 - 实战三让机器学习替你盯日志:
log-pilot周期性从 Loki 按时间窗口取日志,命中关键字交给 LLM 分析错误等级并给建议,严重问题自动推送飞书,status.previousTimestamp保证续传不丢日志。 - 实战四让回答"懂你业务":
rag-log-pilot改从 Pod 读日志,调用配置了运维专家知识库的 RAGFlow,把"模型泛泛而谈"升级成"基于你内部方案的落地处置",再推送到飞书。
这一章真正要体会的是:竞价实例让"算力很贵"这件事便宜了下来,但带来了"随时被回收"的新不确定性;而 Operator 的声明式 + 周期性 reconcile,正是把这种不确定性包起来、让它对外表现为"一个稳定资源池"的关键手段。 把 LLM/RAG 接进来,就是把 AI 的"分析判断"能力也编排进 K8s 控制平面,这就是 AIOps 的核心形态之一。
自测题与动手练习
弱点是随时会被云厂商回收:当库存竞争/更高优先级建机请求进入时,云厂商为了把资源卖给出价更高的人,可能随时把你的竞价实例释放掉,导致业务中断。所以我们用 Operator 自动巡检、补位,把"可能少一台"变成"一旦少一台就自动补一台"。
MinInstances / MaxInstances,Reconcile 里查询腾讯云当前运行实例数 runningCount,然后分三种情况:runningCount < min 就 runInstance 按差值补齐;runningCount > max 就 terminateInstance 按差值退还;符合区间则不动。周期巡检靠
RequeueAfter(本例约 40 秒):Reconcile 不立即完成,而是告诉 controller-runtime 40 秒后再触发一轮,从而形成固定周期的"补位巡检"。实例名带时间戳便于识别新补出的机器。securityGroupId(复数 groups)容易拼错,会在 Schema/部署时报 400;② VPC 所在区域和实例可用区必须匹配,否则报"VPC 所在区域实例所在区不匹配"——要先在控制台确认子网可用区与
availableZone 一致;③ 竞价实例有配额限制,一次性开太多会提示"竞价实例超限,请提交工单提升配额",这是配额问题而非逻辑错误,说明创建请求已经生效;
④ 新实例的公网 IP 是异步分配的,查询时可能还没拿到,需要靠"未分配到公网 IP 就返回错误、稍后重试"来兜底。
/v1/chat/completions 接口,数据结构(model / messages)与 OpenAI 一致。接 OpenAI / langchain 等 SDK 时只需把 baseURL 指向 Ollama,其他代码几乎不用改,Kong 这类网关也能当普通 REST 后端转发。多实例负载均衡:Kong 做加权轮询,把并发推理请求分发到多台 GPU 实例(请求 A→GPU1、请求 B→GPU2……),QPS 远高于单实例排队串行,是私有化高吞吐推理的常用做法。
必须监听在
0.0.0.0,是让 Operator 能通过公网 IP 调用 Admin API来动态同步 upstream;如果只监听本地回环地址,Operator 就够不着它的控制接口了。syncKongUpstream 做的是"CRD 实例列表 ↔ Kong upstream target 对齐":GET 当前 targets → 遍历实例构造目标 IP 集 → 对"在 targets 但实例已释放"的 target 执行 DELETE → 对"在实例但 targets 没有"的 IP POST 新增 target,实现自动同步。status.previousTimestamp 记录上一次取日志的时间,每个 reconcile 周期构造时间窗口 [previous, now],下次从上次结束点继续,从而实现续传、不重不漏;每轮结束把当前时间写回 previousTimestamp,再 RequeueAfter 短周期循环。“要不要发飞书"由 LLM 判断:prompt 里让模型对严重问题(外部系统请求失败/故障、致命错误、数据库连接异常等)返回"飞书"标识,Operator 检查返回内容是否含"飞书"关键字(区分大小写用
ToLower),命中才调 sendFeishuAlert。因为答案来自 RAGFlow 检索到的专家知识库(比如"服务 500 找谁、OOM 谁排查、提高内存上限"),所以给的是"基于你业务的可执行方案",而不是模型泛泛而谈的通用建议。取日志方式也从 Loki 换成了直接读 Pod 容器日志。
动手练习(建议真做一遍):
- 实战一:初始化
spot-poolOperator,填好min=2 / max=2的真实腾讯云参数,跑通"检测到少一台 → 自动补开一台";再手动创建一台,验证runningCount > max时会被自动终止(放宽到max=3)。 - 实战二:用脚本做出"带 Ollama + qwen2"的自定义镜像,部署 Kong(Admin 监听 0.0.0.0),改造 Operator 的
syncKongUpstream;手工释放一台实例,观察下一轮 reconcile 后 Kong upstream 的 target 被替换的时效,并尝试把"坏 IP"的 target 权重改成 0 的优化。 - 实战三选作:修改
log-pilot,把llmEndpoint指向实战二的 Kong 网关地址(背后是 Ollama),去掉llmToken,实现"完全自托管"的日志监测——等于把实战二和实战三串起来。 - 实战四选作:修改
rag-log-pilot的 RAGFlow 模型供应商,配置成 Ollama(OpenAI 兼容后端,RAGFlow 原生支持),让整条"知识库故障排查"也完全走本地自托管推理,验证 RAGFlow 能正常出答案。
本章小结
- 竞价实例 = 高性价比 + 高不确定性:用约 1/8 的价格拿 GPU,代价是"随时被释放"。Operator 的
RequeueAfter周期巡检 + 自动补位,把它包装成"始终稳定"的资源池。 - SpotPool CRD:spec 里放"开通云虚拟机所需的全部参数 + 最小/最大实例数",status 里放当前实例列表,Reconcile 对比查询结果和 min/max 后决定
runInstance还是terminateInstance。排查重点:securityGroupId复数、VPC/可用区匹配、竞价配额、公网 IP 异步分配。 - 多实例私有推理:Ollama 的 OpenAI 兼容
/v1/chat/completions让接入极其方便;用自定义虚拟机镜像让新实例"启动即 ready(自带 Ollama + 模型,监听 8080)";Kong 网关做加权轮询负载均衡,Admin API(监听 0.0.0.0)让 Operator 动态同步 upstream(新实例加入 / 释放实例剔除)。 - log-pilot(LLM 日志监测):按
status.previousTimestamp续传地从 Loki 取日志,命中关键字后交 LLM 分析错误等级与建议(prompt 让模型对严重问题返回"飞书"标识),严重即发飞书,RequeueAfter短周期循环。 - rag-log-pilot(RAG 故障排查):改从 Pod 读日志、按 error 过滤,请求配置了内部专家知识库的 RAGFlow(建会话拿 conversationId → 取
data.answer),把"模型通用回答"升级为"基于你业务落地方案",再推飞书。 - 课后作业方向:把实战二(Ollama 自托管)与实战三/四结合,让日志监测和知识库故障排查也 100% 走本地私有化推理,是完整打通 AIOps 私有栈的一步。