Prometheus 自定义服务发现:基于 file_sd 适配器实现官方发行版未内置的 SD 机制
本篇指南基于 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 适配器的思路是“解耦”:
- 自定义 SD 逻辑运行在一个独立进程中,实现 Prometheus 的
discovery.Discoverer接口; - 适配器(
adapter包)负责驱动该 Discoverer,把目标组序列化为 JSON 写入一个文件; - 该文件通过
prometheus.yml的file_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)形式随目标一起打包进 TargetGroup;adapter 目录则是你需要导入并把自定义 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)
}
接口契约可以归纳为四点:
- 初始全量:首次应发送全部可发现目标组的完整集合;
- 只发“可能变了”的组:Discoverer 不判断是否真的变化,只保证变化发生时发送新
TargetGroup; - 不得关闭传入的 channel:返回时不应关闭
up; - 响应 context:
ctx取消时Run必须返回。
适配器正是通过 discovery/manager.go 的 StartCustomProvider 把这个 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.yml 中 file_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 先由 generateTargetGroups 把 map[string][]*targetgroup.Group(key 为 SD 类型名)压缩为 map[string]*customSD,再用 reflect.DeepEqual 与旧快照比对:只有发生变化才落盘,避免无意义的文件写入触发 file_sd 反复重载。
目标组到文件的转换规则:
- 每个
TargetGroup的Targets展平为字符串数组并排序(sort.Strings,保证输出稳定,也便于 DeepEqual 判定); - 组级
Labels原样复制; - 映射 key 为
fmt.Sprintf("%s:%s:%s", k, group.Source, groupFingerprint.String()),其中 fingerprint 是“所有目标地址指纹 XOR + 组标签指纹”——引入指纹是为了防止sd_type与group.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 的实现特性直接对应:
- 临时文件与目标文件同目录(
filepath.Split取目录),保证os.Rename是同一文件系统内的原子操作——而 file_sd 恰好隐式监听父目录来“efficiently handle atomic renaming”(见 discovery/file/file.go 的 fsnotify 引入与 configuration.md 的说明); - 重命名前先
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.yml 的 consul_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 方法 逻辑:
- 按
refreshInterval定时轮询GET http://<address>/v1/catalog/services获取全部服务名; - 跳过
consul服务本身(对应 README“except Consul itself”的描述); - 对每个服务调用
GET /v1/catalog/service/<name>,由 parseServiceNodes 解析为*targetgroup.Group; - 每轮结束后发送
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、无目标的空 TargetGroup(main.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_targets、scrape_sd_running_duration_seconds、scrape_sd_discovery_refresh_success、scrape_sd_refresh_duration_seconds等机制指标(对应 discovery/metrics.go 中的定义),注册到独立 registry,不会污染 Prometheus 主进程;- 第三个参数
"exampleSD"即name,在 Manager 中作为 Provider 名出现; - 由于
ctx是context.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 落盘。
八、落地清单与注意事项
-
改造三步(即原 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”)。 -
输出文件名必须匹配 file_sd 的 glob:以
.json/.yml/.yaml结尾,否则 file_sd 配置校验 会拒绝该配置。 -
适配器是独立进程:它与 Prometheus 主进程解耦,需要自行保证常驻(systemd / 容器等);它崩溃后文件停止更新,Prometheus 会继续使用文件中的最后目标集直到超时清理,这一点从源码结构看与官方各 SD 机制的单点行为一致。
-
轮询间隔由你的 Discoverer 决定:Consul 示例是 30 秒定时轮询(
RefreshInterval: 30),若你的机制支持 watch/事件推送(如 Consul 的 block queries),可在Run中用事件驱动代替 ticker,降低目标变更延迟;file_sd 侧的refresh_interval(默认 5m)只是磁盘监听的兜底。 -
变更判定基于 DeepEqual:
generateTargetGroups已对目标排序以保证输出稳定;自定义Run中若每轮都发送内容相同的目标组,适配器不会重复写文件,可放心发送全量。 -
运行方式:示例属于主模块(导入路径为
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 抓取目标的最通用路径。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0624
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00