Headscale 集成测试框架实战:在 Docker 中用真实 Tailscale 客户端编写端到端场景测试
【免费下载链接】headscaleAn open source, self-hosted implementation of the Tailscale control server项目地址: https://gitcode.com/GitHub_Trending/he/headscale
Headscale 的集成测试会真正启动一个 Headscale 服务器,并在 Docker 中运行多个支持版本的真实 Tailscale 客户端去执行场景脚本,它是对 Tailscale 协议兼容性最直接的"安全网"。本文以仓库内 integration/README.md 为骨架,结合 integration/、cmd/hi/README.md 中的真实源码,完整讲解 Headscale 集成测试"怎么写"与"怎么跑",读者可由此掌握基于Scenario框架搭建测试环境、用EventuallyWithT处理分布式异步状态、以及按属性而非位置断言节点状态的一整套实战方法。
文档分工与阅读路线
Headscale 将集成测试相关的说明拆成了两份互为补充的文档,请勿混淆:
- integration/README.md(本文主体):讲清如何编写集成测试——框架的四层架构、必须的脚手架(
IntegrationSkip、ScenarioSpec)、EventuallyWithT异步断言模式、节点查找与常见坑。 - cmd/hi/README.md:讲清如何运行集成测试——
hi命令、并发运行安全规则、产物(artefact)目录结构与失败调试工作流。
测试代码位于 integration/ 目录:凡是文件名以_test.go结尾的都是测试用例,其余文件(scenario.go、tailscale.go、helpers,以及hsic/、tsic/、dockertestutil/等包)构成测试框架本身。
一、如何运行集成测试
1. 本地首选:cmd/hi(Headscale Integration runner)
本地运行推荐使用hi命令,运行前先阅读 cmd/hi/README.md:
# 先体检:验证 Docker、Go、磁盘空间、所需镜像是否齐备 go run ./cmd/hi doctor # 运行单个测试(默认参数为单测开发场景调优) go run ./cmd/hi run "TestPingAllByIP" # 用 PostgreSQL 运行数据库密集测试 go run ./cmd/hi run "TestExpireNode" --postgres # 支持模式匹配 go run ./cmd/hi run "TestSubnet*"doctor应在任何新环境首次run之前执行。每次测试会在control_logs/下产生约 100MB 日志,doctor会校验磁盘空间是否充足、必要镜像是否可用。hi的关键参数如下(默认值针对单测开发调整过,改动前请三思):
| 参数 | 默认值 | 作用 |
|---|---|---|
--timeout | 120m | 测试总超时。必须使用内置参数,绝不要用 bashtimeout包裹 |
--postgres | false | 使用 PostgreSQL 而非 SQLite |
--failfast | true | 遇第一个失败即停止 |
--go-version | 自动 | 从go.mod检测(当前仓库为 Go 1.26.1) |
--clean-before | true | 运行前清理残留(已停止/退出的)容器 |
--clean-after | true | 运行结束后清理本次容器 |
--keep-on-failure | false | 失败时保留容器便于人工检查 |
--logs-dir | control_logs | 运行产物保存目录 |
--verbose/--stats | false | 详细输出 / 采集容器资源用量统计 |
--hs-memory-limit/--ts-memory-limit | 0 | 超过指定 MB 即判失败(0 表示关闭) |
超时设置有现实下限,仅供参考:基础功能/CLI 类测试建议不低于 900s(15m);路由/ACL 类不低于 1200s(20m);HA/故障转移类不低于 1800s(30m);长跑类(如TestNodeOnlineStatus)不低于 2100s(35m);全量套件约 45m。
timeout 300 go run ./cmd/hi run "TestName" # 错误:清理中途被杀,遗留孤儿容器 go run ./cmd/hi run "TestName" --timeout=900s # 正确2. 本地复现 CI:act
act可以在本地执行 GitHub Actions 工作流(仓库对应工作流为 .github/workflows/test-integration.yaml):
act pull_request -W .github/workflows/test-integration.yaml每次测试在 GitHub Actions 上都作为独立 workflow 运行。多个hi run也可在同一 Docker daemon 上并发执行——每次调用会获得独立 Run ID(格式YYYYMMDD-HHMMSS-6charhash),容器名、Docker 标签hi.run-id、日志目录control_logs/{runID}/均按 Run ID 隔离,清理只针对自己标签的容器。注意:并发运行期间禁止执行docker system prune -f或hi clean containers/hi clean all,否则会误杀他人正在运行的测试会话;识别自己的容器用docker ps --filter "label=hi.run-id=20260409-104215-mdjtzx"。
二、框架四层架构
从 scenario.go 等源码可以看出,测试框架自顶向下共四层:
scenario.go—Scenario负责编排一个完整的测试环境:一个 Headscale 控制服务器(ControlServer)、若干用户(User)、以及挂在这些用户下的一批 Tailscale 客户端(TailscaleClient)。NewScenario(spec)返回一个可直接使用的环境;Scenario内部持有 dockertest 连接池、Docker 网络、mock OIDC 服务器与临时服务,并在ShutdownAssertNoPanics(t)时逐一回收并检查控制服务器、客户端、DERP、额外服务与网络是否有 panic(见 scenario.go)。hsic/— "Headscale Integration Container",把一个 Headscale 服务器装进 Docker 容器,提供配置、DB 后端、DERP、OIDC、ACL 策略、TLS 等选项(详见下文)。tsic/— "Tailscale Integration Container",封装单个 Tailscale 客户端,提供版本、主机名、认证方式、tags、SSH、网络归属等选项。dockertestutil/— 最底层的 Docker 辅助函数:网络创建、容器生命周期、IsRunningInContainer()容器内运行检测等。
测试通过ScenarioSpec+CreateHeadscaleEnv组合这些构件,而不是直接调 Docker API。从接口看,控制服务器抽象定义于 integration/control.go(含GetEndpoint、CreateAuthKey、ListNodes、ApproveRoutes、GetCert、GetHostname等),客户端抽象定义于 integration/tailscale.go。
ScenarioSpec:一次性声明拓扑
ScenarioSpec的关键字段(定义见 scenario.go):
Users:要创建的用户名列表,每个用户都会获得NodesPerUser个节点;NodesPerUser:每个用户挂几个节点;Networks:需要创建的独立 Docker 网络及其用户归属。若不设置则建单个默认网络、所有用户与节点加入其中。注意 Docker 网络之间未必可路由,跨网连接可能回落 DERP。NetworkSpec.Subnet为空时由 Docker 自动分配子网;若网络须经 Tailscale exit node 可达,则应使用 RFC 5737 TEST-NET 网段(如198.51.100.0/24),因为 Tailscale 的shrinkDefaultRoute会把 RFC1918 私网段从 exit node 转发过滤器中剔除(scenario.go);ExtraService:额外容器服务(通常不跑 Tailscale,例如被测的子网路由后端的 Web 服务);Versions:本测试使用的客户端版本列表;OIDCSkipUserCreation:跳过用 CLI 预创建用户(OIDC 登录会自动建用户,预建会导致重复用户记录);OIDCUsers/OIDCAccessTTL:启动 Mock OIDC 服务器并预置登录用户队列 / Access Token 有效期;MaxWait:docker 池最大等待时间。
版本矩阵:真实多版本兼容验证
scenario.go 中的版本变量直接支撑"跨版本兼容":
AllVersions=["head", "unstable"]+capver.SupportedMajorMinorVersions中近期支持的全部主次版本;其中head/unstable分别指 Tailscale main 分支当前 tip 与最新 unstable 发布版,其余版本来自 Tailscale 的 apt 仓库。MustTestVersions为最小测试集合:两个 unstable(HEAD、unstable)+ 两个最新 + 两个最老受支持版本。
CreateTailscaleNodesInUser在请求版本为"all"时会按spec.Versions(未指定则用MustTestVersions)循环取模分配版本,从而在同一个场景里覆盖多版本客户端。
常用 Option:hsic与tsic
hsic的 Option 定义于 integration/hsic/hsic.go,典型如:
hsic.WithTestName("xxx"):设置测试名并反映到容器名(hs-<test>-<6位hash>);hsic.WithACLPolicy(policy):写入 ACL 策略并设置HEADSCALE_POLICY_PATH;hsic.WithConfigEnv(map):用环境变量覆盖 Headscale 配置(Headscale 支持整份配置经环境变量覆盖);hsic.WithPort(n)/hsic.WithExtraPorts([]string):监听端口与额外暴露端口(如 3478/udp 供 STUN);hsic.WithCACert(cert):把 CA 证书装进容器信任库,客户端因此信任该服务器;hsic.WithCustomTLS(ca, cert, key):使用自定义证书,CA 同时装进信任库并可由GetCert()取回分发给客户端;hsic.WithoutTLS():禁用默认 TLS,仅用于必须测非 TLS 行为的用例。
tsic的 Option 定义于 integration/tsic/tsic.go,典型如:
tsic.WithSSH():启用 Tailscale SSH(对应文档示例中的[]tsic.Option{tsic.WithSSH()});tsic.WithNetwork(network):把客户端放入指定 Docker 网络;tsic.WithTags(tags):以 tags-as-identity 模型为节点打 tag;tsic.WithCACert(cert)/tsic.WithHeadscaleName(name):信任自签 CA 并指向目标控制服务器(Scenario.CreateTailscaleNode会自动附加这两个选项);tsic.WithDERPOverHTTP():使客户端经明文 HTTP websocket 连 DERP,与hsic.WithoutTLS()配套使用——否则服务器用无 TLS 的嵌入式 DERP 时,客户端默认走 HTTPS 拨号会不可达;tsic.WithWebsocketDERP(enabled)、WithExtraHosts、WithTags等。
三、必要脚手架(Required scaffolding)
每个测试函数必须以IntegrationSkip(t)开头
每个集成测试函数的第一条语句都必须是IntegrationSkip(t),否则测试会在错误环境运行并报出令人困惑的错误:
func TestMyScenario(t *testing.T) { IntegrationSkip(t) // ... 其余测试逻辑 }IntegrationSkip定义于 integration/scenario_test.go,其行为是:不在 Docker 测试容器内时跳过(依据dockertestutil.IsRunningInContainer());传了-short时跳过。同文件中的TestHeadscale、TestTailscaleNodesJoiningHeadcale即是最基础的框架自检用例,可当作脚手架范本阅读。
场景搭建:一气呵成
最规范的搭法是在一次调用里完成"建用户 → 起客户端 → 起 Headscale 服务器":
func TestMyScenario(t *testing.T) { IntegrationSkip(t) t.Parallel() spec := ScenarioSpec{ NodesPerUser: 2, Users: []string{"alice", "bob"}, } scenario, err := NewScenario(spec) require.NoError(t, err) defer scenario.ShutdownAssertNoPanics(t) err = scenario.CreateHeadscaleEnv( []tsic.Option{tsic.WithSSH()}, hsic.WithTestName("myscenario"), ) require.NoError(t, err) allClients, err := scenario.ListTailscaleClients() require.NoError(t, err) headscale, err := scenario.Headscale() require.NoError(t, err) // ... 断言 }CreateHeadscaleEnv的内部流程(scenario.go)大致是:先Headscale()启动控制服务器 → 为每个用户CreateUser(或按OIDCSkipUserCreation只注册本地结构)→CreateTailscaleNodesInUser(user, "all", NodesPerUser, ...)并发拉起客户端并等待其进入NeedsLogin→ 创建可复用 pre-auth key 并RunTailscaleUp批量登录。分布式状态同步可借助scenario.WaitForTailscaleSync()或WaitForTailscaleSyncPerUser(后者针对autogroup:self这类跨用户不可见的策略场景,可配合WithPreBarrier先等待服务器端信号,如策略编译完成)。
完整的 Option 集合(DERP、OIDC、策略文件、DB 后端、ACL grants、exit-node 配置等)请查阅 integration/scenario.go、integration/hsic/hsic.go 与 integration/tsic/tsic.go。
四、EventuallyWithT模式:分布式异步状态的正确断言姿势
集成测试作用于真正的分布式系统:客户端上报状态 → 服务器处理 → 变更再流式推送到对端。这整条链路是异步的,状态变更后立刻做直接断言会间歇性失败。因此凡是"读分布式状态"的外部调用都应包在assert.EventuallyWithT里:
assert.EventuallyWithT(t, func(c *assert.CollectT) { status, err := client.Status() assert.NoError(c, err) for _, peerKey := range status.Peers() { peerStatus := status.Peer[peerKey] requirePeerSubnetRoutesWithCollect(c, peerStatus, expectedRoutes) } }, 10*time.Second, 500*time.Millisecond, "client should see expected routes")需要包裹的外部调用
这些调用读取分布式状态、在传播完成前可能返回过期数据:
headscale.ListNodes()client.Status()client.Curl()client.Traceroute()- 当命令会读取状态时的
client.Execute()
绝不能包裹的阻塞操作
改状态(mutation)的命令只应执行一次:要么立刻成功要么立刻失败——不存在"最终会成功"。把它们包进EventuallyWithT只会用重试掩盖真实失败。
只需取一个 ID 供阻塞调用使用时,请用client.MustStatus():
// 正确——mutation 只执行一次 for _, client := range allClients { status := client.MustStatus() _, _, err := client.Execute([]string{ "tailscale", "set", "--advertise-routes=" + expectedRoutes[string(status.Self.ID)], }) require.NoErrorf(t, err, "failed to advertise route: %s", err) }典型的阻塞操作包括:各种tailscale set(路由、exit node、accept-routes、ssh)、通过 CLI 注册节点、通过 gRPC 创建用户等。
四条规则
- 每个
EventuallyWithT块只放一个外部调用。对同一次调用结果的多个相关断言可以放同一块。循环例外:在同一个块内遍历一组客户端(或对端)并各自调用Status()是允许的——这本质上是同一个"检查所有客户端"的逻辑操作;而ListNodes()+Status()这类不同调用必须拆到不同块。 - 绝不嵌套
EventuallyWithT。嵌套重试循环会成倍放大时序窗口,且让失败几乎无法诊断。 - 块内一律用
*WithCollect辅助变体。普通辅助函数用require,第一条断言失败即中止测试,导致无法重试。 - 务必给出描述性的最终消息——它在失败时出现,是你判断该测试在等什么的唯一线索。
变量作用域
跨多个EventuallyWithT块使用的变量必须在函数作用域声明;块内用=赋值而非:=——:=会创建外层不可见的遮蔽变量:
var nodes []*v1.Node var err error assert.EventuallyWithT(t, func(c *assert.CollectT) { nodes, err = headscale.ListNodes() // = not := assert.NoError(c, err) assert.Len(c, nodes, 2) requireNodeRouteCountWithCollect(c, nodes[0], 2, 2, 2) }, 10*time.Second, 500*time.Millisecond, "nodes should have expected routes") // 因为 nodes 声明在函数作用域,此处仍可使用Helper 函数与*WithCollect变体
EventuallyWithT块内必须使用*WithCollect变体,这样断言失败只会重启等待循环而非立刻判测试失败。仓库中现成的辅助函数(接受*assert.CollectT作为首参):
requirePeerSubnetRoutesWithCollect(c, status, expected)— 断言某对端PeerStatus携带期望的子网路由,定义于 integration/route_test.go;requireNodeRouteCountWithCollect(c, node, announced, approved, subnet)— 断言节点声明/已批准/子网路由计数,定义于 integration/route_test.go;assertTracerouteViaIPWithCollect(c, traceroute, ip)— 断言 traceroute 经由某 IP,定义于 integration/route_test.go。
其他常用断言还有 integration/helpers.go 的assertCurlSuccessWithCollect(c, client, url, msg)、assertCurlFailWithCollect,以及 integration/helpers.go 的assertPingAllWithCollect(c, clients, addrs, opts...)等。当你编写新的、需要放进EventuallyWithT内部调用的辅助函数时,其第一个参数必须是*assert.CollectT而非*testing.T。
五、按属性而非位置定位节点
headscale.ListNodes()返回顺序不稳定,依赖nodes[0]这类下标的测试会在节点排序变化时崩溃。请按 ID、主机名或 tag 查找节点:
// 错误——依赖数组位置 require.Len(t, nodes[0].GetAvailableRoutes(), 1) // 正确——找到"本应拥有该路由"的那个节点 expectedRoutes := map[string]string{"1": "10.33.0.0/16"} for _, node := range nodes { nodeIDStr := fmt.Sprintf("%d", node.GetId()) if route, shouldHaveRoute := expectedRoutes[nodeIDStr]; shouldHaveRoute { assert.Contains(t, node.GetAvailableRoutes(), route) } }按属性查找的现成工具是 integration/route_test.go 的MustFindNode(hostname string, nodes []*clientv1.Node),在路由相关测试中广泛使用(见route_test.go中形如MustFindNode(routerUsernet1.Hostname(), nodes)的写法)。
六、完整实例:声明并批准一条子网路由
下面这个完整用例(原文收录于 integration/README.md,此处与 integration/route_test.go 中的真实用法相互印证)演示了三条核心纪律:阻塞操作只执行一次、只读状态放进EventuallyWithT、按属性找节点:
func TestRouteAdvertisementBasic(t *testing.T) { IntegrationSkip(t) t.Parallel() spec := ScenarioSpec{ NodesPerUser: 2, Users: []string{"user1"}, } scenario, err := NewScenario(spec) require.NoError(t, err) defer scenario.ShutdownAssertNoPanics(t) err = scenario.CreateHeadscaleEnv([]tsic.Option{}, hsic.WithTestName("route")) require.NoError(t, err) allClients, err := scenario.ListTailscaleClients() require.NoError(t, err) headscale, err := scenario.Headscale() require.NoError(t, err) // --- 阻塞:在其中一台客户端上声明路由 --- router := allClients[0] _, _, err = router.Execute([]string{ "tailscale", "set", "--advertise-routes=10.33.0.0/16", }) require.NoErrorf(t, err, "advertising route: %s", err) // --- Eventually:headscale 应看到被声明的路由 --- var nodes []*v1.Node assert.EventuallyWithT(t, func(c *assert.CollectT) { nodes, err = headscale.ListNodes() assert.NoError(c, err) assert.Len(c, nodes, 2) for _, node := range nodes { if node.GetName() == router.Hostname() { requireNodeRouteCountWithCollect(c, node, 1, 0, 0) } } }, 10*time.Second, 500*time.Millisecond, "route should be announced") // --- 阻塞:通过 headscale CLI 批准该路由 --- var routerNode *v1.Node for _, node := range nodes { if node.GetName() == router.Hostname() { routerNode = node break } } require.NotNil(t, routerNode) _, err = headscale.ApproveRoutes(routerNode.GetId(), []string{"10.33.0.0/16"}) require.NoError(t, err) // --- Eventually:对端应看到已批准的路由 --- peer := allClients[1] assert.EventuallyWithT(t, func(c *assert.CollectT) { status, err := peer.Status() assert.NoError(c, err) for _, peerKey := range status.Peers() { if peerKey == router.PublicKey() { requirePeerSubnetRoutesWithCollect(c, status.Peer[peerKey], []netip.Prefix{netip.MustParsePrefix("10.33.0.0/16")}) } } }, 10*time.Second, 500*time.Millisecond, "peer should see approved route") }可据此理解requireNodeRouteCountWithCollect(c, node, announced, approved, subnet)三个计数的语义对照:声明(announced)代表客户端已通过tailscale set --advertise-routes上报、服务器侧已入库;批准(approved)代表已通过ApproveRoutes(或自动批准策略)放行;subnet 代表最终进入节点可用路由集合、能被分发到对端。真实路由测试中,未批准路由不会出现在status.Peer[...].PrimaryRoutes,而自动批准的路由(含 exit route)则三个计数同步增长——这些在 integration/route_test.go 均有覆盖。
七、常见坑(Common pitfalls)
- 忘记
IntegrationSkip(t):测试会在 Docker 之外运行并以令人困惑的方式失败。它必须是第一行。 - 在
EventuallyWithT里用require:会在第一次迭代后就中止,而不是重试。应使用assert.*加*WithCollect辅助函数。 - 把 mutation 和 query 混进同一个
EventuallyWithT:会用重试掩盖真实失败。mutation 留在块外,query 放块内。 - 假设节点有序:按属性查找。
- 忽略
client.Status()返回的err:重试只会重跑整个块,块中间调用的错误不能静默丢弃。 - 超时设太紧:本地状态 5s 合理,需要经过 map poll 周期传播的状态要 10s。别为了"加速测试"往下调——你只会得到 flaky 测试。
八、调试失败的测试:产物读取顺序与启发式
测试会把大量诊断产物保存到control_logs/{runID}/下。读取顺序为:服务器 stderr → 客户端 stderr → MapResponse JSON → 数据库快照。一份典型产物的完整布局(来自 cmd/hi/README.md):
control_logs/20260409-104215-mdjtzx/ ├── hs-<test>-<hash>.stderr.log # headscale 服务器错误 ├── hs-<test>-<hash>.stdout.log # headscale 服务器输出 ├── hs-<test>-<hash>.db # 数据库快照(SQLite) ├── hs-<test>-<hash>_metrics.txt # Prometheus 指标导出 ├── hs-<test>-<hash>-mapresponses/ # MapResponse 协议捕获 ├── ts-<client>-<hash>.stderr.log # tailscale 客户端错误 ├── ts-<client>-<hash>.stdout.log # tailscale 客户端输出 └── ts-<client>-<hash>_status.json # 客户端网络状态导出实用排查命令:
# 1) 服务器侧:错误、panic、策略求值失败大多源于此 grep -E "ERROR|panic|FATAL" control_logs/*/hs-*.stderr.log # 2) 客户端侧:认证失败、连通性、DNS 解析问题 # 3) MapResponse JSON:网络图生成、对端可见性、路由分发、策略结果 ls control_logs/*/hs-*-mapresponses/ jq '.Peers[] | {Name, Tags, PrimaryRoutes}' control_logs/*/hs-*-mapresponses/001.json # 4) *_status.json:客户端对端连接状态 # 5) 数据库快照:一致性事后核验 sqlite3 control_logs/<runID>/hs-*.db sqlite> .tables sqlite> SELECT id, hostname, user_id, tags FROM nodes WHERE hostname LIKE '%problematic%';两条重要启发式(详见 cmd/hi/README.md):
- 先怀疑代码,再怀疑基础设施:先完整阅读
hs-*.stderr.log。实践中远超过 99% 的失败是代码缺陷(策略求值、NodeStore 同步、路由批准),而非 Docker/磁盘/网络。基础设施故障有特征性报错:failed to resolve "hs-..."(Docker DNS,重置 Docker 网络)、创建容器超时超过 2 分钟(资源耗尽)、OOM、no space left on device(清理旧control_logs/)。看不到这些特征错误就按代码回归处理,不要靠重试祈祷 flake 消失。 - 产物会保留:清理只针对容器,
control_logs/累积很快,需手动删除旧目录释放磁盘。若需保留失败容器做现场检查,用go run ./cmd/hi run "TestName" --keep-on-failure,再docker exec/docker logs手工排查,最后在确认无其他测试运行时go run ./cmd/hi clean all。
九、相关文件速查
| 文件 | 作用 |
|---|---|
| integration/README.md | 编写集成测试的规范文档(本文骨架) |
| cmd/hi/README.md | 运行集成测试与调试的规范文档 |
| integration/scenario.go | Scenario/ScenarioSpec编排层与版本矩阵 |
| integration/scenario_test.go | IntegrationSkip及框架自检用例 |
| integration/hsic/hsic.go | Headscale 容器封装与hsic.Option |
| integration/tsic/tsic.go | Tailscale 客户端容器封装与tsic.Option |
| integration/control.go / integration/tailscale.go | ControlServer/TailscaleClient接口 |
| integration/route_test.go | 路由场景用例与*WithCollect辅助函数 |
| integration/helpers.go | curl/ping/SSH 等通用*WithCollect断言 |
| .github/workflows/test-integration.yaml | CI 中集成测试 workflow |
总而言之:写 Headscale 集成测试的要点可浓缩为——以IntegrationSkip(t)起手、用ScenarioSpec描述拓扑、mutation 只跑一次、读分布式状态就交给EventuallyWithT+*WithCollect、按属性找节点、超时留给足裕量;跑不通时按"服务器日志 → 客户端日志 → MapResponse → 数据库快照"的顺序从control_logs/{runID}/里找答案。
【免费下载链接】headscaleAn open source, self-hosted implementation of the Tailscale control server项目地址: https://gitcode.com/GitHub_Trending/he/headscale
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考