Vagrant is well-known among developers, and Ansible is well-known among DevOps. Together, these two guys can be a great setup for nearly every situation.
If you are like me, there's the chance you like to experiment with languages and frameworks. The problem is that all this stuff requires you to install additional software on your main machine, and not all pieces of software play well together. With Vagrant you can have a different machine for every project. You can install everything you need (and even something you don't need), and when you're finished, you can trash the machine with no regrets. Cool, uh?
If you don't have it installed yet, you can download Vagrant from the official website as a pre-packaged installer.
You will also need a virtualization software of choice: Vagrant integrates seamlessly with VirtualBox and VMWare Fusion or Workstation.
Most of the times, your Vagrant boxes will be new and minimal instances of the Linux/Unix system of choice. Though even the simplest of projects requires a base stack of dependencies to run. You can install all these things manually, but it's not recommended, since... well, we're humans, and humans are not well suited for this kind of tasks. An automated provisioning tool is a better solution. With Vagrant you can use a bunch of different provisioning tools, from a "simple" shell script to specific provisioning software such as Chef, Puppet, and our best friend, Ansible.
I've used all the three kids, and recently I chose Ansible as my favorite, because:
Ansible can be run from any machine with Python 2.6 or 2.7 installed. This includes Red Hat, Debian, CentOS, OS X, any of the BSDs, but not Windows.
The best way to install it is using your OS package manager. If you're on a Mac I'd recommend using Homebrew: it's as simple as running brew install ansible
.
It's been a few years since I've started using Vagrant, and due to mainly tight deadlines and – I have to admit it — a bit of laziness, I've always relied on shell scripts and "cut and paste" to build my development boxes. The result was a mess of scattered Vagrantfile
s and bootstrap.sh
scripts to be searched for when a new idea came to mind. This lasted until recently I felt the need to make order in all this and have something ready to use whenever I decided to start a new project.
This frenzy is what led me to make a Vagrant template repository. In this repo I've included:
The project is still in progress, I'm planning to add more to this repo, for example, a Postgres box and a Go dev box would be nice to have.
Every machine has a similar directory structure, here is an extract from the PHP box:
/path/to/your/workdir/
ansible/
etc/
apache2/
vhost.conf
other-package/
roles/
apache2/
base/
mysql/
php/
php-apache2/
...
php-apache2.yml
src/
Your PHP app files here...
Vagrantfile
The ansible
directory contains all the stuff needed by Ansible to provision our machine; we'll see it in detail later.
The Vagrantfile
contains settings for your VM and is almost the same for all the boxes, let's try to dissect a basic PHP box.
A Vagrantfile
is written in Ruby; it can be auto-generated with the command vagrant init
and then customised at our will. All the configuration stuff takes place in the Vagrant.configure
block:
# -*- mode: ruby -*-
# vi: set ft=ruby :
# A name for your project
PROJECT = 'php-box'
VAGRANTFILE_API_VERSION = 2
Vagrant.require_version ">= 1.8.0"
Vagrant.configure(VAGRANTFILE_API_VERSION) do |config|
# Your box settings here...
end
Before that block, I have defined two handy constants for the project name and the Vagrant API version we are using.
Vagrant can be extended with plugins, in the first configuration block I'm checking for a plugin named vagrant-cachier
:
# Cache software packages
if Vagrant.has_plugin?("vagrant-cachier")
config.cache.enable :apt
config.cache.enable :apt_lists
config.cache.enable :composer
end
If we have the plugin installed, we can enable the corresponding settings. In this case, vagrant-cachier
enhances Vagrant with a cache for package installers such as apt
and Composer.
Then we define our virtual machine with the config.vm.define
block:
# VM definition
config.vm.define PROJECT do |jessie|
end
This block configures a virtual machine named after our PROJECT
constant, referenced inside the block with the variable jessie
. I chose this name because I'm using a Debian 8 "Jessie" GNU/Linux box, you can choose whatever you want. By giving a name to our VM, we can recognize it among the other VMs installed and running on our system.
When we start the box with vagrant up
, we see our machine name:
Bringing machine 'php-box' up with 'virtualbox' provider...
==> php-box: Checking if box 'debian/jessie64' is up to date...
==> php-box: Clearing any previously set forwarded ports...
==> php-box: Clearing any previously set network interfaces...
==> php-box: Preparing network interfaces based on configuration...
...
And we can identify the same machine among the other boxes present in our system with vagrant global-status
:
id name provider state directory
-----------------------------------------------------------------
25c2e55 php-box virtualbox running /path/to/php-box/homedir
0d93935 mysql virtualbox poweroff /path/to/mysql/homedir
The first step in VM configuration is decide the box we want.
# VM definition
config.vm.define PROJECT do |jessie|
# Box details
jessie.vm.box = 'debian/jessie64'
jessie.vm.box_url = 'https://atlas.hashicorp.com/debian/boxes/jessie64'
jessie.vm.hostname = PROJECT
end
In the first block, I declare that I want to use a box named debian/jessie64
, which is the official Debian box, which is stored in a repository at the URL https://atlas.hashicorp.com/debian/boxes/jessie64
(Atlas is a public repository hosted by Vagrant maker, HashiCorp). I use our PROJECT
value as the hostname for the box.
Next step is configuring the box network. In this case, I've set up a private_network
between my machine and the box using DHCP, so that the VirtualBox provider will take care of IPs and other stuff.
# VM definition
config.vm.define PROJECT do |jessie|
# Box details
# ...
# Networking setup
jessie.vm.network "private_network", type: "dhcp"
# Port forwarding (MySQL, HTTP and HTTPS)
jessie.vm.network :forwarded_port, guest: 80, host: 8000, auto_correct: true
jessie.vm.network :forwarded_port, guest: 443, host: 8443, auto_correct: true
jessie.vm.network :forwarded_port, guest: 3306, host: 3306, auto_correct: true
end
With a network in place, we need a way to "see" the virtual machine services from our host system so that we can configure port forwarding between the host and the guest operating system. Vagrant maps by default port 22 of the VM to 2222 on our machine, if available. I am mapping HTTP, HTTPS and MySQL ports, so that I can lazily manage MySQL with my GUI tool of choice.
Another important thing now is configuring synced folders. This allows us to keep our application code on our host machine and have the VM see it just like it were a local directory. By default, the Vagrantfile
directory is shared as /vagrant
on the virtual machine and can be disabled if we want.
# VM definition
config.vm.define PROJECT do |jessie|
# Box details
# ...
# Networking setup
# ...
# Synced folders
jessie.vm.synced_folder "./src", "/app",
disabled: false,
owner: "www-data",
group: "vagrant"
end
Relative paths are relative to the Vagrantfile
location; here I'm sharing local ./src
directory as /app
in the VM context. I'm also defining optional owner and group settings to Apache's user and vagrant
group. You can then share a combination of other directories.
The section jessie.vm.provider
contains settings specific to the VirtualBox provider.
# VM definition
config.vm.define PROJECT do |jessie|
# Box details
# ...
# Networking setup
# ...
# Synced folders
# ...
# VM Provider specific settings for VirtualBox
jessie.vm.provider "virtualbox" do |vb|
# Share VPN connections
vb.customize ["modifyvm", :id, "--natdnshostresolver1", "on"]
vb.name = PROJECT
# Customise the amount of memory on the VM:
vb.memory = "512"
end
end
In short, I'm defining a name for the VM (you'll see it in VirtualBox UI), a memory amount, and with the option --natdnshostresolver1
set to on; I'm also sharing my host's VPN connections.
The last step is the provisioning of our Vagrant box.
# VM definition
config.vm.define PROJECT do |jessie|
# Box details
# ...
# Networking setup
# ...
# Synced folders
# ...
# VM Provider specific settings for VirtualBox
# ...
# Add custom key
jessie.vm.provision :file, :source => "#{ENV['HOME']}/.ssh/id_rsa.pub", :destination => "/tmp/vagrantfile-pubkey"
jessie.vm.provision :shell, :privileged => false, :inline => <<-SHELL cat /tmp/vagrantfile-pubkey >> $HOME/.ssh/authorized_keys SHELL
# Provisioning with Ansible
jessie.vm.provision "ansible" do |ansible|
# Vagrant auto generates the inventory file, uncomment below to use yours
# ansible.inventory_path = "hosts"
# Custom config
# ENV['ANSIBLE_CONFIG'] = "/path/to/custom/ansible.cfg"
# Example:
# ENV['ANSIBLE_CONFIG'] = "#{ENV['HOME']}/Dev/Ansible/ansible.cfg"
# Add the box to mysql group and use custom group vars
ansible.groups = {
"php" => PROJECT
"php:vars" => {
"variable1" => 9,
"variable2" => "example"
}
}
# Custom host vars
ansible.host_vars = {
PROJECT => {
}
}
# ansible.verbose = "vvvv"
# Use local playbook that has access to shared roles, with custom vars above
ansible.playbook = "ansible/php-apache2.yml"
end
end
I'm using three provisioners here: file, shell, and Ansible. With the file
provisioner, I'm simply copying my SSH public key to a temporary file. Then, with the shell
provisioner, I execute an inline command with the user vagrant
(the default non-privileged working user) that copies the content of the key to the user's authorized_keys
file. Once the VM is provisioned, I can add the line config.ssh.private_key_path = "#{ENV['HOME']}/.ssh/id_rsa"
that uses my SSH key instead of Vagrant default insecure one.
Inside the "ansible" provisioning block I define the settings for the Ansible provisioner that will be called by Vagrant. First, it's important to notice that Vagrant generates some basic settings for us, such as the inventory file (the list of machines that Ansible can control). We could customise ansible.inventory_path
settings and set the ANSIBLE_CONFIG
environment variable to point to a custom ansible.cfg
file, but we're happy with the defaults here.
What we need instead is ansible.groups
directive. In this file, I've created a machine group called "php" that contains the host name of our virtual machine. Our playbook will use the hosts: php
directive, telling Ansible that that playbook must be executed only on the hosts that belong to the "php" group. Inside the ansible.groups
directive, we can also set some group variables, global settings that will be available to our playbook for all the machines inside the group.
We can also use the ansible.host_vars
directive to specify some settings that will be available only to a specific host. The last directive will set the playbook file that we are going to run on our VM: ansible.playbook = "ansible/php-apache2.yml"
.
An Ansible playbook is a YAML file that contains a list of actions that Ansible will perform on a host or group of hosts. The details on how to reach hosts and groups are stored in the inventory file that, in our case, is already provided and injected by Vagrant. Within the playbook, we can apply "roles" and execute "tasks" on the target machines. Let's see how.
---
# A sample PHP playbook
- hosts: php
become: yes
become_user: "root"
become_method: "sudo"
# Custom vars
vars:
# Apply roles
roles:
# Execute tasks
tasks:
Every YAML object inside a playbook is a play. We can define and execute more than one play, but in this case, one is enough. The hosts
directive defines the machine, or group of machines, that will be targeted by the playbook, in this case, the php
group. The become*
directive tells Ansible to execute the playbook as a different user the one used for SSH connection. In this case, it is root
, and the method used to switch the user is sudo
.
The vars
directive can be used to define variables to use within tasks or to override the default roles settings. We are not using it since we can define them inside the Vagrantfile
.
The role
directive is one of the most important here: it applies one or more "roles" to our machine. An Ansible role is a shareable group of tasks, variables, and handlers with a known file structure.
---
# ...
roles:
- { role: php-apache2, tags: ['php','apache2'] }
# ...
Here we are applying the php-apache2
role that, as the name suggests, will install Apache with PHP support, but the really cool stuff is that roles can depend and install other roles. A role's directory must be located inside a roles
parent directory, relative to the playbook file. It is structured more or less like this:
ansible/roles/php-apache2/
defaults/
main.yml
meta/
main.yml
tasks/
main.yml
The file defaults/main.yml
contains the default settings for this role, that can be overridden by a playbook vars
directive and/or by specific group and host variables. The tasks
directory contains a list of task files for the role, while the meta
directory contains the role metadata inside meta/main.yml
. It is in this file that we declare the dependencies for the role:
---
dependencies:
- { role: php }
- { role: apache2 }
As you can see, our php-apache2
role depends on other two roles: apache2
and php
, which in turn depends recursively on the base
role. You can write the roles yourself or get them from the Ansible Galaxy, which is where some of these roles have been taken from and customised.
Hence, by applying the single php-apache2
role, Ansible will first run on our machine the actions of the base
role (i.e install vim, curl, setup timezone, locale, etc.). Then it will execute the php
role, which installs PHP, Composer, and other useful packages. Next the apache2
role will install Apache, and at last the php-apache2
role will install the libapache2-mod-php5
and restart the web server.
Each task is idempotent, so you can execute the playbook 100 times on the same machine, and the final state of the system will be the same.
Ansible gives us a lot of power on our machine, for example we can define a list of packages in defaults/main.yml
as a YAML array:
---
php_packages:
- mysql-client
- bzip2
- zip
- unzip
- ...
And then install the packages from the main tasks file by using a loop:
# roles/php-apache2/tasks/main.yml
---
- name: Debian | install packages
apt: pkg= state=installed
with_items: ""
register: php_install
- name: Install composer
include: composer.yml
tags:
- composer
The first action above, for example, executes the apt
module with the pkg
and state
arguments. This means that Ansible uses apt
commands (on Debian/Ubuntu) to ensure that a given package is installed on the system. The with_items
directive allows to loop through the php_packages
array and use every item as an argument of the module. The optional register
directive saves the output of the task into a variable that can be used in other tasks.
We can also group some tasks into a file and include it other files, such as composer.yml
in this example.
In short, the goal of each task is to execute a module, with specific arguments. And variables can be used as arguments to modules.
The action format is the same for role tasks and playbook tasks. Looking back to our playbook, once we have applied our roles, we can execute other specific tasks on our machine.
With all the roles in place we can copy an Apache vhost
file to our destination machine with some options like owner and mode (the source path is relative to the playbook file):
- name: copy app vhost file
copy:
src: etc/apache2/vhost.conf
dest: /etc/apache2/sites-available/000-app.conf
owner: root
group: root
mode: 0644
We can then execute a shell command to enable our virtual host:
- name: enable website
command: "a2ensite 000-app"
args:
creates: "/etc/apache2/sites-enabled/000-app.conf"
notify:
- reload apache2
The command is executed using the default shell for the user. The creates
directive defines the result of the command: if the file already exists the command is not run. The notify
directive enqueues a call to a handler called reload apache
defined as:
handlers:
- name: reload apache2
action: service name=apache2 state=reloaded
Handlers are processed at the end of all tasks. This handler ensures that the service named apache2
reloads its configuration.
Finally, since we are on a PHP box, Ansible can also run Composer for us:
- name: run composer install
composer:
command: install
working_dir: /app
We've just scratched the surface of what Vagrant and Ansible can do to enhance our development workflow. And don't forget that Ansible can — and should — be used on our production machines too!
Experiment with these templates, and feel free to fork, comment, share and contribute. Happy coding!