573 lines
18 KiB
Lua
573 lines
18 KiB
Lua
--- Module with performance test tools
|
|
-- @module spec.helpers.perf
|
|
|
|
local pl_tablex = require("pl.tablex")
|
|
|
|
local logger = require("spec.helpers.perf.logger")
|
|
local utils = require("spec.helpers.perf.utils")
|
|
local git = require("spec.helpers.perf.git")
|
|
local charts = require("spec.helpers.perf.charts")
|
|
local read_all_env = require("kong.cmd.utils.env").read_all
|
|
|
|
local my_logger = logger.new_logger("[controller]")
|
|
|
|
utils.register_busted_hook()
|
|
|
|
charts.register_busted_hook()
|
|
|
|
-- how many times for each "driver" operation
|
|
local RETRY_COUNT = 3
|
|
local DRIVER
|
|
local DRIVER_NAME
|
|
local LAST_KONG_VERSION
|
|
|
|
-- Real user facing functions
|
|
local driver_functions = {
|
|
"start_worker", "start_kong", "stop_kong", "setup", "setup_kong", "teardown",
|
|
"get_start_load_cmd", "get_start_stapxx_cmd", "get_wait_stapxx_cmd",
|
|
"generate_flamegraph", "save_error_log", "get_admin_uri",
|
|
"save_pgdump", "load_pgdump", "get_based_version", "remote_execute",
|
|
}
|
|
|
|
local function check_driver_sanity(mod)
|
|
if type(mod) ~= "table" then
|
|
error("Driver must return a table")
|
|
end
|
|
|
|
for _, func in ipairs(driver_functions) do
|
|
if not mod[func] then
|
|
error("Driver " .. debug.getinfo(mod.new, "S").source ..
|
|
" must implement function " .. func, 2)
|
|
end
|
|
end
|
|
end
|
|
|
|
local known_drivers = { "docker", "terraform" }
|
|
|
|
--- Set the driver to use.
|
|
-- @function use_driver
|
|
-- @tparam string name name of the driver to use
|
|
-- @tparam[opt] table opts config parameters passed to the driver
|
|
-- @return nothing. Throws an error if any.
|
|
local function use_driver(name, opts)
|
|
name = name or "docker"
|
|
|
|
if not pl_tablex.find(known_drivers, name) then
|
|
local err = ("Unknown perf test driver \"%s\", expect one of \"%s\""):format(
|
|
name, table.concat(known_drivers, "\", \"")
|
|
)
|
|
error(err, 2)
|
|
end
|
|
|
|
local pok, mod = pcall(require, "spec.helpers.perf.drivers." .. name)
|
|
|
|
if not pok then
|
|
error(("Unable to load perf test driver %s: %s"):format(name, mod))
|
|
end
|
|
|
|
check_driver_sanity(mod)
|
|
|
|
DRIVER = mod.new(opts)
|
|
DRIVER_NAME = name
|
|
end
|
|
|
|
--- Set driver operation retry count
|
|
-- @function set_retry_count
|
|
-- @tparam number try the number of retries for each driver operation
|
|
-- @return nothing.
|
|
local function set_retry_count(try)
|
|
if type(try) ~= "number" then
|
|
error("expect a number, got " .. type(try))
|
|
end
|
|
RETRY_COUNT = try
|
|
end
|
|
|
|
--- Setup a default perf test instance.
|
|
-- Creates an instance that's ready to use on most common cases including Github Actions
|
|
-- @function use_defaults
|
|
-- @return nothing.
|
|
local function use_defaults()
|
|
logger.set_log_level(ngx.DEBUG)
|
|
set_retry_count(3)
|
|
|
|
local driver = os.getenv("PERF_TEST_DRIVER") or "docker"
|
|
local use_daily_image = os.getenv("PERF_TEST_USE_DAILY_IMAGE")
|
|
local ssh_user
|
|
|
|
if driver == "terraform" then
|
|
local seperate_db_node = not not os.getenv("PERF_TEST_SEPERATE_DB_NODE")
|
|
|
|
local tf_provider = os.getenv("PERF_TEST_TERRAFORM_PROVIDER") or "equinix-metal"
|
|
local tfvars = {}
|
|
if tf_provider == "equinix-metal" then
|
|
tfvars = {
|
|
-- Kong Benchmarking
|
|
metal_project_id = os.getenv("PERF_TEST_METAL_PROJECT_ID"),
|
|
-- TODO: use an org token
|
|
metal_auth_token = os.getenv("PERF_TEST_METAL_AUTH_TOKEN"),
|
|
metal_plan = os.getenv("PERF_TEST_METAL_PLAN"), -- "c3.small.x86"
|
|
-- metal_region = ["sv15", "sv16", "la4"], -- not support setting from lua for now
|
|
metal_os = os.getenv("PERF_TEST_METAL_OS"), -- "ubuntu_20_04",
|
|
}
|
|
elseif tf_provider == "digitalocean" then
|
|
tfvars = {
|
|
do_project_name = os.getenv("PERF_TEST_DIGITALOCEAN_PROJECT_NAME"), -- "Benchmark",
|
|
do_token = os.getenv("PERF_TEST_DIGITALOCEAN_TOKEN"),
|
|
do_size = os.getenv("PERF_TEST_DIGITALOCEAN_SIZE"), -- "c2-8vpcu-16gb",
|
|
do_region = os.getenv("PERF_TEST_DIGITALOCEAN_REGION"), --"sfo3",
|
|
do_os = os.getenv("PERF_TEST_DIGITALOCEAN_OS"), -- "ubuntu-20-04-x64",
|
|
}
|
|
elseif tf_provider == "aws-ec2" then
|
|
tfvars = {
|
|
aws_region = os.getenv("PERF_TEST_AWS_REGION"), -- "us-east-2",
|
|
ec2_instance_type = os.getenv("PERF_TEST_EC2_INSTANCE_TYPE"), -- "c5a.2xlarge",
|
|
ec2_os = os.getenv("PERF_TEST_EC2_OS"), -- "ubuntu/images/hvm-ssd/ubuntu-focal-20.04-amd64-server-*",
|
|
}
|
|
ssh_user = "ubuntu"
|
|
elseif tf_provider == "bring-your-own" then
|
|
tfvars = {
|
|
kong_ip = os.getenv("PERF_TEST_BYO_KONG_IP"),
|
|
kong_internal_ip = os.getenv("PERF_TEST_BYO_KONG_INTERNAL_IP"), -- fallback to kong_ip
|
|
db_ip = os.getenv("PERF_TEST_BYO_DB_IP"),
|
|
db_internal_ip = os.getenv("PERF_TEST_BYO_DB_INTERNAL_IP"), -- fallback to db_ip
|
|
worker_ip = os.getenv("PERF_TEST_BYO_WORKER_IP"),
|
|
worker_internal_ip = os.getenv("PERF_TEST_BYO_WORKER_INTERNAL_IP"), -- fallback to worker_ip
|
|
ssh_key_path = os.getenv("PERF_TEST_BYO_SSH_KEY_PATH") or "root",
|
|
}
|
|
ssh_user = os.getenv("PERF_TEST_BYO_SSH_USER")
|
|
end
|
|
|
|
tfvars.seperate_db_node = seperate_db_node
|
|
|
|
use_driver("terraform", {
|
|
provider = tf_provider,
|
|
tfvars = tfvars,
|
|
use_daily_image = use_daily_image,
|
|
seperate_db_node = seperate_db_node,
|
|
ssh_user = ssh_user,
|
|
})
|
|
else
|
|
use_driver(driver, {
|
|
use_daily_image = use_daily_image,
|
|
})
|
|
end
|
|
end
|
|
|
|
local function invoke_driver(method, ...)
|
|
if not DRIVER then
|
|
error("No driver selected, call use_driver first", 2)
|
|
end
|
|
|
|
if not DRIVER[method] then
|
|
my_logger.warn(method, " not implemented by driver ", DRIVER_NAME)
|
|
return
|
|
end
|
|
|
|
local happy
|
|
local r, err
|
|
for i = 1, RETRY_COUNT + 1 do
|
|
r, err = DRIVER[method](DRIVER, ...)
|
|
if not err then
|
|
happy = true
|
|
break
|
|
end
|
|
|
|
my_logger.warn("failed in ", method, ": ", err or "nil", ", tries: ", i)
|
|
end
|
|
|
|
if not happy then
|
|
error(method .. " finally failed" .. (RETRY_COUNT > 0 and " after " .. RETRY_COUNT .. " retries" or ""), 2)
|
|
end
|
|
|
|
return r
|
|
end
|
|
|
|
local _M = {
|
|
use_driver = use_driver,
|
|
set_retry_count = set_retry_count,
|
|
use_defaults = use_defaults,
|
|
|
|
new_logger = logger.new_logger,
|
|
set_log_level = logger.set_log_level,
|
|
|
|
setenv = utils.setenv,
|
|
unsetenv = utils.unsetenv,
|
|
execute = utils.execute,
|
|
wait_output = utils.wait_output,
|
|
parse_docker_image_labels = utils.parse_docker_image_labels,
|
|
clear_loaded_package = utils.clear_loaded_package,
|
|
|
|
git_checkout = git.git_checkout,
|
|
git_restore = git.git_restore,
|
|
get_kong_version = git.get_kong_version,
|
|
}
|
|
|
|
--- Start the worker (nginx) with given conf with multiple ports.
|
|
-- @tparam string conf the Nginx snippet under server{} context
|
|
-- @tparam[opt=1] number port_count number of ports the upstream listens to
|
|
-- @return upstream_uri string or table if `port_count` is more than 1
|
|
function _M.start_worker(conf, port_count)
|
|
port_count = port_count or 1
|
|
local ret = invoke_driver("start_worker", conf, port_count)
|
|
return port_count == 1 and ret[1] or ret
|
|
end
|
|
|
|
--- Start Kong with given version and conf.
|
|
-- @tparam[opt] table kong_confs Kong configuration as a lua table
|
|
-- @tparam[opt] table driver_confs driver configuration as a lua table
|
|
-- @return nothing. Throws an error if any.
|
|
function _M.start_kong(kong_confs, driver_confs)
|
|
kong_confs = kong_confs or {}
|
|
for k, v in pairs(read_all_env()) do
|
|
k = k:match("^KONG_([^=]+)")
|
|
k = k and k:lower()
|
|
if k then
|
|
kong_confs[k] = os.getenv("KONG_" .. k:upper())
|
|
end
|
|
end
|
|
return invoke_driver("start_kong", kong_confs, driver_confs or {})
|
|
end
|
|
|
|
--- Stop Kong.
|
|
-- @param ... args passed to the driver "stop_kong" command
|
|
-- @return nothing. Throws an error if any.
|
|
function _M.stop_kong(...)
|
|
return invoke_driver("stop_kong", ...)
|
|
end
|
|
|
|
--- Setup environment.
|
|
-- This is not necessary if `setup_kong` is called
|
|
-- @return nothing. Throws an error if any.
|
|
function _M.setup()
|
|
return invoke_driver("setup")
|
|
end
|
|
|
|
--- Installs Kong. Setup env vars and return the configured helpers utility
|
|
-- @tparam string version Kong version
|
|
-- @return table the `helpers` utility as if it's require("spec.helpers")
|
|
function _M.setup_kong(version)
|
|
LAST_KONG_VERSION = version
|
|
return invoke_driver("setup_kong", version)
|
|
end
|
|
|
|
--- Cleanup the test.
|
|
-- @tparam[opt=false] boolean full teardown all stuff, including those will make next test spin up faster
|
|
-- @return nothing. Throws an error if any.
|
|
function _M.teardown(full)
|
|
LAST_KONG_VERSION = nil
|
|
return invoke_driver("teardown", full)
|
|
end
|
|
|
|
local load_thread
|
|
local load_should_stop
|
|
|
|
--- Start to send load to Kong.
|
|
-- @tparam table opts options table
|
|
-- @tparam[opt="/"] string opts.path request path
|
|
-- @tparam[opt="http://kong-ip:kong-port/"] string opts.uri base URI except path
|
|
-- @tparam[opt=1000] number opts.connections connection count
|
|
-- @tparam[opt=5] number opts.threads request thread count
|
|
-- @tparam[opt=10] number opts.duration perf test duration in seconds
|
|
-- @tparam[opt] string opts.script content of wrk script
|
|
-- @tparam[opt] string opts.kong_name specify the kong name to send load to; will automatically pick one if not specified
|
|
-- @return nothing. Throws an error if any.
|
|
function _M.start_load(opts)
|
|
if load_thread then
|
|
error("load is already started, stop it using wait_result() first", 2)
|
|
end
|
|
|
|
local path = opts.path or ""
|
|
-- strip leading /
|
|
if path:sub(1, 1) == "/" then
|
|
path = path:sub(2)
|
|
end
|
|
|
|
local prog = opts.wrk2 and "wrk2" or "wrk"
|
|
if opts.wrk2 then
|
|
if DRIVER_NAME ~= "terraform" then
|
|
error("wrk2 not supported in docker driver", 2)
|
|
elseif not opts.rate then
|
|
error("wrk2 requires rate", 2)
|
|
end
|
|
end
|
|
|
|
local load_cmd_stub = prog .. " -c " .. (opts.connections or 1000) ..
|
|
" -t " .. (opts.threads or 5) ..
|
|
" -d " .. (opts.duration or 10) .. "s" ..
|
|
(opts.wrk2 and " -R " .. opts.rate or "") ..
|
|
" %s " .. -- script place holder
|
|
" %s/" .. path ..
|
|
" --latency"
|
|
|
|
local load_cmd = invoke_driver("get_start_load_cmd", load_cmd_stub, opts.script, opts.uri, opts.kong_name)
|
|
load_should_stop = false
|
|
|
|
load_thread = ngx.thread.spawn(function()
|
|
return utils.execute(load_cmd,
|
|
{
|
|
stop_signal = function() if load_should_stop then return 9 end end,
|
|
})
|
|
end)
|
|
end
|
|
|
|
local stapxx_thread
|
|
local stapxx_should_stop
|
|
|
|
--- Start to send load to Kong.
|
|
-- @tparam string sample_name stapxx sample name
|
|
-- @tparam string arg extra arguments passed to stapxx script
|
|
-- @tparam table driver_confs driver configuration as a lua table
|
|
-- @return nothing. Throws an error if any.
|
|
function _M.start_stapxx(sample_name, arg, driver_confs)
|
|
if stapxx_thread then
|
|
error("stapxx is already started, stop it using wait_result() first", 2)
|
|
end
|
|
|
|
local start_cmd = invoke_driver("get_start_stapxx_cmd", sample_name, arg, driver_confs or {})
|
|
stapxx_should_stop = false
|
|
|
|
stapxx_thread = ngx.thread.spawn(function()
|
|
return utils.execute(start_cmd,
|
|
{
|
|
stop_signal = function() if stapxx_should_stop then return 3 end end,
|
|
})
|
|
end)
|
|
|
|
local wait_cmd = invoke_driver("get_wait_stapxx_cmd")
|
|
if not utils.wait_output(wait_cmd, "stap_", 30) then
|
|
return false, "timeout waiting systemtap probe to load"
|
|
end
|
|
|
|
return true
|
|
end
|
|
|
|
--- Wait for the load test to finish.
|
|
-- @treturn string the test report text
|
|
function _M.wait_result()
|
|
if not load_thread then
|
|
error("load haven't been started or already collected, " ..
|
|
"start it using start_load() first", 2)
|
|
end
|
|
|
|
-- local timeout = opts and opts.timeout or 3
|
|
-- local ok, res, err
|
|
|
|
-- ngx.update_time()
|
|
-- local s = ngx.now()
|
|
-- while not found and ngx.now() - s <= timeout do
|
|
-- ngx.update_time()
|
|
-- ngx.sleep(0.1)
|
|
-- if coroutine.status(self.load_thread) ~= "running" then
|
|
-- break
|
|
-- end
|
|
-- end
|
|
-- print(coroutine.status(self.load_thread), coroutine.running(self.load_thread))
|
|
|
|
-- if coroutine.status(self.load_thread) == "running" then
|
|
-- self.load_should_stop = true
|
|
-- return false, "timeout waiting for load to stop (" .. timeout .. "s)"
|
|
-- end
|
|
|
|
if stapxx_thread then
|
|
local ok, res, err = ngx.thread.wait(stapxx_thread)
|
|
stapxx_should_stop = true
|
|
stapxx_thread = nil
|
|
if not ok or err then
|
|
my_logger.warn("failed to wait stapxx to finish: ",
|
|
(res or "nil"),
|
|
" err: " .. (err or "nil"))
|
|
end
|
|
my_logger.debug("stap++ output: ", res)
|
|
end
|
|
|
|
local ok, res, err = ngx.thread.wait(load_thread)
|
|
load_should_stop = true
|
|
load_thread = nil
|
|
|
|
if not ok or err then
|
|
error("failed to wait result: " .. (res or "nil") ..
|
|
" err: " .. (err or "nil"))
|
|
end
|
|
|
|
return res
|
|
end
|
|
|
|
local function sum(t)
|
|
local s = 0
|
|
for _, i in ipairs(t) do
|
|
if type(i) == "number" then
|
|
s = s + i
|
|
end
|
|
end
|
|
|
|
return s
|
|
end
|
|
|
|
-- Note: could also use custom lua code in wrk
|
|
local nan = 0/0
|
|
local function parse_wrk_result(r)
|
|
local rps = string.match(r, "Requests/sec:%s+([%d%.]+)")
|
|
rps = tonumber(rps)
|
|
local count = string.match(r, "([%d]+)%s+requests in")
|
|
count = tonumber(count)
|
|
|
|
local lat_avg, avg_m, lat_max, max_m = string.match(r, "Latency%s+([%d%.]+)([mu]?)s%s+[%d%.]+[mu]?s%s+([%d%.]+)([mu]?)s")
|
|
lat_avg = tonumber(lat_avg or nan) * (avg_m == "u" and 0.001 or (avg_m == "m" and 1 or 1000))
|
|
lat_max = tonumber(lat_max or nan) * (max_m == "u" and 0.001 or (max_m == "m" and 1 or 1000))
|
|
|
|
local p90, p90_m = string.match(r, "90%%%s+([%d%.]+)([mu]?)s")
|
|
local p99, p99_m = string.match(r, "99%%%s+([%d%.]+)([mu]?)s")
|
|
p90 = tonumber(p90 or nan) * (p90_m == "u" and 0.001 or (p90_m == "m" and 1 or 1000))
|
|
p99 = tonumber(p99 or nan) * (p99_m == "u" and 0.001 or (p99_m == "m" and 1 or 1000))
|
|
|
|
return rps, count, lat_avg, lat_max, p90, p99
|
|
end
|
|
|
|
--- Compute average of RPS and latency from multiple wrk output.
|
|
-- @tparam table results the table holds raw wrk outputs
|
|
-- @tparam string suite xaxis suite name
|
|
-- @treturn string The human readable result of average RPS and latency
|
|
function _M.combine_results(results, suite)
|
|
local count = #results
|
|
if count == 0 then
|
|
return "(no results)"
|
|
end
|
|
|
|
local rpss = table.new(count, 0)
|
|
local latencies_avg = table.new(count, 0)
|
|
local latencies_max = table.new(count, 0)
|
|
local latencies_p90 = table.new(count, 0)
|
|
local latencies_p99 = table.new(count, 0)
|
|
local count = 0
|
|
|
|
for i, result in ipairs(results) do
|
|
local r, c, la, lm, p90, p99 = parse_wrk_result(result)
|
|
rpss[i] = r
|
|
count = count + c
|
|
latencies_avg[i] = la * c
|
|
latencies_max[i] = lm
|
|
latencies_p90[i] = p90
|
|
latencies_p99[i] = p99
|
|
end
|
|
|
|
local rps = sum(rpss) / 3
|
|
local latency_avg = sum(latencies_avg) / count
|
|
local latency_max = math.max(unpack(latencies_max))
|
|
|
|
if LAST_KONG_VERSION then
|
|
charts.ingest_combined_results(LAST_KONG_VERSION, {
|
|
rpss = rpss,
|
|
rps = rps,
|
|
latencies_p90 = latencies_p90,
|
|
latencies_p99 = latencies_p99,
|
|
latency_max = latency_max,
|
|
latency_avg = latency_avg,
|
|
}, suite)
|
|
end
|
|
|
|
return ([[
|
|
RPS Avg: %3.2f
|
|
Latency Avg: %3.2fms Max: %3.2fms
|
|
P90 (ms): %s
|
|
P99 (ms): %s
|
|
]]):format(rps, latency_avg, latency_max, table.concat(latencies_p90, ", "), table.concat(latencies_p99, ", "))
|
|
end
|
|
|
|
--- Wait until the systemtap probe is loaded.
|
|
-- @tparam[opt=20] number timeout in seconds
|
|
function _M.wait_stap_probe(timeout)
|
|
return invoke_driver("wait_stap_probe", timeout or 20)
|
|
end
|
|
|
|
--- Generate the flamegraph and return SVG.
|
|
-- @tparam string filename the target filename to store the generated SVG.
|
|
-- @tparam[opt] string title the title for flamegraph
|
|
-- @tparam[opt] string opts the command line options string (not a table) for `flamegraph.pl`
|
|
-- @return Nothing. Throws an error if any.
|
|
function _M.generate_flamegraph(filename, title, opts)
|
|
if not filename then
|
|
error("filename must be specified for generate_flamegraph")
|
|
end
|
|
if string.sub(filename, #filename-3, #filename):lower() ~= ".svg" then
|
|
filename = filename .. ".svg"
|
|
end
|
|
|
|
if not title then
|
|
title = "Flame graph"
|
|
end
|
|
|
|
-- If current test is git-based, also attach the Kong binary package
|
|
-- version it based on
|
|
if git.is_git_repo() and git.is_git_based() then
|
|
-- use driver to get the version; driver could implement version override
|
|
-- based on setups (like using the daily image)
|
|
local v = invoke_driver("get_based_version")
|
|
title = title .. " (based on " .. v .. ")"
|
|
end
|
|
|
|
local out = invoke_driver("generate_flamegraph", title, opts)
|
|
|
|
local f, err = io.open(filename, "w")
|
|
if not f then
|
|
error("failed to open " .. filename .. " for writing flamegraph: " .. err)
|
|
end
|
|
|
|
f:write(out)
|
|
f:close()
|
|
|
|
my_logger.debug("flamegraph written to ", filename)
|
|
end
|
|
|
|
--- Enable or disable charts generation.
|
|
-- @tparam[opt=false] boolean enabled enable or not
|
|
-- @return Nothing. Throws an error if any.
|
|
function _M.enable_charts(enabled)
|
|
return enabled and charts.on() or charts.off()
|
|
end
|
|
|
|
|
|
--- Save Kong error log locally.
|
|
-- @tparam string filename filename where to save the log
|
|
-- @return Nothing. Throws an error if any.
|
|
function _M.save_error_log(filename)
|
|
if not filename then
|
|
error("filename must be specified for save_error_log")
|
|
end
|
|
|
|
invoke_driver("save_error_log", filename)
|
|
|
|
my_logger.debug("Kong error log written to ", filename)
|
|
end
|
|
|
|
--- Get the Admin URI accessible from worker.
|
|
-- @tparam[opt] string kong_name specify the kong name; will automatically pick one if not specified
|
|
-- @return Nothing. Throws an error if any.
|
|
function _M.get_admin_uri(kong_name)
|
|
return invoke_driver("get_admin_uri", kong_name)
|
|
end
|
|
|
|
--- Save a .sql file of the database.
|
|
-- @tparam string path the .sql file path to save to
|
|
-- @return Nothing. Throws an error if any.
|
|
function _M.save_pgdump(path)
|
|
return invoke_driver("save_pgdump", path)
|
|
end
|
|
|
|
--- Load a .sql file into the database.
|
|
-- @tparam string path the .sql file path
|
|
-- @tparam[opt=false] boolean dont_patch_service set to true to skip update all services to upstream started by this framework
|
|
-- @return Nothing. Throws an error if any.
|
|
function _M.load_pgdump(path, dont_patch_service)
|
|
return invoke_driver("load_pgdump", path, dont_patch_service)
|
|
end
|
|
|
|
--- Execute command on remote instance.
|
|
-- @tparam string node_type the node to exeute the command on, can be; "kong", "db", or "worker"
|
|
-- @tparam array cmds array of commands to execute
|
|
-- @tparam boolean continue_on_error if true, will continue on error
|
|
function _M.remote_execute(node_type, cmds, continue_on_error)
|
|
return invoke_driver("remote_execute", node_type, cmds, continue_on_error)
|
|
end
|
|
|
|
return _M
|