基于 CockroachDB 动态 SLO 定义与 Prometheus 规则生成实现全域服务质量监控


团队规模扩大后,维护静态的服务等级目标(SLO)YAML 文件变成了一场灾难。每个新服务、每个 SLO 的微调,都需要经过代码审查、合并,然后通过 CI/CD 推送到生产环境的 Prometheus 服务器上。这个流程虽然规范,但在紧急情况下极其缓慢,并且很容易因为人为失误导致配置漂移。更糟糕的是,SLO 定义散落在各个代码仓库中,缺乏一个统一、可审计的视图。我们需要的不是将 SLO 作为配置代码(Config as Code),而是将其视为需要被严谨管理的核心业务数据(SLO as Data)。

我们的初步构想是构建一个动态 SLO 平台。核心目标是:SLO 定义应该存储在一个高可用的、支持事务的数据库中,而不是 Git 仓库里的 YAML 文件。一个独立的控制器服务负责从数据库中读取这些定义,将其动态翻译成 Prometheus 的告警和记录规则,并自动热加载 Prometheus。前端则需要一个高性能、高可用的仪表盘来展示所有服务的 SLO 状态。

技术选型决策是这个项目的第一个关键节点。

  1. 数据库: 我们需要一个分布式、强一致性的 SQL 数据库。SLO 定义是关键元数据,我们不能容忍任何数据丢失或不一致。同时,我们的团队遍布全球,数据库需要具备多区域(multi-region)部署能力以降低延迟。PostgreSQL 集群方案过于复杂,维护成本高。最终我们选择了 CockroachDB,它的分布式事务、水平扩展能力和对 PostgreSQL 协议的兼容性完美契合我们的需求。

  2. 监控与告警: Prometheus 是既定选择,它是我们现有监控体系的核心。挑战在于如何动态更新其规则。

  3. 前端仪表盘: SLO 仪表盘的访问量可能很高,但数据的实时性要求并非毫秒级。我们不希望每次用户刷新页面都给数据库和 Prometheus API 造成巨大压力。Next.js 的增量静态再生 (Incremental Static Regeneration, ISR) 成为了理想方案。它可以预构建页面,并在后台以一定频率重新生成,既保证了极高的访问性能,又能以近乎实时的方式展示数据,同时大幅降低了后端负载。

整个系统的架构图如下:

graph TD
    subgraph "SLO 管理平面"
        A[SRE/开发者] -- "通过内部UI/API" --> B(CockroachDB 集群)
    end

    subgraph "动态规则生成"
        C(SLO控制器 Go服务) -- "1. 定期轮询" --> B
        C -- "2. 生成prometheus_rules.yml" --> D[持久化存储卷]
        C -- "3. 发送 SIGHUP 信号" --> E[Prometheus 实例]
    end

    subgraph "SLO 状态展示"
        F[用户] -- "访问仪表盘" --> G(Next.js / Vercel)
        G -- "getStaticProps (ISR)" --> B
        G -- "getStaticProps (ISR)" --> E
    end

    subgraph "核心监控"
        H[业务服务] -- "暴露 /metrics" --> E
        E -- "4. 应用动态规则" --> H
    end

    style B fill:#4DB6AC,stroke:#333,stroke-width:2px
    style C fill:#FFCA28,stroke:#333,stroke-width:2px
    style G fill:#90CAF9,stroke:#333,stroke-width:2px

第一步:在 CockroachDB 中定义 SLO 模型

我们需要一个清晰的数据库模式来存储 SLO。一个服务可以有多个服务等级指标(SLI),每个 SLI 可以关联一个或多个 SLO。

-- file: schema.sql
-- 在 CockroachDB 中执行以初始化表结构

-- 服务定义表
CREATE TABLE services (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name STRING UNIQUE NOT NULL,
    owner STRING NOT NULL,
    description STRING,
    created_at TIMESTAMPTZ DEFAULT now(),
    updated_at TIMESTAMPTZ DEFAULT now()
);

-- 服务等级指标 (SLI) 定义表
-- SLI 是衡量服务可靠性的量化指标,例如:API请求成功率
CREATE TABLE slis (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    service_id UUID NOT NULL REFERENCES services(id) ON DELETE CASCADE,
    name STRING NOT NULL, -- e.g., "api_request_latency"
    description STRING,
    -- 使用 PromQL 定义好事件总数和坏事件数
    -- 这里的查询必须是 counter 类型,可以使用 increase() 函数
    good_events_query STRING NOT NULL, -- e.g., 'sum(rate(http_requests_total{status_code=~"2..|3.."}[_RANGE_]))'
    total_events_query STRING NOT NULL, -- e.g., 'sum(rate(http_requests_total{}[_RANGE_]))'
    created_at TIMESTAMPTZ DEFAULT now(),
    updated_at TIMESTAMPTZ DEFAULT now(),
    UNIQUE (service_id, name)
);

-- 服务等级目标 (SLO) 定义表
-- SLO 是 SLI 的目标,例如:99.9% 的请求在 30 天内成功
CREATE TABLE slos (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    sli_id UUID NOT NULL REFERENCES slis(id) ON DELETE CASCADE,
    -- 目标值, e.g., 99.9
    target DECIMAL(9, 6) NOT NULL,
    -- 滚动窗口, e.g., '28d'
    window STRING NOT NULL,
    description STRING,
    created_at TIMESTAMPTZ DEFAULT now(),
    updated_at TIMESTAMPTZ DEFAULT now()
);

-- CockroachDB 的时间旅行查询(AS OF SYSTEM TIME)依赖于垃圾回收(GC)配置
-- 为了能回溯错误预算消耗,我们将GC TTL设置得长一些
ALTER TABLE slos CONFIGURE ZONE USING gc.ttlseconds = 86400; -- 24 hours
ALTER TABLE slis CONFIGURE ZONE USING gc.ttlseconds = 86400;

这个 schema 的关键在于 slis 表中的 good_events_querytotal_events_query。我们没有存储原始指标名称,而是直接存储计算 SLI 比率所需的 PromQL 查询片段。这提供了极大的灵活性,允许我们为不同服务定义完全不同的 SLI 计算逻辑。

第二步:实现动态规则生成的 Go 控制器

这个 Go 服务是系统的核心。它定期从 CockroachDB 拉取所有 SLO 定义,并将其渲染成一个 Prometheus 规则文件。

main.go:

// file: main.go
package main

import (
	"context"
	"database/sql"
	"log/slog"
	"os"
	"os/signal"
	"syscall"
	"time"

	"github.com/jackc/pgx/v5/stdlib"
	"github.com/spf13/viper"
)

func main() {
	// 初始化配置和日志
	setupConfig()
	setupLogger()

	slog.Info("Starting SLO rule generator...")

	// 配置数据库连接池
	// 关键:使用 pgx 驱动以获得 CockroachDB 的全部功能,例如重试
	connConfig, err := stdlib.ParseConfig(viper.GetString("database.url"))
	if err != nil {
		slog.Error("Failed to parse database URL", "error", err)
		os.Exit(1)
	}
	db := stdlib.OpenDB(*connConfig)
	db.SetMaxOpenConns(10)
	db.SetMaxIdleConns(5)
	db.SetConnMaxLifetime(5 * time.Minute)
	defer db.Close()

	if err := db.Ping(); err != nil {
		slog.Error("Failed to connect to CockroachDB", "error", err)
		os.Exit(1)
	}
	slog.Info("Successfully connected to CockroachDB")

	// 创建 Generator 实例
	generator := NewGenerator(db, viper.GetString("prometheus.rules_path"), viper.GetString("prometheus.pid_file"))

	// 启动定期生成任务
	ticker := time.NewTicker(viper.GetDuration("generator.interval"))
	defer ticker.Stop()

	// 监听中断信号以优雅关闭
	sigChan := make(chan os.Signal, 1)
	signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)

	// 立即执行一次
	if err := generator.Run(context.Background()); err != nil {
		slog.Error("Initial rule generation failed", "error", err)
	}

	for {
		select {
		case <-ticker.C:
			if err := generator.Run(context.Background()); err != nil {
				slog.Error("Rule generation failed", "error", err)
			}
		case <-sigChan:
			slog.Info("Shutting down...")
			return
		}
	}
}

// setupConfig 使用 Viper 加载配置
func setupConfig() {
	viper.SetConfigName("config")
	viper.SetConfigType("yaml")
	viper.AddConfigPath(".")
	viper.AutomaticEnv()
	if err := viper.ReadInConfig(); err != nil {
		slog.Warn("Could not find config file, using defaults/env vars", "error", err)
	}
	
	// 设置默认值
	viper.SetDefault("database.url", "postgresql://root@localhost:26257/defaultdb?sslmode=disable")
	viper.SetDefault("generator.interval", 60*time.Second)
	viper.SetDefault("prometheus.rules_path", "/etc/prometheus/rules/slo.rules.yml")
	viper.SetDefault("prometheus.pid_file", "/var/run/prometheus/pid")
	viper.SetDefault("log.level", "info")
}

// setupLogger 初始化结构化日志
func setupLogger() {
	var level slog.Level
	switch viper.GetString("log.level") {
	case "debug":
		level = slog.LevelDebug
	case "warn":
		level = slog.LevelWarn
	case "error":
		level = slog.LevelError
	default:
		level = slog.LevelInfo
	}
	logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: level}))
	slog.SetDefault(logger)
}

generator.go: 这是逻辑的核心,包含数据库查询、模板渲染和 Prometheus 重载。

// file: generator.go
package main

import (
	"bytes"
	"context"
	"database/sql"
	"fmt"
	"log/slog"
	"os"
	"strconv"
	"strings"
	"syscall"
	"text/template"
	"time"

	"github.com/Masterminds/sprig/v3"
	"github.com/jackc/pgx/v5/stdlib"
)

const ruleTemplate = `
groups:
- name: service_level_objectives
  rules:
  {{- range . }}
  # Service: {{ .ServiceName }} | SLO: {{ .SliName }} ({{ .Window }})
  - record: slo:sli_error_rate:{{ .Window }}
    expr: |
      (
        {{ .TotalEventsQuery | replace "_RANGE_" .Window }}
      )
      -
      (
        {{ .GoodEventsQuery | replace "_RANGE_" .Window }}
      )
  - record: slo:sli_burn_rate:{{ .Window }}
    expr: |
      slo:sli_error_rate:{{ .Window }}
      /
      (1 - {{ .Target }})
    labels:
      service_name: "{{ .ServiceName }}"
      sli_name: "{{ .SliName }}"
      slo_target: "{{ .Target }}"
      slo_window: "{{ .Window }}"
  {{- end }}
`

// SLOData 是传递给模板的结构体
type SLOData struct {
	ServiceName      string
	SliName          string
	GoodEventsQuery  string
	TotalEventsQuery string
	Target           string // 模板中使用字符串更安全
	Window           string
}

// Generator 封装了生成逻辑
type Generator struct {
	db        *sql.DB
	rulesPath string
	pidFile   string
	template  *template.Template
}

// NewGenerator 创建一个新的 Generator
func NewGenerator(db *sql.DB, rulesPath, pidFile string) *Generator {
	tpl := template.Must(template.New("slo").Funcs(sprig.TxtFuncMap()).Funcs(template.FuncMap{
		"replace": strings.ReplaceAll,
	}).Parse(ruleTemplate))
	return &Generator{db: db, rulesPath: rulesPath, pidFile: pidFile, template: tpl}
}

// Run 执行一次完整的生成和重载流程
func (g *Generator) Run(ctx context.Context) error {
	slog.Info("Starting SLO rule generation run")

	// 1. 从 CockroachDB 获取数据
	data, err := g.fetchSLOData(ctx)
	if err != nil {
		return fmt.Errorf("failed to fetch SLO data: %w", err)
	}

	if len(data) == 0 {
		slog.Warn("No SLOs found in database. Skipping rule generation.")
		// 在真实项目中,这里可能需要生成一个空文件或保留旧文件
		return nil
	}

	// 2. 渲染模板
	var buf bytes.Buffer
	if err := g.template.Execute(&buf, data); err != nil {
		return fmt.Errorf("failed to execute template: %w", err)
	}

	// 3. 写入临时文件,然后原子性地替换
	// 这是一个好的实践,避免 Prometheus 在写入过程中读取到不完整的文件
	tempFile := g.rulesPath + ".tmp"
	if err := os.WriteFile(tempFile, buf.Bytes(), 0644); err != nil {
		return fmt.Errorf("failed to write temp rule file: %w", err)
	}
	if err := os.Rename(tempFile, g.rulesPath); err != nil {
		return fmt.Errorf("failed to rename temp file to rule file: %w", err)
	}
	slog.Info("Successfully generated and wrote new rule file", "path", g.rulesPath)

	// 4. 重载 Prometheus
	if err := g.reloadPrometheus(); err != nil {
		// 这里的失败不是致命的,Prometheus 会在下次启动时加载新规则
		slog.Warn("Failed to reload Prometheus. It will pick up rules on next restart.", "error", err)
	}

	return nil
}

func (g *Generator) fetchSLOData(ctx context.Context) ([]SLOData, error) {
	query := `
	SELECT 
		s.name AS service_name,
		sli.name AS sli_name,
		sli.good_events_query,
		sli.total_events_query,
		slo.target,
		slo.window
	FROM slos slo
	JOIN slis sli ON slo.sli_id = sli.id
	JOIN services s ON sli.service_id = s.id
	ORDER BY s.name, sli.name;
	`
	// CockroachDB 推荐使用 `follower_read_timestamp()` 来进行无锁读取,降低事务冲突
	// 但在这里,我们需要最新的数据,所以直接查询
	// 还可以使用 AS OF SYSTEM TIME '-5s' 来读取5秒前的数据,以获得更好的性能和可用性权衡
	rows, err := g.db.QueryContext(ctx, query)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	var results []SLOData
	for rows.Next() {
		var d SLOData
		var target float64
		if err := rows.Scan(&d.ServiceName, &d.SliName, &d.GoodEventsQuery, &d.TotalEventsQuery, &target, &d.Window); err != nil {
			return nil, err
		}
		// 将 target 转换为 Prometheus 需要的 0.999 格式
		d.Target = fmt.Sprintf("%.6f", target/100.0)
		results = append(results, d)
	}
	return results, rows.Err()
}

func (g *Generator) reloadPrometheus() error {
	pidBytes, err := os.ReadFile(g.pidFile)
	if err != nil {
		return fmt.Errorf("could not read pid file %s: %w", g.pidFile, err)
	}

	pid, err := strconv.Atoi(strings.TrimSpace(string(pidBytes)))
	if err != nil {
		return fmt.Errorf("could not parse PID from %s: %w", g.pidFile, err)
	}

	process, err := os.FindProcess(pid)
	if err != nil {
		return fmt.Errorf("could not find process with PID %d: %w", pid, err)
	}

	slog.Info("Sending SIGHUP to Prometheus process", "pid", pid)
	if err := process.Signal(syscall.SIGHUP); err != nil {
		return fmt.Errorf("failed to send SIGHUP to process %d: %w", pid, err)
	}

	slog.Info("Successfully sent SIGHUP to Prometheus")
	return nil
}

这段 Go 代码包含了一些生产环境的最佳实践:

  • 使用 viper 进行配置管理。
  • 使用 slog 进行结构化日志记录。
  • 数据库连接池配置。
  • 原子性地写入规则文件,防止 Prometheus 读取到损坏的配置。
  • 通过 PID 文件和 SIGHUP 信号安全地热加载 Prometheus。
  • 详尽的错误处理和日志记录。

生成的规则文件 slo.rules.yml 看起来会是这样:

# file: /etc/prometheus/rules/slo.rules.yml
groups:
- name: service_level_objectives
  rules:
  # Service: user-api | SLO: api_availability (28d)
  - record: slo:sli_error_rate:28d
    expr: |
      (
        sum(rate(http_requests_total{job="user-api"}[28d]))
      )
      -
      (
        sum(rate(http_requests_total{job="user-api",status_code=~"2..|3.."}[28d]))
      )
  - record: slo:sli_burn_rate:28d
    expr: |
      slo:sli_error_rate:28d
      /
      (1 - 0.999000)
    labels:
      service_name: "user-api"
      sli_name: "api_availability"
      slo_target: "0.999000"
      slo_window: "28d"

我们在这里计算了错误预算消耗速率(burn rate),这是 SRE 实践中用于设置告警的关键指标。

第三步:构建 ISR 驱动的 Next.js 仪表盘

仪表盘页面将使用 getStaticPropsrevalidate 选项来实现 ISR。

pages/index.tsx:

// file: pages/index.tsx
import { GetStaticProps, NextPage } from 'next';
import { Pool } from 'pg';
import { useEffect, useState } from 'react';

// 定义从数据库和 Prometheus 获取的数据结构
interface SLOStatus {
  serviceName: string;
  sliName: string;
  sloTarget: number;
  window: string;
  currentAvailability: number | null;
  errorBudgetRemaining: number | null;
}

interface HomePageProps {
  slos: SLOStatus[];
  generatedAt: string;
}

// 这是一个简化的Prometheus客户端
// 真实项目中建议使用官方库或更健壮的实现
const queryPrometheus = async (query: string): Promise<number | null> => {
  const prometheusUrl = process.env.PROMETHEUS_URL;
  if (!prometheusUrl) {
    console.error("PROMETHEUS_URL is not set");
    return null;
  }
  
  try {
    const response = await fetch(`${prometheusUrl}/api/v1/query?query=${encodeURIComponent(query)}`);
    if (!response.ok) {
      console.error(`Prometheus query failed with status ${response.status}`);
      return null;
    }
    const data = await response.json();
    if (data.status === 'success' && data.data.result.length > 0) {
      return parseFloat(data.data.result[0].value[1]);
    }
    return null;
  } catch (error) {
    console.error("Error querying Prometheus", error);
    return null;
  }
};


export const getStaticProps: GetStaticProps<HomePageProps> = async () => {
  // 从 CockroachDB 获取 SLO 定义
  // 这里的数据库连接必须在 server-side-only 的代码中
  // 严禁在客户端代码中暴露数据库凭证
  const pool = new Pool({
    connectionString: process.env.DATABASE_URL,
  });

  const { rows } = await pool.query(`
    SELECT 
      s.name AS "serviceName",
      sli.name AS "sliName",
      slo.target,
      slo.window,
      sli.good_events_query AS "goodEventsQuery",
      sli.total_events_query AS "totalEventsQuery"
    FROM slos slo
    JOIN slis sli ON slo.sli_id = sli.id
    JOIN services s ON sli.service_id = s.id
  `);
  await pool.end();

  const slosPromises = rows.map(async (row) => {
    const sloTarget = parseFloat(row.target);
    const window = row.window;

    // 构建 PromQL 查询来获取当前可用性
    const goodQuery = row.goodEventsQuery.replace(/_RANGE_/g, window);
    const totalQuery = row.totalEventsQuery.replace(/_RANGE_/g, window);
    const availabilityQuery = `(${goodQuery}) / (${totalQuery})`;
    
    const currentAvailabilityRaw = await queryPrometheus(availabilityQuery);
    const currentAvailability = currentAvailabilityRaw !== null ? currentAvailabilityRaw * 100 : null;

    let errorBudgetRemaining = null;
    if (currentAvailability !== null) {
      const errorBudget = 100 - sloTarget;
      const errorsMade = 100 - currentAvailability;
      errorBudgetRemaining = (errorBudget - errorsMade) / errorBudget * 100;
    }
    
    return {
      serviceName: row.serviceName,
      sliName: row.sliName,
      sloTarget: sloTarget,
      window: window,
      currentAvailability: currentAvailability,
      errorBudgetRemaining: errorBudgetRemaining
    };
  });
  
  const slos = await Promise.all(slosPromises);

  return {
    props: {
      slos,
      generatedAt: new Date().toISOString(),
    },
    // 关键:设置 revalidate 为 60 秒
    // 页面每 60 秒最多在后台重新生成一次
    revalidate: 60,
  };
};

// 简单的 UI 组件来显示 SLO 状态
const SLOCard: React.FC<{ slo: SLOStatus }> = ({ slo }) => {
    const isHealthy = slo.errorBudgetRemaining !== null && slo.errorBudgetRemaining > 0;
    const budgetColor = isHealthy ? 'text-green-500' : 'text-red-500';
    const budgetWidth = slo.errorBudgetRemaining !== null ? Math.max(0, Math.min(100, slo.errorBudgetRemaining)) : 0;

    return (
        <div className="bg-gray-800 p-4 rounded-lg shadow-lg text-white">
            <div className="flex justify-between items-center mb-2">
                <h3 className="text-lg font-bold">{slo.serviceName}</h3>
                <span className={`px-2 py-1 text-xs rounded ${isHealthy ? 'bg-green-700' : 'bg-red-700'}`}>
                    {isHealthy ? 'Healthy' : 'Alert'}
                </span>
            </div>
            <p className="text-sm text-gray-400">{slo.sliName}</p>
            <div className="mt-4">
                <div className="flex justify-between text-sm mb-1">
                    <span>Error Budget Remaining</span>
                    <span className={budgetColor}>
                        {slo.errorBudgetRemaining !== null ? `${slo.errorBudgetRemaining.toFixed(2)}%` : 'N/A'}
                    </span>
                </div>
                <div className="w-full bg-gray-600 rounded-full h-2.5">
                    <div className={`${isHealthy ? 'bg-green-500' : 'bg-red-500'} h-2.5 rounded-full`} style={{ width: `${budgetWidth}%` }}></div>
                </div>
            </div>
            <div className="mt-2 text-xs text-gray-500 flex justify-between">
                <span>Target: {slo.sloTarget}% over {slo.window}</span>
                <span>Current: {slo.currentAvailability !== null ? `${slo.currentAvailability.toFixed(4)}%` : 'N/A'}</span>
            </div>
        </div>
    );
};


const HomePage: NextPage<HomePageProps> = ({ slos, generatedAt }) => {
    const [timeSince, setTimeSince] = useState('');

    useEffect(() => {
        const update = () => {
            const seconds = Math.floor((new Date().getTime() - new Date(generatedAt).getTime()) / 1000);
            setTimeSince(`${seconds} seconds ago`);
        };
        update();
        const interval = setInterval(update, 1000);
        return () => clearInterval(interval);
    }, [generatedAt]);

    return (
        <div className="bg-gray-900 min-h-screen p-8">
            <div className="max-w-7xl mx-auto">
                <div className="flex justify-between items-center mb-6">
                    <h1 className="text-3xl font-bold text-white">SLO Dashboard</h1>
                    <p className="text-sm text-gray-400">Last generated: {timeSince}</p>
                </div>
                <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
                    {slos.map((slo, index) => (
                        <SLOCard key={index} slo={slo} />
                    ))}
                </div>
            </div>
        </div>
    );
};

export default HomePage;

ISR 的优势在这里体现得淋漓尽致。即使有成百上千的用户同时访问仪表盘,getStaticProps 也只会在后台每 60 秒执行一次。这意味着对 CockroachDB 和 Prometheus 的查询压力是恒定的,与用户数量无关。用户访问到的是一个静态 HTML 页面,响应速度极快,并且能在数据更新后自动刷新。

方案的局限性与未来展望

这个方案成功地将 SLO 管理从易错的、分散的配置文件模式转变为集中、可靠的数据驱动模式。然而,它并非没有缺点。

  1. 控制器的高可用性: 当前的 Go 控制器是一个单点。虽然它的短暂宕机不会影响 Prometheus 的现有规则,但会导致新的 SLO 定义无法生效。在更关键的生产环境中,需要为该控制器实现基于 Leader Election 的高可用部署。

  2. Prometheus 重载机制: SIGHUP 信号是一种简单有效的重载方式,但在大规模集群中,对所有 Prometheus 实例发送信号并确认成功会变得复杂。更云原生的方式是利用 Prometheus Operator,通过更新 PrometheusRule Custom Resource (CR) 来动态管理规则,让 Operator 负责底层的重载逻辑。我们的 Go 控制器可以改造为与 Kubernetes API Server 交互,来创建或更新这些 CR。

  3. 查询性能: 当前仪表盘的实现,是在 getStaticProps 中为每个 SLO 单独查询 Prometheus。当 SLO 数量巨大时,这会导致大量的并发请求。一个优化路径是在 Prometheus 中定义更高级别的记录规则,预先计算所有 SLO 的状态,这样前端只需要一次或几次查询就能获取全部数据。

  4. 写入路径: 目前 SLO 的增删改查依赖于直接操作数据库。下一步是构建一个安全的、带鉴权的内部 API 和 UI,提供给 SRE 和开发团队,实现 SLO 的自助管理,并记录下所有变更的审计日志。


  目录