学习目标
学完本章你应该能够:
- 设计一个
AIOpsRuleCRD:在spec里声明监控目标、阈值、LLM 配置与动作白名单,在status里记录最近一次决策以便复盘。 - 画清并实现一个
reconcile主流程:cooldown 限流 → 查 Prometheus → 超阈值调 LLM → 白名单校验 → 执行动作 → 更新status。 - 讲清 LLM 输出的三重防呆:结构化 JSON 约束、白名单校验、置信度阈值,理解"为什么不能让 LLM 直接乱动生产"。
- 用 client-go 实现
restart_pod/scale_up/cordon_node三种修复动作,并说清cordon不等于drain。 - 把 Operator 部署上线(CRD / ServiceAccount / RBAC / Deployment),并用
kubectl验证触发与排查。
前置知识:
- 上一篇笔记:Operator 基本结构、
reconcile机制与 finalizer client-go/ controller-runtime 基本用法- Prometheus PromQL 查询、OpenAI 兼容的 Chat Completions 接口
本章你会动手做的事:
- 起一个 Prometheus 和目标 Deployment,创建
AIOpsRule并用压测制造高延迟,观察 Operator 自动修复。 - 故意让 LLM 返回一个白名单外的动作(如
delete_namespace),验证被安全挡下、只告警不执行。 - 把
maxActionsPerHour调到很小,制造限流,观察phase进入Cooldown而不再盲目执行。
实战目标
上一篇笔记把 Operator 的基本结构和 reconcile 机制过了一遍。本篇要干的活是把 LLM 接进来,做一个简易的 AIOps Operator,让它能:
- 接收用户声明的
AIOpsRule自定义资源。 - 按配置周期性采集目标 Deployment 的状态和指标。
- 指标触发阈值或异常时,调 LLM 做根因分析。
- 按 LLM 给的决策自动执行修复(重启 Pod、扩容、cordon 节点)。
把 AI 推理能力嵌进 Kubernetes 控制平面,这是 AIOps 平台化很重要的一个方向。说实话我第一次做这玩意儿的时候最大的担心是"LLM 乱来怎么办",所以这套设计里安全控制(白名单、限流、审批)比 LLM 本身还重要,后面会专门讲。
AIOps Operator 设计
类比:这个 AIOps Operator 就像一个"值班 SRE 机器人"。你提前告诉它"盯哪个服务、指标超多少算异常、出事允许干哪些操作(白名单)",它自己盯着 Prometheus;发现异常就问 LLM"该怎么办",但只执行白名单里允许的动作,而且做完把结论写进
status,方便你事后复盘。它替代的是"半夜被报警叫起来手动重启"的那个人,但权力被你用白名单和限流死死摁住。
先建立一张整体数据流图,后续每块代码都是图里的某一个环节:
flowchart LR
P[Prometheus 指标源] --> O[AIOps Operator
reconcile 循环]
O --> L[LLM 根因分析
返回 action]
L --> O
O --> K[Kubernetes API
restart / scale / cordon]
K --> D[(目标 Deployment)]
O --> S[(AIOpsRule.status
记录决策与计数)]CRD 定义
我们定义一个 AIOpsRule CRD。spec 里放监控目标和触发条件,status 里放最近一次决策,方便排查。
apiVersion: aiops.example.com/v1
kind: AIOpsRule
metadata:
name: api-gateway-healer
namespace: prod
spec:
# 监控目标
targetDeployment: api-gateway
namespace: prod
# 指标源
metricsSource: prometheus
prometheusURL: http://prometheus.monitoring:9090
query: |
histogram_quantile(0.99,
sum by (le) (rate(http_request_duration_seconds_bucket{service="api-gateway"}[5m])))
threshold: 0.5 # P99 延迟超 500ms 触发
# LLM 配置
llmEndpoint: http://llm-service:8000/v1/chat/completions
llmModel: gpt-4o-mini
# 允许 LLM 选的动作白名单
allowedActions:
- restart_pod
- scale_up
- cordon_node
- noop
# 安全控制
cooldownSeconds: 300 # 两次修复之间至少隔 5 分钟,防抖
maxActionsPerHour: 5 # 一小时最多执行 5 次,挡住 LLM 失控
status:
phase: Monitoring
lastDecision: ""
lastActionTime: ""
actionCount: 0
observedGeneration: 0
phase 字段贯穿整个 reconcile 生命周期,是观测"Operator 现在在干嘛"的核心信号。它的状态转移如下:
stateDiagram-v2
[*] --> Monitoring
Monitoring --> Analyzing : 指标超阈值
Analyzing --> Healing : LLM 给出合法 action
Healing --> Healed : 执行成功
Healing --> Failed : 执行失败 / LLM 出错
Healed --> Monitoring : 下一轮 RequeueAfter
Failed --> Monitoring : 下一轮重试
Monitoring --> Cooldown : cooldown 未到 / 达每小时上限
Cooldown --> Monitoring : 冷却结束对应的完整 CRD YAML(带 schema 校验和 status 子资源):
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: aiopsrules.aiops.example.com
spec:
group: aiops.example.com
scope: Namespaced
names:
plural: aiopsrules
singular: aiopsrule
kind: AIOpsRule
shortNames: [ar]
versions:
- name: v1
served: true
storage: true
additionalPrinterColumns:
- name: Target
type: string
jsonPath: .spec.targetDeployment
- name: Phase
type: string
jsonPath: .status.phase
- name: LastDecision
type: string
jsonPath: .status.lastDecision
- name: Actions
type: integer
jsonPath: .status.actionCount
- name: Age
type: date
jsonPath: .metadata.creationTimestamp
schema:
openAPIV3Schema:
type: object
required: [spec]
properties:
spec:
type: object
required: [targetDeployment, namespace, query, threshold, llmEndpoint]
properties:
targetDeployment:
type: string
minLength: 1
namespace:
type: string
metricsSource:
type: string
default: prometheus
prometheusURL:
type: string
format: uri
query:
type: string
minLength: 1
threshold:
type: number
minimum: 0
llmEndpoint:
type: string
format: uri
llmModel:
type: string
allowedActions:
type: array
items:
type: string
cooldownSeconds:
type: integer
minimum: 0
default: 300
maxActionsPerHour:
type: integer
minimum: 1
default: 5
status:
type: object
properties:
phase:
type: string
enum: [Monitoring, Analyzing, Healing, Healed, Failed, Cooldown]
lastDecision:
type: string
lastActionTime:
type: string
format: date-time
actionCount:
type: integer
observedGeneration:
type: integer
format: int64
lastError:
type: string
subresources:
status: {}
控制器逻辑
reconcile 流程我画一下,心里有谱再写代码:
1. 读取 AIOpsRule CR(处理删除走 finalizer)
2. 检查 cooldown:距离上次修复是否够 cooldownSeconds,不够就跳过
3. 查 Prometheus 拿当前指标
4. 指标超阈值 -> 构造 prompt 调 LLM
5. 解析 LLM 返回的 action,校验是否在 allowedActions 白名单
6. 执行 action(client-go 调用)
7. 更新 status(phase、lastDecision、actionCount)
8. RequeueAfter 继续下一轮
把上面的文字步骤落成一张流程图,重点看两道"安全闸门":cooldown 限流和 maxActionsPerHour 上限,都是为了挡住 LLM 可能形成的正反馈风暴。
flowchart TD
A[读取 AIOpsRule CR] --> B{cooldown 够?}
B -->|否| B0[进入 Cooldown 跳过]
B -->|是| C[查 Prometheus 当前指标]
C --> D{超阈值?}
D -->|否| M[Monitoring 继续观察]
D -->|是| E{actionCount
达每小时上限?}
E -->|是| E0[跳过 限流保护]
E -->|否| F[调 LLM 根因分析]
F --> G[白名单校验 action]
G --> H[执行修复动作]
H --> I[更新 status 决策与计数]
I --> R[RequeueAfter 下一轮]核心代码示例
指标查询
先封装一个 Prometheus 查询函数。这里有个坑:Prometheus 的 JSON 结构嵌得很深,data.result[0].value[1] 才是值,新手容易解错。
package internal
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"time"
)
// promResponse 对应 Prometheus /api/v1/query 的返回结构
type promResponse struct {
Status string `json:"status"`
Data struct {
ResultType string `json:"resultType"`
Result []struct {
Value [2]interface{} `json:"value"` // [timestamp, "value_string"]
} `json:"result"`
} `json:"data"`
Error string `json:"error"`
}
// QueryPrometheus 查询瞬时值,返回 float64
func QueryPrometheus(ctx context.Context, promURL, promql string) (float64, error) {
// 用 url.Values 转义 query,别手拼字符串,PromQL 里有特殊字符
u := promURL + "/api/v1/query?query=" + url.QueryEscape(promql)
// 带 timeout 的 client,Prometheus 卡死别拖垮 reconcile
httpClient := &http.Client{Timeout: 10 * time.Second}
req, err := http.NewRequestWithContext(ctx, "GET", u, nil)
if err != nil {
return 0, fmt.Errorf("build prometheus request: %w", err)
}
resp, err := httpClient.Do(req)
if err != nil {
return 0, fmt.Errorf("query prometheus: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return 0, fmt.Errorf("prometheus %d: %s", resp.StatusCode, string(body))
}
var pr promResponse
if err := json.NewDecoder(resp.Body).Decode(&pr); err != nil {
return 0, fmt.Errorf("decode prometheus response: %w", err)
}
if pr.Status != "success" {
return 0, fmt.Errorf("prometheus error: %s", pr.Error)
}
if len(pr.Data.Result) == 0 {
// 没数据返回 0,不报错——可能是指标还没采集到
return 0, nil
}
// value[1] 是 string,需要解析
valStr, ok := pr.Data.Result[0].Value[1].(string)
if !ok {
return 0, fmt.Errorf("unexpected prometheus value type")
}
return strconv.ParseFloat(valStr, 64)
}
踩坑提示:Prometheus 查询返回空 result 是正常的(指标没采集到),别当错误抛。我之前就因为空 result 报错,导致 controller 一直 reconcile 失败,整条规则形同虚设。还有,rate(...[5m]) 这种范围查询要用 /api/v1/query_range,瞬时查询用 /api/v1/query,别混了。
LLM 调用与决策
LLM 这块是最容易翻车的地方。我的经验是:prompt 要把上下文喂够,输出要约束成结构化格式(JSON 最好),别让 LLM 自由发挥。
类比:调 LLM 做决策,就像让一个很聪明但爱"自由发挥"的实习生给建议。你不能问"这服务挂了你看着办",得把背景(当前指标)、约束(你能做的动作清单)、输出格式(固定 JSON)都写死,最后还得人工(白名单)复核他给的建议——他说"删库"你绝对不能照做。
整个"调 LLM → 解析 → 白名单校验"的时序如下:
sequenceDiagram
participant O as Operator
participant L as LLM 服务
participant W as 白名单校验
O->>L: 构造 prompt(指标 + 白名单 + JSON 格式要求)
L-->>O: 返回 action / reason / confidence
O->>W: 校验 action 是否在 allowedActions
alt 命中白名单 且 confidence >= 阈值
W-->>O: 通过 执行动作
else 未命中 或 置信度低
W-->>O: 拒绝 仅告警不执行
endpackage internal
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"regexp"
"strings"
"time"
)
// LLMRequest 对应 OpenAI 兼容的 chat completions 接口
type LLMRequest struct {
Model string `json:"model"`
Messages []Message `json:"messages"`
}
type Message struct {
Role string `json:"role"`
Content string `json:"content"`
}
type LLMResponse struct {
Choices []struct {
Message Message `json:"message"`
} `json:"choices"`
}
// LLMDecision 是我们要求 LLM 返回的结构
type LLMDecision struct {
Action string `json:"action"`
Reason string `json:"reason"`
Confidence float64 `json:"confidence"`
}
// AskLLM 构造 prompt 调用 LLM,返回决策
func AskLLM(ctx context.Context, endpoint, model, contextInfo string, allowedActions []string) (*LLMDecision, error) {
// prompt 里把白名单写死,要求 LLM 用 JSON 格式回答
prompt := fmt.Sprintf(`你是一名资深 Kubernetes SRE。当前系统监控信息如下:
%s
请从以下动作中选择一个并返回 JSON(不要 markdown 代码块,直接返回纯 JSON):
%s
返回格式严格如下,action 必须在白名单内:
{"action": "动作名", "reason": "简短原因", "confidence": 0.0-1.0}`, contextInfo, strings.Join(allowedActions, ", "))
reqBody, _ := json.Marshal(LLMRequest{
Model: model,
Messages: []Message{
{Role: "user", Content: prompt},
},
})
// LLM 接口可能很慢,给 30s timeout
httpClient := &http.Client{Timeout: 30 * time.Second}
req, err := http.NewRequestWithContext(ctx, "POST", endpoint, bytes.NewBuffer(reqBody))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
resp, err := httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("call llm: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("llm %d: %s", resp.StatusCode, string(body))
}
var lr LLMResponse
if err := json.NewDecoder(resp.Body).Decode(&lr); err != nil {
return nil, fmt.Errorf("decode llm response: %w", err)
}
if len(lr.Choices) == 0 {
return nil, fmt.Errorf("llm returned no choices")
}
content := lr.Choices[0].Message.Content
return parseLLMDecision(content, allowedActions)
}
// parseLLMDecision 解析 LLM 输出。LLM 经常在 JSON 外面包 markdown 代码块,要清理
var jsonBlockRe = regexp.MustCompile("(?s)```(?:json)?\\s*(.*?)```")
func parseLLMDecision(content string, allowedActions []string) (*LLMDecision, error) {
// 先尝试提取 ```json ... ``` 里的内容
if m := jsonBlockRe.FindStringSubmatch(content); m != nil {
content = m[1]
}
content = strings.TrimSpace(content)
var decision LLMDecision
if err := json.Unmarshal([]byte(content), &decision); err != nil {
return nil, fmt.Errorf("parse llm json: %w, raw: %s", err, content)
}
// 白名单校验:LLM 可能幻觉出不存在的 action,必须挡住
for _, a := range allowedActions {
if a == decision.Action {
return &decision, nil
}
}
return nil, fmt.Errorf("llm action %q not in allowed list", decision.Action)
}
踩坑提示:LLM 返回 JSON 时极大概率包 ```json 代码块,直接 json.Unmarshal 必挂,所以正则先扒一层。还有,confidence 我一般会设个阈值(比如 0.6),低于阈值的决策不执行、只告警——LLM 自己都没把握的事儿你别让它乱动生产。最后,prompt 里写"不要 markdown"基本没用,LLM 该包还包,代码里必须有兜底解析。
执行修复动作
执行这块直接调 client-go。我封了三个动作:重启 Pod、扩容、cordon 节点。
package controllers
import (
"context"
"fmt"
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/types"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/log"
)
// ExecuteAction 执行修复动作。action 必须已经过白名单校验
func ExecuteAction(ctx context.Context, c client.Client, action, namespace, deployment string) error {
log := log.FromContext(ctx)
switch action {
case "restart_pod":
return restartPod(ctx, c, namespace, deployment)
case "scale_up":
return scaleDeployment(ctx, c, namespace, deployment, +1)
case "scale_down":
return scaleDeployment(ctx, c, namespace, deployment, -1)
case "cordon_node":
return cordonNode(ctx, c, namespace, deployment)
case "noop":
log.Info("llm decided noop, skip action")
return nil
default:
return fmt.Errorf("unknown action: %s", action)
}
}
// restartPod 删除目标 Deployment 的 Pod,触发重建
func restartPod(ctx context.Context, c client.Client, namespace, deployment string) error {
pods := &corev1.PodList{}
// 用 label selector 选 Pod,别按名字猜
if err := c.List(ctx, pods,
client.InNamespace(namespace),
client.MatchingLabels{"app": deployment},
); err != nil {
return fmt.Errorf("list pods: %w", err)
}
if len(pods.Items) == 0 {
return fmt.Errorf("no pods found for app=%s in %s", deployment, namespace)
}
for _, pod := range pods.Items {
// 逐个删,别并发删光,留点缓冲
if err := c.Delete(ctx, &pod); err != nil {
return fmt.Errorf("delete pod %s: %w", pod.Name, err)
}
}
return nil
}
// scaleDeployment 调整副本数,delta 为正扩容,为负缩容
func scaleDeployment(ctx context.Context, c client.Client, namespace, deployment string, delta int32) error {
dep := &appsv1.Deployment{}
if err := c.Get(ctx, types.NamespacedName{Namespace: namespace, Name: deployment}, dep); err != nil {
return fmt.Errorf("get deployment: %w", err)
}
// 当前副本数可能为 nil(默认值),要处理,否则解引用 panic
current := int32(1)
if dep.Spec.Replicas != nil {
current = *dep.Spec.Replicas
}
// 防止缩到负数
desired := current + delta
if desired < 0 {
desired = 0
}
dep.Spec.Replicas = &desired
return c.Update(ctx, dep)
}
// cordonNode 把跑着目标 Pod 的节点标记为 Unschedulable
// 注意:cordon 只是不让新 Pod 调度上去,已经在跑的 Pod 不会被驱逐
func cordonNode(ctx context.Context, c client.Client, namespace, deployment string) error {
pods := &corev1.PodList{}
if err := c.List(ctx, pods,
client.InNamespace(namespace),
client.MatchingLabels{"app": deployment},
); err != nil {
return err
}
if len(pods.Items) == 0 {
return fmt.Errorf("no pods to locate node")
}
nodeName := pods.Items[0].Spec.NodeName
if nodeName == "" {
return fmt.Errorf("pod has no node assigned")
}
node := &corev1.Node{}
if err := c.Get(ctx, types.NamespacedName{Name: nodeName}, node); err != nil {
return err
}
// 修改 spec.unschedulable 字段
node.Spec.Unschedulable = true
return c.Update(ctx, node)
}
踩坑提示:cordonNode 只设 unschedulable=true,不会驱逐已运行 Pod。如果你想要真正下线节点,得配合 kubectl drain(调 eviction API)。我见过有人以为 cordon 等于 drain,结果节点照样跑着问题 Pod。还有 scaleDeployment 里 dep.Spec.Replicas 是 *int32,nil 时表示用默认值 1,不处理 nil 直接解引用会 panic。
Reconcile 主流程
把前面几块拼起来,就是完整的 reconcile。我加了 finalizer、cooldown 限流、action 计数这些安全控制。
package controllers
import (
"context"
"fmt"
"time"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
"sigs.k8s.io/controller-runtime/pkg/log"
aiopsv1 "github.com/example/aiops-operator/api/v1"
"github.com/example/aiops-operator/internal"
)
const aiopsRuleFinalizer = "aiops.example.com/finalizer"
type AIOpsRuleReconciler struct {
client.Client
Scheme *runtime.Scheme
}
func (r *AIOpsRuleReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
log := log.FromContext(ctx)
var rule aiopsv1.AIOpsRule
if err := r.Get(ctx, req.NamespacedName, &rule); err != nil {
return ctrl.Result{}, client.IgnoreNotFound(err)
}
// 处理删除:finalizer 清理外部副作用
if !rule.DeletionTimestamp.IsZero() {
return r.reconcileDelete(ctx, &rule)
}
if !controllerutil.ContainsFinalizer(&rule, aiopsRuleFinalizer) {
controllerutil.AddFinalizer(&rule, aiopsRuleFinalizer)
if err := r.Update(ctx, &rule); err != nil {
return ctrl.Result{Requeue: true}, nil
}
}
// cooldown 检查:距离上次修复不够 cooldownSeconds 就跳过执行
if rule.Status.LastActionTime != "" {
lastTime, err := time.Parse(time.RFC3339, rule.Status.LastActionTime)
if err == nil {
cooldown := time.Duration(rule.Spec.CooldownSeconds) * time.Second
if time.Since(lastTime) < cooldown {
_ = r.updatePhase(ctx, &rule, "Cooldown")
return ctrl.Result{RequeueAfter: cooldown - time.Since(lastTime)}, nil
}
}
}
// 1. 查 Prometheus
value, err := internal.QueryPrometheus(ctx, rule.Spec.PrometheusURL, rule.Spec.Query)
if err != nil {
log.Error(err, "query prometheus failed")
_ = r.updatePhaseWithError(ctx, &rule, "Failed", err.Error())
// Prometheus 抖动,短重试
return ctrl.Result{RequeueAfter: 30 * time.Second}, nil
}
// 2. 没超阈值,继续监控
if value <= rule.Spec.Threshold {
_ = r.updatePhase(ctx, &rule, "Monitoring")
return ctrl.Result{RequeueAfter: 60 * time.Second}, nil
}
// 3. 超阈值,但已达 maxActionsPerHour,不再执行
if rule.Status.ActionCount >= rule.Spec.MaxActionsPerHour {
log.Info("max actions per hour reached, skip")
_ = r.updatePhaseWithError(ctx, &rule, "Cooldown", "max actions per hour reached")
return ctrl.Result{RequeueAfter: 10 * time.Minute}, nil
}
// 4. 构造上下文调 LLM
_ = r.updatePhase(ctx, &rule, "Analyzing")
contextInfo := fmt.Sprintf("Deployment: %s/%s\nP99 latency: %.3fs\nThreshold: %.3fs",
rule.Spec.Namespace, rule.Spec.TargetDeployment, value, rule.Spec.Threshold)
decision, err := internal.AskLLM(ctx, rule.Spec.LLMEndpoint, rule.Spec.LLMModel,
contextInfo, rule.Spec.AllowedActions)
if err != nil {
log.Error(err, "llm call failed")
_ = r.updatePhaseWithError(ctx, &rule, "Failed", err.Error())
return ctrl.Result{RequeueAfter: 60 * time.Second}, nil
}
// 5. 执行动作
_ = r.updatePhase(ctx, &rule, "Healing")
if err := ExecuteAction(ctx, r.Client, decision.Action,
rule.Spec.Namespace, rule.Spec.TargetDeployment); err != nil {
log.Error(err, "execute action failed", "action", decision.Action)
_ = r.updatePhaseWithError(ctx, &rule, "Failed", err.Error())
return ctrl.Result{RequeueAfter: 60 * time.Second}, nil
}
// 6. 更新 status:记下决策、累加计数
rule.Status.Phase = "Healed"
rule.Status.LastDecision = fmt.Sprintf("%s (%.2f): %s",
decision.Action, decision.Confidence, decision.Reason)
rule.Status.LastActionTime = metav1.Now().Format(time.RFC3339)
rule.Status.ActionCount++
rule.Status.ObservedGeneration = rule.Generation
rule.Status.LastError = ""
if err := r.Status().Update(ctx, &rule); err != nil {
return ctrl.Result{}, err
}
log.Info("auto-healed", "action", decision.Action, "confidence", decision.Confidence)
return ctrl.Result{RequeueAfter: 60 * time.Second}, nil
}
// reconcileDelete finalizer 清理:取消 cordon 等副作用
func (r *AIOpsRuleReconciler) reconcileDelete(ctx context.Context, rule *aiopsv1.AIOpsRule) (ctrl.Result, error) {
// 如果最后动作是 cordon_node,记得 uncordon,否则节点永久不可调度
// 实际项目里要记录 cordon 过的节点列表,这里简化
controllerutil.RemoveFinalizer(rule, aiopsRuleFinalizer)
if err := r.Update(ctx, rule); err != nil {
return ctrl.Result{Requeue: true}, nil
}
return ctrl.Result{}, nil
}
func (r *AIOpsRuleReconciler) updatePhase(ctx context.Context, rule *aiopsv1.AIOpsRule, phase string) error {
rule.Status.Phase = phase
rule.Status.ObservedGeneration = rule.Generation
return r.Status().Update(ctx, rule)
}
func (r *AIOpsRuleReconciler) updatePhaseWithError(ctx context.Context, rule *aiopsv1.AIOpsRule, phase, errMsg string) error {
rule.Status.Phase = phase
rule.Status.LastError = errMsg
rule.Status.ObservedGeneration = rule.Generation
return r.Status().Update(ctx, rule)
}
func (r *AIOpsRuleReconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
For(&aiopsv1.AIOpsRule{}).
Complete(r)
}
踩坑提示:actionCount 这种累计计数如果要求"每小时清零",得在 status 里记窗口起始时间,reconcile 进来先判断窗口是否过期再清零,我这里简化了。还有 LLM 调用一定要带 context timeout,否则 LLM 接口卡死会拖垮整个 workqueue——workqueue 是串行消费的,一个慢操作堵死后续所有 CR。
部署与验证
部署 YAML
部署需要四样东西:CRD、ServiceAccount、RBAC(ClusterRole + Binding)、Operator Deployment。我贴完整的:
# 1. ServiceAccount
apiVersion: v1
kind: ServiceAccount
metadata:
name: aiops-operator
namespace: aiops-system
---
# 2. RBAC:最小权限原则,只给真正需要的资源权限
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: aiops-operator
rules:
# 自己的 CRD 读写
- apiGroups: ["aiops.example.com"]
resources: ["aiopsrules", "aiopsrules/status", "aiopsrules/finalizers"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
# 操作 Deployment(scale/restart 需要)
- apiGroups: ["apps"]
resources: ["deployments"]
verbs: ["get", "list", "watch", "update", "patch"]
# 操作 Pod(delete pod 重启)
- apiGroups: [""]
resources: ["pods"]
verbs: ["get", "list", "watch", "delete"]
# 操作 Node(cordon 需要)
- apiGroups: [""]
resources: ["nodes"]
verbs: ["get", "list", "watch", "update", "patch"]
# events(记录决策日志)
- apiGroups: [""]
resources: ["events"]
verbs: ["create", "patch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: aiops-operator
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: aiops-operator
subjects:
- kind: ServiceAccount
name: aiops-operator
namespace: aiops-system
---
# 3. Operator Deployment
apiVersion: apps/v1
kind: Deployment
metadata:
name: aiops-operator
namespace: aiops-system
spec:
replicas: 1 # 多副本必须开 leader election
selector:
matchLabels: { app: aiops-operator }
template:
metadata:
labels: { app: aiops-operator }
spec:
serviceAccountName: aiops-operator
containers:
- name: manager
image: registry.example.com/aiops-operator:v0.1
args:
- --leader-elect=false
- --metrics-bind-address=:8080
- --health-probe-bind-address=:8081
resources:
limits: { cpu: 500m, memory: 512Mi }
requests: { cpu: 100m, memory: 128Mi }
# 健康检查:manager 起不来就重启
livenessProbe:
httpGet: { path: /healthz, port: 8081 }
initialDelaySeconds: 15
periodSeconds: 20
readinessProbe:
httpGet: { path: /readyz, port: 8081 }
initialDelaySeconds: 5
periodSeconds: 10
验证步骤
部署完按这个顺序验证:
# 1. 装 CRD
kubectl apply -f config/crd/bases/aiops.example.com_aiopsrules.yaml
kubectl get crd aiopsrules.aiops.example.com
# 2. 部署 Operator
kubectl apply -f config/rbac/
kubectl apply -f config/manager/
kubectl -n aiops-system get pods -l app=aiops-operator
# 确认 Pod Running 且 ready
kubectl -n aiops-system logs deployment/aiops-operator -f
# 3. 创建 AIOpsRule
kubectl apply -f config/samples/aiops_v1_aiopsrule.yaml
# 4. 观察 status 变化
kubectl get ar -w
# NAME TARGET PHASE LASTDECISION ACTIONS AGE
# api-gateway-healer api-gateway Monitoring 10s
# 5. 压测制造高延迟,触发修复
# 用 hey 或 wrk 打流量
hey -z 2m -q 100 -c 50 https://api-gateway.example.com/
# 6. 观察 Operator 日志和 status
kubectl logs -n aiops-system deployment/aiops-operator -f
kubectl get ar api-gateway-healer -o yaml
# 看 status.lastDecision、actionCount 是否更新
# 7. 排查:如果一直没触发
kubectl describe ar api-gateway-healer # 看 events
# 检查 Prometheus 查询是否能手跑通
curl 'http://prometheus:9090/api/v1/query?query=...'
踩坑提示:RBAC 权限不够时 Operator 启动不报错,但 reconcile 时会一直 forbidden,看日志才能发现。建议先用 kubectl auth can-i 验证 ServiceAccount 的权限:
kubectl auth can-i delete pods --as=system:serviceaccount:aiops-system:aiops-operator
还有,kubectl get ar -w 看不到 status 变化是正常的——-w 只 watch metadata,status 变了得 -o yaml 看或者用 kubectl describe。
设计要点与注意事项
这几点都是踩过坑总结的,比我上面代码里写的还重要:
LLM 输出必须白名单校验。LLM 是概率模型,你让它选
restart_pod它可能返回restart_podd、RESTART_POD、甚至delete_all。白名单挡一道,置信度阈值挡一道,低于 0.6 的决策只告警不执行。我亲眼见过没加白名单的 demo,LLM 幻觉出delete_namespace,幸好 RBAC 没给权限。限流是命门。LLM 决策一旦形成正反馈(指标高 -> 重启 -> 重启导致更高 -> 再重启),能把你集群搞崩。
cooldownSeconds+maxActionsPerHour必须有,必要时加人工审批:高危动作(cordon、缩容到 0)写入Pending状态等人工kubectl patch确认。可观测性比功能本身重要。每次决策的上下文、指标值、LLM 原始输出、执行结果,全记到 events 和外部审计日志。出问题时你得能复盘"为啥 Operator 把生产节点 cordon 了"。建议给每次决策创建一个 Event:
// 记录决策到 K8s Event,方便 kubectl describe 查看
r.Recorder.Eventf(&rule, corev1.EventTypeNormal, "AutoHeal",
"action=%s confidence=%.2f reason=%s", decision.Action, decision.Confidence, decision.Reason)
危险操作分级。我一般分三档:只读(noop)、低危(restart pod、scale up)、高危(scale down、cordon、delete)。高危动作默认走审批流,别让 LLM 直接执行。
LLM 调用要降级。LLM 服务挂了别让整个 Operator 挂。LLM 不可用时降级到"基于阈值的硬规则"(比如超阈值直接 scale_up),保证基本可用。实现上就是
AskLLM失败时 fallback 到一个简单规则函数。
总结
把 LLM 决策封装进 Kubernetes Operator,我们实现了"声明式 AIOps":用户声明修复策略和阈值,Operator 自己观测、推理、执行。这套东西看着炫,但我得强调一句——LLM 在这里是"辅助决策",不是"自动驾驶"。真上生产,先从只读分析(LLM 给建议、人工执行)开始,跑稳了再逐步放开自动执行权限。
下一篇会换方向,讲怎么训练流量预测模型做预测性扩容,把 AIOps 的另一面——“主动预防”——补上。
自测题与动手练习
自测题(合上书能答出来,才算懂):
AIOpsRule的spec和status分别放什么?为什么要把"最近一次决策"写进status而不是只打日志?- reconcile 主流程里为什么需要
cooldownSeconds和maxActionsPerHour两道闸门?如果去掉会怎样? - LLM 返回的决策要做哪三重防呆?为什么"白名单校验"比"prompt 里写’不要做危险操作’“更可靠?
cordon_node和drain有什么区别?为什么cordonNode只设unschedulable=true还不够?- 为什么 LLM 调用必须带
context超时?workqueue 是串行消费的,这意味着什么风险?
动手练习(建议真做一遍):
- 起 Prometheus + 一个目标 Deployment,创建
AIOpsRule并用hey/wrk压测制造高延迟,观察kubectl get ar -w的phase变化与最终自动修复动作。 - 故意把 LLM 的 system prompt 改成"返回 delete_namespace”,或在白名单外返回一个动作,验证
parseLLMDecision挡下它、只告警不执行;再用kubectl auth can-i确认 RBAC 也没给这个权限。 - 把
maxActionsPerHour调到 1,连续制造多次异常,观察phase进入Cooldown而不再盲目执行,体会限流这道"命门"。
本章小结
- 声明式 AIOps 的本质:用户声明"盯谁、超多少、能干啥",Operator 自己观测 → 推理 → 执行,把"半夜手动重启"变成自动闭环。
- 安全控制是第一位的:动作白名单 + 置信度阈值 +
cooldown+maxActionsPerHour四重保险,挡住 LLM 的正反馈风暴和幻觉动作。 - LLM 是"辅助决策"不是"自动驾驶":输出必须结构化、必须过白名单校验,低置信度只告警;LLM 挂了要降级到基于阈值的硬规则。
- 可观测与可复盘:每次决策的上下文、原始输出、执行结果都记进
status和 Event,出事能说清"Operator 为什么把生产节点 cordon 了"。 - 危险操作分级:只读(noop)/ 低危(restart、scale up)/ 高危(scale down、cordon、delete)分档,高危默认走人工审批。
真上生产建议从"只读分析(LLM 给建议、人工执行)“起步,跑稳了再逐步放开自动执行权限。下一篇转向"主动预防”——用流量预测模型做预测性扩容。