├── src ├── mypkg │ ├── __init__.py │ └── greetings.py ├── requirements.txt ├── uwsgi.ini ├── tests │ ├── mypkg │ │ └── test_greetings.py │ ├── conftest.py │ └── app │ │ ├── test_main.py │ │ └── test_validation.py ├── app │ ├── invalid_usage.py │ ├── validation.py │ └── main.py └── Dockerfile ├── docker-compose.yml ├── README.md ├── deploy-demo.yml └── .gitignore /src/mypkg/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/mypkg/greetings.py: -------------------------------------------------------------------------------- 1 | def say_hello_to(s): 2 | return "hello {}".format(s) 3 | -------------------------------------------------------------------------------- /src/requirements.txt: -------------------------------------------------------------------------------- 1 | Flask==1.0.2 2 | flask-inputs==0.3.0 3 | jsonschema==3.0.1 4 | pytest==4.6.2 5 | -------------------------------------------------------------------------------- /src/uwsgi.ini: -------------------------------------------------------------------------------- 1 | [uwsgi] 2 | module = app.main 3 | callable = app 4 | #http = :3030 5 | #stats = :3031 6 | #stats-http 7 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | 5 | demo: 6 | # this is the "repository" name. 7 | image: pythondemo 8 | # custom container name (rather than the generated default) 9 | container_name: pythondemo 10 | restart: unless-stopped 11 | build: src 12 | environment: 13 | FLASK_ENV: "development" 14 | ports: 15 | - "0.0.0.0:80:80" 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/tests/mypkg/test_greetings.py: -------------------------------------------------------------------------------- 1 | from mypkg.greetings import say_hello_to 2 | 3 | 4 | def test_say_hello_appends_name(): 5 | assert say_hello_to("world") == "hello world" 6 | 7 | 8 | # this is an example of testing from a fixture 9 | #def test_say_hello_appends_name_from_fixture(my_string_fixture): 10 | # assert say_hello_to(my_string_fixture) == "hello {}".format(my_string_fixture) 11 | -------------------------------------------------------------------------------- /src/app/invalid_usage.py: -------------------------------------------------------------------------------- 1 | # See: http://flask.pocoo.org/docs/0.12/patterns/apierrors/ 2 | class InvalidUsage(Exception): 3 | status_code = 400 4 | 5 | def __init__(self, message, status_code=None, payload=None): 6 | Exception.__init__(self) 7 | self.message = message 8 | if status_code is not None: 9 | self.status_code = status_code 10 | self.payload = payload 11 | 12 | def to_dict(self): 13 | rv = dict(self.payload or ()) 14 | rv['message'] = self.message 15 | return rv 16 | -------------------------------------------------------------------------------- /src/tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from app import main 3 | 4 | 5 | # http://flask.pocoo.org/docs/1.0/testing/ 6 | @pytest.fixture 7 | def client(): 8 | main.app.config['TESTING'] = True 9 | client = main.app.test_client() 10 | yield client 11 | 12 | 13 | @pytest.fixture() 14 | def create_valid_greeting_request(): 15 | """ 16 | Helper function for creating a correctly-structured 17 | json request 18 | """ 19 | def _create_valid_greeting_request(greetee="fixture"): 20 | return { 21 | "greetee": greetee 22 | } 23 | return _create_valid_greeting_request 24 | -------------------------------------------------------------------------------- /src/app/validation.py: -------------------------------------------------------------------------------- 1 | from flask_inputs import Inputs 2 | from flask_inputs.validators import JsonSchema 3 | 4 | # https://pythonhosted.org/Flask-Inputs/#module-flask_inputs 5 | # https://json-schema.org/understanding-json-schema/ 6 | # noinspection SpellCheckingInspection 7 | greeting_schema = { 8 | 'type': 'object', 9 | 'properties': { 10 | 'greetee': { 11 | 'type': 'string', 12 | } 13 | }, 14 | 'required': ['greetee'] 15 | } 16 | 17 | 18 | class GreetingInputs(Inputs): 19 | json = [JsonSchema(schema=greeting_schema)] 20 | 21 | 22 | def validate_greeting(request): 23 | inputs = GreetingInputs(request) 24 | if inputs.validate(): 25 | return None 26 | else: 27 | return inputs.errors 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Python Microservice on Azure Kubernetes 2 | 3 | This is a demo of how to create simple RESTful python microservices and deploy them in Flask on Azure Kubernetes. 4 | 5 | The blog series describing this are [mikebridge.github.io](https://mikebridge.github.io): 6 | 7 | - [Part 1: Getting Started with Python Microservices in Flask](https://mikebridge.github.io/post/python-flask-kubernetes-1/) 8 | - [Part 2: Flask JSON Input Validation](https://mikebridge.github.io/post/python-flask-kubernetes-2/) 9 | - [Part 3: Dockerizing Flask Microservices for Deployment](https://mikebridge.github.io/post/python-flask-kubernetes-3/) 10 | - [Part 4: Set up Kubernetes on Azure](https://mikebridge.github.io/post/python-flask-kubernetes-4) 11 | - [Part 5: Introduction to Kubernetes: kubectl](https://mikebridge.github.io/post/python-flask-kubernetes-5) 12 | 13 | ... and more to come. 14 | -------------------------------------------------------------------------------- /src/tests/app/test_main.py: -------------------------------------------------------------------------------- 1 | def test_info(client): 2 | response = client.get('/') 3 | result = response.get_json() 4 | assert result is not None 5 | assert "message" in result 6 | assert result["message"] == "It Works" 7 | 8 | 9 | # http://flask.pocoo.org/docs/1.0/testing/#testing-json-apis 10 | def test_hello_greets_greetee(client): 11 | request_payload = {"greetee": "world"} 12 | response = client.post("/hello", json=request_payload) 13 | result = response.get_json() 14 | 15 | assert response.status_code == 200 16 | assert result is not None 17 | assert "message" in result 18 | assert result['message'] == "hello world" 19 | 20 | 21 | def test_hello_requires_greetee(client): 22 | request_payload = {} 23 | response = client.post("/hello", json=request_payload) 24 | result = response.get_json() 25 | 26 | assert response.status_code == 400 27 | assert result is not None 28 | assert "message" in result 29 | assert "is a required property" in result['message'][0] 30 | -------------------------------------------------------------------------------- /src/app/main.py: -------------------------------------------------------------------------------- 1 | from flask import Flask, jsonify, request 2 | 3 | from app.invalid_usage import InvalidUsage 4 | from app.validation import validate_greeting 5 | from mypkg.greetings import say_hello_to 6 | 7 | app = Flask(__name__) 8 | 9 | 10 | @app.errorhandler(InvalidUsage) 11 | def handle_invalid_usage(error): 12 | response = jsonify(error.to_dict()) 13 | response.status_code = error.status_code 14 | return response 15 | 16 | 17 | @app.route("/") 18 | def index() -> str: 19 | return jsonify({"message": "It Works"}) 20 | 21 | 22 | @app.route("/hello", methods=['POST']) 23 | def hello() -> str: 24 | errors = validate_greeting(request) 25 | if errors is not None: 26 | print(errors) 27 | raise InvalidUsage(errors) 28 | greetee = request.json.get("greetee", None) 29 | response = {"message": say_hello_to(greetee)} 30 | return jsonify(response) 31 | 32 | 33 | # These two lines are used only while developing. 34 | # In production this code will be run as a module. 35 | if __name__ == '__main__': 36 | app.run(host='0.0.0.0', port=80) 37 | 38 | -------------------------------------------------------------------------------- /deploy-demo.yml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: demo-python-flask-deployment 5 | spec: 6 | selector: 7 | matchLabels: 8 | app: demo-python-flask 9 | template: 10 | metadata: 11 | labels: 12 | app: demo-python-flask 13 | spec: 14 | containers: 15 | - name: demo-python-flask 16 | image: mydemoregistry.azurecr.io/pythondemo:0.0.1 17 | imagePullPolicy: Always 18 | env: 19 | - name: FLASK_ENV 20 | value: "development" 21 | ports: 22 | - containerPort: 80 23 | --- 24 | apiVersion: v1 25 | kind: Service 26 | metadata: 27 | name: pythondemo-flask-service 28 | spec: 29 | selector: 30 | app: demo-python-flask 31 | type: LoadBalancer 32 | # uncomment this if you have preallocated a static IP address 33 | # loadBalancerIP: a.b.c.d 34 | ports: 35 | - protocol: TCP 36 | port: 80 37 | targetPort: 80 38 | loadBalancerSourceRanges: 39 | # replace e.f.g.h with your public IP address: https://www.whatismyip.com 40 | - e.f.g.h/32 -------------------------------------------------------------------------------- /src/tests/app/test_validation.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from flask import Flask, request 3 | 4 | from app.validation import validate_greeting 5 | 6 | app = Flask(__name__) 7 | 8 | 9 | @pytest.mark.parametrize("params", [ 10 | {"greetee": 1}, 11 | {"greetee": ["array"]} 12 | ]) 13 | def test_invalid_types_are_rejected(params, create_valid_greeting_request): 14 | json_input = create_valid_greeting_request(**params) 15 | with app.test_request_context('/', json=json_input): 16 | errors = validate_greeting(request) 17 | assert errors is not None 18 | 19 | 20 | @pytest.mark.parametrize("required_parm_name", ["greetee"]) 21 | def test_missing_required_params_is_rejected(required_parm_name, create_valid_greeting_request): 22 | json_input = create_valid_greeting_request() 23 | del json_input[required_parm_name] 24 | with app.test_request_context('/', json=json_input): 25 | errors = validate_greeting(request) 26 | assert errors is not None 27 | 28 | 29 | def test_valid_greetee_is_accepted(create_valid_greeting_request): 30 | json_input = create_valid_greeting_request(greetee="Tester") 31 | with app.test_request_context('/', json=json_input): 32 | errors = validate_greeting(request) 33 | assert errors is None 34 | -------------------------------------------------------------------------------- /src/Dockerfile: -------------------------------------------------------------------------------- 1 | # "FROM" starts us out from this Ubuntu-based image 2 | # https://github.com/tiangolo/uwsgi-nginx-flask-docker/blob/master/python3.7/Dockerfile 3 | 4 | FROM tiangolo/uwsgi-nginx-flask:python3.7 5 | 6 | # Optionally, install some typical packages used for building and network debugging. 7 | RUN apt-get update 8 | RUN apt-get install -y build-essential \ 9 | software-properties-common \ 10 | apt-transport-https \ 11 | build-essential \ 12 | ca-certificates \ 13 | checkinstall \ 14 | netcat \ 15 | iputils-ping 16 | 17 | # Update to the latest PIP 18 | RUN pip3 install --upgrade pip 19 | 20 | # Uncommenting this will make rebuilding the image a little faster 21 | # RUN pip3 install Flask==1.0.2 \ 22 | # flask-inputs==0.3.0 \ 23 | # jsonschema==3.0.1 \ 24 | # pytest==4.6.2 25 | 26 | # Our application code will exist in the /app directory, 27 | # so set the current working directory to that 28 | WORKDIR /app 29 | 30 | # Backup the default app files. You could also delete these 31 | RUN mkdir bak && \ 32 | mv main.py uwsgi.ini bak 33 | 34 | 35 | # Copy our files into the current working directory WORKDIR 36 | COPY ./ ./ 37 | 38 | # install our dependencies 39 | RUN pip3 install -r requirements.txt 40 | 41 | # Make /app/* available to be imported by Python globally to better support several 42 | # use cases like Alembic migrations. 43 | ENV PYTHONPATH=/app 44 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *.cover 46 | .hypothesis/ 47 | 48 | # Translations 49 | *.mo 50 | *.pot 51 | 52 | # Django stuff: 53 | *.log 54 | local_settings.py 55 | 56 | # Flask stuff: 57 | instance/ 58 | .webassets-cache 59 | 60 | # Scrapy stuff: 61 | .scrapy 62 | 63 | # Sphinx documentation 64 | docs/_build/ 65 | 66 | # PyBuilder 67 | target/ 68 | 69 | # Jupyter Notebook 70 | .ipynb_checkpoints 71 | 72 | # pyenv 73 | .python-version 74 | 75 | # celery beat schedule file 76 | celerybeat-schedule 77 | 78 | # SageMath parsed files 79 | *.sage.py 80 | 81 | # Environments 82 | .env 83 | .venv 84 | env/ 85 | venv/ 86 | ENV/ 87 | 88 | # Spyder project settings 89 | .spyderproject 90 | .spyproject 91 | 92 | # Rope project settings 93 | .ropeproject 94 | 95 | # mkdocs documentation 96 | /site 97 | 98 | # mypy 99 | .mypy_cache/ 100 | 101 | # vscode 102 | .vscode 103 | 104 | # data files 105 | svd_transformer.pkl 106 | SVDSQL.csv 107 | 108 | # IDEA 109 | **/.idea/ 110 | # Crashlytics plugin (for Android Studio and IntelliJ) 111 | com_crashlytics_export_strings.xml 112 | crashlytics.properties 113 | crashlytics-build.properties 114 | fabric.properties 115 | 116 | # Sensitive or high-churn files: 117 | .idea/**/dataSources/ 118 | .idea/**/dataSources.ids 119 | .idea/**/dataSources.xml 120 | .idea/**/dataSources.local.xml 121 | .idea/**/sqlDataSources.xml 122 | .idea/**/dynamic.xml 123 | .idea/**/uiDesigner.xml 124 | .idea/**/workspace.xml 125 | 126 | # Gradle: 127 | .idea/**/gradle.xml 128 | .idea/**/libraries 129 | 130 | .listen_test 131 | *codekit-config.json 132 | 133 | 134 | # VS Code 135 | .vscode/launch.json 136 | .vscode 137 | .vscode/* 138 | 139 | # TEMP 140 | *.bak 141 | tmp/ 142 | *~ --------------------------------------------------------------------------------