基于OpAMP实现OTel Collector动态配置与状态回报实践
本文主要内容为启动 OpAMP server 并在运行时把 otel collector 配置推下去、被支持的字段能否实际生效(处理器注入属性 / 管道增删 / 配置回滚 / 错误状态回报)
概念与理论
OpAMP 协议
OpAMP 全称 Open Agent Management Protocol,是 OpenTelemetry 项目维护的一份开放规范,目标是给"远端 agent 集群的配置管理与状态回报"做一个跨实现的协议。它不绑死 OTel Collector,任何 agent(Fluent Bit、Datadog Agent、自研 collector 等)都可以实现这个协议接入到同一个管理服务器。协议本质是基于 protobuf 的双向通信:
- AgentToServer —— agent 周期性或事件触发地告诉 server 自己的描述(host.name / service.version 等)、健康状态(up / not up)、当前生效的配置(effective_config)、上次远端配置的应用结果(remote_config_status:APPLIED / FAILED + 错误信息)
- ServerToAgent —— server 决策后下发给 agent 的指令,主要是远端配置(remote_config,含一个 config 文件 map 和 sha256 hash)、连接设置、TLS 证书轮换、自定义消息等
承载层既支持 WebSocket(长连,本测试使用),也支持 HTTP polling。
OpAMP server 不直接管理 collector,只管理"实现 OpAMP 协议的 agent"。在 OTel 体系里,这个 agent 就是 opampsupervisor,它把"和 server 通信"这件事和"启动/停止 collector"这件事拆开,server 只面对一个稳定的协议接口,supervisor 负责把抽象的"远端配置"变成本地的"collector 进程 + 配置文件"。
OTel Collector 与 opampsupervisor
OTel Collector 本身没有内置 OpAMP 客户端能力(虽然 contrib 里有个名为 opamp 的 extension,但它只做状态回报,不接管自身配置加载),所以官方拆出了 opampsupervisor 这个独立程序:
| 进程 | 职责 | 协议/接口 |
|---|---|---|
| opampsupervisor | OpAMP 客户端、本地状态机、collector 生命周期管理 | OpAMP(向上)+ exec/SIGTERM(向下) |
| otelcol-contrib | 实际处理 telemetry,通过内嵌的 opamp extension 把 effective_config 报给 supervisor |
OTLP(业务)+ 本地 OpAMP(向上回报) |
supervisor 与 collector 之间通过一个临时本地 OpAMP server通信( ws://127.0.0.1:35817/v1/opamp ),相当于 supervisor 同时扮演 OpAMP 客户端(对外)和 OpAMP server(对 collector),collector 通过 opamp extension 把自己看到的 effective_config 告诉 supervisor,supervisor 再回报给真正的 OpAMP server。
这种拆法有几个重要原因和使用场景:
- 配置变更会导致collector 重启:supervisor 不做热更新,每次新配置到来就 SIGTERM 现 collector、写新
effective.yaml、重新 exec 一个新 collector 进程。所以一次"配置切换"在日志上等价于一次"新进程启动"。 - 失败语义:如果新配置启动 collector 失败(比如引用了不存在的 receiver),旧 collector 已经被杀掉了,不会"回退到旧配置"。supervisor 的行为是:上报
RemoteConfigStatus = FAILED、维持Up: false,等下一份合法配置进来。 - collector 的 opamp extension 是 supervisor 自动注入的:在
01-initial.yaml里的配置不需要手动加opampextension,supervisor 在生成effective.yaml时会把自己的本地 OpAMP server 地址塞进去。
本地 OpAMP server 是否必须?
架构图里 supervisor 容器内有个"本地 OpAMP server"监听在 127.0.0.1:<随机端口>,它是必须的吗?能不能去掉? 答案取决于选哪种部署模型。
opamp-supervisor 源码里 startOpAMP() 是写死的两步——先启动对外的 OpAMP 客户端连远端 server,再启动本地的 OpAMP server 监听 collector:
// cmd/opampsupervisor/supervisor/supervisor.go
func (s *Supervisor) startOpAMP() error {
if err := s.startOpAMPClient(); err != nil { return err }
if err := s.startOpAMPServer(); err != nil { return err } // ← 失败立即退出
return nil
}
只要使用 supervisor,本地 OpAMP server 就必须启动且无法关闭,唯一可调的是端口号(agent.opamp_server_port,默认 0 表示随机分配,可以钉死成固定端口比如 11111,但没有 enabled: false 选项)。它本质是 supervisor↔collector 之间的 IPC 通道——OTel 选择复用 OpAMP 协议而不是发明新 IPC,因为 collector 已经能通过 opamp extension 说 OpAMP,把它指到 localhost 就行。
但是不一定要用 supervisor。OTel 实际有三种部署模型,本地 server 是否存在、能否动态推配置,差异如下:
| 维度 | 模型 A | 模型 B | 模型 C(supervisor) |
|---|---|---|---|
| 本地 OpAMP server | 不存在 | 不存在 | 必须,无法关闭 |
| 可远端 push 配置内容 | NO | 间接(OpAMP 只传 restart 指令) | YES |
| 状态/health/effective_config 回报 | YES | YES | YES |
| collector 进程托管 | 外部(k8s/systemd) | 自身 SIGHUP reload | supervisor 完全托管 |
| 拓扑复杂度 | 最简 | 中等 | 最复杂 |
为什么模型 A、B 不能完整 push 配置?看 extension/opampextension/config.go 的 toAgentCapabilities(),extension 一共只暴露 5 个能力:
ReportsStatus (永远开)
ReportsEffectiveConfig (默认开)
ReportsHealth (默认开)
ReportsAvailableComponents (默认开)
AcceptsRestartCommand (默认关,0.124+ 在 feature gate 后面)
没有 AcceptsRemoteConfig——这是 OTel 故意的设计。纯 extension 不接受远端配置内容,要"server 推 YAML 进来"的能力必须走 supervisor。模型 B 的 AcceptsRestartCommand 是个折中:server 端只发"重启"指令,extension 收到后给 collector 进程发 SIGHUP,collector 重新读 --config 引用的文件——所以新配置文件的下发要靠 OpAMP 之外的机制(k8s ConfigMap、git pull、配置管理 daemon 等)。
选型决策逻辑如下
实际生产里的常见组合
- k8s 集群:模型 A 或 B 居多。配置走 ConfigMap,OpAMP 只做状态聚合。supervisor 在 k8s 里有"双重托管"的隐患——supervisor 想管进程、kubelet 也想管进程,崩溃恢复语义会冲突。
- 裸机/虚机舰队:模型 C 最常见。BindPlane OP、Splunk Otel Distro 这类商业产品就是把 supervisor 包到他们自己的 agent 里。
整套系统对外暴露 5 个端口,搞清楚它们的用途比记 docker-compose 文件更重要:
| 端口 | 谁在听 | 做什么 |
|---|---|---|
4320 |
opamp-server | OpAMP 协议的 WSS(WebSocket over TLS)端点,路径 /v1/opamp。supervisor 用它通信。 |
4321 |
opamp-server | HTTP Web UI + 配置推送 REST 接口(POST /save_config、GET /agent?instanceid=…) |
4317 |
otelcol-contrib | OTLP/gRPC receiver。受管 collector 监听,业务进程发送 telemetry 用。 |
4318 |
otelcol-contrib | OTLP/HTTP receiver。本测试用 curl 直接 POST JSON 用的就是它。 |
13133 |
otelcol-contrib | health_check extension。可探活,也用于断言"配置真的让 collector 起来了"。 |
注意 4317/4318/13133 是 collector 监听的,但是端口暴露在 supervisor 容器上(因为 collector 是 supervisor 的子进程,共享 network namespace)。
部署架构
本次测试的完整文件内容如下
opamp-otel-test/
├── docker-compose.yml
├── supervisor/
│ ├── Dockerfile # 把 opampsupervisor 和 otelcol-contrib 合成一个镜像
│ └── supervisor.yaml # 烤进镜像的 supervisor 配置
├── configs/
│ ├── 01-initial.yaml # 初始配置:traces+metrics+logs 三管道
│ ├── 02-updated.yaml # 改配置:删 logs 管道,新增 attributes processor
│ └── 03-broken.yaml # 故意失败:引用不存在的 receiver
├── scripts/
│ ├── push-config.sh # 通过 POST /save_config 推配置
│ └── test.sh # 6 阶段端到端测试
└── README.md
拼装supervisor和collector
官方在 GHCR 上发布的 opentelemetry-collector-opampsupervisor:latest 镜像只有 supervisor 二进制,没有 collector。这个设计是合理的(不同用户想用不同的 collector distribution),但意味着我们要自己拼一个。原理是从 otel/opentelemetry-collector-contrib 镜像里把 /otelcol-contrib 拷出来,丢到 supervisor 镜像旁边
这里涉及到一个核心因素,因为 supervisor 的核心是 OS 级父进程托管子进程,exec/SIGTERM/wait/stderr pipe/PPID 监控 五件事每一件都要求和 collector 共享 mount namespace + PID namespace。容器是命名空间隔离边界,两个二进制必须在同一个容器里才能维持父子进程关系。本次测试环境使用docker就不得不通过拼镜像来实现需求。
ARG SUPERVISOR_VERSION=latest
ARG COLLECTOR_VERSION=latest
FROM ghcr.io/open-telemetry/opentelemetry-collector-releases/opentelemetry-collector-opampsupervisor:${SUPERVISOR_VERSION} AS sup
FROM otel/opentelemetry-collector-contrib:${COLLECTOR_VERSION} AS col
FROM alpine:3.20
RUN apk add --no-cache ca-certificates \
&& addgroup -S -g 10001 otel \
&& adduser -S -D -u 10001 -G otel otel \
&& mkdir -p /etc/otel/supervisor-data \
&& chown -R otel:otel /etc/otel \
&& chmod 0777 /etc/otel/supervisor-data
COPY --from=sup --chmod=0755 /usr/local/bin/opampsupervisor /usr/local/bin/opampsupervisor
COPY --from=col --chmod=0755 /otelcol-contrib /usr/local/bin/otelcol-contrib
COPY --chown=otel:otel --chmod=0644 supervisor.yaml /etc/otel/supervisor.yaml
USER otel:otel
WORKDIR /home/otel
EXPOSE 4317 4318 13133
ENTRYPOINT ["/usr/local/bin/opampsupervisor"]
CMD ["--config", "/etc/otel/supervisor.yaml"]
注意事项:
USER otel:otel而非 root:和官方镜像保持一致,supervisor 进程不该以 root 运行。配套的mkdir -p /etc/otel/supervisor-data && chown -R otel:otel必须做完,否则进程会因为没法写状态文件而死。CMD ["--config", "/etc/otel/supervisor.yaml"]:必须明确传-config,否则 supervisor 启动会立刻 panic 出failed to load config: path to config file cannot be empty(见 遇到的问题 第 1 个坑)。- 相对 docker-compose volume mount,
COPY进镜像更稳——不会因为宿主机文件 mode 不对让容器内 user 读不了。
supervisor.yaml
server:
endpoint: wss://opamp-server:4320/v1/opamp
tls:
insecure_skip_verify: true
capabilities:
reports_effective_config: true
reports_own_metrics: true
reports_own_logs: true
reports_own_traces: true
reports_health: true
accepts_remote_config: true
reports_remote_config: true
agent:
executable: /usr/local/bin/otelcol-contrib
storage:
directory: /etc/otel/supervisor-data/
endpoint 用了 docker 内部 DNS 名 opamp-server,因为两个容器都在 opamp-net 这个 bridge 上。insecure_skip_verify: true 是因为参考实现 server 用的是自签证书。
capabilities 这一段决定 supervisor 在握手时告诉 server 自己支持什么。生产里可以按需关闭(比如 accepts_remote_config: false 就能让 supervisor 拒绝远端推配置)。
docker-compose.yml
注意 supervisor 容器 expose 的是 otelcol-contrib 的端口,因为 collector 是 supervisor 的子进程、共用 network namespace,对外看起来像 supervisor 在监听这些端口。
services:
opamp-server:
build: { context: ./opamp-server }
image: opamp-otel-test/opamp-server:local
container_name: opamp-server
ports: ["4320:4320", "4321:4321"]
networks: [opamp-net]
opamp-supervisor:
build: { context: ./supervisor }
image: opamp-otel-test/opamp-supervisor:local
container_name: opamp-supervisor
depends_on: [opamp-server]
ports: ["4317:4317", "4318:4318", "13133:13133"]
networks: [opamp-net]
networks:
opamp-net: { driver: bridge }
架构辨析
为什么 supervisor 必须本地起一个 OpAMP server
直接答案:协议复用 + 引导发现。supervisor 复用 OpAMP 协议作为 IPC 的核心动机来自 supervisor specification 第一段——作者 Tigran Najaryan(OpAMP 协议作者本人)写得很直白:
The main idea is to implement a Collector extension with a limited set of OpAMP capabilities, where the extension can be used on its own, then additionally create an external Supervisor that uses the exact same extension as a helper. This way most of OpAMP-related functionality is implemented once only, there is virtually no code duplication.
也就是:让 collector 里那个 opamp extension 在"裸跑直连后端"和"被 supervisor 托管"两种部署里走完全相同的代码路径——配置一改 endpoint URL 就行。supervisor 同时扮演两个角色:对外是 OpAMP 客户端、对 collector 是 OpAMP server。
源码里 startOpAMP() 把这两步硬绑在一起:
func (s *Supervisor) startOpAMP() error {
if err := s.startOpAMPClient(); err != nil { return err }
if err := s.startOpAMPServer(); err != nil { return err } // ← 失败立即退出
return nil
}
唯一可调的是端口(agent.opamp_server_port,默认 0 表示随机分配),没有 enabled: false 选项。
引导发现:鸡生蛋问题
仅凭"复用协议"还不足以解释为什么必须用 OpAMP 这种长连接消息机制。更深的原因写在 spec 的 Bootstrapping 一节:
In order to obtain the remote configuration from the OpAMP Backend the Supervisor must send an AgentDescription to the Backend. Initially the Supervisor doesn't have this information. The AgentDescription becomes available only after the Collector process is started and the AgentDescription is sent from the opamp extension to the Supervisor.
如果没有"本地 OpAMP server"这个机制,supervisor 想拿到 collector 的 AgentDescription 只能解析二进制(脆弱)、跑 --version(信息不够)、或自己再发明一套 IPC——而复用 OpAMP 协议本身正好提供了 AgentDescription 这个标准消息,零成本解决引导问题。
为什么 opamp extension 故意只实现五个能力
extension/opampextension/factory.go 里 extension 的默认能力清单一共只有 5 项:
Capabilities: Capabilities{
ReportsEffectiveConfig: true,
ReportsHealth: true,
ReportsAvailableComponents: true,
AcceptsRestartCommand: false, // 0.124+ 才加,默认关
}
// 加上恒为 true 的 ReportsStatus 总共 5 项
OpAMP 协议本身定义了将近 20 个 capability,extension 故意只暴露其中 5 个,这是由于有三条无法绕过的物理定律强制如此。
定律 1:你不能替换正在运行你的进程
extension 是 collector 进程内的一个 goroutine。配置应用 = collector 重启,binary 升级 = 替换可执行文件,证书轮换 = 重新 dial 长连接,崩溃恢复 = 死掉的进程拉起自己——这些动作发起的瞬间 extension 自身就死了,没有谁来执行后续步骤。这就是经典的"PID 1 模式":systemd 管理你的服务而不是服务自己管自己,supervisor 就是 collector 的 systemd。
定律 2:自观测产生反馈环
如果 extension 自己把 collector 的 stdout/stderr 通过 OTLP exporter 送出去,spec 的警告很直接:
collecting logs may produce logs and that may result in catastrophic amplification of generated logs.
发送一行日志会因为发送动作本身又产生一行日志,再发送,再产生... 这是进程内自观测的根本困难。注意 ReportsOwnMetrics 和 ReportsOwnLogs 在 supervisor 设计里故意不对称——metrics 由 supervisor 配置 collector 自己 OTLP 出去(数据点产生不引发新数据点),logs 必须由 supervisor 拦截 stdout。
定律 3:崩溃可见性
if the Collector crashes the Supervisor will still be able to collect all log output up until the process termination, which can be crucial for understanding the cause of crash.
如果 extension 自己负责送日志,buffer 里积攒的日志在 collector crash 时直接丢——而 crash 前那几条日志通常正是事故现场最关键的证据。supervisor 监听子进程的 stderr 管道,子进程死了它的 read 端会得到 EOF + 退出码,所有 stderr 字节已经在 supervisor 的内存里。
AcceptsRestartCommand 是边界探索
extension 在 0.124+ 加了 AcceptsRestartCommand,看起来像反例:
// extension/opampextension/opamp_agent.go:475
if o.capabilities.AcceptsRestartCommand {
o.logger.Info("received restart command, sending SIGHUP to reload")
collectorProcess, err := os.FindProcess(os.Getpid())
// SIGHUP causes the collector service to gracefully restart all components
}
但仔细看:它发的是 SIGHUP 不是 SIGTERM+exec,collector 收到 SIGHUP 后重新读 --config 引用的文件,而新 config 文件不是 extension 写的,要由外部某个机制(k8s ConfigMap、Ansible、git pull)先放到位。spec 警告:"an invalid config will cause the Collector to error out and stay down until it is manually restarted using some other mechanism"。
所以这个能力没有突破定律 1,只是把"替换"分成了两步——配置下发非 OpAMP 通道、重启信号 OpAMP 通道。OpAMP server 只能说"现在请重启",不能说"用这份新配置重启"。
为什么extension 不能跳过 supervisor 直连后端
先澄清前提:extension 能直连远端 OpAMP——这就是 前面讲的模型 A。把 extension 配置的 endpoint: 字段填成 wss://your-backend.com/v1/opamp 它就直接连。真正的问题是:在 supervisor 受管的部署里(模型 C),为什么 extension 不能跳过 supervisor 直接连后端? 答案是这种双管齐下会因为 5 个具体原因碎成 split-brain。
1. 身份分裂
OpAMP 协议中每个 agent 由 instance_uid (UUIDv7) 唯一标识。supervisor 和 extension 都直连后端 = 两个 instance_uid = 后端看到两个 agent,仪表盘上一个 collector 占两行,告警/状态聚合错乱。supervisor 现在的做法是把自己的 instance_uid 注入到 extension 配置(opampextension.instance_uid),确保整个 supervised pair 在后端就是一个 agent。
2. 能力集合不可聚合
extension 只有 5 个能力,supervisor 注册到后端时声明了完整列表。后端做能力协商时只关心一个 agent 的总能力。如果 extension 直连,后端看到的是只有 5 个能力的小 agent——它不会再 push RemoteConfig(因为对方说"我不接受"),supervised 部署最大的卖点直接消失。OpAMP 协议没有"两个 agent 协作宣告我俩合起来有这些能力"的语义。
3. AgentDescription 增补点丢失
实测我们 phase 6 看到 host.name 是容器 ID(如 639d3d193436),但生产里你可能想覆盖成真实 hostname、加 client.id、加部署环境 tag。这正是 supervisor.go 的 setAgentDescription 干的事:
func (s *Supervisor) setAgentDescription(ad *protobufs.AgentDescription) {
ad.IdentifyingAttributes = applyKeyValueOverrides(
s.config.Agent.Description.IdentifyingAttributes, // ← supervisor.yaml 里的
ad.IdentifyingAttributes) // ← extension 自报的
s.agentDescription.Store(ad)
}
对应的 supervisor.yaml 字段:
agent:
description:
identifying_attributes:
client.id: "01HWWSK84BMT7J45663MBJMTPJ"
non_identifying_attributes:
deployment.region: "us-west-2"
extension 自己只能上报 collector 进程能感知的东西。如果直连,supervisor.yaml 这一段功能就丢了。
4. 连接稳定性
supervisor 的 OpAMP client(连后端的)和 collector 进程是 独立的两个 goroutine:
s.agentWG.Go(func() {
s.runAgentProcess() // 这个会反复 SIGTERM + exec collector
})
// supervisor.opampClient 在 startOpAMPClient() 启动后就一直连着,
// 不受 collector 进程生死影响
sequenceDiagram
participant SRV as OpAMP backend
participant SUP as supervisor (opamp client)
participant COL as collector subprocess
Note over SRV,SUP: 与后端保持唯一长连接 ─────
SRV->>SUP: RemoteConfig
SUP->>COL: SIGTERM
COL-->>SUP: 进程退出
SUP->>COL: exec 新进程
COL->>SUP: AgentDesc / EffectiveConfig (本地 OpAMP)
SUP->>SRV: 同一长连接转发 EffectiveConfig
Note over SRV,SUP: 后端视角:从未断连<br/>只收到 effective_config 更新
extension 直连的话每次配置变更都会触发后端断连/重连/握手。数据中心规模下频繁配置变更(金丝雀部署、A/B 测试)会让后端连接表频繁抖动。
5. 下行消息没有合法路由
后端从 ServerToAgent 发下来的 message 是个组合体:
| 字段 | 谁应该处理 |
|---|---|
RemoteConfig |
supervisor(写文件 + 重启) |
PackagesAvailable |
supervisor(下载 + 替换 binary) |
OpAMPConnectionSettings |
supervisor(更新自己的后端连接) |
OwnMetricsConnectionSettings |
supervisor(注入到 collector 配置) |
CustomMessage |
extension(路由到 capability 注册者) |
Command (Restart) |
supervisor 或 extension |
如果 extension 直连,supervisor 拿不到这条 channel 上的消息——但它才是真正能处理 RemoteConfig/Packages 的人。后端推下来的 message 落到错误的 client,supervised 部署的核心功能集体失灵。
测试设计
整套测试的"动态变化"靠三份配置文件实现,差异是有意设计的,正好覆盖三种动态行为:
| 维度 | 01-initial.yaml |
02-updated.yaml |
03-broken.yaml |
|---|---|---|---|
deployment.environment |
initial |
updated |
(无) |
是否有 attributes processor |
否 | 是(service.namespace=opamp-demo) |
否 |
| logs pipeline 是否存在 | 是 | 否(被删) | 否 |
| 是否合法 | 是 | 是 | 否(receiver 不存在) |
| 用途 | 基线 + 三信号验证 | 验证:值变更、processor 增加、pipeline 删除 | 验证:失败状态回报 |
push-config.sh是唯一对外的写入路径
opamp-go 参考 server 没有公开的 JSON REST API,唯一能编程式推配置的接口是 Web UI 用的表单 endpoint POST /save_config:
INSTANCE_ID="$(curl -fsS "$SERVER_URL/" \
| grep -oE 'instanceid=[0-9a-f-]{36}' | head -n1 | cut -d= -f2)"
curl -X POST "$SERVER_URL/save_config" \
--data-urlencode "instanceid=$INSTANCE_ID" \
--data-urlencode "config@$CONFIG_FILE"
server 端的 handler saveCustomConfigForInstance 收到后会调用 Agents.SetCustomConfigForAgent(...),把配置打包成 AgentRemoteConfig 通过 OpAMP WSS 推给 agent,并等待最多 5 秒让 agent 回报状态更新。
Phase A:启动与 agent 注册
== A1. server reachable at http://localhost:4321 ==
PASS: server up
== A2. supervisor registers as agent ==
PASS: agent connected: 019e6ebe-c495-717d-810a-4bbcb58362af
supervisor 自己的日志只有寥寥三行
{"msg":"Supervisor starting","id":"019e6ebe-c495-717d-810a-4bbcb58362af"}
{"msg":"No last received remote config found"}
{"msg":"Connected to the server."}
这里的 instance_uid 是首次启动时自动生成的 UUIDv7(时间排序),存到 persistent_state.yaml 后续启动会复用:
instance_id: 019e6ebe-c495-717d-810a-4bbcb58362af
last_remote_config_status:
status: 1
last_remote_config_hash: 84a3bd0f19bff38334ee72623eb4d001054a3a931f07debbd872d4596b6b654b
error_message: ""
注意 last_remote_config_hash 是 server 推下来的配置的 sha256,这个值会被 supervisor 报回去和 server 自己的计算对比,是 OpAMP 防止重复推同一配置的优化机制(hash 一致就不重新启 collector)。
Phase B:三信号扇出与处理器注入
推 01-initial.yaml 后,supervisor 把它原样保存到 last_recv_remote_config.dat、生成 effective.yaml、启动新 collector 进程。然后我们用三条 curl 把三种信号发到 OTLP/HTTP 端点(每条都断言 HTTP 200):
send_trace 'B-trace-initial' # → /v1/traces
send_metric 'B-metric-initial' # → /v1/metrics
send_log 'B-log-initial' # → /v1/logs
随后 cat 容器内的 agent.log,用 grep 验证三个 marker 都到了 debug exporter,且 resource processor 注入了 deployment.environment=initial:
Resource attributes:
-> service.name: Str(opamp-test-client)
-> deployment.environment: Str(initial) ← resource processor 注入
ScopeSpans #0
ScopeSpans SchemaURL:
InstrumentationScope manual
Span #0
Name : B-trace-initial
...
PASS: all 3 OTLP HTTP receivers returned 200
PASS: debug exporter logged B-trace-initial
PASS: debug exporter logged B-metric-initial
PASS: debug exporter logged B-log-initial
PASS: resource processor injected deployment.environment=initial on signals
此处要注意,OTLP HTTP 返回 200 不代表 pipeline 真的处理了,只能说明 receiver 接住了。真正的证据是 debug exporter 的输出,它在 pipeline 末端,看到这条记录意味着 otlp → resource → batch → debug 整条链路都跑通了。这也是为什么我们必须 grep agent.log 而不是只看 HTTP 状态码。
Phase C:管道重塑与 attributes 处理器
推 02-updated.yaml,supervisor 杀掉旧 collector,写新的 effective.yaml:
processors:
resource:
attributes:
- action: upsert
key: deployment.environment
value: updated # 变了
attributes: # 新增
actions:
- action: upsert
key: service.namespace
value: opamp-demo
batch: {}
service:
pipelines:
traces:
receivers: [otlp]
processors: [resource, attributes, batch] # 多了 attributes
exporters: [debug]
metrics: { … } # 同上
# logs 管道整段消失
发一条新 trace C-trace-updated,agent.log 里应该看到两个处理器都生效:
ResourceSpans #0
Resource attributes:
-> service.name: Str(opamp-test-client)
-> deployment.environment: Str(updated) ← resource 注入
Span #0
Name : C-trace-updated
...
Attributes:
-> service.namespace: Str(opamp-demo) ← attributes 注入
PASS: effective_config reshaped to 'updated' (attributes processor present)
PASS: collector restarted cleanly with new pipeline
PASS: both processors enriched the trace as configured
注意两个细节:
deployment.environment加在 resource attributes 上,而service.namespace加在 span attributes 上。这是因为resourceprocessor 只能改 resource、attributesprocessor 默认改 span/log/datapoint 自身的 attributes。- collector 的 self-metrics(process_uptime、receiver_accepted_metric_points 等)也带上了
deployment.environment=updated,因为 supervisor 通过reports_own_metrics: truecapability 让 collector 用同一份 pipeline 回送自己的指标。
Phase D:字节级配置完整性
我们怎么知道 server 推下去的字节、supervisor 存到磁盘的字节、agent 报回 server 渲染出来的字节,三者是同一份?
PUSHED_SHA="$(sha256sum configs/02-updated.yaml | awk '{print $1}')"
STORED_BLOB="$(docker exec opamp-supervisor \
cat /etc/otel/supervisor-data/last_recv_remote_config.dat \
| base64 -w0)"
# last_recv_remote_config.dat 是 protobuf,但 YAML 字节段是连续的,
# base64 解码后用 grep 搜我们写在 02-updated.yaml 里的唯一 marker:
echo "$STORED_BLOB" | base64 -d | grep -q 'value: opamp-demo'
PASS: raw remote_config.dat on agent contains exact YAML body we pushed
(sha256 of source: 5b35a52da0b1...)
然后 grep agent UI 渲染出来的 effective_config 段(HTML 里有一段 <pre><code class="language-yaml">…</code></pre>),断言所有 marker 都在:
PASS: effective_config @server contains: deployment.environment
PASS: effective_config @server contains: value: updated
PASS: effective_config @server contains: service.namespace
PASS: effective_config @server contains: value: opamp-demo
PASS: effective_config @server contains: attributes:
PASS: effective_config @server contains: resource:
把 phase D 的发现整理成时序图,能更清楚地看清这条链:
Phase E:回滚
回推 01-initial.yaml:
- effective_config 中
attributesprocessor 整段消失、deployment.environment值回到initial - logs 管道在 phase C 被删掉了,回滚后应该能再次接收 logs
[[ "$(send_log 'E-log-after-rollback')" == 200 ]] || fail
sleep 3
read_agent_log | grep -q 'E-log-after-rollback' || fail
PASS: rollback applied: marker reverted, attributes processor removed
PASS: logs pipeline restored: E-log-after-rollback delivered through debug exporter
这一步在概念上是对"动态配置"的双向验证。
Phase F:失败路径
推 03-broken.yaml:
receivers:
this_receiver_does_not_exist:
bogus_field: 42
service:
pipelines:
traces:
receivers: [this_receiver_does_not_exist]
exporters: [debug]
supervisor 启动 collector 子进程,collector 在加载配置时发现 receiver 类型未注册,exit 1 退出。supervisor 抓到非 0 退出码后写入新的 status 上报:
{"msg":"Agent crashed during config application, reporting FAILED status"}
server 端 /agent 页面渲染出(HTML 摘录):
<td>Up:</td><td>false</td>
...
<span style="color:red">Failed: Agent exited unexpectedly with exit code 1
while applying configuration</span>
测试断言:
PASS: agent reported RemoteConfigStatus=Failed AND Up=false
随后再推 01-initial.yaml,supervisor 启动新 collector 成功,状态恢复到 Up: true:
PASS: agent recovered: Up=true, healthy collector running configs/01-initial.yaml
这个过程揭示了一个生产敏感的事实OpAMP supervisor 不做"失败回退。流程如下
从 Stopping 到 Crashed 的转移路径上,老 collector 已经被杀,新 collector 又起不来。supervisor 在 Crashed 状态时:
- 上报
RemoteConfigStatus = FAILED给 server,附上错误信息("Agent exited unexpectedly with exit code 1") Health.Healthy = false(UI 显示Up: false)- 不重试老配置,也不自动回滚——除非又收到新合法配置
生产防护建议:
- server 端做配置校验:在推下去之前先用
otelcol-contrib validate --config <yaml>试一遍语法和组件是否存在 - canary 部署:先推给 1 个 agent 看 RemoteConfigStatus,再推给全集群
- 监控 RemoteConfigStatus:把 server 端 agent 状态做成 alert,FAILED 立刻通知
最终状态

完整的最终状态(从 server 上回看 agent):
Up: true
Up since: 2026-05-28 13:21:28.790131408 +0000 UTC
service.name string_value:"otelcol-contrib"
service.version string_value:"0.153.0"
service.instance.id string_value:"019e6ebe-c495-717d-810a-4bbcb58362af"
host.name string_value:"639d3d193436"
os.type string_value:"linux"
os.description string_value:"Alpine 3.20.10"
host.name=639d3d193436 是容器 ID 前缀(不是宿主机 hostname),这是 Alpine + 容器默认行为。如果生产里希望 agent 报真实主机标识,需要在 supervisor 配置里通过 agent_description.non_identifying_attributes 显式覆盖。
状态回报机制如下:
AgentDescription 一旦报上来,server 会缓存;后续若 host.name 等没变,agent 可以省略这字段(OpAMP 协议的 sequence_num 增量机制)。Health 和 EffectiveConfig 在每次 collector 重启或心跳时更新;RemoteConfigStatus 只在 supervisor 应用配置后报一次。
附录
发送信号的测试脚本
OTLP_HTTP="${OTLP_HTTP:-http://localhost:4318}"
send_trace() {
local name="$1"
curl -fsS -o /dev/null -w '%{http_code}' \
-X POST "$OTLP_HTTP/v1/traces" \
-H 'Content-Type: application/json' \
-d "{\"resourceSpans\":[{
\"resource\":{\"attributes\":[{\"key\":\"service.name\",
\"value\":{\"stringValue\":\"opamp-test-client\"}}]},
\"scopeSpans\":[{\"scope\":{\"name\":\"manual\"},\"spans\":[{
\"traceId\":\"4bf92f3577b34da6a3ce929d0e0e4736\",
\"spanId\":\"00f067aa0ba902b7\",
\"name\":\"$name\",
\"kind\":1,
\"startTimeUnixNano\":\"1748432400000000000\",
\"endTimeUnixNano\":\"1748432401000000000\"
}]}]}]}"
}
send_metric() {
local name="$1"
curl -fsS -o /dev/null -w '%{http_code}' \
-X POST "$OTLP_HTTP/v1/metrics" \
-H 'Content-Type: application/json' \
-d "{\"resourceMetrics\":[{
\"resource\":{\"attributes\":[{\"key\":\"service.name\",
\"value\":{\"stringValue\":\"opamp-test-client\"}}]},
\"scopeMetrics\":[{\"scope\":{\"name\":\"manual\"},\"metrics\":[{
\"name\":\"$name\",
\"gauge\":{\"dataPoints\":[{\"asInt\":\"42\",
\"timeUnixNano\":\"1748432400000000000\"}]}
}]}]}]}"
}
send_log() {
local body="$1"
curl -fsS -o /dev/null -w '%{http_code}' \
-X POST "$OTLP_HTTP/v1/logs" \
-H 'Content-Type: application/json' \
-d "{\"resourceLogs\":[{
\"resource\":{\"attributes\":[{\"key\":\"service.name\",
\"value\":{\"stringValue\":\"opamp-test-client\"}}]},
\"scopeLogs\":[{\"scope\":{\"name\":\"manual\"},\"logRecords\":[{
\"timeUnixNano\":\"1748432400000000000\",
\"body\":{\"stringValue\":\"$body\"},
\"severityNumber\":9,
\"severityText\":\"INFO\"
}]}]}]}"
}

浙公网安备 33010602011771号