A Django Build and Deploy Example¶
The Example Project¶
This project brings up a Django site on a Ubuntu 14 server. The site
itself is trivial, just what django-admin startproject
produces. The cluster consists of an Nginx http proxy in front of a
uwsgi server, the latter launched by supervisord. The uwsgi server is
running the Django app in a virtualenv that we build, configure and
install. The deploy will copy Django static files into a path
accessible to Nginx for static delivery. Static file processing will
only execute once per deploy, as will database migrations, even if we
add multiple Django application server instances.
We will deploy local settings to the Python app, only if those
settings change. The Django site SECRET_KEY will be kept encrypted
in our deploy configuration, and only decrypted in memory while the
deploy runs.
Project Source and DIY¶
If you want to play along at home, and try this project yourself, the source can be found in the Fabex repo at https://bitbucket.org/rmorison/fabex/src under the fabex-example tree.
Once you pull the source, and pip install Fabex, check and change the
hostenvs section in targets/example.yaml
hostenvs: server: {ip: 127.0.0.1, ssh_host: 127.0.0.1, ssh_user: ubuntu}
You might want to build an Ubuntu VM and point at that, or you can run the example on localhost. Note, the build does install the apt packages listed in example.yaml, so decide if that’s ok for your local machine.
After you’re all installed and configured, you can build the “whole enchilada” with
fab password:123 target:example install setup deploy
Target Yaml & Encrypting Sensitive Settings¶
Here’s the yaml configuration our Fabex fabfile will use:
domain: example.com timezone: America/Los_Angeles locale: en_US.UTF-8 roledefs: proxy: [server] app: [server] hostenvs: server: {ip: 192.168.56.33, ssh_host: 192.168.56.33, ssh_user: ubuntu} roleenvs: proxy: packages: [ntp, nginx] server_names: "*.example.com" httpd_user: www-data static_path: /opt/html static_user: ubuntu project_name: example app: packages: [ntp, supervisor, python-dev, python-pip, virtualenvwrapper] user: ubuntu virtualenvwrapper: /usr/share/virtualenvwrapper/virtualenvwrapper.sh workon_home: /home/ubuntu/pyves project_name: example requirements: [django==1.9 uwsgi] debug: yes secret_key: !decrypt DeZkYMRlEbWoMre7waFBoKFTxB0AFIBCsDxCzuttAkEeVHL1Xivbuc16OaPc7HmCVi2fOXUWEZp8ZDbFRXSCGjHG903lcAyhhdtuv70GdIU= wsgi_port: 10000
Note the !decrypt on the secret_key. !decrypt is a Fabex
specific yaml macro to decrypt on the fly. That encrypted text was
generated by the fxcrypt utility provided by Fabex.
fxcrypt 123 'el!qznto#i@v=v+b-(3^3*%nv=kzx!j3+%j6h*7or95anju#uc'
which outputs
DeZkYMRlEbWoMre7waFBoKFTxB0AFIBCsDxCzuttAkEeVHL1Xivbuc16OaPc7HmCVi2fOXUWEZp8ZDbFRXSCGjHG903lcAyhhdtuv70GdIU=
Note: your output will differ due to the AES initialization
vector. See fabex/crypt.py for more info. We will, however, need
the 123 encryption key, else Fabex will except when it reads the
target yaml.
As a check
fxcrypt 123 --decrypt 'DeZkYMRlEbWoMre7waFBoKFTxB0AFIBCsDxCzuttAkEeVHL1Xivbuc16OaPc7HmCVi2fOXUWEZp8ZDbFRXSCGjHG903lcAyhhdtuv70GdIU='
yields
el!qznto#i@v=v+b-(3^3*%nv=kzx!j3+%j6h*7or95anju#uc
Fabfile Walkthrough¶
A Fabex fabfile.py should import from fabex.api, instead of
fabric.api. Fabex wraps many of the Fabric functions and adds several
of its own, e.g., the dryrun, target, and password
tasks.
Again, you’ll find this complete example at https://bitbucket.org/rmorison/fabex/src under the fabex-example tree.
Import and Initialize¶
"""A Fabex example deploy of Django with an Nginx proxy"""
import os
from contextlib import contextmanager
## get fabric from fabex, with addons
from fabex.api import *
from fabex.contrib.files import *
## fabex specific config
fabex_config(config={'target_dir': 'targets',
'template_dir': 'templates',
'template_config': 'templates.yaml'})
The fabex_config sets up the
- target directory, where site specific deploy values are stored in yaml files
- template directory, where Jinja2 templates for upload to the server are stored
- template config, with per template upload directives.
More on the templating subsystem as we proceed.
Helper Functions¶
Next, a virtualenv context manager, for running under a Python
virtualenv, and a ssh task, to run a cmd on a host via ssh. These
helpers are used later.
## utility functions and helper tasks
@contextmanager
def virtualenv(workon=None, cd=None):
"""Source .virtualenvrc for virtualenvwrapper; workon and cdvirtualenv, if given"""
pre = ["source ~/.virtualenvrc"]
if workon:
pre.append("workon {}".format(workon))
pre.append("cdvirtualenv")
if cd:
pre.append("cd {}".format(cd))
with prefix(" && ".join(pre)):
yield
@task
def ssh(cmd, use_sudo=''):
"""Run a command remotely over ssh. Use sudo if use_sudo=y"""
return sudo(cmd) if use_sudo.startswith('y') else run(cmd)
Install Tasks¶
Next up, we have our first block of tasks, for the install step. We’ll
update existing packages, install per role packages, and setup ssh
keys. In the task_roles decorators, note the group='install'
setting, along with the runs_once_per_host decorator.
The group argument allows several tasks to be bundled into an
“uber” task. The runs_once_per_host decorator is a Fabex add on,
similar to Fabric’s runs_once. A task so decorated will run once,
and only once, on each host it matches up with.
## install
@task_roles(['proxy', 'app'], group='install')
@runs_once_per_host
def apt_upgrade():
"""apt update package source listings and update installed packages"""
sudo("DEBIAN_FRONTEND=noninteractive apt-get -y update")
sudo("DEBIAN_FRONTEND=noninteractive apt-get -y upgrade")
@task_roles(['proxy', 'app'], group='install')
def apt_install():
"""apt install role required packages"""
sudo('DEBIAN_FRONTEND=noninteractive apt-get install --yes {}'
.format(' '.join(env.packages)))
@task_roles(['proxy', 'app'], group='install')
@runs_once_per_host
def setup_sshkey():
"""ssh-keygen a key"""
run("[ -e ~/.ssh/id_dsa ] && echo id_dsa exists"
" || ssh-keygen -t dsa -N '' -f ~/.ssh/id_dsa")
In fact, if you look at the fab -l for this fabfile you’ll see
install Fabex group: apt_upgrade, apt_install, setup_sshkey
in the listed tasks. Fabex added this task when it saw the group
arg in task_roles, and built a list of tasks to invoke.
Setup Tasks¶
Here things get interesting, as setting up Nginx and a Python
virtualenv are more involved. In the Nginx setup, we handle the tricky
business of getting app server ssh public keys and adding then to
the proxy’s .ssh/allowed_hosts file. (Remember, there could be many app servers.)
We use env.roledefs to find all the app servers, and the Fabex host_settings
context manager run the ssh task on an app server instead of the proxy.
With that, we get that app server’s ssh key, and apply it to the proxy.
## setup
@task_roles('proxy', group='setup')
def setup_nginx():
"""Build an (nginx) static server/load balancer/appserver proxy"""
# make sure dirs exist and any default site file is gone
sudo("mkdir -p /etc/nginx/conf.d /etc/nginx/sites-enabled /etc/nginx/sites-available")
sudo("rm -f /etc/nginx/sites-enabled/default")
# landing path for django static files
sudo("mkdir -p {static_path} && chown {static_user} {static_path}".format(**env))
# get app host ssh keys, so app can sync static files to proxy
for app_server in env.roledefs['app']:
with host_settings(app_server):
try:
pubkey = execute(ssh, cmd="cat ~/.ssh/id_dsa.pub").values()[0]
except (ValueError, IndexError):
abort("{} could not get ssh key from {}".format(env.host, app_host))
append('~/.ssh/authorized_keys', pubkey)
Now we setup the Django application server. The virtualenvwrapper package was installed by the previous install task, so we’ll use that to build up a Python virtualenv and pip install to it.
@task_roles('app', group='setup')
def setup_virtualenv():
"""Setup virtualenvwrapper and make a virtualenv for the project"""
# virtualenv setup
upload_project_template('virtualenvrc')
append(".bashrc", "source $HOME/.virtualenvrc")
if not exists(env.workon_home):
run("mkdir -v -p {workon_home}".format(**env))
# mkvirtualenv
with virtualenv():
run("lsvirtualenv | grep -q '^{project_name}$'"
" && echo virtualenv {project_name} exists"
" || mkvirtualenv {project_name}".format(**env))
@task_roles('app', group='setup')
def pip_install():
"""Install requirements"""
# clone repo, setup django project, pip install
with virtualenv(env.project_name):
run('pip install {}'.format(' '.join(env.requirements)))
Here, we meet Fabex’s use of Jinja templates. Fabric contrib’s section
has a template_upload. Fabex extends that with a
upload_project_template facility. “Project templates” are listed
in the templates.yaml we referenced in the fabex_config
init. Here is templates.yaml from the example project:
nginx-site.conf: local_path: nginx-site.conf remote_path: /etc/nginx/sites-available/{{project_name}} reload_command: > cd /etc/nginx/sites-enabled && ln -s -f -v ../sites-available/{{project_name}} . && service nginx restart owner: root:root # app virtualenvrc: local_path: virtualenvrc remote_path: .virtualenvrc supervisord-uwsgi: local_path: supervisord-uwsgi.conf remote_path: "/etc/supervisor/conf.d/uwsgi.conf" reload_command: supervisorctl update owner: root local_settings: local_path: local_settings.py remote_path: "{{settings_dir}}/local_settings.py" uwsgi.ini: local_path: uwsgi.ini remote_path: "{{settings_dir}}/uwsgi.ini"
The top level key is what we reference in the
upload_project_template call. The sub attributes tell Fabex where
to find the source file, where to put the templated output file, any
command to run after placing (optional), and an owner for a chmod
after placing (optional).
Note especially, we can inject env values into attribute values
using Jinja2 syntax. This feature turns out to be extremely useful
in practical applications. Of course, the templates themselves are
Jinja2 processed, with the env context, as provided by Fabex.
And finally, templated output is built and compared to that on the
host. If there’s no diff, there’s no file uploaded, nor
reload_command run.
Kudos to the Mezzanine project for inspiring this approach.
Deploy Tasks¶
And finally, deploy. We use a number of “cross role tricks”. One in
config_proxy is to build the list of app server IP addresses for
Nginx’s upstream proxy directive. Another in deploy_app gets the proxy’s IP address to rsync Django’s collectstatic output into an Nginx accessable tree.
The remainder I’ll leave as an exercise for the reader ;-)
## deploy
@task_roles('proxy', group='deploy')
def config_proxy():
"""Deploy config changes to load balancer"""
wsgi_port = env.roleenvs['app']['wsgi_port']
appserver_ips = [env.hostenvs[host]['ip'] for host in env.roledefs['app']]
wsgi_servers = ["server {}:{};".format(ip, wsgi_port) for ip in appserver_ips]
upstream_servers = '\n '.join(wsgi_servers)
with settings(upstream_servers=upstream_servers):
upload_project_template('nginx-site.conf')
@task_roles('app', group='deploy')
def deploy_app():
"""Deploy webapp"""
with virtualenv(env.project_name):
# pull and update code in real app
run("[ -e {project_name} ] && echo app {project_name} exists"
" || django-admin startproject {project_name}".format(**env))
# uploads, appends don't obey cd
django_path = os.path.join(env.workon_home, *[env.project_name]*2)
app_settings = {'django_path': django_path,
'settings_dir': os.path.join(django_path, env.project_name),
'settings_py': os.path.join(django_path, env.project_name, 'settings.py'),
'static_root': os.path.join(django_path, 'static')}
with settings(**app_settings):
upload_project_template('local_settings', reload=False)
upload_project_template('uwsgi.ini', reload=False)
append(app_settings['settings_py'], "from local_settings import *")
upload_project_template('supervisord-uwsgi')
start_or_restart = sudo("supervisorctl status uwsgi | grep -q '^uwsgi *RUNNING'"
" && echo restart || echo start")
sudo("supervisorctl {} uwsgi".format(start_or_restart))
@task_roles('app', group='deploy')
@runs_once
def once_after_app():
"""Deploy actions that should run only once per deploy; should follow deploy_app"""
with virtualenv(env.project_name, cd=env.project_name):
# update db and statics
run("./manage.py migrate --noinput")
run("mkdir -p static && ./manage.py collectstatic --noinput --clear")
# rsync statics to proxy
proxy_ip = env.hostenvs[env.roledefs['proxy'][0]]['ip']
with role_settings('proxy', proxy_ip=proxy_ip):
run("rsync -e 'ssh -o StrictHostKeyChecking=no' -avz"
" static {proxy_ip}:{static_path}".format(**env))
Target for a “Real” Cluster¶
Finally, let’s look at what it would take to build a cluster with 3 application servers. The top of our target yaml becomes
roledefs: proxy: [server] app: [app1 app2 app3 app4] hostenvs: server: {ip: 192.168.56.33, ssh_host: 192.168.56.33, ssh_user: ubuntu} app1: {ip: 192.168.56.34, ssh_host: 192.168.56.34, ssh_user: ubuntu} app2: {ip: 192.168.56.35, ssh_host: 192.168.56.35, ssh_user: ubuntu} app3: {ip: 192.168.56.36, ssh_host: 192.168.56.36, ssh_user: ubuntu}
That’s it, not a line of change in the fabfile!