团队规模扩大后,维护静态的服务等级目标(SLO)YAML 文件变成了一场灾难。每个新服务、每个 SLO 的微调,都需要经过代码审查、合并,然后通过 CI/CD 推送到生产环境的 Prometheus 服务器上。这个流程虽然规范,但在紧急情况下极其缓慢,并且很容易因为人为失误导致配置漂移。更糟糕的是,SLO 定义散落在各个代码仓库中,缺乏一个统一、可审计的视图。我们需要的不是将 SLO 作为配置代码(Config as Code),而是将其视为需要被严谨管理的核心业务数据(SLO as Data)。
我们的初步构想是构建一个动态 SLO 平台。核心目标是:SLO 定义应该存储在一个高可用的、支持事务的数据库中,而不是 Git 仓库里的 YAML 文件。一个独立的控制器服务负责从数据库中读取这些定义,将其动态翻译成 Prometheus 的告警和记录规则,并自动热加载 Prometheus。前端则需要一个高性能、高可用的仪表盘来展示所有服务的 SLO 状态。
技术选型决策是这个项目的第一个关键节点。
数据库: 我们需要一个分布式、强一致性的 SQL 数据库。SLO 定义是关键元数据,我们不能容忍任何数据丢失或不一致。同时,我们的团队遍布全球,数据库需要具备多区域(multi-region)部署能力以降低延迟。PostgreSQL 集群方案过于复杂,维护成本高。最终我们选择了 CockroachDB,它的分布式事务、水平扩展能力和对 PostgreSQL 协议的兼容性完美契合我们的需求。
监控与告警: Prometheus 是既定选择,它是我们现有监控体系的核心。挑战在于如何动态更新其规则。
前端仪表盘: 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_query
和 total_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 仪表盘
仪表盘页面将使用 getStaticProps
和 revalidate
选项来实现 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 管理从易错的、分散的配置文件模式转变为集中、可靠的数据驱动模式。然而,它并非没有缺点。
控制器的高可用性: 当前的 Go 控制器是一个单点。虽然它的短暂宕机不会影响 Prometheus 的现有规则,但会导致新的 SLO 定义无法生效。在更关键的生产环境中,需要为该控制器实现基于 Leader Election 的高可用部署。
Prometheus 重载机制:
SIGHUP
信号是一种简单有效的重载方式,但在大规模集群中,对所有 Prometheus 实例发送信号并确认成功会变得复杂。更云原生的方式是利用 Prometheus Operator,通过更新PrometheusRule
Custom Resource (CR) 来动态管理规则,让 Operator 负责底层的重载逻辑。我们的 Go 控制器可以改造为与 Kubernetes API Server 交互,来创建或更新这些 CR。查询性能: 当前仪表盘的实现,是在
getStaticProps
中为每个 SLO 单独查询 Prometheus。当 SLO 数量巨大时,这会导致大量的并发请求。一个优化路径是在 Prometheus 中定义更高级别的记录规则,预先计算所有 SLO 的状态,这样前端只需要一次或几次查询就能获取全部数据。写入路径: 目前 SLO 的增删改查依赖于直接操作数据库。下一步是构建一个安全的、带鉴权的内部 API 和 UI,提供给 SRE 和开发团队,实现 SLO 的自助管理,并记录下所有变更的审计日志。