Serverless Python deployments with Github Actions
Intro to Github Actions
Github Actions is an API for cause and effect on GitHub, which became generally available on November 13, 2019. Actions lets you trigger a workflow in response to a number of different events. While I believe it is most commonly used for CI/CD pipelines, it can also be used for other automation tasks, like scheduling a job to automatically clean up stale issues in your project. One of the coolest things about Github Actions is the actions marketplace. As of this writing, it has close to 2000 prebuilt actions you can plug into your workflows and use. Useful things like configuring AWS credentials are available as actions so that you don’t want to have to build everything yourself.
Github Actions is a good choice for a CI/CD solution for side projects. As shown on their pricing page (as of 2020-01-19), they have a great free tier which lets you use it on any of your repositories, private ones included, unlike popular alternatives like Travis.
Hello, Actions
Before diving into the workflows I built for my serverless application, let’s walk through a simple example to get started. Workflow files, like the one shown below, get stored in the github repo under the .github/workflows/
path. Let’s call this .github/workflows/test_ci.yml
:
name: CI
on: [push]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Run a one-line script
run: echo Hello, world!
- name: Run a multi-line script
run: |
echo Add other actions to build,
echo test, and deploy your project.
This workflow gets triggered on any push event. You can get more specific, by only having it run on pushes to certain branches, like master
:
on:
push:
branches:
- master
Or when certain files are modified using glob pattern matching:
on:
push:
paths:
- '**.py' # Matches all .py files in the repository
This workflow has one job, build
. If needed, you can add more jobs, which run in parallel by default. Our build
job will run on ubuntu
, one of Github’s hosted runners.
For our first step
in the job, we leverage Github’s prebuilt checkout action, which pulls down a copy of our repository for the ref/SHA that triggered the workflow. After this step is run, the current working directory will be at the root of the repository.
To finish off, there are two example steps showing how you can run command line programs using the operating system’s shell. It is important to note that each run
keyword represents a new process and shell in the runner environment, so things like environment variables or locally defined variables won’t persist between runs. For multi-line commands, each line runs in the same shell, so if you do:
- name: Sample multi-line script
run: |
foo=bar
echo $foo
The output would be bar
, as expected.
Zappa Deployments
I am currently using Zappa to manage a suite of lambda functions I have running on Amazon Web Services (AWS) to support a slack application I built; domi. If you aren’t familiar with Zappa, it vastly simplifies the process of creating, deploying and updating lambda functions using a Python runtime. You can learn more from the zappa blog or the slides from a talk I gave at a Toronto Python meetup.
In terms of a CI/CD pipeline, I have two simple requirements:
- Run a suite of tests with pytest
- Re-deploy my code to all lambda functions I have running for the project (see previous post for details on these different functions)
I want the test suite (CI portion) to run on every pull request into master, and the deploy workflow (CD portion) to run after a commit is pushed to master.
Pull Request Workflow
Let’s start off with the “Pull Request Workflow”. We’ll have this workflow get triggered on any pull request into the master branch that modifies a specific set of files. For this project, I want the workflow to run if any of the following files get modified:
1) any of the files in the domi
directory (where all the application code lives)
2) the zappa settings file
3) the Pipfile
or Pipfile.lock
name: Pull Request Workflow
on:
pull_request:
branches:
- master
paths:
- domi/**
- Pipfile*
- zappa_settings.json
In terms of jobs, I only need a single one that will run the test suite. We’ll get started by using two prebuilt actions, the checkout action (discussed above) and the setup-python action which sets up the remaining steps of our job to use Python 3.7.
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Set up Python 3.7
uses: actions/setup-python@v1
with:
python-version: 3.7
Next, we’ll install libpq-dev
, which is a requirement for the psycopg2
library, and pipenv
which I am using to manage virtual environments.
- name: Install pipenv and libpq
run: |
sudo apt-get install libpq-dev -y
pip install pipenv
For certain projects, downloading all dependencies and creating a new virtual environment can be a time consuming step. Lucky for us, Github introduced caching functionality and an associated cache action which lets us re-use our virtual environment between runs if the dependencies haven’t changed. This works by saving a copy of the entire ~/.local/share/virtualenvs
directory & sub-directories to the cache. A unique key is associated with each cache. This key will be based on the current operating system being used for the job, along the a hash of all pipenv lock files in the directory.
Each time the job runs, the action will compute the cache key
based on the current run’s OS & Pipfile, and see if there is a corresponding cache for that key. If the cache exists for that key, the action will then copy the contents of the cache to ~/.local/share/virtualenvs
so we don’t have to re-create our virtual environment.
- name: Cache pipenv virtualenv
id: cache-pipenv
uses: actions/cache@v1
with:
path: ~/.local/share/virtualenvs
key: ${{ runner.os }}-pipenv-${{ hashFiles('**/Pipfile.lock') }}
Next, we create our virtual environment and install all the dependencies with pipenv. We only run this step on a cache miss.
- name: Install dependencies
if: steps.cache-pipenv.outputs.cache-hit != 'true'
run: pipenv install
The first time your job runs on a pull request, it will result in a cache miss since the virtual environment has never been created.
For all consecutive invocations of the workflow on the given PR, you will hit the cache and skip the expensive “Install dependencies” step (assuming you don’t change any of the requirements in your sequential commits).
With our virtual environment now created, we can run our code quality checks and tests. Secrets can be uploaded to your repository as discussed here and populated as environment variables that are accessible in the run
shell via the env
parameter.
- name: Code quality checks
run: pipenv run black . --check
- name: Run tests
env:
TH_HOST: ${{ secrets.TH_HOST }}
TH_PORT: ${{ secrets.TH_PORT }}
TH_USER: ${{ secrets.TH_USER }}
TH_PASSWORD: ${{ secrets.TH_PASSWORD }}
TH_DBNAME: ${{ secrets.TH_DBNAME }}
SLACK_API_TOKEN: ${{ secrets.SLACK_API_TOKEN }}
SLACK_SIGNING_SECRET: ${{ secrets.SLACK_SIGNING_SECRET }}
SLACK_ALERTS_WEBHOOK: ${{ secrets.SLACK_ALERTS_WEBHOOK }}
run: pipenv run pytest tests
Putting it all together, the .github/workflows/pr.yml
file currently looks like this:
name: Pull Request Workflow
on:
pull_request:
branches:
- master
paths:
- domi/**
- Pipfile*
- zappa_settings.json
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Set up Python 3.7
uses: actions/setup-python@v1
with:
python-version: 3.7
- name: Install pipenv and libpq
run: |
sudo apt-get install libpq-dev -y
pip install pipenv
- name: Cache pipenv virtualenv
id: cache-pipenv
uses: actions/cache@v1
with:
path: ~/.local/share/virtualenvs
key: ${{ runner.os }}-pipenv-${{ hashFiles('**/Pipfile.lock') }}
- name: Install dependencies
if: steps.cache-pipenv.outputs.cache-hit != 'true'
run: pipenv install
- name: Code quality checks
run: pipenv run black . --check
- name: Run tests
env:
TH_HOST: ${{ secrets.TH_HOST }}
TH_PORT: ${{ secrets.TH_PORT }}
TH_USER: ${{ secrets.TH_USER }}
TH_PASSWORD: ${{ secrets.TH_PASSWORD }}
TH_DBNAME: ${{ secrets.TH_DBNAME }}
SLACK_API_TOKEN: ${{ secrets.SLACK_API_TOKEN }}
SLACK_SIGNING_SECRET: ${{ secrets.SLACK_SIGNING_SECRET }}
SLACK_ALERTS_WEBHOOK: ${{ secrets.SLACK_ALERTS_WEBHOOK }}
run: pipenv run pytest tests
Master Workflow
The “Master Workflow” is responsible for deploying the new code to AWS after any commit to master. We’ll re-use much of the configuration from the Pull Request Workflow above, and only add a few new components. The first step is to configure our AWS credentials so our workflow can deploy stuff to AWS. For most use cases, you can just leverage the prebuilt action I mentioned above: aws-actions/configure-aws-credentials.
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@v1
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: us-east-1
However, I currently have two different AWS accounts I am using (I was exceeding the free tier in one so opened another, 🤫 don’t tell Jeff), and the action above doesn’t support multiple IAM profiles, so I manually configure the profiles and credentials with the AWS CLI.
- name: Configure AWS profile
run: |
pipenv run aws configure set region us-east-1 --profile default
pipenv run aws configure set output json --profile default
pipenv run aws configure set aws_access_key_id ${{ secrets.PRIMARY_AWS_ACCESS_KEY_ID }} --profile default
pipenv run aws configure set aws_secret_access_key ${{ secrets.PRIMARY_AWS_SECRET_ACCESS_KEY }} --profile default
pipenv run aws configure set region us-east-1 --profile domi-secondary
pipenv run aws configure set output json --profile domi-secondary
pipenv run aws configure set aws_access_key_id ${{ secrets.SECONDARY_AWS_ACCESS_KEY_ID }} --profile domi-secondary
pipenv run aws configure set aws_secret_access_key ${{ secrets.SECONDARY_AWS_SECRET_ACCESS_KEY }} --profile domi-secondary
Next, we can run a simple zappa update --all
to update all our lambda functions.
- name: Zappa deployments
run: pipenv run zappa update --all
In order to get notified of build statuses, I leverage another prebuilt Slack action, 8398a7/action-slack, for sending notifications.
- uses: 8398a7/action-slack@v2
if: always() # ensure the step always gets run, even if previous steps fail
with:
status: $
author_name: Build
env:
GITHUB_TOKEN: ${{ secrets.SLACK_GITHUB_TOKEN }}
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_ALERTS_WEBHOOK }}
These notifications are filled with useful links, like the commit message, link, and link to the action’s workflow.
Putting everything together, the .github/worksflows/master.yml
workflow file looks like this:
name: Master Workflow
on:
push:
branches:
- master
paths:
- domi/**
- Pipfile*
- zappa_settings.json
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Set up Python 3.7
uses: actions/setup-python@v1
with:
python-version: 3.7
- name: Install pipenv, libpq, and pandoc
run: |
sudo apt-get install libpq-dev -y
pip install pipenv
- name: Cache pipenv virtualenv
id: cache-pipenv
uses: actions/cache@v1
with:
path: ~/.local/share/virtualenvs
key: ${{ runner.os }}-pipenv-${{ hashFiles('**/Pipfile.lock') }}
- name: Install dependencies
if: steps.cache-pipenv.outputs.cache-hit != 'true'
run: pipenv install
- name: Configure AWS profile
run: |
pipenv run aws configure set region us-east-1 --profile default
pipenv run aws configure set output json --profile default
pipenv run aws configure set aws_access_key_id ${{ secrets.PRIMARY_AWS_ACCESS_KEY_ID }} --profile default
pipenv run aws configure set aws_secret_access_key ${{ secrets.PRIMARY_AWS_SECRET_ACCESS_KEY }} --profile default
pipenv run aws configure set region us-east-1 --profile domi-secondary
pipenv run aws configure set output json --profile domi-secondary
pipenv run aws configure set aws_access_key_id ${{ secrets.SECONDARY_AWS_ACCESS_KEY_ID }} --profile domi-secondary
pipenv run aws configure set aws_secret_access_key ${{ secrets.SECONDARY_AWS_SECRET_ACCESS_KEY }} --profile domi-secondary
- name: Zappa deployments
run: pipenv run zappa update --all
- uses: 8398a7/action-slack@v2
if: always()
with:
status: ${{ job.status }}
author_name: Build
env:
GITHUB_TOKEN: ${{ secrets.SLACK_GITHUB_TOKEN }}
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_ALERTS_WEBHOOK }}
Wrapping up
Github Actions was really easy to get up and running with, even for someone without a formal computer science education or development background, thanks to their amazing documentation. The direct integration with Github, the great free pricing tier, and the library of free, prebuilt actions will make this a go to solution for all my future projects.
Resources
- https://help.github.com/en/actions
- https://github.blog/2019-08-08-github-actions-now-supports-ci-cd/
- https://medium.com/@vanflymen/blazing-fast-ci-with-github-actions-poetry-black-and-pytest-9e74299dd4a5