1 - 扩展观测能力

HUATUO 支持 Metrics、Event 和 AutoTracing 三种观测扩展。三者共享注册机制, 但触发方式、运行开销和数据输出不同。

类型 触发方式 数据输出 适用场景
Metrics 周期采集 Prometheus 持续观测系统性能和长期趋势
Event 内核事件或阈值触发 ES、本地文件,可选 Prometheus 常态运行并捕获异常现场
AutoTracing 系统异常触发 ES、本地文件,可选 Prometheus 按需执行开销较高的上下文采集

模式说明

Metrics

Metrics 通过 procfs、sysfs 或 eBPF 周期采集系统状态,以 Prometheus 格式输出, 适合实时监控和长期趋势分析。内置采集能力包括:

  • CPU:sys、usr、util、load、nr_running 等。
  • 内存:vmstat、memory_stat、directreclaim、asyncreclaim 等。
  • IO:d2c、q2c、freeze、flush 等。
  • 网络:ARP、socket memory、qdisc、netstat、netdev、sockstat 等。

Event

Event 常态监听内核事件或阈值条件,在异常发生时保存内核运行上下文。该模式 面向需要持续开启的低开销观测,数据写入 Elasticsearch 和本地文件,也可以 同步生成 Prometheus 指标。内置事件包括:

  • 调度 tick 间隔异常 sched_tick。
  • 内存异常分配 memory_oom_kill。
  • 软锁定 softlockup。
  • D 状态进程 hungtask。
  • 内存回收 memory_reclaim_events。
  • 异常丢包 dropwatch。
  • 网络接收延迟 net_rx_latency。

AutoTracing

AutoTracing 在检测到系统异常后自动调用诊断工具,采集火焰图或上下文快照等 现场信息。它适合采集成本较高、无法持续运行的诊断数据。结果写入 Elasticsearch 和本地文件,也可以转换为 Prometheus 指标。内置能力包括:

  • CPU 空闲和系统态异常追踪 cpuidle、cpusys。
  • D 状态负载追踪 dload。
  • 内存突发分配追踪 memburst。
  • 磁盘 IO 异常追踪 iotracing。

Event 和 AutoTracing 都属于 Tracing 模式,共享 ITracingEvent 接口。它们既能 保存异常上下文用于根因分析,也能通过实现 Collector 将统计结果暴露给 Prometheus。

添加 Metrics

自定义 Metrics 通过 /metrics 接口输出 Prometheus 指标。

实现 Collector

在 core/metrics 下创建实现 Collector 接口的类型:

type Collector interface {
    Update() ([]*Data, error)
}

type exampleMetric struct{}

func (c *exampleMetric) Update() ([]*metric.Data, error) {
    return []*metric.Data{
        metric.NewGaugeData("example", value, "example value", nil),
    }, nil
}

注册采集器

通过 FlagMetric 将实现注册为指标采集器:

func init() {
    tracing.RegisterEventTracing("example", newExampleMetric)
}

func newExampleMetric() (*tracing.EventTracingAttr, error) {
    return &tracing.EventTracingAttr{
        TracingData: &exampleMetric{},
        Flag:        tracing.FlagMetric,
    }, nil
}

管理 BPF 对象

同一个实现同时提供 Start 和 Update 时,两个方法可能并发执行。不要直接在 collector 字段中读写 bpf.BPF 接口;使用 internal/bpf/bpf_ref.go 提供的 Reference 和 Lease 管理对象生命周期:

type example struct {
    object bpf.Reference
}

func (c *example) Start(ctx context.Context) (retErr error) {
    object, err := bpf.LoadBpf(bpf.ThisBpfOBJ(), nil)
    if err != nil {
        return err
    }

    if err := object.Attach(); err != nil {
        return errors.Join(err, object.Close())
    }
    if err := c.object.Publish(object); err != nil {
        return errors.Join(err, object.Close())
    }
    defer func() {
        retErr = errors.Join(retErr, c.object.UnPublish())
    }()

    <-ctx.Done()
    return nil
}

func (c *example) Update() ([]*metric.Data, error) {
    lease, ok := c.object.Acquire()
    if !ok {
        return nil, nil
    }
    defer lease.Release()

    items, err := lease.DumpMapByName("example_map")
    if err != nil {
        return nil, fmt.Errorf("dump example_map: %w", err)
    }

    return buildMetrics(items), nil
}

接口约束:

  • Publish 将对象所有权转移给 Reference。成功后不要直接调用object.Close()。
  • Acquire 返回的 Lease 将 BPF 对象固定到本次 Update 结束;必须配对调用Release,且不要复制 Lease。
  • UnPublish 先阻止新的 Acquire,再等待所有 Lease 释放,最后关闭 BPF 对象并返回关闭错误。
  • Publish 和 UnPublish 必须串行调用。当前框架保证同一实例的 Start 不会并发执行。

因此,已经开始的 Update 可以完整使用原 BPF 对象;Start 退出或重启时, 对象只会在这些 Update 完成后关闭。

添加 Event

Event 需要实现 ITracingEvent:

type ITracingEvent interface {
    Start(ctx context.Context) error
}

type exampleEvent struct{}

func (e *exampleEvent) Start(ctx context.Context) error {
    // 检测事件并采集上下文。
    // storage.Save 将数据写入 ES 和本地存储。
    storage.Save("example", containerID, time.Now(), eventData)
    return nil
}

注册时使用 FlagTracing:

func init() {
    tracing.RegisterEventTracing("example", newExampleEvent)
}

func newExampleEvent() (*tracing.EventTracingAttr, error) {
    return &tracing.EventTracingAttr{
        TracingData: &exampleEvent{},
        Interval:    10,
        Flag:        tracing.FlagTracing,
    }, nil
}

如果事件还需要输出 Prometheus 指标,可以同时实现 Collector,并将 tracing.FlagMetric 合并到 Flag。

添加 AutoTracing

AutoTracing 与 Event 使用相同的 ITracingEvent 接口和注册框架:

type exampleAutoTracing struct{}

func (t *exampleAutoTracing) Start(ctx context.Context) error {
    // 异常触发后采集上下文。
    storage.Save("example", containerID, time.Now(), tracingData)
    return nil
}

func init() {
    tracing.RegisterEventTracing("example", newExampleAutoTracing)
}

func newExampleAutoTracing() (*tracing.EventTracingAttr, error) {
    return &tracing.EventTracingAttr{
        TracingData: &exampleAutoTracing{},
        Interval:    10,
        Flag:        tracing.FlagTracing,
    }, nil
}

项目中的 core/metrics、core/events 和 core/autotracing 提供了完整实现示例, 包括 BPF map 交互、容器信息获取、数据存储和 Prometheus 输出。

2 - 开发调试

全组件联调

开发 Compose 使用当前工作区源码构建开发镜像,并启动采集器、API Server、 Elasticsearch、Prometheus 和 Grafana。采集器需要访问宿主机内核和 cgroup, 因此应在 Linux 主机的项目根目录使用 root 权限运行:

sudo make compose-dev-up

Compose 在前台聚合所有组件日志。修改源码后,按 Ctrl+C 停止环境并重新执行 命令;Docker 会复用工具链和 Go 编译缓存。

调试结束后删除容器、数据卷和开发镜像:

sudo make compose-dev-down

该命令会删除 Elasticsearch 数据卷。需要保留联调数据时,不要执行该命令。

BPF 调试

BPF 代码可以使用 bpf_dbg() 和 bpf_dbg_msg() 宏在内核态输出调试信息。 宏定义位于 bpf/include/bpf_dbg.h。调试功能包含编译时和运行时两级开关, 默认完全关闭。

添加调试埋点

每个使用调试宏的 BPF 源文件都需要声明独立的调试 map:

#include "bpf_dbg.h"

BPF_DBG_MAP(native_cpu);

SEC("perf_event")
int prog(void *ctx)
{
        bpf_dbg_msg(ctx, native_cpu, "enter prog");
        bpf_dbg(ctx, native_cpu, "pid and addr", pid, addr, 0);
        return 0;
}

bpf_dbg_msg() 只输出消息;bpf_dbg() 还可以携带最多三个 u64 参数。

编译调试对象

通过 BPF_DEBUG=1 将 -DDEBUG_BPF 传给 Clang:

make BPF_DEBUG=1

只重新生成构建产物而不重新编译二进制:

make BPF_DEBUG=1 gen-build

BPF_DEBUG=0 是默认值。此时宏展开为空操作,调试 perf event array、事件结构、 bpf_ktime_get_ns 和 bpf_perf_event_output 都不会进入 BPF 对象。

启用运行时输出

编译调试对象后,启动 profiler 时还需要增加 --log-bpf-debug。当前只有 native profiler 支持该开关:

./profiler --type cpu --language native --log-bpf-debug ...

加载 BPF 对象时,bpf.NewDbg(true) 会在 LoadBpf 前将 bpf_dbg_enabled 常量改写为 1。未启用时,verifier 会将对应分支作为死代码 消除。每个 BPF 对象维护独立开关,互不影响。

查看调试输出

用户态以 Debug 级别输出调试事件:

  • file:BPF 源文件名。
  • line:源文件行号。
  • ts:转换为 UTC 墙钟时间的事件时间戳。
  • msg:调试消息。
  • args:最多三个 u64 参数;全部为零时省略。
bpf_dbg: file=native_oncpu_profiler.c line=120 ts=2026-01-11T08:30:00.123456Z msg=enter prog args=[0x1f4 0xffff8881 0x0]

只有同时使用 BPF_DEBUG=1 编译并在运行时指定 --log-bpf-debug,才会产生 调试输出。

3 - 集成测试

集成测试用于验证 huatuo-bamai在使用模拟的 /proc 和 /sys 文件系统时,能够正确启动并对外暴露符合预期的Prometheus指标。

测试运行的是真实的可执行文件,并通过校验 /metrics 接口的输出结果,确保指标采集与暴露逻辑正确,而不依赖宿主机的内核或硬件环境。

脚本执行流程

该集成测试脚本主要包含以下步骤:

  1. 生成临时的bamai.conf配置文件
  2. 使用模拟的 procfs 和 sysfs 启动 huatuo-bamai 服务
  3. 等待 /metrics 接口可访问
  4. 从 /metrics 接口拉取所有指标数据
  5. 校验所有预期指标是否存在且内容匹配
  6. 停止服务并清理相关资源
  7. 若任意一个预期指标缺失或不匹配,测试将直接失败

运行方式

请在项目根目录下执行集成测试:

bash integration/run.sh

指定文件名可以只运行一个集成测试,第二个参数指定循环次数,默认为 1:

bash integration/run.sh test_metrics_exclude_filter.sh 10

或通过 Makefile 执行:

make integration

前置检查与结果汇总

两套测试共用 integration/run.sh,默认运行 integration;e2e/run.sh 转发到 --suite e2e。现有 make integration、make e2e 和单用例入口继续可用:

bash integration/run.sh --suite e2e
bash integration/run.sh --suite e2e test_metrics.sh 2
bash e2e/run.sh test_metrics.sh 2

两套测试都在独立的 UTS、mount namespace 中运行,hostname 为 huatuo-dev, 挂载传播设为 private。integration 由用例启动服务;e2e 在每例之前启动默认 bamai, 结束时停止服务并检查日志。integration 成功或跳过后删除工作目录,失败时保留; e2e 保留工作目录。

runner 只检查 _output 目录是否存在,不逐个检查项目二进制和 BPF 产物。 直接运行脚本前先执行 make build; 目录存在不代表构建完整,缺失产物会在实际使用时导致失败。

用例使用同一个函数检查 PATH 中的命令或可执行文件路径,缺失时跳过:

require_commands jq curl ss
require_commands "${PROFILER_TOOL_DIR}/bin/asprof"

证书等输入文件使用 require_readable 检查,不可读时输出具体路径并跳过:

require_readable "${KUBELET_CERT}" "${KUBELET_KEY}"

skip 输出原因并以 77 退出,仍会执行 EXIT 清理。runner 将 0 记为 PASS、 77 记为 SKIP,其他状态记为 FAIL;清理失败也记为 FAIL。只有成功和跳过时, runner 返回 0。失败时停止后续用例,并输出截至当前的统计。

每次执行都单独计数,包括重复运行,例如:

summary: total=5 passed=3 skipped=2 failed=0

两套测试缺少 root 权限或 namespace 命令时,所选用例全部计为 SKIP。

失败时的行为

  • huatuo-bamai 服务指标和日志将直接输出到标准输出,便于问题定位
  • 临时工作目录将被保留,用于后续调试分析

成功时的行为

  • 显示验证成功的metrics 列表

后台进程清理

stop_and_wait_by_pid PID [TIMEOUT] 停止并等待子进程,返回其退出码。 未传入 PID 或 PID 为空时立即返回成功,调用方可以直接传入已初始化的 PID 变量。 等待结束后清空变量;尽力清理时使用 || true,退出码属于测试契约时应进行断言。

网络命名空间辅助函数

网络测试可以在加载 lib.sh 后加载 integration/lib_namespace.sh:

  • namespace_setup NAME 创建一个网络命名空间,通过 NETNS 暴露名称。 调用方负责启用 lo 并配置所需地址。
  • namespace_setup_with_pair PREFIX SERVER_ADDR CLIENT_ADDR [PREFIX_LENGTH] 创建通过 veth 连接的两个命名空间,配置地址并启用接口,地址前缀长度默认为 24。 命名空间、接口和地址通过 NETNS_SERVER、NETNS_CLIENT、 NETNS_VETH_SERVER、NETNS_VETH_CLIENT、NETNS_SERVER_ADDR、 NETNS_CLIENT_ADDR 暴露。
  • namespace_cleanup 删除上述函数创建的命名空间并清空状态,支持重复调用。 用例应在 EXIT 清理中先停止命名空间内的进程,再调用该函数。

如何新增指标测试

第一步:新增或更新模拟数据

如果新增的指标依赖 /proc 或 /sys 文件内容,请在以下目录中新增或修改模拟数据:

integration/fixtures/

目录结构需与真实内核文件系统保持一致。

第二步:添加预期指标

在以下目录中新建一个文件:

integration/fixtures/expected_metrics/
├── cpu.txt
├── memory.txt
└── ...

每一行(非空、非注释行)表示一条期望的 Prometheus 指标,指标内容必须与 /metrics 接口返回结果完全一致,新增的*.txt 文件会被测试脚本自动加载并参与校验。

第三步:运行测试

bash integration/run.sh

当任意一个预期指标缺失或不匹配时,测试将失败。

4 - 在 GitHub Actions 中调试虚拟机

使用 OS Distro QEMU Test workflow 可以在支持的 Linux 虚拟机中测试分支, 无需在本地准备虚拟机。

准备 Fork 仓库

  1. Fork 仓库,并在 fork 仓库的 Actions 页面启用 workflows。
  2. 将 SSH 公钥添加到 GitHub 账号。只有启动 workflow 的用户 可以连接。
  3. 将需要调试的分支 push 到 fork 仓库。

启动虚拟机

  1. 在 fork 仓库中打开 Actions > OS Distro QEMU Test > Run workflow。
  2. 选择需要调试的分支。
  3. 保留默认值 ubuntu24.04、填写以逗号分隔的多个发行版,或者使用 all 选择所有支持的发行版。
  4. 在 Debug VM 选项中选择保留虚拟机的时机和时长。
  5. 启动 workflow,并打开对应的 Test in VM job。

如果没有 Run workflow 按钮,请先同步 fork 仓库的默认分支。GitHub 只会 显示默认分支中存在的手动 workflow。

选择 Before test 可以在测试前检查或修改虚拟机;选择 After test on failure 可以排查失败测试;选择 After test always 会在测试结束后保留 虚拟机。

连接虚拟机

打开 job 中的 Debug VM: Retain ... step,并按日志提示操作:

  1. 在本地终端运行日志中的完整 SSH 命令,连接 GitHub runner。
  2. 在 runner 中运行日志中的 ssh -i ... root@<VM_IP> 命令,进入虚拟机。

日志中的命令已经包含正确的密钥路径和虚拟机地址,直接复制即可。

结束调试

退出虚拟机,然后在 runner 中释放 workflow:

cd "$GITHUB_WORKSPACE"
touch continue

workflow 随后继续执行测试或清理。仅断开 SSH 不会释放 workflow;如果不执行 上述命令,workflow 会一直等待到所选保留时间结束。

5 - BPF ABI 指南

BPF 侧 C 结构体是 perf event ABI 的唯一来源。Go 类型由 BPF 对象中的 BTF 自动生成,禁止手工维护同构结构体。

约定

一个 ABI 域对应一个 C 头文件和一个 Go 生成文件:

项目 约定
域名 <domain>
C 头文件 bpf/include/abi/<domain>_types.h
C 结构体前缀 <domain>_
Go 生成文件 internal/bpf/abi/<domain>_types_generated.go

<domain> 必须以小写字母开头,只能包含小写字母、数字和下划线。避免使用 前缀重叠的域,例如 net 和 net_rx。

实现

以下示例创建 sample 域。

1. 定义 ABI 头文件

新建 bpf/include/abi/sample_types.h:

// Copyright 2026 The HuaTuo Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

#ifndef __BPF_ABI_SAMPLE_H__
#define __BPF_ABI_SAMPLE_H__

#include "bpf_abi.h"

#define SAMPLE_DATA_LEN 4

struct sample_detail {
	u32 code;
	u8 data[SAMPLE_DATA_LEN];
};

struct sample_event {
	u64 kernel_observed_ns;
	struct sample_detail detail;
	u32 tgid;
	u8 kind;
	u8 pad[3];
};

BPF_ABI_EXPORT(sample_detail);
BPF_ABI_EXPORT(sample_event);

#endif /* __BPF_ABI_SAMPLE_H__ */

要求:

  • 结构体名称必须使用 <domain>_ 前缀。
  • 每个需要生成 Go 类型的结构体都必须调用 BPF_ABI_EXPORT(<type>),包括嵌套结构体。
  • 头文件只定义 ABI 类型和相关常量,不放置 BPF 程序、map 或业务逻辑。
  • 数组长度必须是编译期常量,且在所有 BPF 编译单元中保持一致。

2. 在 BPF 程序中使用

ABI 头文件依赖 u8、u16 等基础类型,应在 vmlinux.h 和项目基础头文件 之后包含:

#include "vmlinux.h"

#include "bpf_common.h"
#include "abi/sample_types.h"

发送 perf event 时直接使用 ABI 结构体:

struct sample_event event = {};

event.kernel_observed_ns = bpf_ktime_get_ns();
event.tgid = bpf_get_current_pid_tgid() >> 32;
event.kind = kind;

bpf_perf_event_output(ctx, &events, COMPAT_BPF_F_CURRENT_CPU,
		      &event, sizeof(event));

必须零初始化事件,避免将未初始化的 padding 写入 perf buffer。传给 bpf_perf_event_output 的结构体必须定义在 ABI 头文件中。

3. 生成并使用 Go 类型

在项目根目录运行:

make gen-build

生成结果位于:

internal/bpf/abi/sample_types_generated.go

Go 代码直接引用生成类型:

import "github.com/ccfos/huatuo/internal/bpf/abi"

var event abi.SampleEvent
if err := reader.ReadInto(&event); err != nil {
	return fmt.Errorf("read sample event: %w", err)
}

生成文件包含结构体、SampleEventSize 大小常量,以及基于 unsafe.Sizeof 和 unsafe.Offsetof 的布局断言。生成文件只读,禁止手工 修改。

类型和布局

类型 支持情况
1、2、4、8 字节定宽整数 支持
非零长度的定长数组 支持
满足相同约束的嵌套结构体 支持
目标类型受支持的 typedef 支持
指针、union、enum、浮点类型 不支持
位域、_Bool 不支持
零长度数组、柔性数组 不支持
递归、字段重叠或非字节对齐结构体 不支持

布局要求:

  • 使用 u8、s16、u32、s64 等定宽整数。
  • 按对齐要求排列字段,必要时使用 u8 pad[N] 明确 padding。
  • 不使用 long、unsigned long 等随平台变化的类型。
  • 不使用 __attribute__((packed)) 绕过布局校验。
  • 同名结构体出现在多个 BPF 对象中时,字段、偏移和大小必须完全一致。

C 名称按下划线转换为 Go 导出名称,例如 sample_event 转换为 SampleEvent,kernel_observed_ns 转换为 KernelObservedNS。字段语义和跨层命名遵循 事件字段命名规范。避免使用会映射为相同 Go 名称的 C 名称,例如 sample_id 和 sample_i_d。

验证

新增或修改 ABI 后执行:

make gen-build
go test ./build/bpfabi-tool
make check

新增 perf event 还应增加解码测试,至少覆盖:

  • 整数边界值和本机字节序。
  • 嵌套结构体和数组首尾元素。
  • padding 前后的字段。
  • 样本大小与生成的 <GoType>Size 常量。

常见错误

错误 检查项
has no "<domain>_" btf anchors 头文件是否被 BPF 源文件包含;类型是否调用 BPF_ABI_EXPORT
without matching abi header 头文件名与结构体前缀是否一致;域前缀是否重叠
differs between objects 条件编译、数组长度宏和依赖头文件是否导致布局变化
go offset is ... btf offset is ... 调整字段顺序或增加显式 padding,不要使用 packed
go type name ... collides 重命名映射为相同 Go 名称的 C 类型或字段

6 - TCP retransmit 与 dropwatch 关联的难点

本文说明 local correlation 的证据边界与实现。两条事件流没有共同事件 ID, 关联只能根据 namespace、四元组、TCP sequence/ACK 和单调时间作保守推断。

1. 结果语义

结果 含义
software 找到满足全部严格条件且尚未被消费的 host software drop。
hardware 找到满足相同严格条件的 devlink DROP trap 记录。
unknown 未找到严格匹配,或匹配记录的来源枚举未知;correlation_reason 明确区分已匹配与未匹配终态。

no-match 不能证明问题位于网络或硬件。drop 可能发生在采集启动前、另一个 network namespace,或记录虽送达但缺少 TCP 匹配字段。

shutdown 使用同一个时间点处理等待项:已到期项为 warmup 或 wait_timeout,未到期项为 interrupted。这些未匹配结果均输出 drop_location=unknown 和独立诊断信息。

2. 三种运行场景

  1. tcpshark --bpf-path <tcp_retransmit.o> 只采集并直接输出重传。
  2. tcpshark --with-dropwatch --bpf-path-dir <dir> 在一个进程内加载 tcp_retransmit.o 与 net_dropwatch.o,统一持有两条 perf 输入、timer、输出和关闭。
  3. huatuo-bamai 的 standalone dropwatch 仍由 cmd/dropwatch 独立运行,只输出 raw DropWatchTracing,与 embedded source 不共享状态。

三种 retransmit hook 在两种模式下都使用 synthetic L3 filter。关联模式只使用 EventTracing.TCPRetransmit.Filter;空值规范化为 tcp,同一表达式传给两个 BPF 对象。依赖 Ethernet 地址、无法在 synthetic L3 输入上等价执行的表达式会在 启动前被拒绝。EventTracing.Dropwatch.Filter 只控制 standalone dropwatch。

两个命令都使用 HardwareAuto 自动检测 devlink trap 支持。tracepoint 不可用时 输出 warning 并继续软件采集;可用时只接收驱动上报的 DROP trap。 --device / --device-excluded 与独立 dropwatch 同名、互斥,只过滤 embedded source 的软件及硬件记录,要求 --with-dropwatch。重传输入仍使用共同的 L3 filter。

丢包来源和原因由 internal/dropwatch.ResolveMetadata 统一解析。软件 reason 从每会话一次加载的 BTF 表查找;未知值为十进制数字,旧内核不支持为 NOT_SUPPORTED。硬件 reason 和 group 分别来自 trap 名称与分组,不查软件 reason 表。reader 保留独立的来源和 reason 字符串,不借用可复用的 ABI buffer。 成功匹配后输出 drop_source、drop_reason、drop_reason_group;no-match 省略这些字段,输出唯一关联结束原因和独立诊断。drop_location 在匹配时直接使用 drop_source,未匹配时为 unknown;它表示关联分类,不同于独立 dropwatch 的内核地址。未知来源不根据 reason 或 stack 推断。 硬件记录缺少 namespace 或 TCP 匹配字段时无法建立严格匹配。

3. 两条时间约束

dropwatch 与 retransmit 使用不同 perf reader,事件还可能来自不同 CPU,因此 用户态接收顺序不等于内核发生顺序:

CPU 2: drop       monotonic_ns=180,暂留在 dropwatch ring
CPU 0: retransmit monotonic_ns=200,先到达用户态
用户态: retransmit(200) -> drop(180)

实现使用两个不同维度的窗口:

retransmitRetentionDuration = 100 * time.Millisecond
maxDropToRetransmitAge     = time.Second
  • 100ms 是用户态送达乱序预算。retransmit 先到时进入等待队列,到期仍未匹配则 输出 unknown。处理任一新事件前先结算已经到期的记录;timer 只负责唤醒, 不决定 deadline 语义。
  • 1s 是因果候选年龄,使用 BPF kernel_observed_ns 判断。候选必须满足:
drop.kernel_observed_ns <= retransmit.kernel_observed_ns
retransmit.kernel_observed_ns - drop.kernel_observed_ns <= 1s

observed_timestamp 是用户态墙上时间,受调度和系统时间调整影响,不参与匹配。 固定 1s 是当前支持合同,不等于 Linux 所有 RTO 的理论上限;窗口外结果保持 unknown。

4. 有界状态

等待中的 retransmit 与尚未匹配的 drop 共用包内泛型存储 store[T],分别持有 独立实例。容器只负责容量、TTL、flow 索引和删除;严格匹配与结果定型由关联器负责。

type storeEntry[T any] struct {
    value    T
    flow     *flowKey
    deadline time.Time
    sequence uint64
    node     *list.Element
}

type store[T any] struct {
    byDeadline   list.List
    byFlow       map[flowKey][]*storeEntry[T]
    capacity     int
    ttl          time.Duration
    nextSequence uint64
}
实例 存储值 容量 TTL
retransmitStore waitingRetransmit,内联在 entry 中 1024 100ms
dropStore *dropEvent 4096 1s + 100ms

waitingRetransmit 只包含原始事件、匹配字段和跨 namespace 候选标记; 不再持有链表节点、deadline 或独立 ID。drop 也无需额外的缓存包装类型。 链表的 Element.Value 与 flow 桶引用同一个 *storeEntry[T],entry 的 node 指回所属链表元素。entry 引用业务数据中不再修改的 flow,避免重复保存地址。 删除会同时解除两个索引的引用,并清空 flow 切片尾部; 最后一个条目移除后删除空桶。

flow 索引按两个 AddrPort 的规范顺序建键,正反向只占一个桶;原始方向仍保留 在业务数据中,供严格匹配使用。namespace 不进入索引键,保证跨 namespace 的相似候选仍能设置诊断标记。匹配扫描与切片删除为 O(k),k 是该 flow 的候选数; 定位最早 deadline 与链表摘除为 O(1)。

每个实例的 TTL 固定,关联循环传入单调不减的处理时间,因此链表插入顺序也是 deadline 顺序。链表用于到期清理和容量淘汰,不向业务暴露 FIFO 消费接口。 等待容量满时,最早 deadline 的记录以 queue_full 定型;drop 容量满时直接淘汰最早 deadline 的候选。因果判断仍使用 kernel_observed_ns。

两个 reader 通过无缓冲 channel 交付事件,容器只由单个关联循环访问,无锁、 无独立清理 goroutine。一个 timer 始终取两个实例的最早 deadline,因此仅有 drop 缓存、没有等待重传时也会按期清理。每次输入和 timer 唤醒均先清理到期项, 再匹配、检查容量;now >= deadline 即为过期。100ms 是匹配截止时间, 同步输出的阻塞仍可能延迟实际清理和结果送达。

5. 严格匹配

正向 segment 候选要求:

  • 相同 network namespace、地址族和方向四元组;
  • drop 不晚于 retransmit,且时间差不超过 1s;
  • TCP SYN/data/FIN sequence range 与重传 range 重叠;
  • RST 和不受支持的重传类型不产生正向匹配。

反向 ACK 候选使用反向四元组,并要求 ACK 覆盖重传 sequence end。SYN 与 SYN-ACK 使用各自更严格的 ACK/SYN 条件。

多个 drop 候选先选最大的 drop.kernel_observed_ns;时间相同时选较大的插入序号。 drop 后到时,选择插入序号最小的严格匹配重传,同时扫描其余候选以记录各重传的 namespace 匹配状态。严格匹配后立即从 deadline 和 flow 两个索引删除,只能消费一次。 正向 segment 和反向 ACK 共用 flow 索引,先检查 namespace,再检查报文和时间条件。 同一 flow 下出现相同 namespace 的 drop 时,将 netNamespace 置为 true; 即使时间或 sequence 不满足严格匹配条件,该标记也会保留,后续不匹配候选不会清除它。 严格匹配的结果必定为 true。无候选、只有其他 namespace 或 namespace 无法比较时, 该值为 false,表示未观测到匹配,不能据此断定 namespace 不同。 输出通过 NetNamespace 保留该状态,JSON 和文本字段为 matched_net_namespace,false 时省略。

6. 互斥的关联终态

types.CorrelationReason 为 string 类型;每次关联只输出一个 correlation_reason:

Go 常量 JSON 值 含义
CorrelationMatched matched 严格匹配成功,包括来源为 unknown 的 drop。
CorrelationUnsupported unsupported 当前规则无法处理该重传,不进入等待队列。
CorrelationWarmup warmup 等待到期仍未匹配,重传发生时间早于 source ready。
CorrelationWaitTimeout wait_timeout 等待到期仍未匹配,重传发生时间等于或晚于 source ready。
CorrelationQueueFull queue_full 该重传因等待队列已满,在到期前被淘汰。
CorrelationInterrupted interrupted 关联循环退出,未到期的等待被提前中断。

各结束分支直接赋值 correlationResult.reason。输出侧使用该枚举判定结果,并校验 matched 必须带 drop,其他五种终态不能带 drop;空值、未知值或矛盾组合返回错误。 关闭关联时省略输出字段;启用关联后所有已定型事件都包含一个有效终态。 单值字段替代原原因数组,工具端和接收端同步迁移。

等待到期时,按重传的内核时间选择终态:早于 source ready 时为 warmup, 等于或晚于 ready 时为 wait_timeout,包括 ready 后不足 1s 的情况。 drop 到重传的 1s 上限只用于候选匹配,不参与预热判定。用户态处理延迟 不会改变分类。该判定仅在到期分支执行,严格匹配、 不支持、队列淘汰和提前中断保留各自原因。预热不阻止匹配,也不提前结束等待。

matched_net_namespace 保留为独立诊断标记:同一 flow 下观测到相同 namespace 的 drop,独立于报文和时间条件;不能替代 correlation_reason=matched。

该标记可与限流、丢失计数并存,不改变唯一终态。无法规范化的 drop 与 drop 缓存容量淘汰不会直接结束重传等待,也不会生成重传级原因。

7. Perf 状态

状态由三个独立通道组成,各报各的、互不求和:

  • perf_lost:累计 per-CPU bpf_perf_out_dropwatch map(公共 perf output 计数设施 bpf_perf_output.h),BPF 在 bpf_perf_event_output 负返回时 +1(如当前 CPU 未 attach reader)。
  • lost_samples:用户态从 PERF_RECORD_LOST 累计的 ring buffer 溢出数,只在 reader 运行期间可见,处理丢失记录后即可通过 ReadStatus 查询。
  • rate_limited:限流状态 map bpf_rlimit_dropwatch 的 total_missed。

ReadStatus 成功时设置 HasMapCounters=true,JSON 为 map_counters_available=true。出错时该标记为 false,PerfLost、RateLimited 为零,表示不可用而非没有丢失或限流;LostSamples 仍有效,输出保留该快照。 这些值为实例累计计数,map 和 reader 分别采样,不能推断某条重传未匹配的根因。

用户态汇总所有 CPU 的 perf_lost,每次返回当前快照,不检查计数回退或 uint64 加法溢出。该状态只说明 证据完整性,不会把 no-match 提升为确定性网络分类。旧的 active epoch、 旧的双 slot、inflight、frontier 和 drain 水位机制已删除。

关联侧根据 IPv4 total length 或 IPv6 payload length 计算 TCP sequence span。 GSO/offload 下 IP header 长度可能无法覆盖完整 skb;当前不据此扩大匹配范围。

dropwatch 热路径为:

software: hardware marker lookup/delete -> device/pcap filter
          -> status map lookup -> bpf_ktime_get_ns() -> rate limit -> perf output
hardware: device/pcap filter -> status map lookup -> bpf_ktime_get_ns()
          -> rate limit -> perf output

因此 filter 拒绝的事件不支付状态 lookup、事件时间 helper 或计数更新成本; software drop 会先消费可能存在的 hardware dedup marker,避免被拒绝的 kfree 路径遗留 marker。

8. Shutdown

tcpshark 使用一个 errgroup.WithContext 管理 retransmit rate-limit reader、 retransmit reader、embedded dropwatch reader 和关联循环。所有命令 worker 共享同一个 groupCtx;任一 worker 返回错误后取消其余 worker,Wait 返回首个 worker 错误。资源关闭错误由各 owner 使用 errors.Join 合并,不再为 shutdown 维护私有 错误聚合 group。

internal/dropwatch.Tracer 持有 dropwatch 的 object、reader 和限流告警 worker。 实例 context 派生自 groupCtx;限流告警失败会取消实例读取,具体错误由 Close 返回。ReadInto 在读取前检查关闭和取消原因;进入底层读取后原样返回读取结果, 不再以随后发生的关闭或取消覆盖结果。ReadStatus 在 context 取消后仍可读取最终计数。 Close 先取消实例、 detach 并关闭事件与告警 reader,再等待告警 worker 退出,最后释放 object; 关闭告警 reader 会唤醒空闲读取,无需等待轮询期限。 状态类型为 types.DropwatchStatus,输出字段为 drop_perf_status,包含 map 计数可用性标记。

正常结束或 worker 失败时:

  1. 取消共享 groupCtx;
  2. 两个 ReadInto reader 和关联循环退出;
  3. 不再读取 dropwatch perf ring 中尚未交给关联器的记录;
  4. 调用 drainRetransmits(now),以同一时间先结算到期项,再清空未到期项, 按 deadline 顺序写入 output;
  5. 等全部 worker 退出后,关闭 dropwatch 与 retransmit Tracer,收集内部告警 worker 和资源释放错误;
  6. 最后结束 socket output;调用者传入的 io.Writer 不由会话关闭。

shutdown 已到期项输出 warmup 或 wait_timeout,未到期项输出 interrupted,均保留 drop_location=unknown、诊断标记和 perf 状态。已匹配或已淘汰的事件不会重复定型。 尾部 drop 仍可能丢失,因此原本可以匹配的重传也可能被提前结束。

9. 文件职责

cmd/tcpshark/
├── main.go                         # 信号、版本、CLI 入口
├── cli.go                          # 参数校验、路径与 filter 规范化
├── run.go                          # 全局 BPF 生命周期、超时、功能分派
└── retransmit/                     # 重传功能包
    ├── config.go                   # Config、RunConfig
    ├── run.go                      # Run:资源组合、执行和清理
    ├── tracer.go                   # Open、ReadInto、Close 和限流 worker
    ├── bpf_load.go                 # loadBPF、attachBPF 和探针选择
    ├── event_reader.go             # 双流读取、转换和 channel 交付
    ├── correlation_loop.go         # select、timer、退出结算
    ├── correlation.go              # 关联状态机、过期结算
    ├── correlation_match.go        # 候选选择、消费与 namespace 匹配状态
    ├── match.go                    # 匹配类型、namespace/flow/sequence/ktime
    ├── store.go                    # 条目存储、flow 索引、容量与过期管理
    ├── event.go                    # 两类 ABI record 转换
    ├── classify.go                 # 重传分类
    ├── correlation_output.go       # 状态补全、符号解析、关联输出字段
    └── output.go                   # text、JSON、socket 输出

顶层通过 retransmit.Run(ctx, *RunConfig) 运行重传功能,不操作 ABI record、 channel、关联 timer 或状态统计。CLI 先校验标量及参数组合,再编译检查 filter。 设置非空 --output-storage 时不校验 --output 的值;若显式指定后者, 向 stderr 提示其被忽略。未设置 storage 时仅接受 json 或 text。 RunConfig.Tracing 是采集配置, Dropwatch 为 nil 时直接读取并输出;非 nil 时使用两个读取 worker 和单个关联 循环。关联能力是重传功能的组合,不是通用采集框架。

Run 在 attach 探针之前建立输出,返回前完成全部会话 worker 等待、最终结算、 Tracer 关闭及输出结束。配置失败、取消与运行失败均释放已取得资源。 关闭时不保证排空内核缓冲区。普通模式不创建关联 channel 或 timer。

retransmit.Tracer 仍只拥有重传 BPF 对象、reader 和限流告警 worker; 事件转换、分类、关联及输出属于同包的功能会话,不进入 Tracer。 Open 失败会回滚全部已获取资源;会话 context 取消后仍需调用 Close。限流 worker 失败会取消采集,原始错误同时由 Close 保留。 ReadInto 已开始读取后直接保留底层结果,不再检查关闭或取消状态。 Close 可以打断读取,但调用者必须串行调用 Close。 进程级 bpf.Init/Shutdown 和运行超时由命令层统一管理。

关联器在处理每个输入和 timer 时统一清理过期候选,缓存不重复执行过期检查。 关联器返回匹配证据、单值终态和诊断标记,不修改输出字段;correlation_output.go 统一构造最终输出, 每个非空输出批次在入口读取一次 dropwatch 状态,空批次不读取;仅对匹配 drop 解析符号。 即使本批全部匹配也读取状态,读取失败时仍写出本批事件,再返回状态错误; 若写出失败则停止本批输出,并合并写出错误与状态错误。只有 no-match 事件携带 状态快照与诊断标记;读取失败也保留有效的 reader 计数,匹配事件不附加这些字段。 内部重传事件只保留 ABI record 和读取成功时的 observedAt;等待队列不保存 完整的展示对象。匹配直接从 ABI 地址构造 netip 地址,使用 ABI 事件枚举, 不经过字符串转换。IPv4-mapped IPv6 仍归一化为 IPv4。 单事件读取统一处理丢失重试和读取时间;普通模式复用事件并同步输出, 关联模式直接读取到独立事件后发送,避免覆盖等待中的数据。 run.go 统一创建 channel、启动读取 worker,并由发送方关闭 channel; 读取函数不启动 goroutine,不使用消费回调或借用事件合同。 最终输出时才构造 TCPRetransmitTracing;ObservedTimestamp 使用原读取时间, 不使用关联结束时间。ABI、SYNACK flags 补全和匹配规则保持不变。

cmd/tcpshark/retransmit 不提供 Go internal 导入限制;约定仅由 tcpshark 使用,不反向依赖命令层。两个命令共享的采集实现位于:

internal/dropwatch/
├── config.go    # Config、HardwareMode 和配置校验
├── tracer.go    # Tracer API、newTracer 资源接管、回滚及 worker 生命周期
├── load.go      # loadBPF、attachBPF:加载、map/reader 准备及探针挂载
├── netdev.go    # 设备过滤与 map 配置
├── status.go    # ReadStatus:输出失败、ring 丢失和限流计数
├── reason.go    # LoadReasonNames:内核 BTF reason 表和数字回退
├── metadata.go  # ResolveMetadata:来源、软件 reason、硬件 trap/group
└── decode.go    # DecodePacket:ABI header 适配及 packet.Parse

Open(ctx, *Config) 返回具体的 *Tracer。每个实例只能有一个 ReadInto 调用者;状态读取和关闭允许并发,但同一实例的 Close 必须由资源所有者串行调用。 首次关闭返回清理错误;后续调用返回 nil,不重试清理,也不重复返回首次错误。 调用方拥有全局 BPF 初始化和关闭,且必须在最终 结算后关闭 Tracer。DecodePacket 的返回值不借用原始 record 内存。 符号化和 DropWatchTracing 展示转换仍位于 standalone 命令,TCP 关联直接保留 ABI 中的 KernelObservedNS、namespace、stack PC 和已解析的丢包来源及原因。没有新增通用 BPF 消费循环接口。

7 - 时间格式约定

面向集成与存储的统一时间戳格式。

时间格式约定

HUATUO 统一以 UTC 和固定九位纳秒精度序列化时间戳:

2006-01-02T15:04:05.000000000Z

例如,2026 年 7 月 22 日 00:30:00.123456789 的事件会写为:

2026-07-22T00:30:00.123456789Z

为什么使用固定 UTC 精度

  • 所有主机都使用 Z 时区,分布式查询无需处理不同的本地时区。
  • 九位小数使字符串宽度固定,因此字典序与时间序一致,便于日志文件和关键字索引存储排序。
  • 纳秒精度能够保留 Go time.Time 与事件追踪流水线提供的时间信息。

集成建议

向 HUATUO 写入数据时,请使用统一辅助函数:

timestamp := timeutil.FormatUTC(time.Now())

读取时间戳时使用 timeutil.Parse。它既支持统一格式,也支持包含 0 到 9 位小数的 RFC 3339 旧数据;返回值始终会规范化为 UTC。

只有在两侧均由 FormatUTC 生成字符串时,才应直接按字符串比较时间。第三方 RFC 3339 值可能使用可变小数精度或非 UTC 偏移。

8 - 事件字段规范

事件字段必须先统一语义,再按各层的惯例命名。相同名称不得表示不同概念。

分层规则

层 规则 示例
BPF C 名称体现内核原始语义和单位 tgid、kernel_observed_ns
Go MixedCaps;缩写保持全大写 TGID、KernelObservedNS
JSON 使用面向用户的统计语义 pid、observed_timestamp
CLI 参数 小写短横线 --pid、--cpuid
CLI 文本和表头 使用标准大写缩写 PID、CPU、COMM

CPU 和任务

概念 定义 BPF C Go JSON CLI
逻辑 CPU 编号 bpf_get_smp_processor_id() 返回的编号 cpu CPU cpu --cpu、CPU
进程 ID 用户态进程标识,即 Linux TGID tgid 原始事件用 TGID,输出用 PID pid --pid、PID
线程 ID Linux task PID tid TID tid --tid、TID
父进程 ID 父进程 TGID parent_tgid ParentTGID 或输出层 ParentPID parent_pid PPID
task comm 内核 task 的 comm,可能是线程名 comm Comm comm COMM

时间和数值单位

概念 定义 BPF C Go JSON
内核观测原始时间 CLOCK_MONOTONIC 纳秒读数,不含系统挂起时间 kernel_observed_ns KernelObservedNS 不序列化,仅用于内部关联
内核观测时间 原始单调时间转换后的 UTC 时间 不适用 KernelObservedTimestamp kernel_observed_timestamp
用户态观测时间 事件生产者在用户态观测事件的 UTC 时间 不适用 ObservedTimestamp observed_timestamp
Unix 纳秒时间 Unix 纳秒时间 timestamp_ns TimestampNS timestamp_ns
纳秒时长 两个时间点的差值 <name>_ns <Name>NS <name>_ns
纳秒阈值 触发条件对应的时长 <name>_threshold_ns <Name>ThresholdNS <name>_threshold_ns
计数 无单位的累计值 <name>_count <Name>Count <name>_count
字节数 数据大小,不是位数 <name>_bytes <Name>Bytes <name>_bytes

容器、cgroup 和网络命名空间

概念 BPF C Go JSON CLI
容器 ID container_id ContainerID container_id --container-id、CONTAINER_ID
cgroup ID cgroup_id CgroupID cgroup_id CGROUP_ID
cgroup CSS 地址 <subsystem>_css_addr <Subsystem>CSSAddr <subsystem>_css_addr <SUBSYSTEM>_CSS_ADDR
网络命名空间 inode netns_inum NetNamespaceInum net_namespace_inum NETNS_INUM
网络命名空间 cookie netns_cookie NetNamespaceCookie net_namespace_cookie NETNS_COOKIE
网络设备索引 ifindex 或 <name>_ifindex Ifindex 或 <Name>Ifindex ifindex 或 <name>_ifindex IFINDEX

内核术语 inum 和 ifindex 在 Go 中保持一个单词,不拆成 INum 或 IfIndex。面向用户的 Go 和 JSON 字段将 netns 展开为 NetNamespace 和 net_namespace。

观测时间与兼容性

kernel_observed_timestamp、observed_timestamp 和 uploaded_timestamp 分别表示内核观测、用户态观测和存储写入时刻。内核观测表示 hook 执行时刻, 不保证等同于物理硬件错误或网络故障开始的时刻。

Document 顶层保存 UTC 时间,不保存 kernel_observed_ns。 没有内核时间的生产者及旧文档省略 kernel_observed_timestamp,不得用 用户态时间或写入时间补填。历史 RAS 文档的 observed_timestamp 曾表示 内核观测时刻,不能据此推算历史用户态观测时刻。

tcpshark 和 dropwatch 的 JSON/文本输出使用 kernel_observed_timestamp 表示内核观测时间。 工具和 Agent 应一起升级;旧文档保持原样,不自动回填或重写。 单调时钟与 UTC 的转换要求相同主机、同一次启动及主机时间命名空间。 跨越校时跳变或系统挂起的历史事件,仅凭单调时间不能精确恢复 UTC。

UTC 转换缓存单调时钟到实时钟的偏移:距上次成功采样满 1 小时后,由下一次 调用刷新,普通调用不延长缓存期限。采样失败返回错误,下次调用重试。 系统校时跳变或挂起不会提前触发刷新,偏移会在缓存到期后的下一次调用更新。