k6 is the best load test tool available, but it's pricey. I wanted to start unlimited VUs (virtual users) without paying a lot of money and found a neat way to do it by spawning many short-lived EC2 instances!
Too many templates
I used a simple user-data
script with heredocs. Make sure you have an AMI with Docker installed. You will also need S3 access.
Start by setting up AWS credentials.
# load_test.user-data
echo "setting up aws-cli"
mkdir -p /home/ec2-user/.aws
cat <<EOF > /home/ec2-user/.aws/config
[default]
output=json
region=us-west-1
EOF
cat <<EOF > /home/ec2-user/.aws/credentials
[default]
aws_access_key_id={{ aws-creds.access-key-id }}
aws_secret_access_key={{ aws-creds.secret-access-key }}
EOF
Next, you can write out the load-test and make it executable:
# load_test.user-data
echo "writing load test: {{load-test}}"
mkdir -p /home/ec2-user/load_test
chmod a+rw /home/ec2-user/load_test
cat <<EOF > /home/ec2-user/load_test/{{load-test}}
{{load-test-content|safe}}
EOF
We pull the k6 docker image and execute it with a single command.
# load_test.user-data
echo "running load test"
sudo -u ec2-user docker run --rm \
--ulimit nofile=65536:65536 \
--network host \
-v /home/ec2-user/load_test/:/mnt/load_test:rw \
-i loadimpact/k6 \
run --summary-export=/mnt/load_test/out.json \
/mnt/load_test/{{load-test}} > /dev/null
Finally, upload the test results to s3.
# load_test.user-data
echo "uploading results"
sudo -u ec2-user aws s3api put-object \
--bucket load-test-results \
--key {{uuid}}/{{instance-name}} \
--body /home/ec2-user/load_test/out.json > /dev/null
IMPORTANT: You should set a trap
function to call systemctl halt
to shutdown the EC2 instances. Leaving EC2 instances running would be counter-productive to our goal of saving money.
You may have noticed that I have used templates in the user-data
. We can use Selmer to supply all of the important stuff.
(defn build-user-data
[aws-creds uuid instance-name load-test load-test-template aws-creds]
(selmer/render-file
"load_test.user-data"
{:aws-creds aws-creds
:uuid uuid
:instance-name instance-name
:load-test (.getName (io/file (io/resource load-test)))
:load-test-content (selmer/render-file load-test load-test-template)}))
aws-creds
should be a map with keys :access-key-id
and :secret-access-key
. These are your S3 credentials.
You may have noticed that load-test-content
is itself a template. That is so we can point the load test to an arbitrary address and host our web app from anywhere. Here is an example load-test.
import http from 'k6/http';
import { check, sleep } from 'k6';
export let options = {
stages: [
{ duration: '30s', target: 5000 }, // ramp up
{ duration: '30s', target: 5000 }, // sustained load
{ duration: '30s', target: 0 }, // cool down
],
};
export default function () {
let res = http.get('{{url-prefix}}/ping')
check(res, {'status is 200': (r) => r && r.status === 200});
sleep(1);
}
load-test-template
should be a map with a single key :url-prefix
. You may find more exciting uses for using templates in your load tests. 😉
Orchestration
We can use the excellent Cognitect AWS library to kick off our tests.
(defn launch-load-test-instance
[ec2 aws-creds uuid instance-name load-test load-test-template]
(aws/invoke ec2
{:op :RunInstances
:request {:ImageId "my-ami"
:InstanceType "my-size"
:MinCount 1 :MaxCount 1
:InstanceInitiatedShutdownBehavior "terminate"
:UserData (-> (build-user-data aws-creds uuid instance-name load-test load-test-template)
byte-streams/to-byte-array
base64/encode
byte-streams/to-string)
:TagSpecifications [{:ResourceType "instance"
:Tags [{:Key "Name" :Value instance-name}
{:Key "load-test-id" :Value uuid}]}]
:SecurityGroupIds [...]
:KeyName "my-company"}}))
(defn launch-load-test-instances
[ec2 aws-creds uuid instance-prefix load-test load-test-template num-instances]
(doall
(map
#(launch-load-test-instance ec2 aws-creds uuid
(str instance-prefix %)
load-test load-test-template)
(range num-instances))))
There are some gaps in the above code that you will need to fill.
I feel like a mad scientist after writing that code. Wreak havoc, my minions!
Gathering test results
In the previous code, you may have noticed that we supplied a uuid
to each ec2 instance and used it in the user-data
script as a shared bucket for all of our k6 results. I hope you kept track of it. 😈
(def s3-bucket "load-test-results")
(defn get-k6-summary [s3 key]
(->> {:op :GetObject
:request {:Bucket s3-bucket
:Key key}}
(aws/invoke s3)
:Body
io/reader
json/decode-stream))
(defn get-k6-summaries [s3 uuid]
(map
(comp (partial get-k6-summary s3) :Key)
(->> {:op :ListObjects
:request {:Bucket s3-bucket
:Prefix uuid}}
(aws/invoke s3)
:Contents)))
Calling get-k6-summaries
will return a sequence of all the generated results. Those individual results may be helpful for some analysis, but you should aggregate as many metrics as can be combined.
(defn metric-type [m]
(condp = (set (keys m))
#{"count" "rate"} :counter
#{"min" "max" "p(90)" "p(95)" "med" "avg"} :trend
#{"fails" "value" "passes"} :rate
#{"min" "max" "value"} :gauge
:unknown))
(defn merge-summaries
"summaries is a vector of k6 output summaries converted from JSON to EDN.
get-k6-summaries returns this structure precisely. This function works with the various types
of k6 metrics (https://k6.io/docs/using-k6/metrics) and does the following:
- min values are the minimum in all the maps
- max values are the maximum in all the maps
- avg values are the average in all the maps
- count is the sum in all the maps
- rate is the average in all the maps
- fails is the sum in all the maps
- passes is the sum in all the maps
p, med, & 'value' values are not included in the merged summary, but they can still be viewed
for individual machines by inspecting the output from get-k6-summary"
[summaries]
(letfn [(merge-metrics [v1 v2]
(condp = (metric-type v2)
:counter {"count" (+ (get v1 "count") (get v2 "count"))
"rate" (double (/ (+ (get v1 "rate") (get v2 "rate")) 2))}
:trend {"min" (min (get v1 "min") (get v2 "min"))
"max" (max (get v1 "max") (get v2 "max"))
"avg" (double (/ (+ (get v1 "avg") (get v2 "avg")) 2))}
:rate {"fails" (+ (get v1 "fails") (get v2 "fails"))
"passes" (+ (get v1 "passes") (get v2 "passes"))}
:gauge {"min" (min (get v1 "min") (get v2 "min"))
"max" (max (get v1 "max") (get v2 "max"))}
{}))
(merge-checks [v1 v2]
{"passes" (+ (get v1 "passes") (get v2 "passes"))
"fails" (+ (get v1 "fails") (get v2 "fails"))})
(max-vus [m]
(get-in m ["metrics" "vus_max" "max"]))]
{:metrics (apply
merge-with
merge-metrics
(map #(get % "metrics")
summaries))
:checks (apply
merge-with
merge-checks
(map
#(get-in % ["root_group" "checks"])
summaries))
:max-vus (->> summaries
(map max-vus)
(apply +))}))
Call merge-summaries
to get aggregate results.
Tradeoffs
A paid k6 plan will provide you with a friendlier interface and better result-sets. But is $25k/year worth that amount of money? I don't know. That's up to each user.
If you're willing to manage some ec2 instances yourself and work with less information in aggregate, then perhaps this approach is better.
One problem I didn't go into any detail about is managing failed EC2 instances. Certain failure states require an external TerminateInstances
operation: management of 'dangling' ec2 instances is now your problem.
k6 cloud will also distribute traffic across regions. I think it will also simulate clients with slower bandwidth. Could you provide all of this if you need it?
I believe you could get close to k6's paid feature-set, but only with a lot of effort. Hopefully, I have given you a starting point.
Don't forget to have fun!
Top comments (0)