I use both Vagrant and Ansible to run and provision development virtual machines for testing work locally. This provides an easy to build environment as close to production as possible that all developers can easily create from the source code repository. A simple vagrant up and the associated Ansible scripts will handle all of the configuration and package installation for the VM.

This is unbelievably handy and it really helps to reduce the kind of bugs that are difficult to track down - “it works on my machine!”

Shared configuration

Recently, though I got to thinking about how the configuration is bound up in a rather unhelpful Vagrantfile, which is a Ruby script underneath in reality. The same configuration details need for the Vagrantfile will likely also be required by your provisioning scripts.

There are at least two ways to achieve this - each with their respective advantages and pitfalls. You can use a central file or pass the information as arguments to Ansible from the Vagrant provision commands. If you need to support machines that Ansible cannot run on then you’ll prefer the central configuration file as otherwise you need to pass the parameters in two locations. Using a bash script to support Windows machines is discussed further on.

Central configuration file

One way to work around this is to use a universal configuration file that both your provisioning scripts (Ansible, etc) and the Vagrantfile can read. The common thread between Ansible and Ruby (of course) is that they both parse YAML so a central config file is going to be the ticket. I am calling this file vagrant.yml and I have it sat at the same level as Vagrantfile in my projects.

In vagrant.yml you can have a structure like:

---
ip_address: 192.168.33.66
vm_name: example
server_domain: example.dev

From the Ruby script in Vagrantfile it is possible to parse the vagrant.yml configuration file and set the values against internal Vagrant options.

require 'yaml'
settings = YAML.load_file 'vagrant.yml'

Vagrant.configure("2") do |config|
  config.vm.network :private_network, ip: settings['ip_address']
end

You can also use these configuration details from Ansible project by loading it in a vars_files: directive. The variables will then become available in the global space. In the example code you can see the variables in use to define servername:.

---
- hosts: all
  sudo: true
  vars_files:
    - ../vagrant.yml
    - vars/common.yml
  vars:
    servername: "{{ server_domain }} www.{{ server_domain }} {{ ip_address }}"
    timezone: Europe/London
  roles:
    - init

Note that my Ansible configuration is in a subfolder hence the need to call the shared configuration with ../vagrant.yml.

Passed as arguments

Another way of having shared configuration between Vagrant and Ansible is to pass arguments from Vagrant into Ansible at provision time. This is done using the ansible API in your Vagrantfile and specifically the extra_vars property.

The sample code below illustrates how this might look in a simple Ansible backed Vagrant setup.

Vagrant.configure("2") do |config|
  ansible_inventory_dir = "ansible/hosts"

  config.vm.provision "ansible" do |ansible|
    ansible.playbook = "ansible/playbook.yml"
    ansible.inventory_path = "#{ansible_inventory_dir}/vagrant"
    ansible.limit = 'all'
    ansible.extra_vars = {
        vm_cores: cpus,
        vm_memory: mem,
        server_domain: servers['server_domain'],
        ip_address: servers['ip_address'],
        additional_server_domain_aliases: servers['additional_server_domain_aliases'],
        vm_user: settings['vm_user']
    }
  end
end

Just like the shared configuration these variables can be accessed in the global space of Ansible.

---
- hosts: all
  sudo: true
  vars_files:
    - vars/common.yml
  vars:
    servername: "{{ server_domain }} www.{{ server_domain }} {{ ip_address }}"
    timezone: Europe/London
  roles:
    - init

Dynamically create the Ansible inventory file

One aspect of projects that can be annoying to maintain or see committed into the project is the Ansible inventory file. Thankfully this can easily be automated from the Vagrantfile and the path dynamically set against Vagrant’s configuration.

In the code below the Ansible directory is set to a variable and then Ansible is set as the provisioning setup for Vagrant. This is all pretty much standard, but then the code moves onto handle the actual inventory file creation.

Vagrant.configure("2") do |config|
  ansible_inventory_dir = "ansible/hosts"

  config.vm.provision "ansible" do |ansible|
    ansible.playbook = "ansible/playbook.yml"
    ansible.inventory_path = "#{ansible_inventory_dir}/vagrant"
    ansible.limit = 'all'
  end

  # setup the ansible inventory file
  Dir.mkdir(ansible_inventory_dir) unless Dir.exist?(ansible_inventory_dir)
  File.open("#{ansible_inventory_dir}/vagrant" ,'w') do |f|
    f.write "[#{settings['vm_name']}]\n"
    f.write "#{settings['ip_address']}\n"
  end
end

It simply creates the directory if it doesn’t already exist and then opens the inventory file for writing whereupon it puts the machine name and IP address into the file. This is a simple way to save yourself a little work when creating new Ansible backed Vagrant projects.

Give the box all virtual cores and a quarter of the systems memory

Another tip I have picked up is from Stefan Wrobel’s article How to make Vagrant performance not suck. He suggests an automatic method for determining the number of CPU cores available on your host machine and then giving the Vagrant box access to all of them. To further increase performance you can also have the Vagrantfile calculate and assign a quarter of available host system memory.

The Ruby code to perform this is reasonably self explanatory and uses command line to establish the system resources.

Vagrant.configure("2") do |config|
  config.vm.provider :virtualbox do |v|
    v.name = settings['vm_name']

    # taken from http://www.stefanwrobel.com/how-to-make-vagrant-performance-not-suck#toc_1
    # assigns all available CPU cores and 1/4 of the host systems memory to the vm
    host = RbConfig::CONFIG['host_os']

    # Give VM 1/4 system memory & access to all cpu cores on the host
    if host =~ /darwin/
      cpus = `sysctl -n hw.ncpu`.to_i
      # sysctl returns Bytes and we need to convert to MB
      mem = `sysctl -n hw.memsize`.to_i / 1024 / 1024 / 4
    elsif host =~ /linux/
      cpus = `nproc`.to_i
      # meminfo shows KB and we need to convert to MB
      mem = `grep 'MemTotal' /proc/meminfo | sed -e 's/MemTotal://' -e 's/ kB//'`.to_i / 1024 / 4
    else # sorry Windows folks, I can't help you
      cpus = 2
      mem = 1024
    end

    v.customize ["modifyvm", :id, "--memory", mem]
    v.customize ["modifyvm", :id, "--cpus", cpus]
  end
end

Finally, using v.customize the values are set against the Vagrant configuration.

Provision without Ansible installed

It is possible to provision a Vagrant box on a system that doesn’t have Ansible installed by using a small shell script. This is the approach that phansible.com has taken and with some slight modification I have adopted.

The first step is two write some Ruby in the Vagrantfile that determines if Ansible is installed in the user’s path. If it is not then we should use the shell script as the provisioner.

# Check to determine whether we're on a windows or linux/os-x host,
# later on we use this to launch ansible in the supported way
# source: https://stackoverflow.com/questions/2108727/which-in-ruby-checking-if-program-exists-in-path-from-ruby
def which(cmd)
  exts = ENV['PATHEXT'] ? ENV['PATHEXT'].split(';') : ['']
  ENV['PATH'].split(File::PATH_SEPARATOR).each do |path|
    exts.each { |ext|
      exe = File.join(path, "#{cmd}#{ext}")
      return exe if File.executable? exe
    }
  end
  return nil
end

Vagrant.configure("2") do |config|
  if which('ansible-playbook')
    config.vm.provision "ansible" do |ansible|
      ansible.playbook = "ansible/playbook.yml"
      ansible.inventory_path = "#{ansible_inventory_dir}/vagrant"
      ansible.limit = 'all'
    end
  else
    config.vm.provision :shell, path: "ansible/windows.sh"
  end
end

This shell script will handle the base setup of the box before Ansible can run - so installing Ansible dependencies, then Ansible, setup SSH keys, link the Ansible inventory and finally running the playbook locally on the box. This script targets Ubuntu/Debian Vagrant boxes, but it could be adapted for other POSIX systems.

#!/usr/bin/env bash
sudo apt-get update
sudo apt-get install -y python-software-properties
sudo add-apt-repository -y ppa:ansible/ansible
sudo apt-get update
sudo apt-get install -y ansible
cp /vagrant/ansible/hosts/vagrant /etc/ansible/hosts -f
chmod 666 /etc/ansible/hosts
cat /vagrant/ansible/files/authorized_keys >> /home/vagrant/.ssh/authorized_keys
sudo ansible-playbook /vagrant/ansible/playbook.yml --connection=local

Handy plugins

In most of the configurations I prepare I also make use of vagrant-cachier and vagrant-hostsupdater. The former aims to prevent duplicate package downloads for a given Vagrant box so that subsequent provisions are faster. Hostsupdater will automatically add the IP address and host name of the project to your hosts file so that you don’t have to. Both of their configurations are pretty straight forward and dealt with on their respective project pages so I won’t duplicate effort here.

Moving the Vagrant and VirtualBox VMs to an external HDD

It is rare for a computer not to contain an SSD drive of some sort these days and they’re often set as the primary drive for the machine. This means that both Vagrant and Ansible will be storing their large VMs and files on your limited capacity (unless you’re ultra lucky) SSD. To free up space a USB 3.0 external HDD can really help without slowing down performance too much.

If you’ve already got a few boxes and/or VirtualBox VMs setup then this process can take some time as you will be copying large files - you may want to leave it over night rather than a cup of coffee! It is a pretty simple process though.

For the sake of this example I am going to assume the external HDD is mounted at /media/simon/mydrive/ and you’ll need to substitute this for your drive as you follow along.

The first step is to move the Vagrant home directory to a new location on your external hard drive.

rsync -av ~/.vagrant.d/ /media/simon/mydrive/.vagrant.d/
echo 'export VAGRANT_HOME="/media/simon/mydrive/.vagrant.d"' >> ~/.bash_profile

We’ve also added the new location to your .bash_profile so that it will automatically available when you boot your machine.

With that out of the way the bulk of the copying is still to come! Open the VirtualBox application and then in Preferences set the Default Machine Folder to /media/simon/mydrive/VirtualBox VMs. Now to move your current VMs to the new location.

rsync -av ~/VirtualBox VMs/ /media/simon/mydrive/VirtualBox VMs/

The next step maybe unnecessary, but you can then re-open VirtualBox and remove any VMs that are showing as inaccessible. To re-add them it you can simply run

find /media/simon/mydrive/VirtualBox VMs/ -iname *.vbox -exec vboxmanage registervm '{}' \;

Finally, you can now move your actual project directories to the external harddrive too and they’ll use the new locations for storage and access. They could also stay where they are as they are only small so up to you!