利用 Kyua 的 Jail 功能提升 FreeBSD 测试套件的并行效率

测试是一个广泛的概念 如今,无论采用何种方法、学术观点或特定情境,很难抗拒这样一个简单愿望:不要让最终用户代替我们进行测试。即使是一个简单的错误,也可能在多个方面对业务造成重大损害:声誉、上市时间、转化率、业务扩展等。行业已经从依赖复杂的手动检查发布演变为尽可能拥抱自动化。自动化测试提供了许多优势:缩短测试周期、更频繁的反馈、对结果的额外信心、减少对变更的恐惧等。尽管自动化测试是开发流程中的重要组成部分,但它仅是改善软件交付的众多实践之一。

使用 Kyua 组织测试

FreeBSD 测试套件通常位于 /usr/tests,其基础设施基于 Julio Merino 创建的测试框架 Kyua。Kyua 提供了一种富有表现力的测试套件定义语言、安全的运行时引擎以及强大的报告生成能力。测试可以使用任意工具编写,也可以不依赖特定的库。测试套件中的单元和集成测试通常使用诸如 atf-c(3)atf-c++(3)atf-sh(3)pytest 等库。

Kyua 的核心概念层次结构如下:测试套件 > 测试程序 > 测试用例。

一个测试套件将多个二进制文件(测试程序)分组为一个具有单一名称的集合。测试套件通过 Lua 脚本描述,通常保存为特殊的 kyuafile(5)。让我们来看以下已存在的文件示例:

# cat -n /usr/tests/sys/kern/Kyuafile
       1  -- Automatically generated by bsd.test.mk.
       2
       3  syntax(2)
       4
       5  test_suite(“FreeBSD”)
       6
       7  atf_test_program{name=”basic_signal”, }
[skipped]
       34  atf_test_program{name=”sonewconn_overflow”, required_programs=”python”, required_user=”root”, is_exclusive=”true”}
       35  atf_test_program{name=”subr_physmem_test”, }
       36  plain_test_program{name=”subr_unit_test”, }
[skipped]
       45  atf_test_program{name=”unix_seqpacket_test”, timeout=”15”}
       46  atf_test_program{name=”unix_stream”, }
       47  atf_test_program{name=”waitpid_nohang”, }
       48  include(“acct/Kyuafile”)
       49  include(“execve/Kyuafile”)
       50  include(“pipe/Kyuafile”)

第 3 行指定了所使用语法的必要版本。第 5 行为测试套件设置了名称。通常,所有位于 /usr/tests/**/Kyuafile 的描述都会被收集到一个名为 FreeBSD 的测试套件中。如果某个二进制文件基于 ATF 库,它会通过 atf_test_program 进行注册,从而使 Kyua 能够利用 ATF 提供的功能和特性。如果测试程序不基于 Kyua 支持的库,而仅通过退出码传递结果,则会使用 plain_test_program 构造。此外,还有 tap_test_program,用于那些通过经典的 Test Anything Protocol(TAP)协议传递结果的测试程序。

每个 Kyuafile 只描述其所在目录中的测试二进制文件。然而,/usr/tests 的结构设计方式使得每个测试目录都会显式包含其子目录中的测试文件,如第 48、49 和 50 行所示。因此,在该目录运行测试时,将会执行 sys/kern 子树中的所有测试,包括 sys/kern/acctsys/kern/execvesys/kern/pipe 中的测试:

# kyua test -k /usr/tests/sys/kern/Kyuafile

第 1 行表明,FreeBSD 测试套件中的 Kyuafile 并不是手动创建的。相反,与 FreeBSD 构建系统的大多数组件一样,该过程通过 Makefile 自动处理,Makefile 构建测试程序并生成相应的 Kyuafile。要详细了解该过程,可以将生成的 /usr/tests/sys/kern/Kyuafile 与其源文件 /usr/src/tests/sys/kern/Makefile 进行比较——这是一种直观的方法。

未在 Kyuafile 中注册的测试程序将不会被 Kyua 识别,因此也不会被执行。

Kyuafile 中未显式提及测试用例(test cases),因为测试用例是在更低的层级(即测试程序内部)定义的,并需要所使用的库的支持。通常,将多个相似的测试(即测试用例)分组到一个测试程序的二进制文件中会比创建多个独立的测试程序更为方便。对于普通测试程序,通常仅提供一个测试用例,通常根据 main() 函数命名为 “main”。相比之下,基于 ATF 库的测试可以报告多个测试用例。在 Kyua 中,我们通常称为“测试”的内容被称为“测试用例”,并被视为执行的基本单元。因此,尽管 Kyuafile 中描述的测试套件可能看起来只引用了一个测试程序,但它可能包含数十个或更多的测试用例。kyua list 命令会以 <测试程序>:<测试用例> 的格式列出测试用例,这种格式也可以用于其他命令,例如单独运行特定的测试用例:

# cd /usr/tests/sys/kern
# kyua test unix_dgram:basic

每个测试用例都可以包含可选的元数据属性,以键/值对的形式存在,用于修改 Kyua 针对该特定测试用例的行为。上面的 Kyuafile 示例展示了如何将相同的元数据应用于测试程序中的所有测试用例。示例中包含的属性如下:

  • timeout:允许更改默认超时时间(默认值为 300 秒)。

  • required_programs:如果所指定的二进制文件无论是通过完整路径还是在 PATH 环境变量中未找到,该测试用例将被标记为跳过,并显示相应的消息。

  • required_user="root":如果 Kyua 未以 root 权限运行,则跳过该测试;而 required_user="unprivileged" 则确保测试在无 root 访问权限的情况下运行。

  • is_exclusive="true":指定该测试不能与其他测试同时运行。

并行性和 Jail

Kyua 可以通过配置实现测试用例的并行运行。默认情况下,parallelism 设置为 1,这意味着测试是按顺序运行的。该设置可以在 kyua.conf(5) 文件中调整,或作为选项指定:

# kyua -v parallelism=8 test

测试用例需要独占访问共享资源时,应标记为 is_exclusive="true",以便 Kyua 知道不与其他测试并行运行。Kyua 的操作分为两个阶段。第一阶段运行所有非独占的测试用例,如果配置了并行性设置,这些测试可以并行执行。第二阶段顺序运行所有独占的测试用例。为了保持测试套件的高效性,最好避免添加新的独占测试,并尽量创建非独占的替代版本,否则测试套件可能会耗费过多时间来执行。

然而,有些测试利用了 jail(8) 功能来处理其他方式难以复现的场景。例如,网络模块测试通常通过创建临时 jail,利用主机通过 epair(4) 对模块行为进行验证。这类测试必须标记为独占有以下几个原因:为了方便通常会重用相同的 jail 名称(但系统中的每个 jail 必须有唯一名称),主机端使用为演示用途分配的相同 IP 地址配置接口,可能导致共享路由表的冲突,以及其他相关问题。虽然这些问题可以由测试用例自身解决,但这样会显著增加测试编写者和维护者的复杂性,并且某些问题可能在没有外部干预的情况下无法解决。这时,最新版本的 Kyua 发挥了作用。

执行环境概念

在 15-CURRENT 版本中,Kyua 引入了一个新的概念——“执行环境”。该功能将在 14.2-RELEASE 中提供。

默认情况下,测试仍然按照之前的方式运行,即通过生成子进程——这种方式被称为主机执行环境。测试用例可以通过指定一个新的元数据属性 execenv,选择使用不同的执行环境。针对每个测试用例的一般步骤顺序已扩展,包括以下内容:

  1. 初始化执行环境

  2. 执行测试

  3. (可选)测试清理

  4. 清理执行环境

目前,Kyua 仅支持一种额外的执行环境——jail 环境。虽然可以为单个测试用例配置该环境,但以下示例展示了如何将 execenv 元数据属性应用于测试程序中的所有测试用例:

atf_test_program{name=”test_program”, execenv=”jail”}

此配置使 Kyua 为 test_program 中的每个测试用例提供一个临时的 jail 来执行。如果某个测试用例声明了清理例程,该例程也将在相同的 jail 中执行。Kyua 使用 jail(8) 创建这些 jail,测试用例可以通过一个名为 execenv_jail_params 的新元数据属性传递额外的参数:

atf_test_program{name=”test_program”, execenv=”jail”, execenv_jail_params=”vnet allow.raw_sockets”}

只要不同父 jail 中的子 jail 名称不冲突,并且每个 jail 都能拥有自己的 VNET 堆栈,我们就可以轻松地将测试(例如前面提到的网络测试)隔离到独立的 jail 中运行,并通过移除 is_exclusive 标志实现并行运行。具体效果取决于环境和配置,但有报告显示,在相同环境下,netpfil/pf 测试套件的运行速度提高了 4 至 5 倍,仅需几分钟就能完成,而非原来的半小时。

隐式参数与分层 Jail

由于测试用例及其可选清理例程分别在独立的子进程中运行,Kyua 会隐式地添加 persist 参数以保持临时 jail 存在,确保两个子进程都在同一个 jail 中运行。Kyua 会在“执行环境清理”步骤中删除临时 jail。

网络测试中常见的做法是生成 jail。这就引出了一个问题:是否允许已经运行在 jail 内的测试用例创建子 jail。从原则上讲,只要不超过系统限制,这是允许的。每个 jail 都有一个关于可创建子 jail 数量的限制。在 15-CURRENT 中引入了以下新的只读 sysctl 变量,用于提供这些信息:

# sysctl security.jail.children
security.jail.children.cur: 0
security.jail.children.max: 999999

显然,上述内容指的是层级中的最高 jail,称为 prison0。根据当前值和最大值,系统最多可以创建近百万个 jail。当使用 jail(8) 创建新 jail 时,会应用以下默认配置:

# jail -c command=sysctl security.jail.children
security.jail.children.cur: 0
security.jail.children.max: 0

这表示不允许创建子 jail。显然,在这种条件下,尝试创建新 jail 的测试用例将会失败。为了解决这个问题,Kyua 通过添加另一个隐式参数来提供帮助,该参数允许最大数量的子 jail,这个数量是父 jail 最大限制减去 1。虽然可以通过测试用例中的 execenv_jail_params 元数据属性来配置这一点,但这似乎是一项繁琐且重复的工作。

以下公式阐明了 Kyua 如何创建临时 jails 以及如何通过元数据属性修改这一过程:

jail -qc name=<name> children.max=<parent_max-1> <test case defined params> persist

临时 jail 的名称来源于测试程序路径和测试用例名称。例如,测试用例 /usr/tests/sys/kern/unix_dgram:basic 将使用名为 kyua_usr_tests_sys_kern_unix_dgram_basic 的临时 jail。

kldload 问题

由于除了 prison0 外的所有 jail 都没有加载内核模块的权限,这会导致如果测试用例依赖于 jail 执行环境时的不便。

Kyua 的初衷是供开发人员和用户使用。这意味着系统管理员应能够在操作系统升级后运行测试套件,以确保一切正常工作。显然,这样的主机不是一个测试实验室,开发人员不能随意实验、破坏系统或引发故障。因此,测试应设计为避免干扰主机的正常操作,除非明确指示。这也是为什么 FreeBSD 测试套件有类似 allow_sysctl_side_effects 的配置变量来遵循这一方法。尽管该套件主要被视为开发工具,但许多现有的测试仍然遵循这一原则,通过检查所需的模块是否已加载,而不是隐式加载它。例如,如果防火墙的测试未被主机使用,却意外地影响了主机流量甚至使其无法访问,系统管理员会不满。

因此,推荐的策略是在测试用例中使用 kldstat -q -m <module-name> 来检查所需模块是否存在,如果模块未找到,则跳过测试。FreeBSD CI 的配置确保在运行测试套件之前,所有必要的模块已加载,所需的软件包已安装。

execenvs 和 WITHOUT_JAIL

提供了一个新的引擎配置变量——execenvs。默认情况下,它设置为一个包含所有支持的执行环境的列表:

# kyua config
architecture = aarch64
execenvs = host jail
parallelism = 1
platform = arm64
unprivileged_user = tests

这个变量可以通过 kyua.conf(5) 进行操作,也可以作为 kyua(1) 命令行工具的选项来指定。例如,以下命令将仅执行基于主机的测试,并跳过所有其他测试:

# kyua -v execenvs=host test

如果系统在构建时没有启用 jail 支持,则仅会提供默认的主机执行环境。因此,任何需要 jail 执行环境的测试都将被跳过。

入门示例

以下示例基于 atf-sh(3),演示了如何在测试用例级别配置 jail 环境。它还提醒了 root 用户权限的重要性。

# cat /usr/src/tests/sys/kern/test_program.sh
atf_test_case “case1” “cleanup”
case1_head()
{
atf_set descr 'Test that X does Y'
       atf_set require.user root
       atf_set execenv jail
       atf_set execenv.jail.params vnet allow.raw_sockets
}
case1_body()
{
       if ! kldstat -q -m tesseract; then
               atf_skip “This test requires tesseract”
       fi

       # 测试代码……
}
case1_cleanup()
{
       # 清除代码……
}

atf_init_test_cases()
{
       atf_add_test_case “case1”
}

在 Makefile 中添加一行即可完成该测试程序的配置:

# grep test_program /usr/src/tests/sys/kern/Makefile
ATF_TESTS_SH+= test_program

构建系统将会在脚本前添加 #!/usr/libexec/atf-sh 的 shebang 行,将脚本安装到 /usr/tests/sys/kern/test_program 并去掉 .sh 后缀,同时在 Kyuafile 中进行相应的注册:

# grep test_program /usr/tests/sys/kern/Kyuafile
atf_test_program{name=”test_program”, }

在单个测试程序中包含多个测试用例可能会导致“不要重复你自己”(Don’t Repeat Yourself, DRY)的情况。为了解决这个问题,可以将通用的元数据提升到 Kyuafile 中的测试套件级别,从而使其适用于整个测试程序,而无需为每个测试用例重复设置。然而,个别测试用例仍然可以在必要时覆盖这些属性:

# cat /usr/src/tests/sys/kern/test_program2.sh
atf_test_case “case2”
case2_head()
{
       atf_set descr 'Test that A does B'
}
case2_body()
{...}

atf_test_case “case3”
case3_head()
{
       atf_set descr 'Test that Foo does Bar'
       atf_set execenv.jail.params vnet allow.raw_sockets
}
case3_body()
{...}

atf_init_test_cases()
{
       atf_add_test_case “case2”
       atf_add_test_case “case3”
}

现在,主要的配置是在测试程序级别提供的:

# grep test_program2 /usr/src/tests/sys/kern/Makefile
ATF_TESTS_SH+= test_program2
TEST_METADATA.test_program2+= execenv=”jail”,execenv_jail_params=”vnet”

因此,Kyua 将在不同级别定义的元数据合并为以下内容:

# kyua list -k /usr/tests/sys/kern/Kyuafile -v test_program2
test_program2:case2 (FreeBSD)
    description = Test that A does B
    execenv = jail
    execenv_jail_params = vnet
test_program2:case3 (FreeBSD)
    description = Test that Foo does Bar
    execenv = jail
    execenv_jail_params = vnet allow.raw_sockets

需要注意的是,ATF 和 Kyua 之间元数据属性命名约定的关键区别——点(execenv.jail.params)与下划线(execenv_jail_params)。此外,名称本身可能会略有不同,可以对比 kyuafile(5)atf-test-case(4) 的手册页面。

要将现有的测试切换到 jail 执行环境,应该取反或移除 is_exclusive="true" 元数据属性。否则,该测试将无法从并行执行中受益。

进一步阅读

FreeBSD 测试套件的入口点在 tests(7) 中有描述。对于测试作者,以下 wiki 页面是一个有价值的起点:https://wiki.freebsd.org/TestSuite/DeveloperHowTo

官方的 Kyua wiki 是了解历史背景、设计理念和功能概述的极好资源。有关执行环境的详细信息可以在 kyua.conf(5)kyuafile(5) 的手册页面中找到。

此外,审查现有基于 jail 的测试是如何编写和组织的也至关重要,以避免重复造轮子。位于 /usr/src/tests/sys/netpfil/pf 的 PF 测试套件是了解既定实践的一个重要来源。

虽然在现有代码中回溯性地添加测试可能是一项庞大的工作,但结合解决 bug 修复的测试,是提升 FreeBSD 测试套件以及整个项目的一个值得做的机会。


Igor Ostapenko 是一位 FreeBSD 贡献者,拥有广泛的软件开发经验,涉及的领域包括操作导航设备的系统、企业优化业务流程的解决方案、逆向工程、以及 B2B/B2C 创业公司等多个领域。

最后更新于

FreeBSD 中文社区 2024