# 服务发现 · 去抓谁

> 容器来来去去，不可能手工维护目标列表。这一章读 discovery/ ——一个只有一个方法的接口撑起了 30 种服务发现实现，以及「推送全量而非增量」这个反直觉却极聪明的契约设计。

- 作者：David（道雾轩）
- 专栏：指标采集与观测（https://daiw.org/manual/observability.md）
- 最后更新：2026-10-01
- 原文：https://daiw.org/manual/observability/service-discovery
- 转载与引用：请注明出处并附原文链接（https://daiw.org/about/copyright）

# 服务发现 · 去抓谁

[pull 模型](https://daiw.org/manual/observability/prometheus-architecture)有个前提：**Prometheus 得知道去抓谁**。在容器时代，pod 来来去去、IP 随时变，手工维护目标列表是不可能的。这就是 `discovery/` 的活。

## 一个方法的接口，30 种实现

先看接口有多小（真实源码 `discovery/discovery.go:35`）：

```go
// Discoverer does not know if an actual change happened.
// It does guarantee that it sends the new TargetGroup whenever a change happens.
//
// Discoverers should initially send a full set of all discoverable TargetGroups.
type Discoverer interface {
    // Run hands a channel to the discovery provider (Consul, DNS, etc.) through which
    // it can send updated target groups. It must return when the context is canceled.
    Run(ctx context.Context, up chan<- []*targetgroup.Group)
}
```

**就一个方法。** 而 `discovery/` 目录下有 **30 个子目录**：`kubernetes`、`consul`、`dns`、`file`、`http`、`aws`、`azure`、`gce`、`eureka`、`nomad`、`marathon`、`openstack`、`hetzner`、`linode`、`scaleway`、`ovhcloud`、`digitalocean`、`puppetdb`、`moby`（Docker）……

<Callout type="info">
  **一个只有一个方法的接口，撑起了 30 种服务发现——这是[接口即边界](https://daiw.org/manual/observability/reading-the-source)的教科书级示范。** 每种 SD 只需回答一个问题：“**当前该抓的目标有哪些？**”，通过一个 channel 源源不断地告诉 Prometheus。

  它们内部千差万别（K8s 用 watch API、DNS 定期解析、Consul 用长轮询、file 监听文件变化），但**对上只暴露同一个 `Run`**。于是：
  - **加一种新 SD** = 实现一个方法，核心代码一行不改。
  - **Prometheus 核心**完全不知道 Kubernetes 或 Consul 的存在——它只认 channel 里流出来的 `[]*targetgroup.Group`。

  **接口越小，能塞进去的实现就越多。** 这个接口小到极致，所以生态长到 30 种。
</Callout>

## 反直觉的契约：推送全量，不推增量

注释里藏着一个**极重要、也极聪明**的设计决定，值得逐句读：

> **Discoverer does not know if an actual change happened.** It does guarantee that it sends the new TargetGroup **whenever a change happens**. Discoverers should initially send a **full set** of all discoverable TargetGroups.

<Callout type="info">
  **契约是：每次变化就把**当前全量**推过来——不是推“增加了谁、删除了谁”这样的增量事件，而是推“**现在应该是这个样子**”。而且 Discoverer **不必知道到底变没变**（多推几次全量是允许的）。**

  这看起来很浪费（每次变化都传全量），但它换来了**巨大的简化**：

  - **实现方极简** —— 写一个 SD 不用维护“上次是什么状态、这次 diff 出了什么”。K8s watch 到任何事件？**重新列一遍当前所有 pod 推过去**就完了。**不用管增量逻辑，不会出增量 bug**。
  - **天生自愈** —— 增量模型最怕“漏掉一个事件”→ 状态永久错位、且**没人发现**。全量模型下，**下一次推送自动修正一切**。任何不一致都是暂时的。
  - **允许“不知道变没变”** —— 这句注释是点睛之笔：Discoverer **可以偷懒**，拿不准就推一次全量，反正核心会自己 diff。这让实现方的心智负担降到最低。

  **这是“声明式”胜过“命令式”的经典案例**——和 [Kubernetes 的 reconcile 循环](https://daiw.org/manual/observability/service-discovery)同一个哲学：**不描述“怎么变”，只描述“该是什么样”，由消费方去对齐**。代价是传输和 diff 的开销，但换来的是**永远不会状态漂移**。对一个必须[比被监控系统更可靠](https://daiw.org/manual/observability/prometheus-architecture)的监控系统来说，这笔交易毫无悬念。
</Callout>

## 目标不只是地址

Discoverer 吐出的是 `targetgroup.Group`（源码 `discovery/targetgroup/targetgroup.go:24`）：

```go
type Group struct {
    // Targets is a list of targets identified by a label set. Each target is
    // uniquely identifiable in the group by its address label.
    Targets []model.LabelSet
    // Labels is a set of labels that is common across all targets in the group.
    Labels model.LabelSet
    // Source is an identifier that describes a group of targets.
    Source string
}
```

<Callout type="info">
  **注意：目标**不是一个 IP 字符串，而是一组标签**（`model.LabelSet`）。** 这个设计非常关键——SD 不只告诉你“去抓 10.1.2.3:8080”，它还**附带一堆元数据标签**：

  - Kubernetes SD 会带上 `__meta_kubernetes_pod_name`、`__meta_kubernetes_namespace`、`__meta_kubernetes_pod_label_*`（pod 上的所有 label！）、`__meta_kubernetes_pod_annotation_*`……
  - EC2 SD 会带上实例类型、可用区、tag……

  这些 `__meta_*` 标签是**[relabeling](https://daiw.org/manual/observability/relabeling) 的原料**：[下一章](https://daiw.org/manual/observability/relabeling)你会看到，正是靠它们，你才能写出“只抓带某个注解的 pod”、“把 K8s 的 namespace 变成指标的 `namespace` 标签”这类规则。

  `Group` 里的 `Labels` 是**组级公共标签**（整组共享），`Targets` 里每个目标再带自己的标签——这是个省内存的小设计（公共部分不必每个目标重复一遍）。
</Callout>

## 实战：最常用的三种

30 种里，现实中你 90% 只会用到这几种：

| SD | 场景 | 关键点 |
| --- | --- | --- |
| **kubernetes** | K8s 集群里（最常用） | 能发现 pod/service/endpoints/node/ingress，带海量 `__meta_kubernetes_*` 标签 |
| **file** | 静态或外部系统生成 | 监听 JSON/YAML 文件变化，**由别的系统写这个文件**——万能逃生舱 |
| **static** | 手工写死 | 测试和少量固定目标 |
| **http** | 自定义 | Prometheus 定期 GET 一个 URL 拿目标列表，**你用任何语言实现它** |

<Callout type="info">
  **`file_sd` 和 `http_sd` 是“万能逃生舱”，值得记住。** 如果你的目标来源是个奇怪的内部系统（自研的 CMDB、某个数据库表），**不需要给 Prometheus 提 PR 写一个新 SD**——你只要写个小脚本，定期把目标列表输出成 JSON 文件（`file_sd`）或暴露成一个 HTTP 接口（`http_sd`），Prometheus 就能用。

  **这是“小接口 + 通用逃生舱”的组合拳：核心只认一个接口，而 `file_sd`/`http_sd` 把这个接口开放给了整个外部世界**——任何系统都能接进来，而 Prometheus 一行代码不用改。
</Callout>

## 小结与下一章

服务发现回答“[去抓谁](https://daiw.org/manual/observability/prometheus-architecture)”。**`Discoverer` 接口只有一个方法** `Run(ctx, up chan<- []*targetgroup.Group)`（`discovery/discovery.go:35`），却撑起 **30 种实现**（[接口越小，生态越大](https://daiw.org/manual/observability/reading-the-source)）。它的契约反直觉却极聪明：**推送全量而非增量，且实现方不必知道到底变没变**——换来[实现极简（不用维护 diff）](https://daiw.org/manual/observability/service-discovery)、[天生自愈（下次推送自动修正，永不状态漂移）](https://daiw.org/manual/observability/service-discovery)，是“声明式胜过命令式”的典型。目标**不是 IP 而是一组标签**（`targetgroup.Group`），SD 附带的 `__meta_*` 元数据标签是 [relabeling 的原料](https://daiw.org/manual/observability/relabeling)。实战常用 **kubernetes**，而 **`file_sd`/`http_sd` 是万能逃生舱**——任何外部系统都能借它接入，核心不用改。

目标发现了，但“发现的”和“该抓的”往往不是一回事。下一章讲那个把两者连起来、也是 Prometheus 里出了名强大、也出了名烧脑的机制。👉 [relabeling](https://daiw.org/manual/observability/relabeling)。

## 参考资料

- [1] Prometheus 源码 `discovery/discovery.go:35`（`Discoverer` 接口与契约注释）、`discovery/targetgroup/targetgroup.go:24`（`Group`）。
- [2] Prometheus 源码 `discovery/` 下 30 个 SD 实现（`kubernetes/`、`consul/`、`file/`、`http/` 等）。
- [3] Prometheus 官方文档：Configuration — `<kubernetes_sd_config>`、`<file_sd_config>`、`<http_sd_config>`。
