Creating development environments with Vagrant and Ansible

Use virtual machines to improve your development.

  • 2nd April 2015

A common method of testing websites when developing, is to use a local PHP server such as XAMPP, or the built in server provided with PHP. However, there are times when just using $ php -S localhost:4000 doesn't quite cut it. Especially when external services are required such as Memcached, Redis or a database. It's easy to install these services locally on your Mac using tools like Homebrew, but after a while, its only going to clog up your machine.

There are also scenarios where you may want to test updated software packages without affecting a production environment, for example, when a newer version of PHP is released, or when investigating switching from Node.js to io.js.

Vagrant to the rescue!

Vagrant is a tool for building complete development environments. With an easy-to-use workflow and focus on automation.

https://www.vagrantup.com/about.html

Vagrant can be used to create and configure lightweight, reproducible, and portable development environments. It makes it possible to closely replicate your production environment, using a local virtual machine


Requirements / Setup:

The following packages are required:

I've created a repo on Github containing all the examples in this post, plus some additional tasks. Feel free to clone the repo, run $ vagrant up --provision and see for yourself.


Once Vagrant has been installed, getting started is as easy as navigating to your desired folder and running:

$ vagrant init ubuntu/trusty64
$ vagrant up

This will create a virtual machine running Ubuntu 14.04, and symlink the current directory to the /vagrant/ folder on the VM.

Note: Using a box for the first time might take a while, as it has to be downloaded. Don't let this put you off. Once the box has been downloaded, it can be used for multiple Vagrant machines without needing to be downloaded again and will load in seconds.


Configuring

The Vagrantfile created by the above commands will be minimal, however, it will be full of comments and examples of what can be done.

Here is an example of a trimmed down Vagrantfile containing only the bare essentials. It has been configured to use Ubuntu 14.04. The config.vm.network option is set to use a public network, meaning that the machine will be accessible via other computers within your house, or on the network. config.vm.network has also been set to forward traffic from the port 8080 on your physical machine, to port 80 on the virtual machine.

# -*- mode: ruby -*-
# vi: set ft=ruby :

# Vagrantfile API/syntax version. Don't touch unless you know what you're doing!
VAGRANTFILE_API_VERSION = "2"

Vagrant.configure(VAGRANTFILE_API_VERSION) do |config|
    config.vm.box = "ubuntu/trusty64"
    config.vm.network :public_network
    config.vm.network "forwarded_port", host: 8080, guest: 80
end

Vagrant only does part of the work, creating the machines, and obviously by default, a box will not be configured perfectly for everyone's individual use cases. This is where provisioning comes in, allowing you to do things such as automatically install software and alter configurations.

Provisioning

Vagrant supports a wide range of options when provisioning machines, including Puppet, Chef, Salt, Ansible and the shell. We will be using Ansible in this example as it is very easy to get started with, but very powerful.

The main reason for this is because Ansible is agentless. This means that no additional packages need to be installed on the machines that will be provisioned, all the work is done via SSH. Even though it is agentless, it still provides idempotency, meaning that it can be run multiple times without affecting the state.

Lets create a playbook that will be used to define which hosts should be configured, the roles, and the tasks belonging to each role. Create a folder called ansible, and a file inside that called playbook.yml. Also, we need to tell Vagrant that we will be using Ansible as our provisioner. Add the following lines to the Vagrantfile:

config.vm.provision "ansible" do |ansible|
    ansible.playbook = "ansible/playbook.yml"
end

This sets Ansible as the provider, and points Vagrant to our playbook.

Next, we need to add some contents to the playbook.yml file, like so:

---
- hosts: all

This means that all available hosts should be provisioned, however, by default Vagrant limits this to the VM that was created.

Next, we can add our first task. Before we start installing software, it's always a good idea to ensure that the apt package manager is up to date. This is as simple as adding the following task:

---
- hosts: all
  tasks:
    - name: Update apt
      apt: update_cache=yes
      sudo: true

The apt package manager features its own module built right into the core of ansible, more information about this module can be found on the documentation page.

For the next step, let's make sure that the latest version of PHP and some essential packages are installed.

  - name: Ensure php5 is up to date
    apt: pkg={% raw %}{{ item }}{% endraw %} state=latest
    with_items:
      - php5-cli
      - php5-mcrypt
      - php5-fpm
      - php5-mysql
    sudo: true

Rather than having to write four separate tasks for each PHP package, we can use the {% raw %}{{ item }}{% endraw %} placeholder, and provide a list of packages. When running Ansible, if it finds and installs a newer version of PHP, we need to make sure that any services are restarted, so that the processes use the updated version. This can be done with a handler, which is only executed when the state of a task changes.

    notify: restart php

and add the handler:

---
- hosts: all

  ...tasks...

  handlers:
    - name: restart php
      service: name=php5-fpm state=restarted
      sudo: true
---

As more tasks are added, it is harder to follow what is happening in the playbook. Luckily, Ansible has support for defining roles. Roles allow you to split up the tasks and handlers into separate files and folders. It also possible to assign different roles to different servers within the inventory. For example, all servers could share a core role which updates the apt cache. A select few of these could have a php role that would ensure a LEMP or LAMP stack is present, up to date and correctly configured.

We can add a roles folder, and start to create directories for each one, like so:

ansible
├── playbook.yml
└── roles
    ├── core
    │   └── tasks
    │       └── main.yml
    └── php
        ├── handlers
        │   └── main.yml
        └── tasks
            └── main.yml

Our updated playbook.yml will then define the roles

---
- hosts: default
  roles:
    - core
    - php

and the main.yml file for each role will contain the tasks and handlers:

---
- name: Install php5
  apt: pkg={% raw %}{{ item }}{% endraw %} state=latest
  with_items:
    - php5-cli
    - php5-mcrypt
    - php5-fpm
    - php5-mysql
  sudo: true
  notify: restart php
---
- name: restart php
  service: name=php5-fpm state=restarted

Finally, to see all of this in action, run $ vagrant up --provision (or $ vagrant provision if already running) and watch Ansible do its thing.

If you have any comments or questions, please get in touch.