Hey,

there are plenty of Docker Ansible roles out there but it turns out that most of them are overly complex while at the same time it’s very simple to install it.

Actually, most of the times not even Ansible is needed: if you have a single machine and just needs to have it there, Docker already got you covered on how to install Docker:

  1. head to get.docker.com
  2. pick a script that installs it from a specific release channel and done (if you’re curious - or worried - about the development of that script, head to github.com/docker/docker-install).
  3. configure user permissions

If you’re into Ansible though, maybe because you use it to make a bunch of machines reach a given state or makes use of it to bake machine images, here’s my suggestion of minimal Docker role.

The role

The role consists of three directories following Ansible’s patterns (see Ansible - Roles).

If you’re not familiar with it, it looks like this:

docker
├── defaults            # Variables used by the docker role.
│   └── main.yml        # Here I place some default variables
│                       # like the `apt` repository and a default
│                       # docker version to be installed.
│
├── files               # static files that we plan to use in the
│   └── daemon.json     # role (i.e, send to the host) but not
│                       # modify (it's not a template)
│ 
└── tasks               # the actual list of actions to take.
    └── main.yml

The main part of this role is the tasks/main.yml file which defines the actions to take:

---
# As an initial step we make sure that we have our
# dependencies ready. Here we're installing just
# two:
# - apt-transport-https makes us be able to use
#   TLS in the transport of packages coming
#   from APT repositories
# - ca-certificates gives us a bundle of common
#   certificate authorities' certificates
- name: 'install docker dependencies'
  apt:
    name: '{{ item }}'
    state: 'present'
  with_items:
    - 'apt-transport-https'
    - 'ca-certificates'


# Because apt makes use of public key crypto to fetch
# packages we must tell it what's the public key of the
# source that is signing the packages we want to retrieve,
# that is, we need to add the repository's key.
- name: 'add docker repo apt key'
  apt_key:
    url: 'https://download.docker.com/linux/ubuntu/gpg'
    id: '9DC858229FC7DD38854AE2D88D81803C0EBFCD88'
    state: 'present'
  register: 'add_repository_key'
  ignore_errors: true


# Add the official docker apt repository so that `apt`
# can list packages from it and then fetch them from
# there.
# With `update_cache` we force an `apt update` which
# would essentially be the equivalent of updating the
# list of packages from a list of source repositories.
- name: 'add Docker repository'
  apt_repository:
    repo: '{{ docker_apt_repository }}'
    state: 'present'
    update_cache: 'yes'


# With the list of packages updated we can install
# a specific version of the `docker-ce` package. This
# way we can declaratively tell the role which version
# of docker we want: a stable (17.09, for instance) or an 
# edge (17.11-rc3)?
- name: 'install docker'
  apt:
    name: 'docker-ce={{ docker_version }}'
    state: 'present'


# Once Docker has finished the installation (which involves
# setting a systemd service) we have the option to either
# enable that service or not. By enabling it, systemd hooks
# the docker unit into specific places such that whenever the
# machine boots we have this service started.
- name: 'enable docker systemd service'
  service:
    name: 'docker'
    state: 'started'
    enabled: 'yes'


# As we can configure the docker daemon via the configuration
# file `/etc/docker/daemon.json` here we take the opportunity
# of placing one of our own at the relevant destination.
- name: 'prepare default daemon configuration'
  copy:
    src: 'daemon.json'
    dest: '/etc/docker/daemon.json'


# If you use something like `docker swarm mode` it's
# very common to have dangling containers around.
# By setting a cron job to clean thing ups every N
# hours we make sure that dangling containers don't 
# stay around for too long.
- name: 'set periodic docker system prune'
  cron:
    name: 'docker-prune'
    minute: '0'
    hour: '*/2'
    job: 'docker container prune -f'


# Without adding the unprivileged 
# user to the docker group we can't 
# make use of the socket that is activated
# by systemd. Here we take a list of users that
# we want to make part of the `docker` group and
# do it.
- name: 'add users to docker group'
  user:
    name: '{{ item }}'
    groups: 'docker'
    append: 'yes'
  with_items: '{{ docker_group_members }}'
  when: 'docker_group_members is defined'

This file makes use of what’s defined under defaults/main.yml, which looks like this:

---
# The specific version that we aim at installing.
# If we wanted to get whatever is the latest not 
# marked as a release candidate we could instead
# not specify a version but simply `docker-ce`.
docker_version: '17.09.0~ce-0~ubuntu'

# The release channel to look for packages.
# If your curious about what are the channels and which
# versions do they have, head to
# https://download.docker.com/linux/ubuntu/dists/zesty/ (or
# any other distro you want).
docker_apt_release_channel: 'stable'

# The URL of the apt repository.
# Here we're picking the values from the knowledge
# that ansible already took from the system. This way
# we can make fewer changes in this code when changing
# from one distro to another.
# Note that there's a compatibility matrix but in general
# new versions of Ubuntu are well covered.
docker_apt_repository: 'deb https://download.docker.com/linux/{{ ansible_distribution|lower }} {{ ansible_distribution_release }} {{ docker_apt_release_channel }}'

# List of users that we want to add to the
# `docker` group. As mentioned, by adding them to
# the `docker` group they can access the Unix socket
# that Docker places at `/var/run/docker.sock` (by default)
# which the docker CLI uses to communicate with
# the docker daemon.
docker_group_members: 
  - 'ubuntu'

While the Docker daemon configuration looks like:

{
    "experimental": true,
    "icc": false,
    "max-concurrent-downloads": 30,
    "max-concurrent-uploads": 30,
    "metrics-addr": "0.0.0.0:9102",
    "storage-driver": "overlay2",
    "userland-proxy": false
}

If you’re not very sharp about Docker configuration, this is what the last file means:

  • enabling experimental mode allows us to build images with --squash;
  • icc means that container won’t be able to communicate with each others over the bridge network;
  • max-concurrent-(downloads|uploads) increases the number of downloads/uploads (layers) at a given time;
  • metrics-addr makes the daemon expose Prometheus metrics at all interfaces on port 9102;
  • storage-driver sets the storage driver to overlay2 (see docs.docker.com - Select a storage driver);
  • userland-proxy deactivates the use of the userland proxy that docker places for each port mapping - this way we rely solely on iptables for the port mapping. I’d like to go deeper into this one but I’d need to research more first (please send me some articles!).

With the role ready we can now prepare a playbook that makes use of it and test it against a real (in this case, virtual) machine.

Testing it with Vagrant

The easiest way I can think of testing this is making use of Vagrant. I wrote a bit about it when I was setting a VM in an article regarding augmenting swap space in Linux but the gist of it is that it allows us to create a little piece of code that describes how a VM (or a container) should be prepared.

Here, just like in the article I mentioned, I’ll be creating an Ubuntu 17.04 (zesty) machine with 512MB of RAM and 1 CPU. The difference now is that when the machine gets up we’ll be turning ansible against it and executing our role.

To do so, let’s prepare the project directory structure a bit:

.
├── ansible             # ansible related configuration
│   │                   # and execution files
│   │   
│   ├── ansible.cfg     # general configuration
│   ├── playbooks       # playbooks that execute the roles
│   │   │               # and tasks we want
│   │   └── provision-vagrant.yml
│   └── roles           # the docker role described before
│       └── docker
│           ├── defaults
│           │   └── main.yml
│           ├── files
│           │   └── daemon.json
│           └── tasks
│               └── main.yml
└── vagrant             # vagrant configuration
    └── Vagrantfile

With that structure ready, let’s populate the files. The roles/docker/* ones should already be done given the previous sections, now the Vagrantfile should look like the following:

Vagrant.configure(2) do |config|
        # - set the image to be used to be ubuntu/zesty64
        # - set the hostname of the machine
        # - do not check for base image updates
        # - do not sync the default vagrant directory
        config.vm.box = "ubuntu/zesty64"
        config.vm.hostname = "test-machine"
        config.vm.box_check_update = false
        config.vm.synced_folder ".", "/vagrant", disabled: true


        # - configure some parameters from the virtualbox provider
        config.vm.provider "virtualbox" do |v|
                v.memory = 512
                v.cpus = 1
        end


        # hook ansible into the process of provisioning
        # the machine.
        # -     using the ansible configuration file
        #       located at `../ansible/ansible.cfg`, 
        #       execute the playbook at `playbooks/provision-vagrant.yml`
        config.vm.provision "ansible" do |ansible|
                ansible.playbook = "../ansible/playbooks/provision-vagrant.yml"
                ansible.config_file = "../ansible/ansible.cfg"
        end
end

Now moving to the ansible configuration, let’s tell it where it should look for the roles. In ansible/ansible.cfg we can put the following:

[defaults]
roles_path    = ./roles

Now moving to the playbook itself, configure it to target the host that Vagrant will create and make use of the Docker role we just created:

---
# This is a little role that I always execute at first
# when I'm provision machines from the ground up. Because
# some roles might require knowledge about the machine
# (gathered via `gather_facts`) and obtaining such knowledge
# require python, with this role we can bootstrap the 
# python installation with plain ansible SSH communication.
#       -       by not gathering facts and execution 
#               almost manually the `apt install` script
#               we can install it.
# Check the role at https://github.com/cirocosta/example-docker-ansible
- hosts: 'default'
  any_errors_fatal: true
  become: true
  gather_facts: false
  roles:
    - 'bootstrap'


# * Target whatever is the default group of hosts;
# * If any errors happen in the middle, stop the
# whole execution;
# * Make sure to `become` a privileged user;
# * Gather facts about the machine (requires
# python);
# * Execute the `docker` role.
- hosts: 'default'
  become: true
  any_errors_fatal: true
  roles:
    - 'docker'

Having the playbook set up, we can now go back to the root of the repository, enter in the vagrant directory and execute have the ansible execution at the launch of the Vagrant machine.

cd ./vagrant
vagrant up

Bringing machine 'default' up with 'virtualbox' provider...
==> default: Importing base box 'ubuntu/zesty64'...
...
==> default: Machine booted and ready!
...
==> default: Running provisioner: ansible...
    default: Running ansible-playbook...

PLAY [default] ...........................
TASK [bootstrap : bootstrap python] ......
changed: [default]

...

TASK [docker : install docker dependencies] ************************************
ok: [default] => (item=[u'apt-transport-https', u'ca-certificates'])

TASK [docker : add docker repo apt key] ****************************************
ok: [default]
...

That’s it!

Can we simplify it even more?

For sure!

As I mentioned, get.docker.com already install Docker for you. It won’t allow you to specify the version you want but it does the job. As it’s a command that you’d manually copy and paste into the terminal, we can mimic that with Ansible’s command module:

---
# Fetches the docker installation script
# from `get.docker.com` and then pipes the
# script to `sh` so that it gets executed.
- name: 'install docker'
  command: 'bash -c "curl -fsSL https://get.docker.com/ | sh"'

Closing thoughts

From having this Ansible role to having a process of baking AWS AMIs or images for GCE is just a matter of adding Packer to the mix or even manually just targetting a VM and snapshotting it. I tend to keep using a distribution for a reasonable time so I don’t see many reasons to try to make my Ansible roles very complicated with support to a bunch of distros so that’s why I wrote this article.

You can find this example in github.com/cirocosta/example-docker-ansible.

Assuming you have Ansbile and Vagrant installed it should be a matter of running make run from the root of the repository.

Please let me know if I left something uncovered or if you want to share how you’re doing this kind of stuff! I’d really like to know.

Thanks,

Ciro