为 Kubernetes 构建自定义指标导出器

Kubernetes 内置了对 CPU 和内存的感知能力,但大多数实际的扩缩容决策所依赖的信号完全超出了这个范围: 队列中有多少消息在等待、上一个批处理作业花费了多长时间、Pod 持有多少个活跃的 WebSocket 连接。 当内置指标不够用时,指标导出器(metrics exporter) 可以填补这一空白。

本文将从头开始编写一个指标导出器,将其打包为容器,并将其接入集群,以便 Prometheus —— 最终是 HorizontalPodAutoscaler —— 能够消费这些指标。

指标导出器实际上做什么

导出器是一个小型 HTTP 服务器,只有一个职责:将应用状态以文本形式暴露在 /metrics 端点上。 Prometheus 定期抓取该端点,存储时间序列数据,并使其可用于查询、告警和自动扩缩容规则。

在某些情况下,你可以直接在应用中添加监控 —— 嵌入 Prometheus 客户端库并从同一进程中暴露 /metrics —— 而不是运行单独的导出器。 当数据源位于应用外部或你无法控制应用代码时,独立的导出器更有意义。

Prometheus 期望的格式是纯文本 —— 每行一个指标,包含名称、可选标签和数值。 客户端库会处理序列化,因此实际上你只需要决定要测量什么,并在值变化时调用正确的函数。

选择要暴露的内容

在编写任何代码之前,最好先确定你要处理的信号类型。Prometheus 数据模型有三种主要类型:

  • Counter(计数器)只会增加。 它们是统计总量的正确工具:服务的请求数、处理的作业数、遇到的错误数。永远不要用计数器来表示可能下降的值。

  • Gauge(仪表)代表一个可以自由上升和下降的值的当前快照。 队列深度、活跃连接数和缓存大小都是仪表。

  • Histogram(直方图)记录观察值的分布,如请求延迟。 它们允许你计算百分位数(p99、p50)而不仅仅是平均值。

一旦你知道哪种类型适合你的信号,选择一个遵循 <namespace>_<name>_<unit> 约定的 snake_case 名称。 一个作业处理器可能会暴露 worker_jobs_processed_total(计数器)、worker_queue_depth(仪表) 和 worker_job_duration_seconds(直方图)。清晰的名称可以节省以后的调试时间。

设置项目

Go Prometheus 客户端是 Kubernetes 生态系统中导出器最常见的选择, 主要因为大多数官方 Kubernetes 组件都使用同一个库。首先创建一个模块并引入依赖:

mkdir my-exporter && cd my-exporter
go mod init example.com/my-exporter
go get github.com/prometheus/client_golang/prometheus
go get github.com/prometheus/client_golang/prometheus/promhttp

注册指标

创建 main.go。首先要做的是声明指标并将它们注册到 Prometheus 的默认注册表中。 注册告诉库这些指标存在,因此即使在记录第一个观测值之前,它们也会出现在输出中:

package main

import (
    "log"
    "net/http"

    "github.com/prometheus/client_golang/prometheus"
    "github.com/prometheus/client_golang/prometheus/promhttp"
)

var (
    jobsProcessed = prometheus.NewCounterVec(
        prometheus.CounterOpts{
            Name: "worker_jobs_processed_total",
            Help: "Total number of jobs processed, partitioned by status.",
        },
        []string{"status"},
    )

    queueDepth = prometheus.NewGauge(prometheus.GaugeOpts{
        Name: "worker_queue_depth",
        Help: "Current number of jobs waiting in the queue.",
    })

    jobDuration = prometheus.NewHistogram(prometheus.HistogramOpts{
        Name:    "worker_job_duration_seconds",
        Help:    "Time spent processing a single job.",
        Buckets: prometheus.DefBuckets,
    })
)

func init() {
    prometheus.MustRegister(jobsProcessed, queueDepth, jobDuration)
}

prometheus.MustRegister 在重复注册时会 panic,这使得配置错误在启动时就很明显,而不是在运行时静默发生。 如果你将此导出器嵌入到其它包也会添加监控的库中,建议使用 prometheus.Register 并自行处理错误。

收集实际值

指标注册后,下一步是保持它们的更新。 下面的模式显示了一个轮询循环 —— 一个 goroutine,定期从应用拥有的任何数据源读取数据并更新注册的指标。 将模拟值替换为对数据库、内部 API 或消息代理的实际调用:

import (
    "math/rand"
    "time"
)

func collectMetrics() {
    for {
        // Replace these with real reads from your application.
        depth := float64(rand.Intn(50))
        queueDepth.Set(depth)

        start := time.Now()
        time.Sleep(time.Duration(rand.Intn(200)) * time.Millisecond)
        jobDuration.Observe(time.Since(start).Seconds())
        jobsProcessed.WithLabelValues("success").Inc()

        time.Sleep(5 * time.Second)
    }
}

轮询间隔(此处为五秒)应短于 Prometheus 的抓取间隔,以便每次抓取都能看到最新值。 大多数集群部署中的默认抓取间隔是十五秒,这给了你足够的余量。

暴露端点

main 中将收集循环和 HTTP 处理器连接在一起。 /healthz 路径与 /metrics 一起提供,使 Kubernetes 有一个存活探针目标,而不会在健康路由上暴露指标数据:

func main() {
    go collectMetrics()

    http.Handle("/metrics", promhttp.Handler())
    http.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
        w.WriteHeader(http.StatusOK)
    })

    log.Println("Listening on :8080")
    if err := http.ListenAndServe(":8080", nil); err != nil {
        log.Fatalf("server error: %v", err)
    }
}

在构建镜像之前,先在本地验证输出:

go run .
curl http://localhost:8080/metrics | grep worker_

你应该看到三个 # HELP# TYPE 块,后面跟着当前的指标值。 如果这些行出现,说明导出器工作正常,可以进行容器化了。

构建容器镜像

多阶段构建使最终镜像更小,并避免将 Go 工具链运送到生产环境。 第一阶段编译静态链接的二进制文件;第二阶段只将该二进制文件复制到最小化的基础镜像中。 下面的示例使用 Docker,但相同的模式适用于任何 OCI 兼容的构建工具,如 Buildah 或 Podman:

FROM golang:1.21-alpine AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /exporter .

FROM gcr.io/distroless/static:nonroot
COPY --from=builder /exporter /exporter
EXPOSE 8080
ENTRYPOINT ["/exporter"]

distroless/static:nonroot 不包含 shell、不包含包管理器, 并且默认以非 root 用户身份运行,这在无需额外配置的情况下满足了大多数集群安全策略。

构建并推送镜像,将 <registry> 替换为你自己的镜像仓库地址:

docker build -t <registry>/my-exporter:v1.0.0 .
docker push <registry>/my-exporter:v1.0.0

部署到集群

运行导出器只需要两个清单:一个管理 Pod 生命周期的 Deployment, 以及一个为 Prometheus 提供稳定抓取地址的 Service。

下面的示例使用 monitoring 命名空间,这是一起运行 Prometheus 和相关组件时的常见约定。根据你自己的集群设置调整命名空间。

Deployment 设置了适合轻量级边车式进程的保守资源限制,并使用 /healthz 路由作为存活探针:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: my-exporter
  namespace: monitoring
  labels:
    app.kubernetes.io/name: my-exporter
spec:
  replicas: 1
  selector:
    matchLabels:
      app.kubernetes.io/name: my-exporter
  template:
    metadata:
      labels:
        app.kubernetes.io/name: my-exporter
    spec:
      containers:
      - name: exporter
        image: <registry>/my-exporter:v1.0.0
        ports:
        - name: metrics
          containerPort: 8080
        livenessProbe:
          httpGet:
            path: /healthz
            port: 8080
          initialDelaySeconds: 5
          periodSeconds: 10
        resources:
          requests:
            cpu: 50m
            memory: 32Mi
          limits:
            cpu: 100m
            memory: 64Mi

Service 将端口命名为 metrics,下一节中的 ServiceMonitor 将通过该名称引用它:

apiVersion: v1
kind: Service
metadata:
  name: my-exporter
  namespace: monitoring
  labels:
    app.kubernetes.io/name: my-exporter
spec:
  selector:
    app.kubernetes.io/name: my-exporter
  ports:
  - name: metrics
    port: 8080
    targetPort: metrics

应用这两个清单:

kubectl apply -f deployment.yaml -f service.yaml

告诉 Prometheus 去哪里找

如何配置抓取取决于 Prometheus 的安装方式。

选项 1:Prometheus Operator(ServiceMonitor)

如果你使用 Prometheus Operatorkube-prometheus-stack Helm Chart 安装了 Prometheus, 那么在创建 ServiceMonitor 之前,Operator 必须在你的集群中运行。 release 标签必须与 Prometheus 资源上配置的标签选择器匹配——标准 Helm 安装的默认值是 kube-prometheus-stack

apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
  name: my-exporter
  namespace: monitoring
  labels:
    release: kube-prometheus-stack
spec:
  selector:
    matchLabels:
      app.kubernetes.io/name: my-exporter
  endpoints:
  - port: metrics
    interval: 15s
    path: /metrics

选项 2:基于注解的发现

如果你的 Prometheus 使用基于注解的 Pod 发现,那么你需要在 Prometheus 配置中添加匹配的 scrape_config 规则 —— 请与管理你 Prometheus 安装的人确认它已到位。

无论你使用哪种抓取方法,都可以向 Pod 模板添加以下两个注解。 Prometheus Operator 会忽略它们,但基于注解的设置会自动抓取它们:

annotations:
  prometheus.io/scrape: "true"
  prometheus.io/port: "8080"     # omit if not using annotation-based discovery
  prometheus.io/path: "/metrics" # omit if not using annotation-based discovery

如果你不确定你的集群使用哪种设置,ServiceMonitor 方法更明确且更容易调试。

验证抓取

端口转发到 Prometheus 服务并打开目标页面,确认导出器已被发现:

kubectl port-forward svc/prometheus-operated 9090 -n monitoring

导航到 http://localhost:9090/targetsmy-exporter 目标应该显示状态为 UP。 如果显示为 DOWN,请检查 ServiceMonitor 的 release 标签是否匹配以及 Pod 是否正在运行:

kubectl get pods -n monitoring -l app.kubernetes.io/name=my-exporter
kubectl describe servicemonitor my-exporter -n monitoring

目标健康后,在表达式浏览器中运行快速查询,确认数据正在流动:

rate(worker_jobs_processed_total{status="success"}[2m])

非零结果意味着整个管道正在工作:你的应用正在生成数据,Prometheus 正在抓取它,时间序列已存储且可查询。

下一步是什么

一个工作正常的导出器是基础,而不是终点。自然的下一步是将这些指标呈现给 HorizontalPodAutoscaler, 以便你的工作负载根据实际驱动负载的信号进行扩缩容,而不仅仅是 CPU。 这需要一个指标适配器 —— Prometheus Adapter 是部署最广泛的选项 —— 它将你的自定义指标注册到 Kubernetes Custom Metrics API。一旦注册,集群中的任何 HorizontalPodAutoscaler 都可以在其 metrics 块中直接引用 worker_queue_depthworker_jobs_processed_total

有关该设置的演练,请参阅 基于多个指标和自定义指标进行自动扩缩容。 有关涵盖数据库、消息代理和云服务的现成导出器目录,请访问 Prometheus exporters and integrations 页面,这是一个很好的起点。

最后修改 July 06, 2026 at 10:42 AM PST: [zh-cn]Add blog: custom-metrics-exporter-kubernetes (de3ee263a4)