[i18n] print_printable_section [i18n] print_click_to_print.
开发手册
- 1: 扩展观测能力
- 2: 开发调试
- 3: 集成测试
- 4: 在 GitHub Actions 中调试虚拟机
- 5: BPF ABI 指南
- 6: TCP retransmit 与 dropwatch 关联的难点
- 7: 时间格式约定
- 8: 事件字段规范
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 接口的输出结果,确保指标采集与暴露逻辑正确,而不依赖宿主机的内核或硬件环境。
脚本执行流程
该集成测试脚本主要包含以下步骤:
- 生成临时的
bamai.conf配置文件 - 使用模拟的
procfs和sysfs启动huatuo-bamai服务 - 等待
/metrics接口可访问 - 从
/metrics接口拉取所有指标数据 - 校验所有预期指标是否存在且内容匹配
- 停止服务并清理相关资源
- 若任意一个预期指标缺失或不匹配,测试将直接失败
运行方式
请在项目根目录下执行集成测试:
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 仓库
- Fork 仓库,并在 fork 仓库的 Actions 页面启用 workflows。
- 将 SSH 公钥添加到 GitHub 账号。只有启动 workflow 的用户 可以连接。
- 将需要调试的分支 push 到 fork 仓库。
启动虚拟机
- 在 fork 仓库中打开 Actions > OS Distro QEMU Test > Run workflow。
- 选择需要调试的分支。
- 保留默认值
ubuntu24.04、填写以逗号分隔的多个发行版,或者使用all选择所有支持的发行版。 - 在 Debug VM 选项中选择保留虚拟机的时机和时长。
- 启动 workflow,并打开对应的
Test in VMjob。
如果没有 Run workflow 按钮,请先同步 fork 仓库的默认分支。GitHub 只会 显示默认分支中存在的手动 workflow。
选择 Before test 可以在测试前检查或修改虚拟机;选择 After test on failure 可以排查失败测试;选择 After test always 会在测试结束后保留 虚拟机。
连接虚拟机
打开 job 中的 Debug VM: Retain ... step,并按日志提示操作:
- 在本地终端运行日志中的完整 SSH 命令,连接 GitHub runner。
- 在 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. 三种运行场景
tcpshark --bpf-path <tcp_retransmit.o>只采集并直接输出重传。tcpshark --with-dropwatch --bpf-path-dir <dir>在一个进程内加载tcp_retransmit.o与net_dropwatch.o,统一持有两条 perf 输入、timer、输出和关闭。- huatuo-bamai 的 standalone dropwatch 仍由
cmd/dropwatch独立运行,只输出 rawDropWatchTracing,与 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-CPUbpf_perf_out_dropwatchmap(公共 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:限流状态 mapbpf_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 失败时:
- 取消共享
groupCtx; - 两个
ReadIntoreader 和关联循环退出; - 不再读取 dropwatch perf ring 中尚未交给关联器的记录;
- 调用
drainRetransmits(now),以同一时间先结算到期项,再清空未到期项, 按 deadline 顺序写入 output; - 等全部 worker 退出后,关闭 dropwatch 与 retransmit Tracer,收集内部告警 worker 和资源释放错误;
- 最后结束 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 小时后,由下一次 调用刷新,普通调用不延长缓存期限。采样失败返回错误,下次调用重试。 系统校时跳变或挂起不会提前触发刷新,偏移会在缓存到期后的下一次调用更新。