Using fabric to deploy symfony application

Deploying an web application is not an easy task :

  • copy the code accross servers
  • migrate the database if a new schema is present
  • make sure models are updated with the last schema version
  • open/close the website

Everyone has his/her own recipe to deploy an application :

  • FTP (…)
  • rsync
  • archive file
  • svn update

Now if you want to deploy your code across different servers you might need some new tools, some are very time consuming to install and to maintain. I like to keep stuff as simple as possible. Here comes fabric.

Fabric

“Fabric is a Python library and command-line tool for streamlining the use of SSH for application deployment or system administration tasks.

It provides a basic suite of operations for executing local or remote shell commands (normally or via sudo) and uploading/downloading files, as well as auxiliary functionality such as prompting the running user for input, or aborting execution.”

Let’s say you have 3 web servers and you want to remove the cache. You need to connect on each server and runs the symfony clear cache command 3 times: time consuming and error prone. With fabric you just have to code a little function:

def prod()
    env.roles = {
    'webfarms': ['web1', 'web2']
    }
    env.remote_path = '/yourpath'

@roles('webfarms')
def remove_cache():
    run('%(remote_path)s/symfony cc' % env)

Now you can run the task from your local machine :

fab prod remove_cache

This will automatically connect to different hosts listed in the roles annotation and remove the cache on each server. The prod function is a trick to define different environments.

If you want to update the code with a svn update:

def update(path=""):
    "execute svn update on webfarm servers"
    env.path = path
    run('svn up %(remote_path)s/current/%(path)s' % env)

You launch the command: fab prod update:lib/model/MyBuggyModel.class.php - and all your webservers will be updated.

This was a small introduction to fabric.

A deployment process

Let’s see now how we can build a deployment script.

  • build the source code archive
  • deploy the code on different servers
  • close the website (setup a maintenance page)
  • migrate the database
  • make the version live (the webserver root directory is a symlink to the last version)
  • clear cache instances
  • open website

build an archive with the source code

The runs_once annotation tells fabric to run the function only one time.

@runs_once
def build(version):
    "build an archive version"

    env.version = version

    local('if [ -d build ]; then echo "directory \'build\' exists"; else mkdir build; fi' % env )
    local('if [ -d build/%(version)s ]; then echo "directory build/%(version)s exists"; else mkdir build/%(version)s; fi' % env )
    local('php symfony doctrine:build --model --forms --filters')
    local('./symfony plugin:publish-assets')
    local('./symfony sw:combine frontend --no-confirmation')
    local("gnutar -X fabric.exclude -cvzf build/%(version)s/build_%(version)s.tar.gz ." % env)

deploy the code on different servers

The function will copy the archive into different server and extract it. As the factories.yml and databases.yml are not present in the archive, the function will copy these files from the webserver base configuration folder. So no password is required into subversion/git.

@roles('webfarm')
def deploy(version):
    "deploy the application to the server - but DO NOT activate the application"

    env.version = version

    put('build/%(version)s/build_%(version)s.tar.gz' % env, '/tmp/build_%(version)s.tar.gz' % env)
    if not files.exists('%(remote_path)s/%(version)s' % env):
    run('mkdir %(remote_path)s/%(version)s' % env)

    with cd('%(remote_path)s/%(version)s' % env):
    run('tar xzf /tmp/build_%(version)s.tar.gz' % env)

    run('cp -r %(remote_path)s/../base_config/* %(remote_path)s/%(version)s/config/' % env)
    run('rm /tmp/build_%(version)s.tar.gz' %  env)

close and open the website

@roles('webfarm')
def close():
    "put the maintenance page"

    run('cp %(remote_path)s/config/error/maintenance.html.php %(remote_path)s/web/index.php' % env)

@roles('webfarm')
def open():
    "open the website"

    run('cp %(remote_path)s/web/frontend.php %(remote_path)s/web/index.php' % env)

migrate the database

The dump_database function dumps the database and copies dump files to the local machine (the machine which starts the fab task)

@roles('database')
@runs_once
def dump_database(version):
    "dump the database"
    env.version = version

    for database_name in ['database_1', 'database_1']:
    env.database_name = database_name
    run('mysqldump --opt -u %(remote_mysql_user)s -p%(remote_mysql_password)s -h %(remote_mysql_host)s %(database_name)s | gzip > /tmp/dump_%(version)s_%(database_name)s.sql.gz' % env )
    get('/tmp/dump_%(version)s_%(database_name)s.sql.gz' % env, 'build/%(version)s' % env)
    run('rm /tmp/dump_%(version)s_%(database_name)s.sql.gz' % env)

@roles('database')
@runs_once
def migrate(version, migration_version=""):
    "runs migration presents in the migration"

    env.version = _normalize_version(version)
    env.migration_version = migration_version

    dump_database(version)

    run('php -d memory_limit=1024M %(remote_path)s/%(version)s/symfony sw:doctrine-migrate %(migration_version)s' % env)

make the version live

As each version has its own directory, the webserver is configured to use a symlink (current) pointing to the current version…

@roles('webfarm')
def make_live(version):
    "enable the version (update symlink)"

    env.version = version

    run('rm %(remote_path)s/current' % env)
    run('ln -s %(remote_path)s/%s %(remote_path)s/current' % env)

Clear cache

The website might use different caching strategies, depends on their implementation, extra commands might be launched to clear their cache. Here, the webserver is restarted so APC/xcache is correctly reset. The symfony cli cannot clear these caches.

@roles('webfarm')
def remove_cache():
    "remove cache"

    sudo('/etc/init.d/your-server stop')
    run('rm -rf %(remote_path)s/current/symfony cc' % env)
    run('rm -rf %(remote_path)s/current/cache/*' % env)
    sudo('/etc/init.d/your-server start')

How fabric runs command

Each command is run on one server, server by server. That means we cannot create a master task which build and activate the website. However we can tell fabric which task to start from the command line. At this point each task is started one by one, server by server.

So we can start a full deployment by running the command: fab production build:TEST deploy:TEST close migrate:TEST make_live:TEST remove_cache open

The :TEST is the version name of this current release. Of course some of them can be aggregated like: fab production install:TEST close activate:TEST open

That’s all. Have good fun by reading the fabric documentation and code our own fabfile.