Code for this can be found on GitHub. Or you can use this template as a starting point.
The Microservice Scenario
So here’s the situation: you’ve added data scientists to your team, and they’re coming up with great ideas that they’re implementing in jupyter. Lots of these new bits and pieces need to make it into the rest of your non-python system, so you decide that these small bits of logic should be deployed separately as microservices. The problem is that there are lot of things you need to do to get something like this into production, and… you know very little about Python. Or microservice devops.
This short series describes from end-to-end how to take a simple python function and publish it as a RESTful service in the Azure cloud using Kubernetes. It will also mention some important practical issues like testing, security, scaling, capturing output and logging.
The ultimate goal is to arrive at the point where developers can concentrate on business logic and get their code into production from the command line without devops issues standing in the way.
Although most of this code is not platform-specific, I’m doing this on Windows, so the scripting is in PowerShell rather than bash.
Set up Python on Windows
Some people use Anaconda on Windows, but I just use the standard Python distribution from the python.org site.
Once you have Python installed (and it’s in your PATH
), you should set up a virtual environment.
I’m calling this project pythondemo, so I’ll also create a virtual environment with the same name.
You’ll see this project name appear in various places as we proceed.
I had problems (in 2019!) with spaces in the virtual environment path, so I put pythondemo in my space-free home directory using venv
# create the virtual environment
PS> mkdir ~/venv
PS> python -m venv ~/venv/pythondemo
# run the virtual environment
PS> ~\venv\pythondemo\Scripts\activate.ps1
# upgrade pip so it stops complaining every time you run it
(pythondemo) PS> python -m pip install --upgrade pip
I also created a file src/requirements.txt
which has the following lines—we’ll need all these
eventually, so we may as well install them now:
# src/requirements.txt
Flask==1.0.2
pytest==4.6.2
flask-inputs==0.3.0
jsonschema==3.0.1
> cd src
> python -m pip install -r .\requirements.txt
We should now have everything we need to get a rest service running locally in Python.
Python Microservices, Starting from Zero
The business logic for this demo is going to be simple: we want to be able to greet people by name.
“Hello,
your name
“
def say_hello_to(s):
pass
If you don’t know much about python, you’ll quickly realize that the module loading system is confusing. At least, I find it confusing and I can see that I’m not the only one. Here’s my current recommendation for laying out the directories to minimize the pain:
src/app # the Flask REST interface code
src/mypkg # the business logic
src/tests/app # unit tests for the Flask app
src/tests/mypkg # unit tests for the business logic
Put a magical empty file called __init__.py
in src
, and
every directory under src/app
, and src/mypkg
.
We’ll start out with good TDD habits and write a test for our business logic first:
# src/tests/mypkg/test_greeter.py
from mypkg.greetings import say_hello_to
def test_say_hello_appends_name():
assert say_hello_to("world") == "hello world"
Run it and watch it fail:
> python -m pytest -s
========================================= FAILURES ===============================================
________________________________ test_say_hello_appends_name _____________________________________
def test_say_hello_appends_name():
> assert say_hello_to("world") == "hello world"
E AssertionError: assert None == 'hello world'
E + where None = say_hello_to('world')
tests\test_greetings.py:5: AssertionError
================================ 1 failed in 0.08 seconds ========================================
If you know how to make pytest load modules from the command-line,
that’s great. It doesn’t work for me, but I’ve decided that I have exceeded my
life-quota of debugging python-module-loading issues, so I invoke it
with python -m pytest
and add -s
to see stuff logged to the console.
Now let’s make it pass:
def say_hello_to(s):
return "hello {}".format(s)
python -m pytest -s
If that worked, we’re done with the business logic. Onward to the RESTful wrapper.
RESTful Python via Flask
Flask is the most common way to publish a RESTful microservice in Python. Let’s get a simple API working just to make sure we’re doing it right:
# src/app/main.py:
from flask import Flask, jsonify
app = Flask(__name__)
@app.route("/")
def index() -> str:
# transform a dict into an application/json response
return jsonify({"message": "It Works"})
if __name__ == '__main__':
app.run(host='0.0.0.0', port=80)
Note that the
if __name__ == '__main__'
part is just for debugging—flask normally runs your app in production as a python module.
Let’s run it:
> cd src
> python -m app.main
If all went well, you should now be able to navigate to http://localhost and see your hardcoded JSON:
{"message": "It works"}
Let’s set up a simple pytest test while things are simple so we
know how writing tests is done. First create a conftest.py
pytest fixture file to
create a testing client for us (more info here):
# src/tests/conftest.py
import pytest
from app import main
# see: http://flask.pocoo.org/docs/1.0/testing/
@pytest.fixture
def client():
main.app.config['TESTING'] = True
client = main.app.test_client()
yield client
Now let’s use it:
# src/tests/app/test_main.py
def test_info(client):
response = client.get('/')
result = response.get_json()
assert result is not None
assert "message" in result
assert result["message"] == "It Works"
Do the tests still pass?
python -m pytest -s
Great—let’s wrap our “greeting” business logic as an HTTP POST call. (Yes, this isn’t very RESTful, but this is just an example.)
We’ll change the imports in main.py
and add a new route to handle
calls to /hello
:
# src/app/main.py
from flask import Flask, jsonify, request
from mypkg.greetings import say_hello_to
# ...
@app.route("/hello", methods=['POST'])
def hello() -> str:
greetee = request.json.get("greetee", None)
response = {"message": say_hello_to(greetee)}
return jsonify(response)
This isn’t production-quality yet, but it should provide the basics. From postman
or curl, send a json post to http://localhost/hello with
the application/json
payload "{"greetee": "world"}"
. Did it work?
Let’s add a unit test. Note that the flask test client
is found in the pytest fixtures file and passed in as a fixture.
# src/tests/app/test_main.py
# http://flask.pocoo.org/docs/1.0/testing/#testing-json-apis
def test_hello_greets_greetee(client):
request_payload = {"greetee": "world"}
response = client.post("/hello", json=request_payload)
result = response.get_json()
assert response.status_code == 200
assert result is not None
assert "message" in result
assert result['message'] == "hello world"
That test shows that flask is connecting to our business logic and greeting users by name.
Next is input validation, described in Part 2.