Simple Benchmarking for GET requests between Gin and Flask

Preamble

You need to have Go installed and Python installed for this to work.

Introduction

Just noticed after finishing up all the code for this post that both of these are drinking related (Gin and Flask). After analyzing the go ecosystem for web frameworks (Gin, Martini, Web.go, Beego, Goji, Gorilla) I decided on pitting Gin against Flask. This appeared to be the most straight forward comparison between what I’m comfortable with as one of the more popular python web frameworks, but also it’s known as one of the fastest ones of the bunch.

Ideally I would do this in a few files, and probably use gunicorn in production for Python and structure the app differently, but I just wanna build the most stripped down version of this example in ython to compare pure speeds of Flask vs. Gin and the size of their minimal docker builds for just returning a simple JSON object on a get request to the app.

Make a directory called pygo-benchmark and change your current directory to that dir.

Go web-app with Gin

Do this all in a subdirectory within pygo-benchmark, I called it go-stuff, but you can call it whatever you’d like.

Write the file

Lets get to building. I use vim-go so you get a whole package scaffold upon the first vim file.go command. So when I type vim main.go I get:

package main

import "fmt"

func main() {
	fmt.Println("vim-go")
}

So you should also create a file called main.go and add the code above to that file. Now we are going to want to install gin. In order to do this we are going to type go get -u github.com/gin-gonic/gin and that will install the go module for us. Alright, now that we have gin installed lets modify the file to make our main.go use gin now and return a simple json object on a GET to localhost:8080, the default port for this webserver.

package main

import "github.com/gin-gonic/gin"

func main() {
	router := gin.Default()

	router.GET("/", func(c *gin.Context) {
		c.JSON(200, gin.H{
			"message": "pong",
		})
	})

	// router runs on :8080 by default
	router.Run()
}

Build and Run

It is pretty simple to build a file with go, go build main.go is all you need. Now you should have an executable file called main in the same directory you’re in. If you just run /main in the command line at the same directory you built the file from, it should have some standard output like:

[GIN-debug] [WARNING] Creating an Engine instance with the Logger and Recovery middleware already attached.

[GIN-debug] [WARNING] Running in "debug" mode. Switch to "release" mode in production.
 - using env:	export GIN_MODE=release
 - using code:	gin.SetMode(gin.ReleaseMode)

[GIN-debug] GET    /                         --> main.main.func1 (3 handlers)
[GIN-debug] Listening and serving HTTP on :8080

If you’ve gotten this far congrats you built and ran your first go app!

Python web-app with Flask

Do all of this in a subdirectory in pygo-benchmark, I called it flask-stuff, but once again do whatever you need to here.

Write the file

In order to make this post shorter I’m not going to go through the whole setup, I’m just gonna blurgghhhhh the file out here. Make a file called main.py with the contents below:

from flask import Flask

app = Flask(__name__)


@app.route("/")
def ping():
    return '{"message": "ping"}'

if __name__ == "__main__":
    app.run(host="0.0.0.0")

Build and Run python

So there is no build in python, we will get to that benefit as one the favors Go in our Benchmarking section.

I like to do everything on the commandline in virtualenv, but if you dont mind messing up your packages do whatever you want, this is what I would do in order to get this file running:

virtualenv .venv -p python3.6
source .venv/bin/activate
pip install flask
python main.py

Because of the way this file is setup with the if __name__... stuff we can just run this file from the command line, like this python main.py and we should see something like this if we did it successfully:

(.venv) ➜  flask-stuff ✗ python main.py
 * Serving Flask app "main" (lazy loading)
 * Environment: production
   WARNING: Do not use the development server in a production environment.
   Use a production WSGI server instead.
 * Debug mode: on
 * Running on http://0.0.0.0:5000/ (Press CTRL+C to quit)
 * Restarting with stat
 * Debugger is active!
 * Debugger PIN: 313-728-815

Benchmarking the pure speed of each setup.

In python using requests and timeit

Now let’s get the standard boilerplate dislaimer out here. You know, the one about how results may vary on the machine that you run the benchmarks on and that you really need to have a multitude of machines in order to really prove out benchmars, yada-yada.

Alright, so with that boring stuff out of the way I LOVEEE using timeit to time stuff in python. You can do multiple runs one after the other in order to find a more valuable benchmark (the average response time).

Since we should have both webservers running as of now in this blog the go server is running on localhost:8080 and the python on at localhost:5000. Lets time them both, shall we?:

pip install requests
python
>>>
>>>import timeit
>>>import requests
>>>
>>> # gin / go
>>> timeit.timeit("requests.get('http://localhost:8080')", setup="import requests", number=10000)
21.956489593023434
>>> # flask / python
>>> timeit.timeit("requests.get('http://localhost:5000')", setup="import requests", number=10000)
34.483878459897824

Cool. So as you can see the the Go option is ~36% faster! That’s amazing, no wonder people are going for go > python these days. But then again this isn’t the most fair test as we can put a multithreadable WSGI server in front of our python setup. Now onto round two…

Using a new fangled benchmarking tool called wrk

and multithreading all the things! Thanks to a great suggestion by a dedicated reader who tweeted in, I am also adding in a multhreaded benchmark and adding gunicorn to sit in front of our Flask app.

In order to get up to speed with me you’ll have to download and make the binary for wrk using the instructions found here and if you look there are more options on the side if you’re a Windows/Mac user. Also we are going to wanna pip install gunicorn as well and in the flask-stuff directory we are going to want to type gunicorn -w 8 main:app which tells gunicorn (our multithreaded WSGI server) to use 8 threads (since the output of nproc --all on my machine was 8) and it says look at the main.py file and the app function as the entrypoint for the app.

Now down to benchmarking the two using “a real benchmarking tool” - Some guy named Ben.

➜  go-hello wrk -t8 -c32 -d30s http://127.0.0.1:5000 # flask-python
Running 30s test @ http://127.0.0.1:5000
  8 threads and 32 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency     3.58ms    1.70ms  42.16ms   94.75%
    Req/Sec     1.12k   158.54     2.83k    84.48%
  268403 requests in 30.10s, 45.82MB read
Requests/sec:   8917.30
Transfer/sec:      1.52MB
➜  go-hello wrk -t8 -c32 -d30s http://127.0.0.1:8080 # go-gin
Running 30s test @ http://127.0.0.1:8080
  8 threads and 32 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency     2.64ms    4.71ms  60.39ms   85.07%
    Req/Sec     6.86k     0.93k   33.27k    82.09%
  1639477 requests in 30.10s, 220.46MB read
Requests/sec:  54471.39
Transfer/sec:      7.32MB

Okay, maybe ben was right, this really shows how much better go is than python. Surprisingly though, the one thing python did have was a more deterministic response time as the stdev of the latency was < 2ms where it was almost 5ms in go.

But once again, this is another benchmark proving out go is faster than python. Req/sec wise go is 6.125 times faster than flask, on average.

Benchmarking for Docker image size.

Alrighty then. I’m just gonna go ahead and say it that this post is TOO freaking long already and you’re just not going to get me to breakdown the multistage docker builds I iterated through in order to get the smallest image size for each one.

Python Dockerfile

Lets put this file in the flask-stuff/Dockerfile directory with the contents below:

Our structure should look like this:

  • flask-stuff
    • main.py
    • Dockerfile
FROM python:3.6-alpine
COPY . /
WORKDIR /
RUN pip install flask
RUN pip install gunicorn
CMD ["gunicorn"  , "-w", "8", "main:app"]

In order to build this you’d type docker build . -t benchmark/py.

Go Dockerfile

Lets put this file in the go-stuff/Dockerfile directory with the contents below:

Our structure should look like this:

  • go-stuff
    • main.go
    • Dockerfile
FROM golang:latest
ADD . .
RUN go get -u github.com/gin-gonic/gin
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main .
RUN ls
RUN pwd

FROM alpine:latest  
RUN apk --no-cache add ca-certificates
COPY --from=0 /go/main .
CMD ["./main"] 

In order to build this you’d type docker build . -t benchmark/go.

Surely what are the results?!?

I have the results, and my name’s not Shirley! Bad joke, I know, but I’m trying to keep you awake here.

Alright so here’s the data:

➜  pygo-benchmark docker images
REPOSITORY          TAG                 IMAGE ID            CREATED             SIZE
benchmark/go        latest              02ef15405471        About an hour ago   21.7MB
benchmark/py        latest              8fd24d5e54f1        About an hour ago   89.6MB
python              3.6-alpine          83d065b0546b        19 hours ago        79MB
golang              latest              901414995ecd        2 weeks ago         816MB
alpine              latest              caf27325b298        3 weeks ago         5.53MB

Now this is astonishing! The benchmark/go image is ~76% smaller than the benchmark/py image. This is because of a few reasons, go literally just needs that one binary main to run, where as python needs all of the supporting libraries because it isn’t compiled.

If we used the golang image as a base instead of the alpine image as a base for our benchmark/go image it would be ~835MB. The reason why Go docker images are so small is because you can run them directly on an alpine image, which is ~5MB.

Questions, comments, concerns

Feel free to click one of these buttons in order to signal me with something that was messed up with this. I’d be glad to fix anything that didnt work for you.


ferg codes

Backend and DevOps guy.

Just trying to get by. Loves Python