Skip to content

OpenResty 学习笔记(6) - 测试工具

Published: at 09:32 AM21 min read

目录

test__nginx

test::nginx 糅合了 Perl、数据驱动以及 DSL(领域小语言)。对于同一份测试案例集,通过对参数和环境变量的控制,可以实现乱序执行、多次重复、内存泄漏检测、压力测试等不同的效果。

安装

参考官方 CI travis 中的方法

  1. 先安装 Perl 的包管理器 cpanminus。

  2. 然后,通过 cpanm 来安装 test::nginx

    Terminal window
    sudo cpanm --notest Test::Nginx IPC::Run > build.log 2>&1 || (cat build.log && exit 1)
  3. 再接着, clone 最新的源码:

    Terminal window
    git clone https://github.com/openresty/test-nginx.git
  4. 最后,通过 Perl 的 prove 命令来加载 test-nginx 的库,并运行 /t 目录下的测试案例集:

    Terminal window
    prove -Itest-nginx/lib -r t

示例解析

下面这段代码改编自官方文档

use Test::Nginx::Socket 'no_plan';
run_tests();
__DATA__
=== TEST 1: set Server
--- config
location /foo {
echo hi;
more_set_headers 'Server: Foo';
}
--- request
GET /foo
--- response_headers
Server: Foo
--- response_body
hi

test::nginx 本身就是作者自己用 Perl 实现的 DSL(小语言),是专门针对 Nginx 和 OpenResty 的测试而抽象出来的。

编写测试案例

上节的测试

Terminal window
$ resty -e 'local memcached = require "resty.memcached"
local memc, err = memcached:new()
memc:set_timeout(1000) -- 1 sec
local ok, err = memc:connect("127.0.0.1", 11212)
local ok, err = memc:set("dog", 32)
if not ok then
ngx.say("failed to set dog: ", err)
return
end
local res, flags, err = memc:get("dog")
ngx.say("dog: ", res)'

使用 test::nginx 改编

use Test::Nginx::Socket::Lua::Stream;
run_tests();
__DATA__
=== TEST 1: basic get and set
--- config
location /test {
content_by_lua_block {
local memcached = require "resty.memcached"
local memc, err = memcached:new()
if not memc then
ngx.say("failed to instantiate memc: ", err)
return
end
memc:set_timeout(1000) -- 1 sec
local ok, err = memc:connect("127.0.0.1", 11212)
local ok, err = memc:set("dog", 32)
if not ok then
ngx.say("failed to set dog: ", err)
return
end
local res, flags, err = memc:get("dog")
ngx.say("dog: ", res)
}
}
--- stream_config
lua_shared_dict memcached 100m;
--- stream_server_config
listen 11212;
content_by_lua_block {
local m = require("memcached-server")
m.go()
}
--- request
GET /test
--- response_body
dog: 32
--- no_error_log
[error]

其它的测试框架:断言风格的测试框架 busted

可见,test::nginx 的测试,本质上是根据每一个测试案例的配置,先去生成 nginx.conf,并启动一个 Nginx 进程;然后,模拟客户端发起请求,其中包含指定的请求体和请求头;紧接着,测试案例中的 Lua 代码会处理请求并作出响应,这时,test::nginx 解析响应体、响应头、错误日志等关键信息,并和测试配置做对比。如果发现不符,就报错退出,测试失败;否则就算成功。

原语和使用方法

Nginx 配置

test::nginx 的原语中带有 config 这个关键字的,就和 Nginx 配置相关,比如前文中提到的 configstream_confighttp_config 等。

它们的作用都是一样的,即在 Nginx 的不同上下文中,插入指定的 Nginx 配置。这些配置可以是 Nginx 指令,也可以是 content_by_lua_block 封装起来的 Lua 代码。

节选自Apisix 项目 key-auth 插件的的一份测试用例:

=== TEST 1: sanity
--- config
location /t {
content_by_lua_block {
local plugin = require("apisix.plugins.key-auth")
local ok, err = plugin.check_schema({key = 'test-key'})
if not ok then
ngx.say(err)
end
ngx.say("done")
}
}

这个测试案例的目的,是为了测试代码文件 plugins.key-auth 中, check_schema 这个函数能否正常工作。它在location /t 中使用 content_by_lua_block 这个 Nginx 指令,require 需要测试的模块,并直接调用需要检查的函数。

发送请求

request
--- request
GET /t

这段代码在 request 原语中,发起了一个 GET 请求,地址是 /t

test::nginx 隐藏了 ip 地址、域名和端口、HTTP 版本号,这就是 DSL 的好处之一,你只需要关心业务逻辑,不用被各种细节所打扰。

当然也支持单独指定:

--- request
GET /t HTTP/1.0

POST 示例

--- request
POST /t
hello world

同样, test::nginx 在这里自动计算了请求体长度,并自动增加了 hostconnection 这两个请求头,以保证这是一个正常的请求。

注释:

--- request
# post request
POST /t
hello world

配合 eval 嵌入 perl 代码

--- request eval
"POST /t
hello\x00\x01\x02
world\x03\x04\xff"

这里使用 eval 来指定不可打印的字符,双引号之间的内容,会被当做 perl 的字符串来处理后,再传给 request 来作为参数。

--- request eval
"POST /t\n" . "a" x 1024

用 POST 方法,向 /t 地址,发送包含 1024 个字符 a 的请求。

pipelined_requests

在同一个 keep-alive 的连接里面,依次发送多个请求

--- pipelined_requests eval
["GET /hello", "GET /world", "GET /foo", "GET /bar"]

好处:

  1. 省去重复的测试代码,把 4 个测试案例压缩到一个测试案例中完成;

  2. 你可以用流水线的请求,来检测代码逻辑在多次访问的情况下,是否会有异常

test::nginx 在执行完每一个测试案例后, 都会关闭当前的 Nginx 进程,内存中所有数据也都随之消失了。当运行下一个测试案例时,又会重新生成 nginx.conf,并启动新的 Nginx worker。这种机制是为了保证测试案例之间不会互相影响。

repeat_each

如何对同一个测试执行多次?

test::nginx 提供了一个全局的设置:repeat_each。它其实是一个 perl 函数,默认情况下是 repeat_each(1),表示测试案例只运行一次。

run_test() 函数之前来设置它,比如将参数改为 2:

repeat_each(2);
run_tests();

那么,每个测试案例就都会被运行两次,以此类推。

more_headers

添加请求头

--- more_headers
X-Foo: blah

多个头多行

--- more_headers
X-Foo: 3
User-Agent: openresty

处理响应

response_body
=== TEST 1: sanity
--- config
location /t {
content_by_lua_block {
ngx.say("hello")
}
}
--- request
GET /t
--- response_body
hello

这个测试案例,在响应体是 hello 的情况下会通过,其他情况就会报错。

支持用正则来检测响应体:

--- response_body_like
^he\w+$

支持 unlike 的操作:

--- response_body_unlike
^he\w+$

这时候,如果响应体是hello,测试就不能通过了。

多个请求的检测。下面是配合 pipelined_requests 一起使用的示例:

--- pipelined_requests eval
["GET /hello", "GET /world", "GET /foo", "GET /bar"]
--- response_body eval
["hello", "world", "oo", "bar"]

注意的是,发送了多少个请求,就需要有多少个响应来对应。

response_headers

响应头和请求头类似,每一行对应一个 header 的 key 和 value:

--- response_headers
X-RateLimit-Limit: 2
X-RateLimit-Remaining: 1

和响应体的检测一样,响应头也支持正则表达式和 unlike 操作,分别是 response_headers_likeraw_response_headers_likeraw_response_headers_unlike

error_code

响应码也支持 like 操作

--- error_code: 302
--- error_code_like: ^(?:500)?$

对于多个请求的情况,error_code 也需要检测多次:

--- pipelined_requests eval
["GET /hello", "GET /hello", "GET /hello", "GET /hello"]
--- error_code eval
[200, 200, 503, 503]
error_log

没有错误日志,使用no_error_log 来检测:

--- no_error_log
[error]

在上面的例子中,如果 Nginx 的错误日志 error.log 中,出现 [error] 这个字符串,测试就会失败。建议在正常的测试中,都加上对错误日志的检测。

error_log :检测错误日志中出现指定的字符串

--- error_log
hello world

上面这段配置,其实就在检测 error.log 中是否出现了 hello world

支持用 eval 嵌入 perl 代码:

--- error_log eval
qr/\[notice\] .*? \d+ hello world/

调试相关原语

ONLY

只运行指定的某一个测试案例呢

=== TEST 1: sanity
=== TEST 2: get
--- ONLY

--- ONLY 放在需要单独运行的测试案例的最后一行,那么使用 prove 来运行这个测试案例文件的时候,就会忽略其他所有的测试案例,只运行这一个测试了。

SKIP

忽略掉某一个测试案例。一般用于测试尚未实现的功能:

=== TEST 1: sanity
=== TEST 2: get
--- SKIP

LAST

在它之前的测试案例集都会被执行,后面的就会被忽略掉:

=== TEST 1: sanity
=== TEST 2: get
--- LAST
=== TEST 3: set

测试计划 plan

plan tests => repeat_each() * (3 * blocks());

这里 plan 的含义是,在整个测试文件中,按照计划应该会做多少次检测项。如果最终运行的结果和计划不符,整个测试就会失败。

拿这个示例来说,如果 repeat_each 的值是 2,一共有 10 个测试案例,那么 plan 的值就应该是 2 x 3 x 10 = 60。这里估计你唯一搞不清楚的,就是数字 3 的含义吧,看上去完全是一个 magic number!

=== TEST 1: sanity
--- config
location /t {
content_by_lua_block {
ngx.say("hello")
}
}
--- request
GET /t
--- response_body
hello

上面这个测试案例中,plan = 2。

test::nginx 中隐含了一个校验,也就是--- error_code: 200,它默认检测 HTTP 的 response code 是否为 200。

所以,上面的 magic number 3,真实含义是在每一个测试中都显式地检测了两次,比如 body 和 error log;同时,隐式地检测了 response code。

由于这个地方太容易出错,所以,我的建议是,推荐你用下面的方法,直接关闭掉 plan:

use Test::Nginx::Socket 'no_plan';

如果无法关闭,比如在 OpenResty 的官方测试集中遇到 plan 不准确的情况,建议你也不要去深究原因,直接在 plan 的表达式中增加或者减少数字即可:

plan tests => repeat_each() * (3 * blocks()) + 2;

这也是官方会使用到的方法。

预处理器

在同一个测试文件的不同测试案例之间,有一些共同的设置,使用 add_block_preprocessor 指令,来增加一段 perl 代码来抽出公共部分。比如:

add_block_preprocessor(sub {
my $block = shift;
if (!defined $block->config) {
$block->set_value("config", <<'_END_');
location = /t {
echo $arg_a;
}
_END_
}
});

这个预处理器,就会为所有的测试案例,都增加一段 config 的配置,而里面的内容就是 location /t。这样,在后面的测试案例里,就都可以省略掉 config,直接访问即可:

=== TEST 1:
--- request
GET /t?a=3
--- response_body
3
=== TEST 2:
--- request
GET /t?a=blah
--- response_body
blah

自定义函数

可以在 run_tests 原语之前,随意地增加 perl 函数,也就是我们所说的自定义函数。

下面是一个示例,它增加了一个读取文件的函数,并结合 eval 指令,一起实现了 POST 文件的功能:

sub read_file {
my $infile = shift;
open my $in, $infile
or die "cannot open $infile for reading: $!";
my $content = do { local $/; <$in> };
close $in;
$content;
}
our $CONTENT = read_file("t/test.jpg");
run_tests;
__DATA__
=== TEST 1: sanity
--- request eval
"POST /\n$::CONTENT"

乱序

test::nginx 还有一个鲜为人知的坑:默认乱序、随机来执行测试案例,而非按照测试案例的前后顺序和编号来执行。

请关闭掉这个特性。可以用下面这两行代码来关闭:

no_shuffle();
run_tests;

reindex

工具 reindex自动格式化测试用例,在 [openresty-devel-utils]

(3 个换行分割测试用例,测试案例的编号自增长)

性能测试工具

ab Apache Benchmark 利用不到机器的多核,生成的请求压力不够大,得到的结果,并不真实。

推荐使用 wrk

Terminal window
wrk -t12 -c400 -d30s http://127.0.0.1:8080/index.html

上面表示 wrk 使用 12 个线程,保持 400 个长连接,持续 30 秒钟,来给指定的 API 接口发送 HTTP 请求。

检查项一:关闭 SELinux

CentOS/RedHat 系列的操作系统,建议关闭 SELinux,不然可能会遇到不少诡异的权限问题。

Terminal window
$ sestatus
SELinux status: disabled

检查项二:最大打开文件数

Terminal window
$ cat /proc/sys/fs/file-nr
3984 0 3255296

这里的最后一个数字,就是最大打开文件数。如果你的机器中这个数字比较小,那就需要修改 /etc/sysctl.conf 文件来增大:

fs.file-max = 1020000
net.ipv4.ip_conntrack_max = 1020000
net.ipv4.netfilter.ip_conntrack_max = 1020000

修改完以后,还需要重启系统服务来生效:

Terminal window
sudo sysctl -p /etc/sysctl.conf

检查项三:进程限制

除了系统的全局最大打开文件数,一个进程可以打开的文件数也是有限制的,你可以通过命令 ulimit 来查看:

Terminal window
$ ulimit -n
1024

这个值默认是 1024,是一个很低的数值。因为每一个用户请求都会对应着一个文件句柄,而压力测试会产生大量的请求,所以我们需要增大这个数值,把它改为百万级别,你可以用下面的命令来临时修改:

Terminal window
$ ulimit -n 1024000

也可以修改配置文件 /etc/security/limits.conf 来永久生效:

Terminal window
* hard nofile 1024000
* soft nofile 1024000

检查项四:Nginx 配置

最后,你还需要对 Nginx 的配置,做一个小的修改,也就是下面这两行代码的操作:

events {
worker_connections 10240;
}

这样,就可以把每个 worker 的连接数增大了。因为它的默认值只有 512,这在大压力的测试下显然是不够的。

设置完成后,也可以检测下当前的系统环境能否支持 100 万并发连接

https://github.com/ideawu/c1000k

Terminal window
./server 7000
./client 127.0.0.1 7000

开始测试

Terminal window
$ wrk -d 30 http://127.0.0.2:9080/hello
Running 30s test @ http://127.0.0.2:9080/hello
2 threads and 10 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 595.39us 178.51us 22.24ms 90.63%
Req/Sec 8.33k 642.91 9.46k 59.80%
499149 requests in 30.10s, 124.22MB read
Requests/sec: 16582.76
Transfer/sec: 4.13MB

这里没有指定参数,wrk 会默认启动 2 个线程和 10 个长连接。

不需要把 wrk 的线程数和连接数调整得很大,只要能够让目标程序跑满 CPU 就达到要求了。

压测的时间一定不能太短。

在压测期间,你需要使用 top 或者 htop 这样的监控工具,来确认服务端目标程序是否跑满 CPU。比较理想的情况是,从现象上来看,如果 CPU 满载,而且压测停止后,CPU 和内存占用迅速降低。

但如果有下面这样的异常,需要留意

测试结果分析

我们一般会关注两个值:

第一个是 QPS,也就是 Requests/sec: 16582.76,这个数据很直接,表示服务端每秒钟处理了多少请求。

第二个是延时 Latency 595.39us 178.51us 22.24ms 90.63%,这个数据和 QPS 一样重要,它体现了系统的响应速度。比如对于网关的应用来讲,我们就希望能够把延时控制在 1 毫秒以内。

另外, wrk 还提供了 latency 参数,可以把延时的分布百分比详细地打印出来,比如:

Latency Distribution
50% 134.00us
75% 180.00us
90% 247.00us
99% 552.00us

不过,wrk 的延时分布数据并不准确,因为它人为地加入了网络和工具的扰动,放大了延时,这一点需要你特别注意。关于 wrk Latency Distribution,这篇文章了解详细。

性能测试工具可能存在 Coordinated Omission (协调遗漏)问题,在分析工具的延时数据的时候,你一定要特别留意。

Coordinated Omission(协调遗漏) 是指,在做压力测试时,对于响应来说,只统计发送和收到回复之间的时间是不够的,这只是服务时间,这样统计会遗漏很多潜在的问题。因此,我们还需要把测试请求的等待时间也计算在内,这个整体才算是用户关心的响应时间。当然,如果你的服务端程序可能会出现阻塞,一定需要考虑这个问题,否则就可以忽略掉了。


Previous Post
OpenResty 学习笔记(5) - shared dict、cosocket、特权进程
Next Post
OpenResty 学习笔记(7) - 性能优化和编码指南