Playing with Ansible... on Docker

(TL;DR: The project presented here, with all the necessary files and setup steps, can be found on this repository)

So, this week I decide to study Ansible and, after reading and watching some tutorials about the technology, I decided to get my hands dirty.

So, my first think was:

OK, I need some machines to provision, so quite probably I’ll need to create some EC2 instances to play with Ansible…

But then I realised:

Wait a second… I’ve Docker… Hum…

What’s Ansible?

Ansible is a set of tools that automates software provisioning, configuration of services and application deployment. More information can be found on the official website.

The project

So, before starting, we need to define the architecture and the roles of each component.

On this project, we’ll have four Docker containers running:

  • One container with Ansible installed. This will be our “master” machine, responsible to provision the other ones.

  • Three clean containers, with only sshd (ssh deamon) installed and configured. These containers will play the role of servers, it means, the machines that will actually be prepared to run our service.

Also, we need some service to test that our servers are well configured and ready to use. So, we’re going to use this project, which is a simple “hello world” written in NodeJS that I found on Github.

Architecture and actors defined, let’s move to the setup.

The server “machine”

So, first, we need a clean Docker image, having only the ssh daemon installed. This clean image will have none of our dependencies installed, since the objective here is to see Ansible taking care of that.

So, here’s the Dockerfile used to create this image:

FROM ubuntu:16.04

RUN apt-get update && apt-get install -y openssh-server
RUN mkdir /var/run/sshd
RUN echo 'root:ansible' | chpasswd
RUN sed -i 's/PermitRootLogin prohibit-password/PermitRootLogin yes/' /etc/ssh/sshd_config

# SSH login fix. Otherwise user is kicked off after login
RUN sed 's@session\s*required\s*pam_loginuid.so@session optional pam_loginuid.so@g' -i /etc/pam.d/sshd

ENV NOTVISIBLE "in users profile"
RUN echo "export VISIBLE=now" >> /etc/profile

EXPOSE 22
CMD ["/usr/sbin/sshd", "-D"]

This image creates a Docker container with SSH access enabled, necessary in order to Ansible run the remote commands. The SSH access is set using root and ansible as user and password, respectively.

SSH config

To avoid strict host key checking when connection to the servers the first time, we need to disable this check in the ssh config file:

Host *
   StrictHostKeyChecking no

Ansible hosts

Ansible relies on /etc/ansible/hosts files to define which machines are going to be provisioned. Ansible supports groups of machines, so you can refer to a group in the playbook instead of each machine individually. Since we’ve defined an architecture with 3 server machines, our hosts file will be the following:

[server_hosts]
ansibledocker_server_[1:3]

[server_hosts:vars]
ansible_python_interpreter=/usr/bin/python3
ansible_ssh_user=root
ansible_ssh_pass=ansible

Our group server_hosts includes 3 machines:

  • ansibledocker_server_1
  • ansibledocker_server_2
  • ansibledocker_server_3

These hostnames were defined based on the containers names given by docker compose when scaling the number of servers. Don’t worry: it will become clearer when we run the entire docker environment.

The server_hosts:vars section is used to define some extra parameters for Ansible when provisioning machines from the server_hosts group. In our case, we’re defining the Python interpreter and the SSH authentication credentials to connect to our servers.

We could use some ssh public key here to avoid hardcoding the auth credentials, but for the sake of simplicity, we’re using the user:pass approach.

Ansible playbook

The most important file of the whole project: the Ansible playbook.

Here’s the file content with comments, explaining the purpose of each task:

---
# Provision all hosts
- hosts: all
  remote_user: root

  tasks:
    # Install Git binary.
    # Git is necessary to download our NodeJS project from Github.
    - name: Install Git
      include_role:
        name: ANXS.git

    # Downloads the Node project from Github and save
    # on `hello-world/` directory
    - name: Download project
      git:
        repo: 'https://github.com/azat-co/nodejs-hello-world'
        dest: 'hello-world'

    # Install NodeJS library
    - name: Install NodeJS
      include_role:
        name: geerlingguy.nodejs

    # Install all the necessary Node modules for the project,
    # using `npm`.
    - name: Install project dependencies
      command: npm install
      args:
        chdir: hello-world/

    # Install Forever tool.
    # This tool is used to run the Node server in background
    # and keep tracking of the running process
    - name: Install Forever
      npm: name=forever global=yes state=present

    # This is an auxiliary task, used to identify if our Node service
    # is already running (avoids restarting the service on each playbook
    # execution)
    - name: Get Forever's list of running processes
      command: forever list
      register: forever_list
      changed_when: false

    # Start the node server using "Foverer"
    # The `when` clause identifies if the server is already
    # running. If so, this task is skipped
    - name: Start service
      command: forever start web.js
      when: "forever_list.stdout.find('hello-world/web.js') == -1"
      args:
        chdir: hello-world/

The Docker compose file

Finally, to glue everything together, comes the compose file.

So, considering we’ve the following file structure with our existing resources:

The resulting compose file is:

version: '3'
services:
  ansible:
    image: williamyeh/ansible:debian9
    volumes:
      - "./ssh/config:/root/.ssh/config"
      - "./ansible/hosts:/etc/ansible/hosts"
      - "./playbooks:/root/playbooks"
    links:
      - server

  server:
    build: .

The compose basically defines two types of container:

  • ansible: this is our main container, with Ansible installed. This is the container that will dispatch the commands for the other containers. Some configuration files for ssh and Ansible are mapped as volumes on this container, since they’re going to be used to provision the servers.

  • server: this is our “server” container. It’s created based on the clean Docker image we’ve defined in Dockerfile file, with only sshd installed.

Getting hands dirty

Architecture explained, it’s time to see the things running.

So, first, let’s start the base environment:

$ docker-compose run ansible bash

This will start the ansible container and one server container, opening a console with the former one:

Unfortunately, docker-compose run doesn’t support the scale flag, so we need to scale the servers containers manually. In a separated terminal, run:

$ docker-compose scale server=3

This will scale our servers to three containers, with the hostnames ansibledocker_server_1, ansibledocker_server_2 and ansibledocker_server_3. Not by coincidence, these are exactly the same hosts defined on the Ansible’s host file, as explained before.

Back to the Ansible console, we can now test that our 3 servers are accessible. Therefore, we can use the Ansible’s ping role:

$ ansible all -m ping

Some readers are getting the following error when running the ping:

Failed to connect to the host via ssh: Bad owner or permissions on /root/.ssh/config

If this is your case, please check the troubleshooting section.

Now that all servers are accessible, we can prepare to run the playbook. First, we need to download some additional Ansible roles. The best place to find reusable Ansible content is Ansible Galaxy. It behaves like a central repository for Ansible, containing roles for basically everything you can imagine. Worth to give a check :)

So, let’s install the extra roles:

# ANXS.git:           role to install the Git binary
# geerlingguy.git:    role to operate Git (i.e. download repository)
# geerlingguy.nodejs: role to install NodeJS
$ ansible-galaxy install ANXS.git geerlingguy.git geerlingguy.nodejs

Now that all roles are available, we can finally fire the playbook:

$ ansible-playbook /root/playbooks/setup.yml

Ansible will now run all our tasks, ensuring that all the dependencies are installed and configured. This can take some time, so go grab a coffee… I’ll wait :)

After finished, Ansible will display a recapitulation of the affected servers and those who changed (installation/uninstallation/modification/etc):

Testing

At this point, all the servers should be configured and our Node project should be running on port 5000.

So, let’s see if everything is OK:

$ curl -XGET ansibledocker_server_1:5000
$ curl -XGET ansibledocker_server_2:5000
$ curl -XGET ansibledocker_server_3:5000

For each one of the requests above, you should get a Hello World as response:

Success!

Hooray!

You’ve just provisioned 3 servers with NodeJS using Ansible :)

Conclusion

  • This project uses Docker for convenience. Therefore, we don’t have to bother about starting Amazon servers only for testing purposes. However, since the containers used here behave exactly like a plain machine with sshd installed, the very same setup should work on any cloud or bare-metal architecture.

  • The objective of this project isn’t to teach how to provision Docker containers using Ansible. As stated by Michael DeHaan, creator of Ansible, Docker containers typically have a single responsibility and, thus, much less configuration. So, the overhead of having a complete Ansible configuration to provision them are, most of the time, unnecessary.

Finally, the entire project, with the configuration files, Ansible playbook and everything else presented here can be found on this repository.

Hope you enjoyed this post and happy coding! :)


Troubleshooting

1. Failed to connect to the host via ssh: Bad owner or permissions on /root/.ssh/config

This happens because Docker compose is mapping the ssh config file with the wrong permissions.

Checking ssh man documentation, we can read this:

Because of the potential for abuse, this file must have strict permissions: read/write for the user, and not writable by others. It may be group-writable provided that the group in question contains only the user.

So, in order to fix this problem, inside the Ansible container, run:

$ chown root ~/.ssh/config
$ chmod 644 ~/.ssh/config

Liked this post?

Have questions? Suggestions?

Follow me on Twitter and let's engage in a conversation