BLOG
Structuring Serverless Applications with Python
There are two core problems with Python code on Lambda, and theyโre both caused by extra packages in your project. Learn an approach to manage different deployments of your serverless stack and facilitate quick iterations of serverless applications with Python in this blog post.
This post was originally published on Brianโs SaaS application architecture blog.
In spite of my intentions to get more involved in Elixir, Iโve been stuck in the Python tractor beam.
For all the issues that may arise in large Python web applications, Python really is a fantastic do-it-all language. As one of my colleagues recently said: “Python is the second-best language for everything.”
Iโm still a very big fan of the Serverless framework and have been using it almost constantly at work. So far, Iโve written fairly substantial Serverless systems for a variety of projects:
- ETL jobs synchronizing Shopify orders with 3rd party fulfillment centers.
- Data pipeline / ETL process for Strava data.
- REST APIs
- Alexa skill
Iโve come up with a pattern thatโs been working out well. If youโre curious, keep reading.
The Problems
There are two core problems with Python code on Lambda, and theyโre both caused by extra packages in your project. In the real world, youโll probably want (or need) some packages beyond the standard library.
- Python packages that have C bindings need to be built using a machine with the same architecture on which Lambda functions run (i.e., Linux).
- With Lambda, youโre responsible for managing Pythonโs path so that it can find your dependencies.
Letโs walk through my setup and see how these problems are solved.
The Solutions
- Docker
- Add four lines of code at the top of handler.py to add a directory to your sys.path
Docker
In this setup, we’re using Docker as a utility. The Docker image Iโm using is the official Python 2 image with the Serverless framework installed globally.
This image is on Docker hub and maintained by me. Itโs updated whenever thereโs a new version of Serverless. You can see the Dockerfile as well since itโs all open source.
If youโre running Linux on your host system, you wonโt need to deal with this at all. Rather, this tip is for the OS X and Windows folks out there.
Structure
This is how I structure my Serverless projects:
โโโ Makefile
โโโ envs
โ โโโ dev
โโโ requirements.txt
โโโ serverless
โโโ handler.py
โโโ lib
โโโ serverless.yml
The important bits:
- Makefile is used as a controller, making it easier to remember a lot of commands and allowing you to type less. Weโll go through this in more detail.
- “envs” will hold one or more environment variable files. You may have different files in here for your different stacksโฆdev, test, production. This allows us to easily switch between environments.
- requirements.txt should be self explanatory.
- “serverless” is the root of your serverless project.
Makefile and Envs
It may be better to show an example of whatโs needed to deploy a new stack. Using the Makefile I can simply do:
$ ENV=dev make shell
docker run –rm -it -v `pwd`:/code –env ENV=dev –env-file envs/dev –name=supersecret-serverless-dev “verypossible/serverless:1.17” bash
root@f513331941bc:/code#
root@f513331941bc:/code# make deploy
Breaking that down:
ENV=dev make shell launches the container with the variable ENV set to dev. The value for this variable needs to map to a file in your envs directory. Provided youโre getting configuration from the environment in your Python code (and you should be), this makes it simple to change the stack youโre working with.
Imagine you also have envs/test and envs/production files, which hold key-value pairs for configuration. To launch your test stack, you would do:
$ ENV=test make shell
How is this working? The baseline Makefile is shown below. You will see a command called run, which is executed using the ENV variable when the make shell is called. Using the docker–env-file argument, we inject those variables into the Docker container.
NAME = “verypossible/serverless:1.17”
ENVDIR=envs
LIBS_DIR=serverless/lib
PROJECT=supersecret
.PHONY: clean \
deploy \
env-dir \
shell \
test \
test-watch \
libs
run = docker run –rm -it \
-v `pwd`:/code \
–env ENV=$(ENV) \
–env-file envs/$2 \
–name=$(PROJECT)-serverless-$(ENV) $(NAME) $1
shell : check-env env-dir
$(call run,bash,$(ENV))
env-dir :
@test -d $(ENVDIR) || mkdir -p $(ENVDIR)
clean :
@test -d $(LIBS_DIR) || mkdir -p $(LIBS_DIR)
rm -rf $(LIBS_DIR)/*
# make libs should be run from inside the container
libs :
@test -d $(LIBS_DIR) || mkdir -p $(LIBS_DIR)
pip install -t $(LIBS_DIR) -r requirements.txt
rm -rf $(LIBS_DIR)/*.dist-info
find $(LIBS_DIR) -name ‘*.pyc’ | xargs rm
find $(LIBS_DIR) -name tests | xargs rm -rf
# NOTE:
#
# Deployments assume you are already running inside the docker container
#
deploy : check-env
cd serverless && sls deploy -s $(ENV)
# Note the ifndef must be unindented
check-env:
ifndef ENV
$(error ENV is undefined)
endif
To deploy your serverless project, youโll need AWS credentials. Each envs file you create must have the following:
AWS_DEFAULT_REGION=us-west-2
AWS_SECRET_ACCESS_KEY=ASFASDFASFASFASFDSADF
AWS_ACCESS_KEY_ID=1234ABCDEF
envs should be in your .gitignore. You really donโt want to commit sensitive variables into source controlโฆso make sure youโve added envs into your .gitignore.
Now that we have a bash shell open in our container, the deployment is simply make deploy. Looking above, you can see there isnโt much magic here. The only trick: weโre taking the value for ENV โ which also gets injected as a variable into the container โ and using that as the Serverless stage with the -s argument. You can now work on completely separate stacks using the exact same code.
Libraries
Now that the hard work is out of the way, weโre all clear to install some libraries. Common libraries with C bindings that you may want to use are psycopg2, python-mysql, yaml, and all or most of the data science packages (numpy, etc.).
Add whatever you need into requirements.txt. From within the container in the same directory as the Makefile (which happens to be /code run):
root@f513331941bc:/code# make libs
Looking at the Makefile, youโll see (again) that there isnโt much magic to this. The key here is that weโre building our C bindings on the same architecture that Lambda uses to run your functions โ that is, Linux.
If you shut down your container, youโll notice that your libs directory is still there. This is nice, and itโs on purpose: using the -v (volume) argument to docker run, weโre able to map our hostโs directory into the container. Any packages we install will be built from within the Linux container but will ultimately be written to our hostโs file-system. Youโll only need to make libs when you add or update your requirements.txt files. There is also a “make clean” command, which can be used to start over.
Handler.py
Now that we have all our libraries, we need to tell our Python code how to find them. At the top of handler.py, I always have these first four lines of code (two imports + two lines to deal with +`sys.path`):
# begin magic four lines
import os
import sys
CWD = os.path.dirname(os.path.realpath(__file__))
sys.path.insert(0, os.path.join(CWD, “lib”))
# end magic four lines
# now it’s ok to import extra libraries
import numpy as np
def handler(event, context):
pass
Another very useful convention: using a single handler.py function as the entrypoint for all my functions. The handler does nothing more than the basic bootstrapping of the path, importing my own modules, and handing off the work to those other modules. In the end, the file structure looks something like this:
$ tree -L 2
.
โโโ Dockerfile
โโโ Makefile
โโโ envs
โ โโโ dev
โ โโโ production
โโโ requirements.txt
โโโ serverless
โ โโโ handler.py
โ โโโ lib
โ โโโ serverless.yml
โ โโโ very
โ โโโ aws.py
โ โโโ constants.py
โ โโโ feed.py
โโโ tests
โโโ __init__.py
โโโ conftest.py
โโโ test_aws.py
โโโ test_feed.py
handler.py will import my other modules โ which happen to be inside the very directory in this example โ and rely on them to execute my business logic. Using this convention, you can be sure that the system path is already set up so that importing your extra modules will work as youโd expect, without needing to alter the path again.
Conclusion
Docker along with this Makefile make is extremely easy to manage different deployments of your Serverless stack and facilitate quickly iterating on your code. Still, there are a few gotchas that take a little time to learn and master. Organizing my Serverless projects like this has saved me quite a bit of time. I can spin up a new project in a matter of minutes and deploy code changes within seconds, all while keeping my host system clean and free of any installations of the Serverless framework. Changing versions of Serverless is a one-line change in the Makefile.