Operator AIOps 实战

2025-11-08T14:11:02+08:00 | 15分钟阅读 | 更新于 2025-11-08T14:11:02+08:00

@

学习目标

学完本章你应该能够:

  1. 设计一个 AIOpsRule CRD:在 spec 里声明监控目标、阈值、LLM 配置与动作白名单,在 status 里记录最近一次决策以便复盘。
  2. 画清并实现一个 reconcile 主流程:cooldown 限流 → 查 Prometheus → 超阈值调 LLM → 白名单校验 → 执行动作 → 更新 status
  3. 讲清 LLM 输出的三重防呆:结构化 JSON 约束、白名单校验、置信度阈值,理解"为什么不能让 LLM 直接乱动生产"。
  4. 用 client-go 实现 restart_pod / scale_up / cordon_node 三种修复动作,并说清 cordon 不等于 drain
  5. 把 Operator 部署上线(CRD / ServiceAccount / RBAC / Deployment),并用 kubectl 验证触发与排查。

前置知识

  • 上一篇笔记:Operator 基本结构、reconcile 机制与 finalizer
  • client-go / controller-runtime 基本用法
  • Prometheus PromQL 查询、OpenAI 兼容的 Chat Completions 接口

本章你会动手做的事

  1. 起一个 Prometheus 和目标 Deployment,创建 AIOpsRule 并用压测制造高延迟,观察 Operator 自动修复。
  2. 故意让 LLM 返回一个白名单外的动作(如 delete_namespace),验证被安全挡下、只告警不执行。
  3. maxActionsPerHour 调到很小,制造限流,观察 phase 进入 Cooldown 而不再盲目执行。

实战目标

上一篇笔记把 Operator 的基本结构和 reconcile 机制过了一遍。本篇要干的活是把 LLM 接进来,做一个简易的 AIOps Operator,让它能:

  1. 接收用户声明的 AIOpsRule 自定义资源。
  2. 按配置周期性采集目标 Deployment 的状态和指标。
  3. 指标触发阈值或异常时,调 LLM 做根因分析。
  4. 按 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: 拒绝 仅告警不执行
    end
package 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。还有 scaleDeploymentdep.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

设计要点与注意事项

这几点都是踩过坑总结的,比我上面代码里写的还重要:

  1. LLM 输出必须白名单校验。LLM 是概率模型,你让它选 restart_pod 它可能返回 restart_poddRESTART_POD、甚至 delete_all。白名单挡一道,置信度阈值挡一道,低于 0.6 的决策只告警不执行。我亲眼见过没加白名单的 demo,LLM 幻觉出 delete_namespace,幸好 RBAC 没给权限。

  2. 限流是命门。LLM 决策一旦形成正反馈(指标高 -> 重启 -> 重启导致更高 -> 再重启),能把你集群搞崩。cooldownSeconds + maxActionsPerHour 必须有,必要时加人工审批:高危动作(cordon、缩容到 0)写入 Pending 状态等人工 kubectl patch 确认。

  3. 可观测性比功能本身重要。每次决策的上下文、指标值、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)
  1. 危险操作分级。我一般分三档:只读(noop)、低危(restart pod、scale up)、高危(scale down、cordon、delete)。高危动作默认走审批流,别让 LLM 直接执行。

  2. LLM 调用要降级。LLM 服务挂了别让整个 Operator 挂。LLM 不可用时降级到"基于阈值的硬规则"(比如超阈值直接 scale_up),保证基本可用。实现上就是 AskLLM 失败时 fallback 到一个简单规则函数。

总结

把 LLM 决策封装进 Kubernetes Operator,我们实现了"声明式 AIOps":用户声明修复策略和阈值,Operator 自己观测、推理、执行。这套东西看着炫,但我得强调一句——LLM 在这里是"辅助决策",不是"自动驾驶"。真上生产,先从只读分析(LLM 给建议、人工执行)开始,跑稳了再逐步放开自动执行权限。

下一篇会换方向,讲怎么训练流量预测模型做预测性扩容,把 AIOps 的另一面——“主动预防”——补上。


自测题与动手练习

自测题(合上书能答出来,才算懂)

  1. AIOpsRulespecstatus 分别放什么?为什么要把"最近一次决策"写进 status 而不是只打日志?
  2. reconcile 主流程里为什么需要 cooldownSecondsmaxActionsPerHour 两道闸门?如果去掉会怎样?
  3. LLM 返回的决策要做哪三重防呆?为什么"白名单校验"比"prompt 里写’不要做危险操作’“更可靠?
  4. cordon_nodedrain 有什么区别?为什么 cordonNode 只设 unschedulable=true 还不够?
  5. 为什么 LLM 调用必须带 context 超时?workqueue 是串行消费的,这意味着什么风险?

动手练习(建议真做一遍)

  1. 起 Prometheus + 一个目标 Deployment,创建 AIOpsRule 并用 hey / wrk 压测制造高延迟,观察 kubectl get ar -wphase 变化与最终自动修复动作。
  2. 故意把 LLM 的 system prompt 改成"返回 delete_namespace”,或在白名单外返回一个动作,验证 parseLLMDecision 挡下它、只告警不执行;再用 kubectl auth can-i 确认 RBAC 也没给这个权限。
  3. maxActionsPerHour 调到 1,连续制造多次异常,观察 phase 进入 Cooldown 而不再盲目执行,体会限流这道"命门"。

本章小结

  • 声明式 AIOps 的本质:用户声明"盯谁、超多少、能干啥",Operator 自己观测 → 推理 → 执行,把"半夜手动重启"变成自动闭环。
  • 安全控制是第一位的:动作白名单 + 置信度阈值 + cooldown + maxActionsPerHour 四重保险,挡住 LLM 的正反馈风暴和幻觉动作。
  • LLM 是"辅助决策"不是"自动驾驶":输出必须结构化、必须过白名单校验,低置信度只告警;LLM 挂了要降级到基于阈值的硬规则。
  • 可观测与可复盘:每次决策的上下文、原始输出、执行结果都记进 status 和 Event,出事能说清"Operator 为什么把生产节点 cordon 了"。
  • 危险操作分级:只读(noop)/ 低危(restart、scale up)/ 高危(scale down、cordon、delete)分档,高危默认走人工审批。

真上生产建议从"只读分析(LLM 给建议、人工执行)“起步,跑稳了再逐步放开自动执行权限。下一篇转向"主动预防”——用流量预测模型做预测性扩容。

About Me

没什么想介绍的,一个很大众的码农…

喜欢代码,车,马,真的是 🐎

讨厌别人让我给自己的代码写注释 最厌烦别人的程序没有写注释

目标

学AI,加油!加油!