Posted on June 30, 2011.
For the past year and a half or so I’ve been working full-time at Dumbwaiter Design doing Django development. I’ve picked up a bunch of useful tricks along the way that help me work, and I figured I’d share them.
I’m sure there are better ways to do some of the things that I mention. If you know of any feel free to hit me up on Twitter and let me know.
Also: this entry was written over several months, so if there are inconsistencies let me know and I’ll try to fix them.
I used to develop Django sites by running them on my OS X laptop locally and deploying to a Linode VPS. I had a whole section of this post written up about tricks and tips for working with that setup.
Then I found Vagrant.
I just deleted the entire section of this post I wrote.
Vagrant gives you a better way of working. You need to use it.
If you haven’t used it before, Vagrant is basically a tool for managing VirtualBox VMs. It makes it easy to start, pause, and resume VMs. Instead of installing Django in a virtualenv and developing against that, you run a VM which runs your site and develop against that.
This may not sound like much, but it’s kind of a big deal. The critical difference is that you can now develop against the same setup that you’ll be using in production.
This cuts out a huge amount of pain that stems from OS differences. Here are a few examples off the top of my head:
requirements.txt
files before you updated.Using Vagrant and VMs means you can just worry about ONE operating system and its quirks. It saves you a ton of time.
Aside from that, there’s another benefit to using Vagrant: it strongly encourages you to learn and use an automated provisioning system. Support for Puppet and Chef is built in. I chose Puppet, but if you prefer Chef that’s cool too.
You can also use other tools like Fabric or some simple scripts, but I’d strongly recommend giving Puppet or Chef a fair shot. It’s a lot to learn, but they’re both widely tested and very powerful.
Because you’re developing against a VM and deploying to a VM, you can reuse 90% of the provisioning code across the two.
When I make a new site, I do the following to initialize a new Vagrant VM:
vagrant up
(which runs Puppet to initialize the VM)fab dev bootstrap
When I’m ready to go live, I do the following:
fab prod bootstrap
No more screwing around with different paths, different versions of Nginx, different versions of Python. When I’m developing something I can be pretty confident it will “just work” in production without any major surprises.
One of the problems with this setup is that I can’t just run python manage.py
whatever
any more because I need it to run on the VM.
To get around this I’ve created many simple Fabric tasks to automate the common things I need to do. Fabric is an awesome little Python utility for scripting tasks (like deployments). We use it constantly at Dumbwaiter. Here are a few examples from our fabfiles.
This first set is for running abitrary commands easily.
cmd
and vcmd
will cd
into the site directory on the VM and run a command of my
choosing. vcmd
will prefix the command with the path to the virtualenv’s bin
directory, so I can do something like fab dev vcmd
, pip install markdown
.
The sdo
commands do the same thing, but sudo
‘ed.
def cmd(cmd=""): '''Run a command in the site directory. Usable from other commands or the CLI.''' require('site_path') if not cmd: sys.stdout.write(_cyan("Command to run: ")) cmd = raw_input().strip() if cmd: with cd(env.site_path): run(cmd) def sdo(cmd=""): '''Sudo a command in the site directory. Usable from other commands or the CLI.''' require('site_path') if not cmd: sys.stdout.write(_cyan("Command to run: sudo ")) cmd = raw_input().strip() if cmd: with cd(env.site_path): sudo(cmd) def vcmd(cmd=""): '''Run a virtualenv-based command in the site directory. Usable from other commands or the CLI.''' require('site_path') require('venv_path') if not cmd: sys.stdout.write(_cyan("Command to run: %s/bin/" % env.venv_path.rstrip('/'))) cmd = raw_input().strip() if cmd: with cd(env.site_path): run(env.venv_path.rstrip('/') + '/bin/' + cmd) def vsdo(cmd=""): '''Sudo a virtualenv-based command in the site directory. Usable from other commands or the CLI.''' require('site_path') require('venv_path') if not cmd: sys.stdout.write(_cyan("Command to run: sudo %s/bin/" % env.venv_path.rstrip('/'))) cmd = raw_input().strip() if cmd: with cd(env.site_path): sudo(env.venv_path.rstrip('/') + '/bin/' + cmd)
This next set is just some common commands that I need to run often.
def syncdb(): '''Run syncdb.''' require('site_path') require('venv_path') with cd(env.site_path): run(_python('manage.py syncdb --noinput')) def collectstatic(): '''Collect static media.''' require('site_path') require('venv_path') with cd(env.site_path): sudo(_python('manage.py collectstatic --noinput')) def rebuild_index(): '''Rebuild the search index.''' require('site_path') require('venv_path') require('process_owner') with cd(env.site_path): sudo(_python('manage.py rebuild_index')) sudo('chown -R %s .xapian' % env.process_owner) def update_index(): '''Update the search index.''' require('site_path') require('venv_path') require('process_owner') with cd(env.site_path): sudo(_python('manage.py update_index')) sudo('chown -R %s .xapian' % env.process_owner)
We also use Fabric to automate some of the more complex things we need to do.
This task curl
‘s the site’s home page to make sure we haven’t completely borked
things. We use it in lots of other tasks as a sanity check.
def check(): '''Check that the home page of the site returns an HTTP 200.''' require('site_url') print('Checking site status...') if not '200 OK' in local('curl --silent -I "%s"' % env.site_url, capture=True): _sad() else: _happy()
The _happy
and _sad
functions just print out some simple messages to get our attention:
from fabric.colors import red, green def _happy(): print(green('\nLooks good from here!\n')) def _sad(): print(red(r''' ___ ___ / /\ /__/\ / /::\ \ \:\ / /:/\:\ \__\:\ / /:/ \:\ ___ / /::\ /__/:/ \__\:\ /__/\ /:/\:\ \ \:\ / /:/ \ \:\/:/__\/ \ \:\ /:/ \ \::/ \ \:\/:/ \ \:\ \ \::/ \ \:\ \__\/ \__\/ ___ ___ ___ ___ /__/\ / /\ / /\ / /\ ___ \ \:\ / /::\ / /:/_ / /:/_ /__/\ \ \:\ / /:/\:\ / /:/ /\ / /:/ /\ \ \:\ _____\__\:\ / /:/ \:\ / /:/ /:/_ / /:/ /::\ \ \:\ /__/::::::::\ /__/:/ \__\:\ /__/:/ /:/ /\ /__/:/ /:/\:\ \ \:\ \ \:\~~\~~\/ \ \:\ / /:/ \ \:\/:/ /:/ \ \:\/:/~/:/ \ \:\ \ \:\ ~~~ \ \:\ /:/ \ \::/ /:/ \ \::/ /:/ \__\/ \ \:\ \ \:\/:/ \ \:\/:/ \__\/ /:/ __ \ \:\ \ \::/ \ \::/ /__/:/ /__/\ \__\/ \__\/ \__\/ \__\/ \__\/ Something seems to have gone wrong! You should probably take a look at that. '''))
This one is for when python manage.py reset APP
is broken because you’ve changed
some db_column
names and Django chokes because of some constraits and you just want
to reset the fucking app.
It’s the “NUKE IT FROM ORBIT!!” option.
def KILL_IT_WITH_FIRE(app): require('site_path') require('venv_path') with cd(env.site_path): # Generate and download the reset SQL. sudo(_python('manage.py sqlreset %s > reset.orig.sql' % app)) get('reset.orig.sql') with open('reset.sql', 'w') as f: with open('reset.orig.sql') as orig: # Step through the first chunk of the file (the "drop" part). line = orig.readline() while not line.startswith('CREATE'): if 'CONSTRAINT' in line: # Don't write out CONSTRAINT lines. # They're a problem when you change db_colum names. pass elif 'DROP TABLE' in line: # Cascade drops. # Hence with "with fire" part of this task's name. line = line[:-2] + ' CASCADE;\n' f.write(line) else: # Write other lines through untoched. f.write(line) line = orig.readline() # Write out the rest of the file untouched. f.write(line) f.write(orig.read()) # Upload the processed SQL file. put('reset.sql', os.path.join(env.site_path, 'reset.ready.sql'), use_sudo=True) with cd(env.site_path): # Use the SQL to reset the app, and fake a migration. run(_python('manage.py dbshell < reset.ready.sql')) sudo(_python('manage.py migrate --fake --delete-ghost-migrations ' + app))
This task uses Mercurial’s local tags to add a production
or staging
tag in your local
repository, so you can easy see where the production/staging servers are at
compared to your local repo.
def retag(): '''Check which revision the site is at and update the local tag. Useful if someone else has deployed (which makes your production/staging local tag incorrect. ''' require('site_path', provided_by=['prod', 'stag']) require('env_name', provided_by=['prod', 'stag']) with cd(env.site_path): current = run('hg id --rev . --quiet').strip(' \n+') local('hg tag --local --force %s --rev %s' % (env.env_name, current))
This task tails the Gunicorn logs on the server so you can quickly find out what’s happening when things blow up.
def tailgun(follow=''): """Tail the Gunicorn log file.""" require('site_path') with cd(env.site_path): if follow: run('tail -f .gunicorn.log') else: run('tail .gunicorn.log')
We’ve got a lot of other tasks but they’re pretty specific to our setup.
If you’re not using South, you need to start. Now.
No, really, I’ll wait. Take 30 minutes, try the tutorial, wrap your head around it and come back. It’s far more important than this blog post.
South is awesome but its commands are very long-winded. Here’s the set of fabric tasks I use to save quite a bit of typing:
def migrate(args=''): '''Run any needed migrations.''' require('site_path') require('venv_path') with cd(env.site_path): sudo(_python('manage.py migrate ' + args)) def migrate_fake(args=''): '''Run any needed migrations with --fake.''' require('site_path') require('venv_path') with cd(env.site_path): sudo(_python('manage.py migrate --fake ' + args)) def migrate_reset(args=''): '''Run any needed migrations with --fake. No, seriously.''' require('site_path') require('venv_path') with cd(env.site_path): sudo(_python('manage.py migrate --fake --delete-ghost-migrations ' + args))
Remember that running a migration without specifying an app will migrate everything,
so a simple fab dev migrate
will do the trick.
When developing locally you’ll want to make a change to your code and have the server reload that code automatically. The Django development server does this, and we can hack it into our Vagrant/Gunicorn setup too.
First, add a monitor.py
file at the root of your project (I believe I found this
code here, but I may be wrong):
import os import sys import time import signal import threading import atexit import Queue _interval = 1.0 _times = {} _files = [] _running = False _queue = Queue.Queue() _lock = threading.Lock() def _restart(path): _queue.put(True) prefix = 'monitor (pid=%d):' % os.getpid() print >> sys.stderr, '%s Change detected to \'%s\'.' % (prefix, path) print >> sys.stderr, '%s Triggering process restart.' % prefix os.kill(os.getpid(), signal.SIGINT) def _modified(path): try: # If path doesn't denote a file and were previously # tracking it, then it has been removed or the file type # has changed so force a restart. If not previously # tracking the file then we can ignore it as probably # pseudo reference such as when file extracted from a # collection of modules contained in a zip file. if not os.path.isfile(path): return path in _times # Check for when file last modified. mtime = os.stat(path).st_mtime if path not in _times: _times[path] = mtime # Force restart when modification time has changed, even # if time now older, as that could indicate older file # has been restored. if mtime != _times[path]: return True except: # If any exception occured, likely that file has been # been removed just before stat(), so force a restart. return True return False def _monitor(): while 1: # Check modification times on all files in sys.modules. for module in sys