mirror of
https://github.com/basecamp/once-campfire.git
synced 2026-08-28 09:32:37 +09:00
187 lines
6.2 KiB
Ruby
Executable File
187 lines
6.2 KiB
Ruby
Executable File
#!/usr/bin/env ruby
|
|
require "optparse"
|
|
require "json"
|
|
require "net/http"
|
|
require "fileutils"
|
|
require "etc"
|
|
require "time"
|
|
|
|
options = {
|
|
users: 3000, ramp: 480, hold: 120, cpus: 2, memory: "2g",
|
|
send_interval: 1, sample: 25, build: true, port: 3000, image: "campfire-bench"
|
|
}
|
|
|
|
OptionParser.new do |parser|
|
|
parser.on("--label LABEL", "Run label (required), e.g. redis-2cpu-2g") { |v| options[:label] = v }
|
|
parser.on("--users N", Integer, "Ramp target (default 3000, max 10000)") { |v| options[:users] = v }
|
|
parser.on("--ramp SECONDS", Integer, "Ramp duration (default 480)") { |v| options[:ramp] = v }
|
|
parser.on("--hold SECONDS", Integer, "Hold duration at target (default 120)") { |v| options[:hold] = v }
|
|
parser.on("--cpus N", Integer, "App container CPU count via cpuset (default 2)") { |v| options[:cpus] = v }
|
|
parser.on("--memory SIZE", "App container memory limit (default 2g)") { |v| options[:memory] = v }
|
|
parser.on("--send-interval S", Float, "Seconds between messages (default 1)") { |v| options[:send_interval] = v }
|
|
parser.on("--sample N", Integer, "1-in-N receivers log latency lines (default 25)") { |v| options[:sample] = v }
|
|
parser.on("--port PORT", Integer, "Host port for the app (default 3000)") { |v| options[:port] = v }
|
|
parser.on("--image TAG", "Image tag (default campfire-bench)") { |v| options[:image] = v }
|
|
parser.on("--no-build", "Skip docker build") { options[:build] = false }
|
|
end.parse!
|
|
|
|
abort "--label is required" unless options[:label]
|
|
|
|
class Bench
|
|
APP_CONTAINER = "campfire-bench-app"
|
|
K6_CONTAINER = "campfire-bench-k6"
|
|
|
|
attr_reader :opts, :results_dir
|
|
|
|
def initialize(opts)
|
|
@opts = opts
|
|
@root = File.expand_path("..", __dir__)
|
|
@perf_dir = File.join(@root, "test/performance")
|
|
@results_dir = File.join(@root, "tmp/bench", opts[:label])
|
|
end
|
|
|
|
def run
|
|
FileUtils.mkdir_p(results_dir)
|
|
build if opts[:build]
|
|
write_meta
|
|
start_app
|
|
wait_for_app
|
|
sampler = start_stats_sampler
|
|
run_k6
|
|
ensure
|
|
stop(sampler)
|
|
capture_app_state
|
|
cleanup
|
|
end
|
|
|
|
private
|
|
def build
|
|
sh "docker", "build", "-t", opts[:image], @root
|
|
end
|
|
|
|
def write_meta
|
|
meta = opts.merge(
|
|
git_revision: `git rev-parse HEAD`.strip,
|
|
git_branch: `git rev-parse --abbrev-ref HEAD`.strip,
|
|
host_cpus: Etc.nprocessors,
|
|
started_at: Time.now.utc.iso8601
|
|
)
|
|
File.write(File.join(results_dir, "meta.json"), JSON.pretty_generate(meta))
|
|
end
|
|
|
|
def start_app
|
|
system "docker", "rm", "-f", APP_CONTAINER, err: File::NULL, out: File::NULL
|
|
sh "docker", "run", "-d", "--name", APP_CONTAINER,
|
|
"--cpuset-cpus", app_cpuset,
|
|
"--memory", opts[:memory], "--memory-swap", opts[:memory],
|
|
"-p", "#{opts[:port]}:80",
|
|
"-e", "SECRET_KEY_BASE=dummy",
|
|
"-e", "RAILS_ENV=performance",
|
|
opts[:image]
|
|
end
|
|
|
|
def app_cpuset
|
|
(0...opts[:cpus]).to_a.join(",")
|
|
end
|
|
|
|
def wait_for_app
|
|
timeout_at = Time.now + 300
|
|
uri = URI("http://127.0.0.1:#{opts[:port]}/")
|
|
loop do
|
|
raise "App not up after 300s" if Time.now > timeout_at
|
|
begin
|
|
break if Net::HTTP.get_response(uri).code.to_i < 400
|
|
rescue Errno::ECONNREFUSED, Errno::ECONNRESET, EOFError
|
|
end
|
|
puts "Waiting for app (seeding 10k users on first boot)..."
|
|
sleep 2
|
|
end
|
|
puts "App is up."
|
|
end
|
|
|
|
def start_stats_sampler
|
|
stats_path = File.join(results_dir, "stats.csv")
|
|
fork do
|
|
File.open(stats_path, "w") do |file|
|
|
file.puts "epoch_ms,cpu_perc,mem_bytes"
|
|
file.sync = true
|
|
loop do
|
|
line = `docker stats --no-stream --format "{{.CPUPerc}} {{.MemUsage}}" #{APP_CONTAINER} 2>/dev/null`.strip
|
|
if line =~ /([\d.]+)%\s+([\d.]+)(\w+)/
|
|
file.puts "#{(Time.now.to_f * 1000).to_i},#{$1},#{to_bytes($2.to_f, $3)}"
|
|
end
|
|
sleep 1
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
def to_bytes(value, unit)
|
|
factors = { "B" => 1, "KiB" => 1024, "MiB" => 1024**2, "GiB" => 1024**3,
|
|
"kB" => 1000, "MB" => 1000**2, "GB" => 1000**3 }
|
|
(value * factors.fetch(unit, 1)).to_i
|
|
end
|
|
|
|
def run_k6
|
|
total = opts[:ramp] + opts[:hold]
|
|
puts "Running k6: ramp to #{opts[:users]} users over #{opts[:ramp]}s, hold #{opts[:hold]}s (total #{total}s)..."
|
|
system "docker", "rm", "-f", K6_CONTAINER, err: File::NULL, out: File::NULL
|
|
k6_log = File.join(results_dir, "k6.log")
|
|
summary = File.join(results_dir, "summary.json")
|
|
finished = system(
|
|
"docker", "run", "--rm", "--name", K6_CONTAINER,
|
|
"--network", "host",
|
|
"--cpuset-cpus", k6_cpuset,
|
|
"--ulimit", "nofile=262144:262144",
|
|
"-u", "#{Process.uid}:#{Process.gid}",
|
|
"-v", "#{@perf_dir}:/src",
|
|
"-v", "#{results_dir}:/results",
|
|
"-e", "HOST=127.0.0.1", "-e", "PORT=#{opts[:port]}",
|
|
"-e", "USERS=#{opts[:users]}",
|
|
"-e", "RAMP_S=#{opts[:ramp]}", "-e", "HOLD_S=#{opts[:hold]}",
|
|
"-e", "SEND_INTERVAL_S=#{opts[:send_interval]}",
|
|
"-e", "SAMPLE=#{opts[:sample]}",
|
|
"grafana/k6", "run",
|
|
"--summary-export", "/results/summary.json",
|
|
"--quiet",
|
|
"/src/ramp.js",
|
|
out: k6_log, err: [ k6_log, "a" ]
|
|
)
|
|
if finished
|
|
puts "k6 finished."
|
|
else
|
|
puts "k6 exited non-zero — results still captured in #{k6_log}."
|
|
end
|
|
end
|
|
|
|
def k6_cpuset
|
|
(opts[:cpus]...Etc.nprocessors).to_a.join(",")
|
|
end
|
|
|
|
def stop(sampler)
|
|
Process.kill("TERM", sampler)
|
|
Process.wait(sampler)
|
|
rescue Errno::ESRCH, Errno::ECHILD
|
|
nil
|
|
end
|
|
|
|
def capture_app_state
|
|
oom = `docker inspect --format '{{.State.OOMKilled}}' #{APP_CONTAINER} 2>/dev/null`.strip
|
|
File.write(File.join(results_dir, "oom.txt"), oom)
|
|
system "docker logs --tail 200 #{APP_CONTAINER} > #{File.join(results_dir, 'app.log')} 2>&1"
|
|
puts "App container was OOM-killed during the run." if oom == "true"
|
|
end
|
|
|
|
def cleanup
|
|
system "docker", "rm", "-f", APP_CONTAINER, err: File::NULL, out: File::NULL
|
|
end
|
|
|
|
def sh(*cmd)
|
|
puts "+ #{cmd.join(' ')}"
|
|
system(*cmd) || abort("Command failed: #{cmd.join(' ')}")
|
|
end
|
|
end
|
|
|
|
Bench.new(options).run
|
|
puts "Results in tmp/bench/#{options[:label]}/"
|