首页
/ Istio 多端口指标合并深度解析:prometheus.istio.io/scrape-targets 从设计到实现

Istio 多端口指标合并深度解析:prometheus.istio.io/scrape-targets 从设计到实现

2026-09-05 21:30:55作者:史锋燃Gardner

本文基于 Istio 设计提案 multi-port-metrics-merging.md(对应社区 issue 59567)展开,完整覆盖多容器 Pod 指标合并的问题背景、prometheus.istio.io/scrape-targets 注解的设计取舍、注入 Webhook 与 pilot-agent 的改造细节,以及基于并发 fan-out 的指标合并策略。结合 pilot-agent 状态服务器源码注入 Webhook 源码 的实际实现,读者读完后将掌握:如何在 STRICT mTLS 下让 Prometheus 抓取同一 Pod 内多个容器的指标、注解格式的解析与校验规则、合并响应中的格式协商与 OpenMetrics # EOF 处理逻辑。

问题背景:为什么单端点指标合并不够用

Istio 的 sidecar 注入后,istio-agent 会在 :15020 暴露合并指标端点(/stats/prometheus/metrics,见 server.go 中的路由注册),把 agent 自身指标、Envoy 统计与应用指标拼接成一个响应。合并的应用指标来源由环境变量 ISTIO_PROMETHEUS_ANNOTATIONS 决定——它是注入时由 Pod 上 prometheus.io/* 注解序列化而来的一份 JSON,其中只携带一个 port一个 path

这在单容器出指标的 Pod 上工作良好,但对常见的多容器模式会失效:例如主应用在 :8080/metrics,同时挂了一个 JMX exporter 或 node-exporter sidecar 在 :9100/metrics。由于 STRICT mTLS 下 Prometheus 无法直达应用端口,所有抓取都必须经过 agent 的 :15020/stats/prometheus,而 agent 只会向一个应用端点扇出,第二个容器的指标会被静默丢弃且无任何报错。雪上加霜的是,注入时的注解重写(applyPrometheusMerge)会把 prometheus.io/port 覆盖为 15020,导致第二个容器在 sidecar 注入后完全没有任何被抓取的路径。

这一问题的完整陈述见设计提案的 Problem Statement 章节

注解格式设计:prometheus.istio.io/scrape-targets

基本用法

新注解 prometheus.istio.io/scrape-targets 的值是逗号分隔的 port:path 列表:

annotations:
  prometheus.istio.io/scrape-targets: "8080:/metrics,9100:/metrics"

解析规则(提案与实现一致,实现见 ParseScrapeTargets):

  • 每个条目做空白裁剪(trim);
  • 顺序即合并顺序,决定响应中指标体的书写次序;
  • path 组件为空时默认 /metrics
  • 任一条目的 port 为空则解析报错。

仓库中的测试样例 hello-multiport-metrics.yaml 演示了真实用法:

apiVersion: v1
kind: Pod
metadata:
  annotations:
    prometheus.io/scrape: "true"
    prometheus.istio.io/scrape-targets: "8080:/metrics,9100:/custom-metrics"
  name: hellopod
spec:
  containers:
    - name: hello
      image: "fake.docker.io/google-samples/hello-go-gke:1.0"
      ports:
        - name: http
          containerPort: 80

为什么不用其他方案

提案中论证了三个替代方案被否掉的原因:

  1. 为什么不扩展 prometheus.io/port 该注解被集群外工具链消费(kube-state-metrics、Prometheus operator 的 CRD、基于注解的抓取配置等)。改变其语义(例如允许逗号分隔)会静默破坏任何没有走 Istio agent 路径的抓取场景,属于不可接受。
  2. 为什么不搞 prometheus.io/port2prometheus.io/port3……? 不存在这种标准;它需要 n 个互相独立的注解且没有定义上限。注解集合无序时解析脆弱、顺序未定义——Kubernetes 注解 key 空间不保证迭代顺序。
  3. 为什么选 prometheus.istio.io/ 命名空间? 这是 Istio 自有的注解命名空间,已用于其他 Istio 特定提示(如 secure-port 注解),对运维者明确表达了 Istio 专属语义。

向后兼容性

提案承诺且实现保证了:既有的 prometheus.io/port + prometheus.io/path 单端点流程完全不变。若未设置 prometheus.istio.io/scrape-targets,Webhook 与 agent 行为与以往完全一致;在特性可用之前注入的 Pod 依然工作,因为旧格式 ISTIO_PROMETHEUS_ANNOTATIONS JSON 仍然合法——旧 JSON({"scrape":"true","port":"8080","path":"/metrics"})反序列化后 Targets 字段为 nil,不产生破坏性变更。

改造一:agent 端数据模型与配置归一化

PrometheusScrapeConfiguration 增加 Targets 字段

提案在 server.go 中定义的数据模型已落地:

// ScrapeTarget represents a single application metrics endpoint to scrape.
type ScrapeTarget struct {
    Port string `json:"port"`
    Path string `json:"path"`
}

type PrometheusScrapeConfiguration struct {
    Scrape  string         `json:"scrape"`
    Path    string         `json:"path"`   // kept for backward compat
    Port    string         `json:"port"`   // kept for backward compat
    Targets []ScrapeTarget `json:"targets,omitempty"`
}

NewServer() 中的归一化与校验

agent 启动时反序列化 ISTIO_PROMETHEUS_ANNOTATIONS 后,NewServer() 会把配置归一化到 Targets 表示,这里可以看到比提案更完整的实现细节:

  • 旧格式路径Targets 为空但 Port 非空):Path 缺省补 /metricsPort 缺省补 80,然后合成单元素 Targets 列表;
  • 新格式路径Targets 非空):反向用 Targets[0] 回填旧字段 Port/Path,这样 handleStats 中直接读 Port/Path 的单目标热路径依然能正确抓取主端点(见 server.go 注释);
  • 逐目标校验Path 为空补 /metrics;端口必须能转为 1–65535 的整数(注释指出这是为了捕获 015020 这类带前导零的表示——字符串比较 != "15020" 但运行时拨号会命中 15020);端口等于 agent status 端口(15020)时直接报错,避免递归抓取死循环。

相比提案,当前实现额外校验了Istio 保留端口istioReservedPorts 映射表覆盖了 15000(Envoy admin)、15001/15006(流量捕获)、15008(HBONE 隧道)、15021(Envoy 健康检查)、15053(DNS 代理)、15090(Envoy Prometheus 原始输出,已被合并)等。把 target 指向这些端口会命中非应用端口或造成重复/递归抓取,因此启动即报错并给出人类可读的原因。

注入端:getPrometheusScrapeConfiguration()applyPrometheusMerge()

Webhook 侧的改造落在 pkg/kube/inject/webhook.go

  • 常量定义:prometheusIstioScrapeTargetsAnnotation = "prometheus_istio_io_scrape_targets"webhook.go#L91);
  • getPrometheusScrapeConfiguration():在读取既有 prometheus.io/* 三个注解的 switch 中新增一个 case,调用 agent 包导出的 status.ParseScrapeTargets(val) 把原始字符串解析为 []ScrapeTarget 填入 cfg.Targets(解析失败仅告警,不影响注入);
  • applyPrometheusMerge():构建 Targets 并执行注入时校验——每个目标端口必须是合法数值、不得等于 agent 端口(StatusPort)、不得是 Istio 保留端口;随后把完整结构(含 Targets)JSON 编码进 ISTIO_PROMETHEUS_ANNOTATIONS 环境变量。值得注意的是实现细节:当 Targets 存在时会用 Targets[0] 回填 Port/Path 旧字段,使得旧版本 agent(不认识 Targets 字段)也能继续抓取主端点,这是跨版本滚动升级的关键兼容手段;
  • 注解重写块保持不变:prometheus.io/port 仍被写为 15020prometheus.io/path 写为 /stats/prometheusprometheus.io/scrapetruewebhook.go#L972-L981)。

发布说明 multi-port-scrape-targets.yaml 确认了这一特性:目标端口与 agent status 端口或任何 Istio 保留数据面端口冲突时,会在注入时以可读错误被拒绝。

合并策略:从串行单端点到并发 fan-out

现状(N=1):全串行流式写

单目标场景下 handleStats 的写入顺序是:agent 指标 → Envoy 统计 → 应用端点,三者全部串行 io.Copy,响应写入器是唯一的同步点。handleStats 从不返回 HTTP 错误;任何抓取失败只记日志并跳过(handleStats 注释 明确写道 "we do not return any errors here... Instead, errors are tracked in the failed scrape metrics/logs")。

提案(N>1):goroutine 并发扇出 + 缓冲写

len(s.prometheus.Targets) > 1 时,handleStats 分发到 scrapeMultipleApps:每个 target 一个 goroutine 并发抓取,各自缓冲响应体,最后按 Targets 顺序写入响应。单目标情况(len(Targets) <= 1)保留既有流式 io.Copy 热路径,逐字节不变,存量 Pod 不付出任何并发或缓冲开销。

提案给出的核心伪代码与实际实现高度一致,关键要点:

并发扇出wg.Wait() 等待全部 goroutine,每个 target 拥有独立的 URL、context 与 cancel(X-Prometheus-Scrape-Timeout-Seconds 请求头经 scrape() 逐 goroutine 转发,每个 target 拿到自己的超时)。

顺序确定性:结果按 Targets 下标(索引化切片)而非完成顺序写入,无论哪个 goroutine 先结束,响应都是确定的。

缓冲而非流式:每个 body 读入内存,原因是 (a) # EOF 剥离规则需要检查 body 尾部;(b) 按 Targets 顺序写本就要求等待所有 goroutine。预期 N 较小(实践中 ≤10),内存开销上界为 N × 响应大小

内存上限:每个 target 的 body 经 io.LimitReader(body, maxAppBodyBytes+1) 限长,maxAppBodyBytes 默认 10 MiBdefaultMaxAppMetricsBodyBytes)。多读 1 字节是为了区分"恰好到达上限的合法 body"与"超限饱和";超限时记 Warnf 日志(避免被攻击时日志洪水)并丢弃该 target,读错误记 Errorf,两种情况都只 AppScrapeErrors 计数一次。实现中 read buffer 预分配 64 KiB,避免 10 MiB body 触发十几次指数扩容。

尽力而为语义:失败的 target(抓取错误或读取错误)留 bodies[i] == nil,只增量计数一次,不中止响应——合并输出仍返回 200 和部分数据,与"我们不在此返回错误"的既有哲学一致。

格式协商

响应 Content-Type 是第一个成功 target 的协商格式;若后续任一成功 target 格式不一致,整体降级为 text/plainscrapeMultipleApps 格式选择逻辑)。原因是:单一响应体必须承诺一种结构格式——把 OpenMetrics 体和 text 体塞在同一个 Content-Type 下会产出消费者解析器眼中的非法输出。negotiateMetricsFormat 识别 OpenMetrics 0.0.1/1.0.0、delimited Prometheus protobuf 和 text/plain(实现),无法识别的一律按 text 处理以保持向后兼容。常见情况下 Targets[0] 是主端点且其余一致,协商格式即 Targets[0] 的格式,运维者看不到行为变化;全部失败时回退 FmtText

实现额外返回了 bodyFormats 切片:即使聚合格式降级为 text,每个 body 仍保留其真实格式,供二进制 proto body 的解析路径按各自格式解码,避免被当文本解析而静默丢失。

OpenMetrics # EOF 处理

规则(提案与 stripOpenMetricsEOF 实现 一致):对每个 body 无条件剥离尾部的 # EOF 行(容忍尾随空白与 \r\n);仅当协商格式为 OpenMetrics 时,在全部 body 写完后用 expfmt.FinalizeOpenMetrics 追加唯一的一个 # EOF\n。text 响应(含混合格式降级情形)不带终结符。这样无论多少个 target 成功、body 长什么样,OpenMetrics 响应恰好一个 # EOF,text 响应零个。多目标写入循环还处理了一个细节:若某 body 尾部缺换行,补一个 '\n' 分隔,且用独立的 w.Write 写分隔符,避免对剥离后的子切片触发大数组的 append+realloc(server.go 注释)。

指标族名冲突

两个 target 暴露同名指标族时,消费端(Prometheus)依然会产生解析错误——这与现状 Envoy 与应用冲突的行为相同。去重不在范围内,用户有责任保持各 target 的指标命名空间互斥。提案注明现有 TestStats 冲突测试用例文档化了这一契约。

超出提案的仓库实现:protobuf 解析合并路径

值得指出,当前仓库的 handleStats 已演进到提案之外的能力:当任一上游(Envoy 或应用 target)宣告 delimited Prometheus protobuf 格式(application/vnd.google.protobuf; proto=io.prometheus.client.MetricFamily; encoding=delimited,原生 Prometheus 直方图所需的格式)时,合并器切换到解析并重编码路径(writeMergedProtoPath):把每个上游解码为 MetricFamily、从 registry 直接采集 agent 自指标、按协商格式重新编码整条流。选择该路径的两个判据是任一上游为 proto,或 Envoy 返回非 text/plain 的 body(防御性检查,防止未来 Envoy 支持 OpenMetrics 时 # EOF 落进流中间)。全 text 场景仍走字节拼接快路径以保性能。此外 handleStats 入口支持 MetricsLocalhostAccessOnly 特性限制仅本机访问(server.go#L640-L643)。这些扩展不改变提案描述的核心合并语义,属于实现侧的自然演进。

测试方案与仓库中的对应物

提案给出的三层测试计划与仓库现状对照:

单元测试(pilot/cmd/pilot-agent/status/server_test.go:扩展 TestStats 的表格用例,覆盖:双端点健康且指标族互异(合并输出含两个族)、双端点其一产出 OpenMetrics(第一个体剥离 EOF、第二个保留)、首端点宕机次端点健康(次端点指标存在且 AppScrapeErrors 增量)、双端点全宕(仅返回 agent + Envoy 指标)、多端点指标族冲突(Prometheus 解析错误,属预期行为);扩展 TestStatsError 覆盖逐端点错误路径;新增 TestParseScrapeTargets 覆盖畸形输入、空 path 默认值、空白裁剪。这些用例在 server_test.goserver_protobuf_test.go 中均已存在(含多 target 与 protobuf 场景)。

Webhook 单元测试(pkg/kube/inject:新增 golden 文件验证三件事——ISTIO_PROMETHEUS_ANNOTATIONS 编码了两个 target、prometheus.io/port 重写为 15020prometheus.io/path 重写为 /stats/prometheus。仓库中对应的输入文件是 hello-multiport-metrics.yaml,断言逻辑在 webhook_test.go 的多端口聚合测试中。

集成测试(tests/integration/telemetry/api/:按 TestStatsFilter 模式部署一个双容器 Pod,各暴露互异的指标族,抓取 :15020/stats/prometheus 并断言两个族的指标都出现,走通"注解 → webhook → 环境变量 → handleStats → 合并响应"全链路。该测试已落地为 multiport_metrics_test.go

PR 拆分策略

提案将交付拆为三个可独立评审的 PR,这一策略也解释了当前代码中"数据先行、行为随后"的结构:

  1. PR 1 — 数据模型与注解解析(不改变抓取行为):为 PrometheusScrapeConfiguration 增加 ScrapeTargetTargets 字段;NewServer() 归一化旧单端口配置并校验全部目标端口;getPrometheusScrapeConfiguration() + applyPrometheusMerge() 解析并编码 prometheus.istio.io/scrape-targets;解析与向后兼容的单元测试;多端口注入 golden 文件更新。此 PR 纯增量——即使 Targets 已填充而 handleStats 仍只读 s.prometheus.Port/Path,既有行为也完全不变。
  2. PR 2 — handleStats 中的并发扇出:以 goroutine fan-out 替换单端点块;新增 stripOpenMetricsEOF 辅助函数;handleStats 改读 s.prometheus.Targets;补充多端点正常路径、部分失败、格式协商的单元测试。依赖 PR 1 先合入。
  3. PR 3 — 集成测试tests/integration/telemetry/api/ 下新增多容器部署,断言合并抓取包含两个容器的指标;可在 PR 2 分支上开发,PR 2 落地后再评审合入。

总结

prometheus.istio.io/scrape-targets 以极小的接口面(一个逗号分隔注解 + 一个 JSON 数组字段)解决了 STRICT mTLS 下多容器 Pod 指标被静默丢弃的问题:注入端在 applyPrometheusMerge 中解析、校验并序列化多目标配置,agent 端在 NewServer 中归一化旧格式、在 scrapeMultipleApps 中以并发扇出抓取并按 Targets 顺序确定性合并。实现还带来了提案之外的纵深:Istio 保留端口拒绝、10 MiB 每 target 内存上限、逐 body 格式保留以支撑 protobuf 解析合并路径。对使用者而言,只需在 Pod 注解中追加一行 prometheus.istio.io/scrape-targets: "8080:/metrics,9100:/metrics",其余抓取、改写与合并全部由注入模板与 pilot-agent 自动完成;而对存量单端点 Pod,行为逐字节不变。

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