Docker containers with Systemd and Ansible

Earlier on I've shown you how I run docker containers on NAS using systemd. This time, I'll show you an easy way to configure systemd with Ansible that will get you running containers in no time. (and in reproducible and automated way)

What we want

I really like how well systemd works with Docker. It is still not ideal, because Docker kind of escapes the cgroups defined in systemd - something like rkt would probably integrate much better, nonetheless it's still very robust configuration and good improvement even from quite decent upstart configuration I had before.

What we need here is to create service file for each container we'd like to run and make sure that service is set to start at boot time in correct order. (after docker)

Note while this configuration is very simple and even Ansible beginner should be able to follow it, I'm not going to explain how to install ansible or how to create your inventory - this might be very specific to your setup and there are good tutorials out there on that topic.

First create role

We know, that there will be multiple containers deployed on a single machine, so to save us some repetition, we are going to create a role. For all we need, we are fine with just creating a directory structure, but if you want to get a quick and nice skeleton for your role, use ansible-galaxy like this:

ansible-galaxy init --offline -p roles/ <role_name>

I'm going to call mine docker-systemd. Once we have that done, we can start by..

Creating the template

Let's take a sample template for my Plex server:

[Unit]
Description=Plex in Docker
After=docker.service
Requires=docker.service

[Service]
TimeoutStartSec=0
ExecStartpre=-/usr/bin/docker stop plex
ExecStartpre=-/usr/bin/docker rm plex
ExecStartpre=-/usr/bin/docker pull my_registry:5000/plex
ExecStart=/usr/bin/docker run --rm -t \
  -v /data/media/:/media/ \
  -v /data/system/plex/:/config/ \
  --net host \
  --name plex my_registry:5000/plex
ExecStop=-/usr/bin/docker stop -t 3 plex
ExecStop=-/usr/bin/docker rm plex
Restart=always
RestartSec=10

[Install]
WantedBy=multi-user.target

It's quite straightforward stuff so far. On start we make sure that any previously launched instance is stopped and removed. This should be only needed if something weird happened (like unexpected reboot) so I'm prepending the commands with dash to ignore any failures on these steps, most of the time there will be nothing to stop. As the last pre-start step, we try to fetch the latest image, again ignoring any failures. (we still want our services to start even if repository is unreachable)

The actual start is your usual docker run, but this time we're also starting it with extra flags: --rm -t to remove container on stop (I don't want persistent containers, my persistent data resides on mounted volumes) and to attach to container tty.

The later gives us two things:
  • container logs are accessible via journalctl
  • the docker binary will be running as long as container is running, so in case the container stops, systemd will know that it needs to restart the service to bring it back up.

Stop is just some cleanup - again unnecessary under normal circumstances, so we ignore any failures on these.

Systemd is also instructed to restart the service if it fails (after 10 seconds) and it has docker set as dependency, so it will start in proper order during boot.

Now let's make a template out of the service file by replacing the service specific stuff with Ansible variables:

[Unit]
Description={{ container_description }}
After=docker.service
Requires=docker.service

[Service]
TimeoutStartSec=0
Restart=always
ExecStartPre=-/usr/bin/docker stop {{ container_name }}
ExecStartPre=-/usr/bin/docker rm {{ container_name }}
{% if container_pull %}
ExecStartPre=-/usr/bin/docker pull {{ container_image }}
{% endif %}
ExecStart=/usr/bin/docker run --rm -t \
{% for port in container_ports %}
    -p {{ port }} \
{% endfor %}
{% for volume in container_volumes %}
    -v {{ volume }} \
{% endfor %}
{% for option in container_extra_options %}
    {{ option }} \
{% endfor %}
    --name {{ container_name }} {{ container_image }} {{ container_cmd }}
ExecStop=-/usr/bin/docker stop -t 3 {{ container_name }}
ExecStop=-/usr/bin/docker rm {{ container_name }}
Restart=always
RestartSec=10s

[Install]
WantedBy=multi-user.target
Quite simple, isn't it? The parts that aren't absolutely trivial are:
  • Optional docker pull - if set to true Ansible will add a startup step to first pull the latest image from registry.
  • Ports, Volumes and extra run options - Ansible will iterate over any defined volumes, port mappings and extra options and will include them - one line at a time

Let's save the template as templates/systemd-container.service.j2 in our role directory.

Creating the tasks

Let's have a look at the tasks we need to define in Ansible:

---
- name: create service file
  template:
    dest: "/etc/systemd/system/{{ container_service_name }}"
    src: "templates/systemd-container.service.j2"
  register: service_config

- name: reload daemon
  shell: systemctl daemon-reload
  when: service_config.changed

- name: restart service
  service:
    name: "{{ container_service_name }}"
    state: restarted
  when: service_config.changed

- name: enable service
  service:
    name: "{{ container_service_name }}"
    state: started
    enabled: yes
There are just four steps needed:
  • Create the service file using our template
  • Reload daemon configuration if the file was just created or updated
  • Restart service if it was created/changed
  • Make sure the service is started and enabled

That was easy one, wasn't it? Let's save it as tasks/main.yml.

Adding default variables

This is not really necessary, but if we can make our life easier in couple lines of code, why wouldn't we? There are some variables, that will have some common setting for most of the containers, so let's define defaults for those and we don't have to define them for each individual role invocation.

---
container_description: "{{ container_name }} in Docker container"
container_pull: true
container_cmd: ""
container_service_name: "container-{{ container_name }}.service"
container_repository: "{{ container_name }}"
container_image: "{{ docker_registry}}{{ '/' if docker_registry else '' }}{{ container_repository }}"
container_ports: []
container_volumes: []
container_extra_options: []

Save that one in vars/main.yml and we're done with the role. Now while it isn't the best role I've seen in my life, it's definitely a role we can use. All we need now is..

Playbook

---
- hosts: "myhost"
    roles:
        - role: "docker-systemd"
            container_name: "plex"
            container_extra_options:
                - "--net host"
            container_volumes:
                - "/data/media/:/media/"
                - "/data/system/plex/:/config/"
            become: true

Save this to some playbook.yaml file and that's it. Assuming you have docker_registry set properly (mot likely in group_vars?) and the image exists there, you're done. For multiple containers just invoke the role multiple times with different settings.

We're done

Now we're just one ansible-playbook run away from deploying the configuration and running the container. We can even automate that with Drone for example, but I'll leave that one for some later article. Now that we have Plex running it's movie time.

See you on the next one.