7 min read

Backup Docker volumes with Ansible and restic

March 16, 2020

In a new assignment I’m in charge of the infrastructure for a new startup. I was given a blank canvas and decided to use Ansible and Docker from the start. Therefore I’ve setup an Ansible project containing various roles and deployment scenarios. Have a look here for details: https://github.com/Mint-System/Ansible-Playbooks. To put it simply, this project deploys open source web application as Docker containers on a target system. Currently, I am adding new features and polishing existing ones. An important role that is still missing is the backup. Having a robust and reliable backup and recovery system is key. While developing the backup system I had a few key points in mind:

  • Backup Docker volumes
  • Backup location is remote
  • No friction between backup and recovery
  • Backup tool must manage rotation policies
  • Docker host stores backups
  • Support for multiple backup clients and servers
  • Encrypted backups
  • Simple configuration

Solution

In the past I have used duplicity for my backups. I remember it as quite handy for managing backups, but complicated regarding encryption. While doing more research (aka googling duplicity alternatives), I found restic. It seems restic has become the standard for remote backups. Folks from camptocamp use it to backup their kubernetes cluster using the restic based backup tool bivac. Obviously, restic has been proven to be a worthy candidate.

Files

First I want to give you a short intro to the Ansible project. I have isolated the inventory and roles of the restic client and server deployment.

These are the relevant Ansible project files:

.
├── backup.yml
├── inventories/
│   └── backup/ # name of the inventory
│       ├── group_vars/
│       │   ├── all.yml
│       │   ├── client/
│       │   │   ├── vars.yml
│       │   │   └── vault.yml # Use ansible-vault to create this file
│       │   └── server/
│       │       ├── vars.yml
│       │       └── vault.yml  # Use ansible-vault to create this file
│       ├── host_vars/
│       │   ├── client.example.com.yml
│       │   └── server.example.com.yml
│       └── hosts.yml
└── roles/
    ├── restic-client/
    │   └── tasks/
    │   │   ├── main.yml
    │       └── install.yml
    └── restic-server/
        └── tasks/
            ├── main.yml
            └── main.yml

Inventory

Let’s have a look at the inventory.

The all.yml file contains configs which is applied to all hosts of the backup inventory.

inventories/backup/group_vars/all.yml

docker_network_name: example.com
docker_log_driver: "json-file"
docker_log_max_size: "10m"
docker_log_max_file: "3"

ansible_python_interpreter: /usr/bin/python3

For every inventory group, in this case client and server, there is a folder and a vars config file.

inventories/backup/group_vars/client/vars.yml

restic_client_package: "restic=0.8.3+ds-1"
restic_client_user: admin
restic_client_password: "{{ vault_restic_client_password }}"
restic_repo: server.example.com:8080/backup
restic_repo_password: "{{ vault_restic_repo_password }}"

Every variable that is prefixed with vault_ is defined in the vault.yml file.

inventories/backup/group_vars/server/vars.yml

# https://hub.docker.com/r/restic/rest-server
restic_server_image: restic/rest-server:0.9.7
restic_server_user: admin
restic_server_password: "{{ vault_restic_server_password }}"
restic_server_port: 8080

For host specific configurations there is a file in the host_vars folder.

inventories/backup/host_vars/client.example.com.yml

restic_backup_sets:
 - id: "odoo volume"
   type: docker-volume
   volume: odoo_data01
   tags:
    - odoo01
    - postgres01
   hour: "1"
   minute: "0"
 - id: "postgres volume"
   type: docker-volume
   volume: postgres_data01
   tags:
    - odoo01
    - postgres01
   hour: "1"
   minute: "0"
restic_backup_rotation:
  daily: 7
  weekly: 4
  monthly: 1

Each client has a set of backup jobs.

inventories/backup/host_vars/server.example.com.yml

restic_server_hostname: restic01
restic_server_backup_dir: /path/to/mount

The server mounts the backupfolder via bind mount.

Roles

Next we will have a look at the Ansible roles.

Restic server

The restic server receives and stores backup files.

roles/restic-server/tasks/main.yml

- name: Include install tasks
  include_tasks: install.yml
  when: restic_server_image is defined

If a restic server docker image is defined, the install tasks are included.

roles/restic-server/tasks/install.yml

- name: Install htpasswd module
  apt: 
    name: python3-passlib
    state: latest

- name: Configure user access for restic server
  htpasswd:
    path: "{{ restic_server_backup_dir }}/.htpasswd"
    name: "{{ restic_server_user }}"
    crypt_scheme: ldap_sha1
    password: "{{ restic_server_password }}"

- name: Start restic server container
  docker_container:
    name: "{{ restic_server_hostname }}"
    image: "{{ restic_server_image }}"
    volumes:
      - "{{ restic_server_backup_dir }}:/data"
    ports:
      - "{{ restic_server_port }}:8000"
    networks:
      - name: "{{ docker_network_name }}"
    log_driver: "{{ docker_log_driver }}"
    log_options:
      max-size: "{{ docker_log_max_size }}"
      max-file: "{{ docker_log_max_file }}"

The install tasks creates a .htpasswd file in the mounted directory and then starts a restic server container with configurations applied from the inventory.

As you can see the http communication is not encrypted. However, this is not a problem as restic encrypts its payload.

Restic client

The restic client runs backups and uses the server as storage.

roles/restic-client/tasks/main.yml

- name: Include install tasks
  include_tasks: install.yml
  when: restic_client_package is defined

Now comes the most interesting part. The install.yml of the restic client does a few things:

  • Install the restic package
  • Check if backup repo has been initialized and if not does so
  • Ensure the repo url and password are set as global environment variables
  • Register the backup jobs
  • Register the backup rotation job

roles/restic-client/tasks/install.yml

- name: Install restic
  apt:
    name: "{{ restic_client_package }}"

- name: Check if repo is initialized
  shell: restic snapshots
  environment:
    RESTIC_PASSWORD: "{{ restic_repo_password }}"
    RESTIC_REPOSITORY: "rest:http://{{ restic_client_user }}:{{ restic_client_password }}@{{ restic_repo }}"
  ignore_errors: yes
  changed_when: false
  register: repo_initalized

- name: Init restic repository
  shell: restic init
  environment:
    RESTIC_PASSWORD: "{{ restic_repo_password }}"
    RESTIC_REPOSITORY: "rest:http://{{ restic_client_user }}:{{ restic_client_password }}@{{ restic_repo }}"
  when: repo_initalized.failed

- name: Ensure restic environment vars exists
  lineinfile:
    dest: "/etc/environment"
    state: present
    regexp: "^{{ item.key }}="
    line: "{{ item.key }}={{ item.value}}"
  loop:
    - key: RESTIC_PASSWORD
      value: "{{ restic_repo_password }}"
    - key: RESTIC_REPOSITORY
      value: "rest:http://{{ restic_client_user }}:{{ restic_client_password }}@{{ restic_repo }}"

- name: Register docker volume backup jobs
  cron:
    name: "Backup job {{ item.id }}"
    hour: "{{ item.hour }}"
    minute: "{{ item.minute }}"
    job: ". /etc/environment; restic backup /var/lib/docker/volumes/{{ item.volume }}/_data/ --tag {{ item.tags | join(' --tag ')}}"
  loop: "{{ restic_backup_sets }}"
  when: item.type == "docker-volume"

- name: Register backup rotation job
  cron:
    name: "Backup rotation job"
    hour: "23"
    minute: "0"
    job: ". /etc/environment; restic forget --keep-daily {{ restic_backup_rotation.daily }} --keep-weekly {{ restic_backup_rotation.weekly }} --keep-monthly {{ restic_backup_rotation.monthly }} --prune"

For every item in the restic_backup_sets var a cron job is configured. The item.type property allows you to define and select more backup types.

Deployment

This is the final section. I will show you how to deploy the roles.

Playbook

First we need an Ansible playbook.

backup.yml

- hosts: all
  become: true
  roles:
  - role: restic-server
    tags: restic-server
  - role: restic-client
    tags: restic-client

This playbook includes the role. Note that we deploy as root on the target machine.

Installation

If everything has been configured properly, we can install the server:

ansible-playbook -i inventories/backup backup.yml -l server.example.com

And the client:

ansible-playbook -i inventories/backup backup.yml -l client.example.com

Here is an example output of the client installation:

MacBuechLuft:ansible-playbooks janikvonrotz$ ansible-playbook -i inventories/backup backup.yml -t restic-client

PLAY [all] ***********************************************************************************************************************************************************************************************************************************

TASK [Gathering Facts] ***********************************************************************************************************************************************************************************************************************
ok: [server.example.com]
ok: [client.example.com]

TASK [restic-client : Include install tasks] *************************************************************************************************************************************************************************************************
skipping: [server.example.com]
included: /Users/janikvonrotz/ansible-playbooks/roles/restic-client/tasks/install.yml for client.example.com

TASK [restic-client : Install restic] ********************************************************************************************************************************************************************************************************
ok: [client.example.com]

TASK [restic-client : Check if repo is initialized] ******************************************************************************************************************************************************************************************
ok: [client.example.com]

TASK [restic-client : Init restic repository] ************************************************************************************************************************************************************************************************
skipping: [client.example.com]

TASK [restic-client : Ensure restic environment vars exists] *********************************************************************************************************************************************************************************
ok: [client.example.com] => (item={'key': 'RESTIC_PASSWORD', 'value': '********'})
ok: [client.example.com] => (item={'key': 'RESTIC_REPOSITORY', 'value': 'rest:http://admin:'********'})@server.example.com:8080/backup'})

TASK [restic-client : Register docker volume backup jobs] ************************************************************************************************************************************************************************************
ok: [client.example.com] => (item={'id': 'odoo volume', 'type': 'docker-volume', 'volume': 'odoo_data01', 'tags': ['odoo', 'odoo02', 'postgres03'], 'hour': '1', 'minute': '0'})
ok: [client.example.com] => (item={'id': 'postgres volume', 'type': 'docker-volume', 'volume': 'postgres_data01', 'tags': ['postgres', 'odoo02', 'postgres03'], 'hour': '1', 'minute': '0'})

TASK [restic-client : Register backup rotation job] ******************************************************************************************************************************************************************************************
ok: [client.example.com]

PLAY RECAP ***********************************************************************************************************************************************************************************************************************************
client.example.com      : ok=7    changed=0    unreachable=0    failed=0    skipped=1    rescued=0    ignored=0   
server.example.com     : ok=1    changed=0    unreachable=0    failed=0    skipped=1    rescued=0    ignored=0  

Note that the passwords are not hidden in your output. They might end up on a log aggregation server.

Manual backups

Remember that we installed the restic package on the target host and defined two important environment variables on the global scope? This helps managing backups without looking up any secrets.

I can easily browse and if necessary restore backups with personal user.

MacBuechLuft:~ janikvonrotz$ ssh client.example.com
janikvonrotz@hades:~$ restic snapshots
password is correct
ID        Date                 Host        Tags            Directory
----------------------------------------------------------------------
a8f86bf6  2020-03-23 14:07:02  hades       odoo01      ┌── /var/lib/docker/volumes/odoo_data01/_data
                                           postgres01  └── 
c0686e66  2020-03-23 14:07:02  hades       odoo01      ┌── /var/lib/docker/volumes/postgres_data01/_data
                                           postgres01  └── 
----------------------------------------------------------------------
2 snapshots

Cool isn’t it? If you have any feedback, let me know? In the near future I will add new backup types and share them in the referenced GitHub repo.

Categories:  DevOps

Tags:  backup , ansible , restic

comments powered by Disqus