服务发现产生候选目标及其元数据,目标重新标记在 Prometheus 抓取之前处理这些候选项。它可以保留或过滤目标、改变访问地址,并创建随采集序列一起保存的标签。这与改写单个抓取样本发生在不同阶段。先弄清操作阶段,才能避免本来只想去掉一个指标,却因为配置位置错误而把整个目标从采集范围中移除。

定义规则前先查看发现输入

发现结果包含 __address__ 等内部标签,以及由具体发现机制提供的 __meta_ 字段。文件发现中的 __meta_filepath 指明目标来自哪个文件。这些元数据在目标重新标记期间可以使用,但内部标签会在该阶段结束后从最终目标标签集中移除。如果后续查询需要某个值,应明确把它复制到普通标签中,不能假定所有发现信息都会自动入库。

准确的元数据名称取决于发现机制。从 Kubernetes 发现配置复制过来的规则,不会自动适用于文件发现。组合输入时,缺少的源标签贡献空值,因此依赖一个不存在字段的保留规则,可能过滤所有候选项。排查网络故障之前,应通过目标接口或发现视图检查实际输入标签,先确认目标是否真的进入了抓取阶段。

规则顺序决定最终身份

重新标记步骤按照配置顺序执行。keep 动作仅保留组合源标签值匹配正则表达式的目标,drop 动作则移除匹配目标。正则表达式默认在两端锚定。replace 动作可以把捕获的文本复制到目标标签,而新值又能成为同一序列中后续规则的输入。因此,互换两个步骤可能改变结果,即使每条规则本身都能够通过解析。

复制一个值并不会自动删除原标签。确实需要删除时,它是另一项操作,而且不能破坏抓取或消费者依赖的身份信息。修改 __address__ 会改变 Prometheus 实际访问的端点,修改普通标签则改变上下文。两者都不能简单描述成给面板字段换一个好看的名字:地址变化可能影响可达性,标签变化则可能影响路由、聚合和历史查询。

目标标签能够影响从该目标收集的所有序列。应选择服务或有意义的部署范围等稳定字段,而不是复制全部发现注解。频繁变化的构建标识,可能让大量指标同时产生新身份。因此,发现配置中的一个小改动,能够影响远大于配置文件自身规模的存储数据及其消费者,需要根据真实查询需求审阅。

坏掉的样子

被重新标记过滤的目标,不是一个 up=0 的已配置目标。它没有被抓取,失败抓取查询可能永远不会报告它。预期服务消失时,应对照发现候选项、被过滤目标与活动目标。若告警假定全部预期成员始终存在对应 up 序列,一条错误保留规则就可能让整组目标看起来像安静且健康的空集合。

复制错误元数据也可能得到外观合理、语义却错误的归属标签。查询结果很整齐,不代表数据真的属于被标注的服务。应一起审阅执行顺序、捕获组和最终标签。大范围删除标签还可能合并原本不同的身份,目标重新标记不会为了补偿信息损失而自动计算聚合值,不能把去标签当成汇总方法。

如果没有规则设置 instance,Prometheus 会使用最终的 __address__ 作为默认值,因此改写地址可能同时改变访问端点与默认身份。还应检查 honor_labels:目标导出标签与抓取侧标签冲突时,结果由这个配置决定,不能笼统认为目标标签一定覆盖导出器提交的所有同名值。

快速验证

把程序保存为 target-relabel.py,执行 python3 target-relabel.py。需要 Docker 和 Prometheus 3.5.1,镜像未缓存时可能下载。隔离容器内同时提供静态文本指标端点与文件发现清单,清单包含一个接口目标和一个应被过滤的目标。指标路径使用 /metrics.txt,使 HTTP 响应具有 Prometheus 接受的文本内容类型,避免端点格式问题遮住标签实验。

import http.client
import io
import json
import subprocess
import time
import uuid
from urllib.parse import quote

name = "xbcatm-monitoring-" + uuid.uuid4().hex[:12]


class ResponseSocket:
    def __init__(self, data):
        self.data = data

    def makefile(self, mode):
        return io.BytesIO(self.data)


def request(method, path, body="", content_type="application/json"):
    body = body.encode()
    headers = (f"{method} {path} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\n"
               f"Connection: close\r\nContent-Type: {content_type}\r\n"
               f"Content-Length: {len(body)}\r\n\r\n").encode()
    result = subprocess.run(
        ["docker", "exec", "-i", name, "/bin/nc", "-w", "3", "127.0.0.1", str(port)],
        input=headers + body, capture_output=True, timeout=5,
    )
    if result.returncode:
        raise RuntimeError(result.stderr.decode())
    response = http.client.HTTPResponse(ResponseSocket(result.stdout))
    response.begin()
    return response.status, response.read().decode()


def get_json(path):
    status, body = request("GET", path)
    assert status == 200, (status, body)
    return json.loads(body)


def eventually(check):
    for attempt in range(150):
        try:
            value = check()
            if value:
                return value
        except (RuntimeError, AssertionError, http.client.HTTPException):
            pass
        time.sleep(0.1)
    raise AssertionError("Expected local server state did not appear")


def query(expression):
    response = get_json("/api/v1/query?query=" + quote(expression, safe=""))
    assert response["status"] == "success"
    return response["data"]["result"]


def value(expression):
    result = query(expression)
    assert len(result) == 1, (expression, result)
    return float(result[0]["value"][1])

port = 9090
# Requires Docker and the Prometheus 3.5.1 image.
script = r"""set -eu
mkdir -p /tmp/www /tmp/targets
cat > /tmp/prom.yml <<'PROM_CONFIG'
global:
  scrape_interval: 1s
  scrape_timeout: 500ms
scrape_configs:
  - job_name: discovered
    metrics_path: /metrics.txt
    file_sd_configs:
      - files: ['/tmp/targets/*.json']
    relabel_configs:
      - source_labels: [role]
        regex: api
        action: keep
      - source_labels: [__meta_filepath]
        regex: '.*/([^/]+)[.]json'
        target_label: discovery_file
        replacement: '$1'

PROM_CONFIG
cat > /tmp/www/metrics.txt <<'METRIC_DATA'
# TYPE demo_requests_total counter
demo_requests_total{route="/orders",build_sha="release-a"} 42
# TYPE debug_alloc_bytes gauge
debug_alloc_bytes{kind="scratch"} 9

METRIC_DATA
cat > /tmp/targets/lab.json <<'TARGETS'
[{"targets": ["127.0.0.1:8080"], "labels": {"role": "api"}}, {"targets": ["127.0.0.1:1"], "labels": {"role": "discard"}}]
TARGETS
/bin/httpd -f -p 127.0.0.1:8080 -h /tmp/www > /tmp/httpd.log 2>&1 &
exec /bin/prometheus --config.file=/tmp/prom.yml --storage.tsdb.path=/tmp/data --web.listen-address=127.0.0.1:9090
"""
try:
    subprocess.run(
        ["docker", "run", "-d", "--rm", "--name", name, "--network", "none",
         "--entrypoint", "/bin/sh", "prom/prometheus:v3.5.1", "-c", script],
        check=True, capture_output=True, text=True, timeout=20,
    )
    eventually(lambda: value('up{job="discovered"}') == 1)
    targets = get_json('/api/v1/targets?state=any')["data"]
    assert len(targets["activeTargets"]) == 1 and len(targets["droppedTargets"]) == 1
    active = targets["activeTargets"][0]
    dropped = targets["droppedTargets"][0]
    assert active["discoveredLabels"]["__meta_filepath"] == "/tmp/targets/lab.json"
    assert dropped["discoveredLabels"]["role"] == "discard"
    labels = query('up{job="discovered"}')[0]["metric"]
    assert labels["discovery_file"] == "lab" and labels["role"] == "api"
    assert not any(label.startswith("__meta_") for label in labels)
    print(json.dumps({"active": 1, "dropped": 1, "stored_labels": labels,
                      "source_file": active["discoveredLabels"]["__meta_filepath"]}, indent=2))
finally:
    subprocess.run(["docker", "rm", "-f", name],
                   stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)

程序等待真实采集发生,再检查一个活动目标与一个被过滤目标。活动目标的发现元数据包含 /tmp/targets/lab.json,保存的 up 序列具有 role="api" 和 discovery_file="lab",没有遗留的 __meta_ 标签。另一个目标可以在过滤证据中看到,但没有被当成一次失败抓取。这些结果区分了候选输入、选择结果与最终存储身份。

全部请求留在容器回环地址,没有外部网络、宿主机挂载或公开端口,退出时删除唯一名称的容器。断言验证真实文件发现、过滤、元数据复制和最终标签,没有验证其他发现提供方的字段,也不能证明生产清单已经覆盖全部预期服务。

来源