This commit is contained in:
Einar Forselv 2019-04-13 19:04:54 +02:00
commit a3cdb38239
11 changed files with 280 additions and 0 deletions

1
.dockerignore Normal file
View File

@ -0,0 +1 @@
.venv/

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
.venv
__pycache__

3
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"python.pythonPath": "${workspaceFolder}/.venv/bin/python"
}

12
Dockerfile Normal file
View File

@ -0,0 +1,12 @@
FROM restic/restic
ADD requirements.txt /
RUN apk update && apk add python3 dcron
RUN pip3 install -r requirements.txt
ADD restic-backup /restic-backup
WORKDIR /restic-backup
ENTRYPOINT []
CMD ["/restic-backup/entrypoint.sh"]

15
README.md Normal file
View File

@ -0,0 +1,15 @@
# restic-volume-backup
*WORK IN PROGRESS*
Backup using using https://restic.net/ for a docker-compose setup.
Backs up all docker volumes. This includes both host mapped volumes and actual docker volumes.
* Cron triggers backup
* Volumes for all running containers are backed up
## Configuration
TODO

1
requirements.txt Normal file
View File

@ -0,0 +1 @@
docker-py==1.10.6

54
restic-backup/backup.py Normal file
View File

@ -0,0 +1,54 @@
import os
import sys
from containers import RunningContainers
import restic
cmds = ['volumes', 'backup', 'snapshots', 'check']
class Config:
container_name = os.environ['CONTAINER_NAME']
password = os.environ['RESTIC_PASSWORD']
@classmethod
def check(cls):
if not cls.container_name:
raise ValueError("CONTAINER env var not set")
if not cls.password:
raise ValueError("PASSWORD env var not set")
def main():
if len(sys.argv) < 2:
raise ValueError("Missing argument: {}".format(cmds))
mode = sys.argv[1]
if mode not in cmds:
raise ValueError("Valid arguments: {}".format(cmds))
Config.check()
containers = RunningContainers()
if mode == 'volumes':
volumes = containers.volume_mounts()
for vol in volumes:
print(vol)
print(vol.mount_string())
binds = containers.bind_mounts()
for vol in binds:
print(vol.mount_string())
if mode == 'backup':
restic.init_repo(Config.container_name)
for vol in containers.backup_volumes():
restic.backup_volume(Config.container_name, vol)
if mode == 'snapshots':
restic.snapshots(Config.container_name)
if __name__ == '__main__':
main()

128
restic-backup/containers.py Normal file
View File

@ -0,0 +1,128 @@
import os
import docker
import json
DOCKER_BASE_URL = os.environ.get('DOCKER_BASE_URL') or "unix://var/run/docker.sock"
VOLUME_TYPE_BIND = "bind"
VOLUME_TYPE_VOLUME = "volume"
class Container:
def __init__(self, data):
self.id = data.get('Id')
self.names = data.get('Names')
self.mounts = [Mount(mnt, container=self) for mnt in data.get('Mounts')]
def to_dict(self):
return {
"Id": self.id,
"Mounts": [mnt.data for mnt in self.mounts]
}
class Mount:
"""Mount wrapper"""
def __init__(self, data, container=None):
self.data = data
self._container = container
@property
def container(self):
return self._container
@property
def type(self):
return self.data.get('Type')
@property
def name(self):
return self.data.get('Name')
@property
def source(self):
return self.data.get('Source')
@property
def destination(self):
return self.data.get('Destination')
@property
def driver(self):
return self.data.get('Driver')
@property
def mode(self):
return self.data.get('Mode')
@property
def rw(self):
return self.data.get('RW')
def mount_string(self):
if self.type == VOLUME_TYPE_VOLUME:
return "- {}:{}:ro".format(self.name.split('_')[-1], self.destination)
elif self.type == VOLUME_TYPE_BIND:
return "- {}:{}:ro".format(self.source, self.destination)
else:
raise ValueError("Uknown volume type: {}".format(self.type))
def __repr__(self):
return str(self)
def __str__(self):
return str(self.data)
def __hash__(self):
"""Uniquness for a volume"""
if self.type == VOLUME_TYPE_VOLUME:
return hash(self.name)
elif self.type == VOLUME_TYPE_BIND:
return hash(self.source)
else:
raise ValueError("Uknown volume type: {}".format(self.type))
class RunningContainers:
def __init__(self):
client = docker.Client(base_url=DOCKER_BASE_URL)
all_containers = client.containers()
client.close()
self.containers = []
self.backup_container = None
for entry in all_containers:
if entry['Id'].startswith(os.environ['HOSTNAME']):
self.backup_container = Container(entry)
else:
if entry['State'] == "running":
self.containers.append(Container(entry))
if not self.backup_container:
raise ValueError("Cannot find metadata for backup container")
def backup_volumes(self):
return self.backup_container.mounts
def gen_volumes(self, volume_type):
"""Generator yielding volumes of a specific type"""
for cont in self.containers:
for mnt in cont.mounts:
if mnt.type == volume_type:
yield mnt
def volume_mounts(self):
"""Docker volumes"""
return set(mnt for mnt in self.gen_volumes(VOLUME_TYPE_VOLUME))
def bind_mounts(self):
"""Host mapped volumes"""
return set(mnt for mnt in self.gen_volumes(VOLUME_TYPE_BIND))
def print_all(self):
print("Backup container:")
print(json.dumps(self.backup_container.to_dict(), indent=2))
print("All containers:")
print(json.dumps([cnt.to_dict() for cnt in self.containers], indent=2))

1
restic-backup/crontab Normal file
View File

@ -0,0 +1 @@
30 * * * * source /env.sh && python3 restic-backup/backup.py backup >> /backup.log 2>&1

8
restic-backup/entrypoint.sh Executable file
View File

@ -0,0 +1,8 @@
#!/bin/sh
# Dump all env vars so we can source them in cron jobs
printenv | sed 's/^\(.*\)$/export \1/g' > /root/env.sh
# start cron in the foreground
crontab crontab
crond -f

55
restic-backup/restic.py Normal file
View File

@ -0,0 +1,55 @@
import os
from subprocess import Popen, PIPE, check_call
def repo_path(container_name):
return "swift:{}:/".format(container_name)
def init_repo(container_name):
run_command([
"restic",
"-r",
repo_path(container_name),
"init",
])
def backup_volume(container_name, volume):
run_command([
"restic",
"-r",
repo_path(container_name),
"--verbose",
"backup",
volume.destination,
])
def snapshots(container_name):
run_command([
"restic",
"-r",
repo_path(container_name),
"snapshots",
])
def check(container_name):
run_command([
"restic",
"-r",
repo_path(container_name),
"check",
])
def run_command(cmd):
child = Popen(cmd, stdout=PIPE, stderr=PIPE)
stdoutdata, stderrdata = child.communicate()
if stdoutdata:
print(stdoutdata.decode())
if stderrdata:
print(stderrdata.decode())