首页
/ Prometheus 自定义服务发现:基于 file_sd 适配器实现官方发行版未内置的 SD 机制

Prometheus 自定义服务发现:基于 file_sd 适配器实现官方发行版未内置的 SD 机制

2026-09-06 21:19:06作者:虞亚竹Luna

本篇指南基于 Prometheus 仓库中的 custom-sd 示例 展开,讲解如何用 file_sd 适配器把任意“非官方”服务发现(Service Discovery,SD)实现接入 Prometheus:你只需实现一个 Discoverer 接口,由适配器将其产出的目标组(TargetGroup)落盘为 file_sd 兼容的 JSON 文件,再由 prometheus.yml 中的 file_sd_configs 消费。读完本文,你可以照着仓库示例接入 Consul 等任意注册中心,并理解适配器从目标同步、变更判定到原子写文件的完整实现。

一、为什么需要 file_sd 适配器

Prometheus 官方发行版内置了大量 SD 机制(EC2、Kubernetes、Consul、DNS 等,源码位于 discovery/ 目录),但企业内网常存在私有注册中心或自研配置系统,这些机制无法直接写进发行版。custom SD 适配器的思路是“解耦”:

  1. 自定义 SD 逻辑运行在一个独立进程中,实现 Prometheus 的 discovery.Discoverer 接口;
  2. 适配器(adapter 包)负责驱动该 Discoverer,把目标组序列化为 JSON 写入一个文件;
  3. 该文件通过 prometheus.ymlfile_sd 机制交给 Prometheus 抓取,无需使用静态配置(static config)即可动态传递目标。

整体数据流如下:

自定义 Discoverer(如 Consul 查询逻辑)
        │  []*targetgroup.Group(chan)
        ▼
discovery.Manager(discovery/manager.go)
        │  变更后的目标组
        ▼
Adapter:生成 JSON 并原子写入 custom_sd.json
        │  文件变更(fsnotify 磁盘监听)
        ▼
file_sd(discovery/file/file.go)→ Prometheus 抓取目标

从源码结构看,这条链路复用了 Prometheus 官方的服务发现核心组件——适配器内部直接创建 discovery.Manager 来驱动 Discoverer,因此自定义 SD 的行为(目标增删、标签变化)与内置 SD 机制在语义上完全一致,只是输出端从“内存中的 scrape manager”换成了“磁盘文件 + file_sd 监听”。

二、示例目录结构

示例位于 documentation/examples/custom-sd/,包含三部分:

路径 内容
adapter/adapter.go file_sd 适配器核心实现,实现自定义 SD 时无需修改此文件
adapter/adapter_test.go 适配器的单元测试(目标组生成、文件写出)
adapter-usage/main.go 一个可直接运行的示例:为 Consul 实现的 Discoverer + 适配器调用入口

按原 README 的说明,adapter-usage 目录包含一个基础 Consul 服务发现的 Discoverer 实现:它向 Consul 查询所有已知服务(跳过 Consul 自身),把服务的全部元数据以标签(label)形式随目标一起打包进 TargetGroupadapter 目录则是你需要导入并把自定义 Discoverer 传入的适配器代码。

三、file_sd:适配器与 Prometheus 的对接契约

适配器产出的文件必须能被 file_sd 解析。根据官方配置文档 docs/configuration/configuration.md<file_sd_config> 一节,file_sd 的定义是:

File-based service discovery provides a more generic way to configure static targets and serves as an interface to plug in custom service discovery mechanisms.

关键行为与参数(原文档内容,完整继承):

  • 读取一组包含零个或多个 <static_config> 的文件,文件格式支持 JSON 或 YAML
  • 文件变更通过磁盘监听(fsnotify)检测并立即生效;父目录也被隐式监听,以高效处理原子重命名和新增的 glob 匹配文件(若父目录文件过多,监听开销会增大);
  • 仅“结果良好(well-formed)的目标组变更”会被应用;
  • 作为兜底,文件会按 refresh_interval 周期性重读
  • 每个目标在 relabel 阶段带有元标签 __meta_filepath,值为其来源文件路径。
# prometheus.yml 片段
scrape_configs:
  - job_name: custom_sd_consul
    file_sd_configs:
      - files:
          - /var/lib/prometheus/custom_sd.json
        refresh_interval: 5m   # 周期性重读文件的兜底间隔,默认 5m

文件内容格式(JSON / YAML 二选一):

[
  {
    "targets": [ "<host>", ... ],
    "labels": {
      "<labelname>": "<labelvalue>", ...
    }
  }
]
- targets:
  [ - '<host>' ]
  labels:
    [ <labelname>: <labelvalue> ... ]

文件名约束来自 discovery/file/file.go 中的校验逻辑:路径必须以 .json.yml.yaml(大小写不敏感)结尾,最后一段路径可含一个 * 通配符(如 my/path/tg_*.json),正则定义为 ^[^*]*(\*[^/]*)?\.(json|yml|yaml|JSON|YML|YAML)$files 至少需要一个条目。默认刷新间隔在 DefaultSDConfig 中定义为 5 * time.Minute。这也解释了为什么适配器的输出文件默认命名为 custom_sd.json——后缀天然符合 file_sd 的 glob 校验。

四、Discoverer 接口:自定义 SD 的唯一硬性要求

discovery/discovery.go 可以看到接口定义(实现自定义 SD 必须遵守的契约):

// Discoverer provides information about target groups. It maintains a set
// of sources from which TargetGroups can originate. Whenever a discovery provider
// detects a potential change, it sends the TargetGroup through its channel.
//
// 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.
	// It should not close the update channel on returning.
	Run(ctx context.Context, up chan<- []*targetgroup.Group)
}

接口契约可以归纳为四点:

  1. 初始全量:首次应发送全部可发现目标组的完整集合;
  2. 只发“可能变了”的组:Discoverer 不判断是否真的变化,只保证变化发生时发送新 TargetGroup
  3. 不得关闭传入的 channel:返回时不应关闭 up
  4. 响应 contextctx 取消时 Run 必须返回。

适配器正是通过 discovery/manager.goStartCustomProvider 把这个 Discoverer 挂进 discovery.Manager。该方法的源码注释值得注意:“used for sdtool. Only use this if you know what you're doing”(仅供 sdtool 类工具使用,非标准路径),说明这是面向工具侧的扩展入口,而非 prometheus.yml 的常规配置路径。其内部逻辑是创建 Provider、调用 startProvider,后者在两个 goroutine 中分别执行 p.d.Run(ctx, updates)m.updater(ctx, p, updates),完成“Discoverer 产出 → Manager 聚合 → 下游订阅”的闭环。

五、适配器核心实现解析(adapter/adapter.go)

5.1 数据结构

适配器内部用 customSD 结构 描述单个目标组,即 file_sd JSON 的最小形态:

type customSD struct {
	Targets []string          `json:"targets"`
	Labels  map[string]string `json:"labels"`
}

Adapter 结构 持有驱动所需的全部依赖:

// Adapter runs an unknown service discovery implementation and converts its target groups
// to JSON and writes to a file for file_sd.
type Adapter struct {
	ctx     context.Context
	disc    discovery.Discoverer   // 你的自定义 SD 实现
	groups  map[string]*customSD   // 当前已知的目标组快照
	manager *discovery.Manager     // 驱动 Discoverer 的官方 Manager
	output  string                 // 输出文件路径
	name    string                 // 该 SD 机制的名称(Provider 名)
	logger  *slog.Logger
}

5.2 NewAdapter 参数说明

构造入口见 NewAdapter

func NewAdapter(ctx context.Context, file, name string, d discovery.Discoverer,
	logger *slog.Logger, sdMetrics *discovery.SDMetrics, registerer prometheus.Registerer) *Adapter
参数 含义
ctx 生命周期 context;取消时 Manager 会停止 provider
file 输出文件路径,即 prometheus.ymlfile_sd_configs.files 指向的文件
name 自定义 SD 机制名,用作 Manager 中 Provider 的名字
d 你的 discovery.Discoverer 实现实例
logger slog.Logger
sdMetrics *discovery.SDMetrics(机制指标 + refresh 指标),示例中通过 discovery.RegisterSDMetrics / NewRefreshMetrics 注册
registerer prometheus.Registerer,此处示例使用独立的 prometheus.NewRegistry()

NewAdapter 内部通过 discovery.NewManager(ctx, logger, registerer, sdMetrics) 创建 Manager——即适配器直接复用 Prometheus 主进程同款的服务发现管理器,无需自行实现目标聚合、去重与清理逻辑。

5.3 运行主流程

Run() 只有三行:

func (a *Adapter) Run() {
	//nolint:errcheck
	go a.manager.Run()
	a.manager.StartCustomProvider(a.ctx, a.name, a.disc)
	go a.runCustomSD(a.ctx)
}
  • a.manager.Run() 在独立 goroutine 中启动 Manager 主循环;
  • StartCustomProvider 把你的 Discoverer 注册为 Provider 并启动(对应第四节 discovery/manager.go 中的实现);
  • runCustomSD 订阅 Manager 的同步通道。

runCustomSD 持续从 a.manager.SyncCh() 读取目标组全集,并在 ctx 取消或通道关闭时退出:

func (a *Adapter) runCustomSD(ctx context.Context) {
	updates := a.manager.SyncCh()
	for {
		select {
		case <-ctx.Done():
		case allTargetGroups, ok := <-updates:
			// Handle the case that a target provider exits and closes the channel
			// before the context is done.
			if !ok {
				return
			}
			a.refreshTargetGroups(allTargetGroups)
		}
	}
}

5.4 变更判定与原子写文件

refreshTargetGroups 先由 generateTargetGroupsmap[string][]*targetgroup.Group(key 为 SD 类型名)压缩为 map[string]*customSD,再用 reflect.DeepEqual 与旧快照比对:只有发生变化才落盘,避免无意义的文件写入触发 file_sd 反复重载。

目标组到文件的转换规则:

  • 每个 TargetGroupTargets 展平为字符串数组并排序sort.Strings,保证输出稳定,也便于 DeepEqual 判定);
  • 组级 Labels 原样复制;
  • 映射 key 为 fmt.Sprintf("%s:%s:%s", k, group.Source, groupFingerprint.String()),其中 fingerprint 是“所有目标地址指纹 XOR + 组标签指纹”——引入指纹是为了防止 sd_typegroup.Source 都不唯一时 key 冲突。

写文件采用临时文件 + 原子重命名,见 writeOutput

// Writes JSON formatted targets to output file.
func (a *Adapter) writeOutput() error {
	arr := mapToArray(a.groups)
	b, _ := json.MarshalIndent(arr, "", "    ")

	dir, _ := filepath.Split(a.output)
	tmpfile, err := os.CreateTemp(dir, "sd-adapter")
	// ...
	// Close the file immediately for platforms (eg. Windows) that cannot move
	// a file while a process is holding a file handle.
	tmpfile.Close()
	err = os.Rename(tmpfile.Name(), a.output)
	// ...
}

两个细节与 file_sd 的实现特性直接对应:

  1. 临时文件与目标文件同目录filepath.Split 取目录),保证 os.Rename 是同一文件系统内的原子操作——而 file_sd 恰好隐式监听父目录来“efficiently handle atomic renaming”(见 discovery/file/file.go 的 fsnotify 引入与 configuration.md 的说明);
  2. 重命名前先 tmpfile.Close(),源码注释说明这是为了兼容 Windows 等平台“持有句柄时无法移动文件”的限制。

六、完整示例走读:Consul 版 Discoverer(adapter-usage/main.go)

adapter-usage/main.go 是一个可编译运行的完整程序(package main),演示了 README 中“Usage”一节要求的全部动作:替换示例 SD 配置、实现 Discoverer、把实例传给 NewAdapter。源码中对应的 Note: / NOTE: 注释即原文档指代的改造点。

6.1 命令行参数

程序基于 kingpin 提供两个标志(main.go#L41-L43):

标志 默认值 说明
--output.file custom_sd.json file_sd 兼容的输出文件路径
--listen.address localhost:8500 Consul HTTP API 监听地址

6.2 自定义 SD 配置(替换点一)

按注释 “Note: create a config struct for your custom SD type here” 定义自己的配置结构(main.go#L80-L85):

// Note: create a config struct for your custom SD type here.
type sdConfig struct {
	Address         string
	TagSeparator    string
	RefreshInterval int
}

注意这与官方内置 Consul SD 不同:官方机制的配置走 prometheus.ymlconsul_sd_configs 并由 discovery.RegisterConfig 注册,而这里因为运行在独立进程中,配置由程序自身的命令行/硬编码给出,在 main() 中组装(main.go#L259-L264):

// NOTE: create an instance of your new SD implementation here.
cfg := sdConfig{
	TagSeparator:    ",",
	Address:         *listenAddress,
	RefreshInterval: 30, // 每 30 秒轮询一次 Consul
}

6.3 实现 Discoverer 接口(替换点二)

按注释 “Note: This is the struct with your implementation of the Discoverer interface (see Run function)” 定义结构(main.go#L87-L95),核心是必须实现的 Run 函数。示例的 Run 方法 逻辑:

  1. refreshInterval 定时轮询 GET http://<address>/v1/catalog/services 获取全部服务名;
  2. 跳过 consul 服务本身(对应 README“except Consul itself”的描述);
  3. 对每个服务调用 GET /v1/catalog/service/<name>,由 parseServiceNodes 解析为 *targetgroup.Group
  4. 每轮结束后发送 ch <- tgs,然后等待下一 tick 或 ctx.Done()

几个值得学习的实现细节:

服务地址选择:若服务注册了 ServiceAddress(可能来自远端节点注册),则用它拼接端口,否则回退到节点 Address

var addr string
if node.ServiceAddress != "" {
	addr = net.JoinHostPort(node.ServiceAddress, strconv.Itoa(node.ServicePort))
} else {
	addr = net.JoinHostPort(node.Address, strconv.Itoa(node.ServicePort))
}

元标签集:示例把 Consul 的全部服务数据以 __meta_consul_* 标签形式带上(main.go#L46-L57 定义):

元标签 内容
__meta_consul_address 节点地址
__meta_consul_node 节点名
__meta_consul_tags 服务标签(前后包裹分隔符,使 relabel 正则无需考虑位置)
__meta_consul_service_address 可选的服务地址
__meta_consul_service_port 服务端口
__meta_consul_service_id 服务 ID

节点元数据(NodeMeta)还会经 strutil.SanitizeLabelName 清洗后逐个附加为 __meta_<key> 标签。这些 __meta_* 标签只存在于 relabel 阶段,你可以在 relabel_configs 中把它们映射为正式标签或据此过滤目标。

消失目标的处理discovery 结构维护 oldSourceList,每轮记录本次出现的服务;若某服务从 Consul 目录中消失,则补发一个只有 Source、无目标的空 TargetGroupmain.go#L215-L222),确保下游能及时清理旧目标。

错误容忍策略:注释明确说明,对“单个服务查询失败”视为本轮致命(break 跳出本轮),宁可保留部分陈旧目标,也不因一次超时就提交不完整的目标列表;若服务真的消失,下一轮外层循环会处理(main.go#L190-L194)。

6.4 组装并启动适配器(替换点三)

main() 的最后几步(main.go#L276-L291)展示了把 Discoverer 交给适配器的标准写法:

reg := prometheus.NewRegistry()
refreshMetrics := prom_discovery.NewRefreshMetrics(reg)
mechanismMetrics, err := prom_discovery.RegisterSDMetrics(reg, refreshMetrics)
if err != nil {
	logger.Error("failed to register service discovery metrics", "err", err)
	os.Exit(1)
}
sdMetrics := &prom_discovery.SDMetrics{
	MechanismMetrics: mechanismMetrics,
	RefreshManager:   refreshMetrics,
}

sdAdapter := adapter.NewAdapter(ctx, *outputFile, "exampleSD", disc, logger, sdMetrics, reg)
sdAdapter.Run()

<-ctx.Done()

说明:

  • RegisterSDMetrics 会注册 scrape_sd_discovered_targetsscrape_sd_running_duration_secondsscrape_sd_discovery_refresh_successscrape_sd_refresh_duration_seconds 等机制指标(对应 discovery/metrics.go 中的定义),注册到独立 registry,不会污染 Prometheus 主进程;
  • 第三个参数 "exampleSD"name,在 Manager 中作为 Provider 名出现;
  • 由于 ctxcontext.Background()<-ctx.Done() 永不返回,进程将持续运行——这正是适配器作为常驻 sidecar 进程的形态。

七、测试依据

适配器的正确性由 adapter_test.go 覆盖,可作为实现自定义 SD 时的验收参照:

  • TestGenerateTargetGroups 用四组表驱动用例验证目标组转换:空组(key 为 customSD:Consul:0000000000000000,即零指纹)、多目标组(如 customSD:Azure:282a007a18fadbbb)、空/非空混合、以及乱序 IP 的排序稳定性(输入 192.168.1.55, 192.168.1.44,输出排序后的 192.168.1.44, 192.168.1.55)——最后一个用例专门验证“地址乱序不导致无谓的重复写文件”;
  • TestWriteOutput 验证 writeOutput 能真实地把 JSON 落盘。

八、落地清单与注意事项

  1. 改造三步(即原 README 的 Usage 要求):在 adapter-usage/main.go 中替换示例 sdConfig、实现自己的 discovery.Discoverer(重点是 Run 函数)、把其实例传给 adapter.NewAdapter;所有改造点均有 Note: / NOTE: 注释标记,adapter/adapter.go 无需改动(其头部注释明确写明 “you do not need to edit this file when implementing a custom sd”)。

  2. 输出文件名必须匹配 file_sd 的 glob:以 .json / .yml / .yaml 结尾,否则 file_sd 配置校验 会拒绝该配置。

  3. 适配器是独立进程:它与 Prometheus 主进程解耦,需要自行保证常驻(systemd / 容器等);它崩溃后文件停止更新,Prometheus 会继续使用文件中的最后目标集直到超时清理,这一点从源码结构看与官方各 SD 机制的单点行为一致。

  4. 轮询间隔由你的 Discoverer 决定:Consul 示例是 30 秒定时轮询(RefreshInterval: 30),若你的机制支持 watch/事件推送(如 Consul 的 block queries),可在 Run 中用事件驱动代替 ticker,降低目标变更延迟;file_sd 侧的 refresh_interval(默认 5m)只是磁盘监听的兜底。

  5. 变更判定基于 DeepEqualgenerateTargetGroups 已对目标排序以保证输出稳定;自定义 Run 中若每轮都发送内容相同的目标组,适配器不会重复写文件,可放心发送全量。

  6. 运行方式:示例属于主模块(导入路径为 github.com/prometheus/prometheus/documentation/examples/custom-sd/adapter),可在仓库根目录直接构建运行,例如:

    go run ./documentation/examples/custom-sd/adapter-usage \
      --output.file=/var/lib/prometheus/custom_sd.json \
      --listen.address=localhost:8500
    

    前提是目标注册中心(示例为 Consul,默认 localhost:8500)可用。

九、小结

custom-sd 示例展示了 Prometheus 服务发现体系的标准扩展姿势:自定义侧只需要一个满足 Discoverer 契约Run 实现,官方侧由 Adapter 负责驱动 discovery.Manager、做变更判定与原子写文件,Prometheus 侧则零改动地用 file_sd_configs 消费。相比把第三方 SD 直接编译进发行版,这种“进程级适配 + 文件接口”的方案边界清晰、可独立升级与测试(见 adapter_test.go),是把私有注册中心、自研配置系统接入 Prometheus 抓取目标的最通用路径。

登录后查看全文
热门项目推荐
相关项目推荐