Headscale 集成测试框架实战:在 Docker 中用真实 Tailscale 客户端编写端到端场景测试
Headscale 集成测试框架实战在 Docker 中用真实 Tailscale 客户端编写端到端场景测试【免费下载链接】headscaleAn open source, self-hosted implementation of the Tailscale control server项目地址: https://gitcode.com/GitHub_Trending/he/headscaleHeadscale 的集成测试会真正启动一个 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/hiHeadscale 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的关键参数如下默认值针对单测开发调整过改动前请三思参数默认值作用--timeout120m测试总超时。必须使用内置参数绝不要用 bashtimeout包裹--postgresfalse使用 PostgreSQL 而非 SQLite--failfasttrue遇第一个失败即停止--go-version自动从go.mod检测当前仓库为 Go 1.26.1--clean-beforetrue运行前清理残留已停止/退出的容器--clean-aftertrue运行结束后清理本次容器--keep-on-failurefalse失败时保留容器便于人工检查--logs-dircontrol_logs运行产物保存目录--verbose/--statsfalse详细输出 / 采集容器资源用量统计--hs-memory-limit/--ts-memory-limit0超过指定 MB 即判失败0 表示关闭超时设置有现实下限仅供参考基础功能/CLI 类测试建议不低于 900s15m路由/ACL 类不低于 1200s20mHA/故障转移类不低于 1800s30m长跑类如TestNodeOnlineStatus不低于 2100s35m全量套件约 45m。timeout 300 go run ./cmd/hi run TestName # 错误清理中途被杀遗留孤儿容器 go run ./cmd/hi run TestName --timeout900s # 正确2. 本地复现 CIactact可以在本地执行 GitHub Actions 工作流仓库对应工作流为 .github/workflows/test-integration.yamlact 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 labelhi.run-id20260409-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()容器内运行检测等。测试通过ScenarioSpecCreateHeadscaleEnv组合这些构件而不是直接调 Docker API。从接口看控制服务器抽象定义于 integration/control.go含GetEndpoint、CreateAuthKey、ListNodes、ApproveRoutes、GetCert、GetHostname等客户端抽象定义于 integration/tailscale.go。ScenarioSpec一次性声明拓扑ScenarioSpec的关键字段定义见 scenario.goUsers要创建的用户名列表每个用户都会获得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.goExtraService额外容器服务通常不跑 Tailscale例如被测的子网路由后端的 Web 服务Versions本测试使用的客户端版本列表OIDCSkipUserCreation跳过用 CLI 预创建用户OIDC 登录会自动建用户预建会导致重复用户记录OIDCUsers/OIDCAccessTTL启动 Mock OIDC 服务器并预置登录用户队列 / Access Token 有效期MaxWaitdocker 池最大等待时间。版本矩阵真实多版本兼容验证scenario.go 中的版本变量直接支撑跨版本兼容AllVersions[head, unstable]capver.SupportedMajorMinorVersions中近期支持的全部主次版本其中head/unstable分别指 Tailscale main 分支当前 tip 与最新 unstable 发布版其余版本来自 Tailscale 的 apt 仓库。MustTestVersions为最小测试集合两个 unstableHEAD、unstable 两个最新 两个最老受支持版本。CreateTailscaleNodesInUser在请求版本为all时会按spec.Versions未指定则用MustTestVersions循环取模分配版本从而在同一个场景里覆盖多版本客户端。常用 Optionhsic与tsichsic的 Option 定义于 integration/hsic/hsic.go典型如hsic.WithTestName(xxx)设置测试名并反映到容器名hs-test-6位hashhsic.WithACLPolicy(policy)写入 ACL 策略并设置HEADSCALE_POLICY_PATHhsic.WithConfigEnv(map)用环境变量覆盖 Headscale 配置Headscale 支持整份配置经环境变量覆盖hsic.WithPort(n)/hsic.WithExtraPorts([]string)监听端口与额外暴露端口如 3478/udp 供 STUNhsic.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 模型为节点打 tagtsic.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.gorequireNodeRouteCountWithCollect(c, node, announced, approved, subnet)— 断言节点声明/已批准/子网路由计数定义于 integration/route_test.goassertTracerouteViaIPWithCollect(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-routes10.33.0.0/16, }) require.NoErrorf(t, err, advertising route: %s, err) // --- Eventuallyheadscale 应看到被声明的路由 --- 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.mdcontrol_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.goScenario/ScenarioSpec编排层与版本矩阵integration/scenario_test.goIntegrationSkip及框架自检用例integration/hsic/hsic.goHeadscale 容器封装与hsic.Optionintegration/tsic/tsic.goTailscale 客户端容器封装与tsic.Optionintegration/control.go / integration/tailscale.goControlServer/TailscaleClient接口integration/route_test.go路由场景用例与*WithCollect辅助函数integration/helpers.gocurl/ping/SSH 等通用*WithCollect断言.github/workflows/test-integration.yamlCI 中集成测试 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),仅供参考