Compare commits

...

15 Commits

Author SHA1 Message Date
Frederik Ring
8caac8724c Add documentation for header format option 2022-04-19 21:21:23 +02:00
Frederik Ring
9eda23e512 Make header format for created tar archive configurable 2022-04-19 21:11:29 +02:00
Frederik Ring
1b1fc4856c List objects recursively when selecting candidates from S3 (#92) 2022-04-15 11:05:52 +02:00
Frederik Ring
e81c34b8fc Consider S3 Path when selecting candidates for pruning (#91) 2022-04-13 17:09:37 +02:00
Simon Dünhöft
9c23767fce Fixed wrong env name for S3 bucket in README (#89)
The README was using `AWS_BUCKET_NAME` instead of `AWS_S3_BUCKET_NAME` in the recipes. 
This resulted in no data being uploaded to S3.
2022-04-12 19:38:15 +02:00
Frederik Ring
51af8c3c77 Deprecate BACKUP_FROM_SNAPSHOT (#81) 2022-03-25 18:28:58 +01:00
Frederik Ring
1ea0b51b23 Tag releases with major version too (#82) 2022-03-25 18:27:00 +01:00
Frederik Ring
da8c63f755 Support identical cron schedule (#87)
* Retry on lock being unavailable

* Refactor locking to return plain error

* Collect LockedTime in stats

* Add test case

* Add documentation for LOCK_TIMEOUT

* Log in case lock needs to be awaited

* Release resources created for awaiting lock
2022-03-25 18:26:34 +01:00
Frederik Ring
9bc8db0f7c Build using Go 1.18 (#86) 2022-03-17 11:22:41 +01:00
Frederik Ring
508bc07b4f Disable healthcheck in swarm test (#85) 2022-03-17 11:13:07 +01:00
Frederik Ring
b8f71b04a1 Use errgroup for running commands in parallel (#83) 2022-03-10 11:09:39 +01:00
Frederik Ring
5f3832d621 Consider prefix rules when pruning WebDAV storages (#79) 2022-03-05 13:33:15 +01:00
Frederik Ring
4b1127b8c4 Document BACKUP_SOURCES 2022-03-04 19:57:32 +01:00
Frederik Ring
ae50a3ac4f Add attribution to code taken from moby repository 2022-03-04 16:40:34 +01:00
Frederik Ring
bad22eee93 Fix syntax highlighting in container 2022-03-04 14:08:31 +01:00
20 changed files with 254 additions and 75 deletions

View File

@@ -48,6 +48,7 @@ jobs:
if [[ "$CIRCLE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then if [[ "$CIRCLE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
# prerelease tags like `v2.0.0-alpha.1` should not be released as `latest` # prerelease tags like `v2.0.0-alpha.1` should not be released as `latest`
tag_args="$tag_args -t offen/docker-volume-backup:latest" tag_args="$tag_args -t offen/docker-volume-backup:latest"
tag_args="$tag_args -t offen/docker-volume-backup:$(echo "$CIRCLE_TAG" | cut -d. -f1)"
fi fi
docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 \ docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 \
$tag_args . --push $tag_args . --push

View File

@@ -1,7 +1,7 @@
# Copyright 2021 - Offen Authors <hioffen@posteo.de> # Copyright 2021 - Offen Authors <hioffen@posteo.de>
# SPDX-License-Identifier: MPL-2.0 # SPDX-License-Identifier: MPL-2.0
FROM golang:1.17-alpine as builder FROM golang:1.18-alpine as builder
WORKDIR /app WORKDIR /app
COPY go.mod go.sum ./ COPY go.mod go.sum ./

124
README.md
View File

@@ -27,6 +27,7 @@ It handles __recurring or one-off backups of Docker volumes__ to a __local direc
- [Using with Docker Swarm](#using-with-docker-swarm) - [Using with Docker Swarm](#using-with-docker-swarm)
- [Manually triggering a backup](#manually-triggering-a-backup) - [Manually triggering a backup](#manually-triggering-a-backup)
- [Update deprecated email configuration](#update-deprecated-email-configuration) - [Update deprecated email configuration](#update-deprecated-email-configuration)
- [Replace deprecated `BACKUP_FROM_SNAPSHOT` usage](#replace-deprecated-backup_from_snapshot-usage)
- [Using a custom Docker host](#using-a-custom-docker-host) - [Using a custom Docker host](#using-a-custom-docker-host)
- [Run multiple backup schedules in the same container](#run-multiple-backup-schedules-in-the-same-container) - [Run multiple backup schedules in the same container](#run-multiple-backup-schedules-in-the-same-container)
- [Recipes](#recipes) - [Recipes](#recipes)
@@ -107,7 +108,7 @@ docker run --rm \
--env AWS_SECRET_ACCESS_KEY="<xxx>" \ --env AWS_SECRET_ACCESS_KEY="<xxx>" \
--env AWS_S3_BUCKET_NAME="<xxx>" \ --env AWS_S3_BUCKET_NAME="<xxx>" \
--entrypoint backup \ --entrypoint backup \
offen/docker-volume-backup:latest offen/docker-volume-backup:v2
``` ```
Alternatively, pass a `--env-file` in order to use a full config as described below. Alternatively, pass a `--env-file` in order to use a full config as described below.
@@ -149,6 +150,11 @@ You can populate below template according to your requirements and use it as you
# BACKUP_LATEST_SYMLINK="backup.latest.tar.gz" # BACKUP_LATEST_SYMLINK="backup.latest.tar.gz"
# ************************************************************************
# The BACKUP_FROM_SNAPSHOT option has been deprecated and will be removed
# in the next major version. Please use exec-pre and exec-post
# as documented below instead.
# ************************************************************************
# Whether to copy the content of backup folder before creating the tar archive. # Whether to copy the content of backup folder before creating the tar archive.
# In the rare scenario where the content of the source backup volume is continously # In the rare scenario where the content of the source backup volume is continously
# updating, but we do not wish to stop the container while performing the backup, # updating, but we do not wish to stop the container while performing the backup,
@@ -156,6 +162,11 @@ You can populate below template according to your requirements and use it as you
# BACKUP_FROM_SNAPSHOT="false" # BACKUP_FROM_SNAPSHOT="false"
# By default, the `/backup` directory inside the container will be backed up.
# In case you need to use a custom location, set `BACKUP_SOURCES`.
# BACKUP_SOURCES="/other/location"
########### BACKUP STORAGE ########### BACKUP STORAGE
# The name of the remote bucket that should be used for storing backups. If # The name of the remote bucket that should be used for storing backups. If
@@ -330,6 +341,29 @@ You can populate below template according to your requirements and use it as you
# DOCKER_HOST="tcp://docker_socket_proxy:2375" # DOCKER_HOST="tcp://docker_socket_proxy:2375"
########### LOCK_TIMEOUT
# In the case of overlapping cron schedules run by the same container,
# subsequent invocations will wait for previous runs to finish before starting.
# By default, this will time out and fail in case the lock could not be acquired
# after 60 minutes. In case you need to adjust this timeout, supply a duration
# value as per https://pkg.go.dev/time#ParseDuration to `LOCK_TIMEOUT`
# LOCK_TIMEOUT="60m"
########### HEADER FORMAT USED BY THE TAR ARCHIVE
# By default, tar archive creation will pick a header format that is appropriate
# for the archive's contents. In case you have special requirements or need to
# work with tools that do not support all standard header formats, you can use
# this option to enforce a certain header format. Valid options are "USTAR",
# "PAX" and "GNU". Be aware that setting this value might create situations where
# it's not possible to encode the information about a certain file, making the
# backup fail.
# In case no value is set, an appropriate format will be selected for each file.
# TAR_ARCHIVE_HEADER_FORMAT="USTAR"
########### EMAIL NOTIFICATIONS ########### EMAIL NOTIFICATIONS
# ************************************************************************ # ************************************************************************
@@ -386,7 +420,7 @@ services:
- docker-volume-backup.stop-during-backup=service1 - docker-volume-backup.stop-during-backup=service1
backup: backup:
image: offen/docker-volume-backup:latest image: offen/docker-volume-backup:v2
environment: environment:
BACKUP_STOP_CONTAINER_LABEL: service1 BACKUP_STOP_CONTAINER_LABEL: service1
volumes: volumes:
@@ -409,7 +443,7 @@ version: '3'
services: services:
# ... define other services using the `data` volume here # ... define other services using the `data` volume here
backup: backup:
image: offen/docker-volume-backup:latest image: offen/docker-volume-backup:v2
environment: environment:
BACKUP_FILENAME: backup-%Y-%m-%dT%H-%M-%S.tar.gz BACKUP_FILENAME: backup-%Y-%m-%dT%H-%M-%S.tar.gz
BACKUP_PRUNING_PREFIX: backup- BACKUP_PRUNING_PREFIX: backup-
@@ -432,7 +466,7 @@ version: '3'
services: services:
backup: backup:
image: offen/docker-volume-backup:latest image: offen/docker-volume-backup:v2
environment: environment:
# ... other configuration values go here # ... other configuration values go here
NOTIFICATION_URLS=smtp://me:secret@smtp.example.com:587/?fromAddress=no-reply@example.com&toAddresses=you@example.com NOTIFICATION_URLS=smtp://me:secret@smtp.example.com:587/?fromAddress=no-reply@example.com&toAddresses=you@example.com
@@ -510,7 +544,7 @@ services:
- docker-volume-backup.exec-label=database - docker-volume-backup.exec-label=database
backup: backup:
image: offen/docker-volume-backup:latest image: offen/docker-volume-backup:v2
environment: environment:
EXEC_LABEL: database EXEC_LABEL: database
volumes: volumes:
@@ -587,7 +621,7 @@ version: '3'
services: services:
backup: backup:
image: offen/docker-volume-backup:latest image: offen/docker-volume-backup:v2
volumes: volumes:
- data:/backup/my-app-backup:ro - data:/backup/my-app-backup:ro
- /etc/timezone:/etc/timezone:ro - /etc/timezone:/etc/timezone:ro
@@ -610,7 +644,7 @@ When running in Swarm mode, it's also advised to set a hard memory limit on your
```yml ```yml
services: services:
backup: backup:
image: offen/docker-volume-backup:latest image: offen/docker-volume-backup:v2
deployment: deployment:
resources: resources:
limits: limits:
@@ -645,6 +679,37 @@ After:
NOTIFICATION_URLS=smtp://me:secret@posteo.de:587/?fromAddress=no-reply@example.com&toAddresses=you@example.com NOTIFICATION_URLS=smtp://me:secret@posteo.de:587/?fromAddress=no-reply@example.com&toAddresses=you@example.com
``` ```
### Replace deprecated `BACKUP_FROM_SNAPSHOT` usage
Starting with version 2.15.0, the `BACKUP_FROM_SNAPSHOT` feature has been deprecated.
If you need to prepare your sources before the backup is taken, use `exec-pre`, `exec-post` and an intermediate volume:
```yml
version: '3'
services:
my_app:
build: .
volumes:
- data:/var/my_app
- backup:/tmp/backup
labels:
- docker-volume-backup.exec-pre=cp -r /var/my_app /tmp/backup/my-app
- docker-volume-backup.exec-post=rm -rf /tmp/backup/my-app
backup:
image: offen/docker-volume-backup:latest
environment:
BACKUP_SOURCES: /tmp/backup
volumes:
- backup:/backup:ro
- /var/run/docker.sock:/var/run/docker.sock:ro
volumes:
data:
backup:
```
### Using a custom Docker host ### Using a custom Docker host
If you are interfacing with Docker via TCP, set `DOCKER_HOST` to the correct URL. If you are interfacing with Docker via TCP, set `DOCKER_HOST` to the correct URL.
@@ -658,13 +723,13 @@ In case you are using a socket proxy, it must support `GET` and `POST` requests
Multiple backup schedules with different configuration can be configured by mounting an arbitrary number of configuration files (using the `.env` format) into `/etc/dockervolumebackup/conf.d`: Multiple backup schedules with different configuration can be configured by mounting an arbitrary number of configuration files (using the `.env` format) into `/etc/dockervolumebackup/conf.d`:
``` ```yml
version: '3' version: '3'
services: services:
# ... define other services using the `data` volume here # ... define other services using the `data` volume here
backup: backup:
image: offen/docker-volume-backup:latest image: offen/docker-volume-backup:v2
volumes: volumes:
- data:/backup/my-app-backup:ro - data:/backup/my-app-backup:ro
- /var/run/docker.sock:/var/run/docker.sock:ro - /var/run/docker.sock:/var/run/docker.sock:ro
@@ -676,7 +741,8 @@ volumes:
A separate cronjob will be created for each config file. A separate cronjob will be created for each config file.
If a configuration value is set both in the global environment as well as in the config file, the config file will take precedence. If a configuration value is set both in the global environment as well as in the config file, the config file will take precedence.
The `backup` command expects to run on an exclusive lock, so it is your responsibility to make sure the invocations do not overlap. The `backup` command expects to run on an exclusive lock, so in case you provide the same or overlapping schedules in your cron expressions, the runs will still be executed serially, one after the other.
The exact order of schedules that use the same cron expression is not specified.
In case you need your schedules to overlap, you need to create a dedicated container for each schedule instead. In case you need your schedules to overlap, you need to create a dedicated container for each schedule instead.
When changing the configuration, you currently need to manually restart the container for the changes to take effect. When changing the configuration, you currently need to manually restart the container for the changes to take effect.
@@ -692,9 +758,9 @@ version: '3'
services: services:
# ... define other services using the `data` volume here # ... define other services using the `data` volume here
backup: backup:
image: offen/docker-volume-backup:latest image: offen/docker-volume-backup:v2
environment: environment:
AWS_BUCKET_NAME: backup-bucket AWS_S3_BUCKET_NAME: backup-bucket
AWS_ACCESS_KEY_ID: AKIAIOSFODNN7EXAMPLE AWS_ACCESS_KEY_ID: AKIAIOSFODNN7EXAMPLE
AWS_SECRET_ACCESS_KEY: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY AWS_SECRET_ACCESS_KEY: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
volumes: volumes:
@@ -713,10 +779,10 @@ version: '3'
services: services:
# ... define other services using the `data` volume here # ... define other services using the `data` volume here
backup: backup:
image: offen/docker-volume-backup:latest image: offen/docker-volume-backup:v2
environment: environment:
AWS_ENDPOINT: s3.filebase.com AWS_ENDPOINT: s3.filebase.com
AWS_BUCKET_NAME: filebase-bucket AWS_S3_BUCKET_NAME: filebase-bucket
AWS_ACCESS_KEY_ID: FILEBASE-ACCESS-KEY AWS_ACCESS_KEY_ID: FILEBASE-ACCESS-KEY
AWS_SECRET_ACCESS_KEY: FILEBASE-SECRET-KEY AWS_SECRET_ACCESS_KEY: FILEBASE-SECRET-KEY
volumes: volumes:
@@ -735,10 +801,10 @@ version: '3'
services: services:
# ... define other services using the `data` volume here # ... define other services using the `data` volume here
backup: backup:
image: offen/docker-volume-backup:latest image: offen/docker-volume-backup:v2
environment: environment:
AWS_ENDPOINT: minio.example.com AWS_ENDPOINT: minio.example.com
AWS_BUCKET_NAME: backup-bucket AWS_S3_BUCKET_NAME: backup-bucket
AWS_ACCESS_KEY_ID: MINIOACCESSKEY AWS_ACCESS_KEY_ID: MINIOACCESSKEY
AWS_SECRET_ACCESS_KEY: MINIOSECRETKEY AWS_SECRET_ACCESS_KEY: MINIOSECRETKEY
volumes: volumes:
@@ -757,7 +823,7 @@ version: '3'
services: services:
# ... define other services using the `data` volume here # ... define other services using the `data` volume here
backup: backup:
image: offen/docker-volume-backup:latest image: offen/docker-volume-backup:v2
environment: environment:
WEBDAV_URL: https://webdav.mydomain.me WEBDAV_URL: https://webdav.mydomain.me
WEBDAV_PATH: /my/directory/ WEBDAV_PATH: /my/directory/
@@ -779,7 +845,7 @@ version: '3'
services: services:
# ... define other services using the `data` volume here # ... define other services using the `data` volume here
backup: backup:
image: offen/docker-volume-backup:latest image: offen/docker-volume-backup:v2
environment: environment:
BACKUP_FILENAME: backup-%Y-%m-%dT%H-%M-%S.tar.gz BACKUP_FILENAME: backup-%Y-%m-%dT%H-%M-%S.tar.gz
BACKUP_LATEST_SYMLINK: backup-latest.tar.gz BACKUP_LATEST_SYMLINK: backup-latest.tar.gz
@@ -800,9 +866,9 @@ version: '3'
services: services:
# ... define other services using the `data` volume here # ... define other services using the `data` volume here
backup: backup:
image: offen/docker-volume-backup:latest image: offen/docker-volume-backup:v2
environment: environment:
AWS_BUCKET_NAME: backup-bucket AWS_S3_BUCKET_NAME: backup-bucket
AWS_ACCESS_KEY_ID: AKIAIOSFODNN7EXAMPLE AWS_ACCESS_KEY_ID: AKIAIOSFODNN7EXAMPLE
AWS_SECRET_ACCESS_KEY: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY AWS_SECRET_ACCESS_KEY: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
volumes: volumes:
@@ -822,11 +888,11 @@ version: '3'
services: services:
# ... define other services using the `data` volume here # ... define other services using the `data` volume here
backup: backup:
image: offen/docker-volume-backup:latest image: offen/docker-volume-backup:v2
environment: environment:
# take a backup on every hour # take a backup on every hour
BACKUP_CRON_EXPRESSION: "0 * * * *" BACKUP_CRON_EXPRESSION: "0 * * * *"
AWS_BUCKET_NAME: backup-bucket AWS_S3_BUCKET_NAME: backup-bucket
AWS_ACCESS_KEY_ID: AKIAIOSFODNN7EXAMPLE AWS_ACCESS_KEY_ID: AKIAIOSFODNN7EXAMPLE
AWS_SECRET_ACCESS_KEY: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY AWS_SECRET_ACCESS_KEY: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
volumes: volumes:
@@ -845,9 +911,9 @@ version: '3'
services: services:
# ... define other services using the `data` volume here # ... define other services using the `data` volume here
backup: backup:
image: offen/docker-volume-backup:latest image: offen/docker-volume-backup:v2
environment: environment:
AWS_BUCKET_NAME: backup-bucket AWS_S3_BUCKET_NAME: backup-bucket
AWS_ACCESS_KEY_ID: AKIAIOSFODNN7EXAMPLE AWS_ACCESS_KEY_ID: AKIAIOSFODNN7EXAMPLE
AWS_SECRET_ACCESS_KEY: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY AWS_SECRET_ACCESS_KEY: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
BACKUP_FILENAME: backup-%Y-%m-%dT%H-%M-%S.tar.gz BACKUP_FILENAME: backup-%Y-%m-%dT%H-%M-%S.tar.gz
@@ -869,9 +935,9 @@ version: '3'
services: services:
# ... define other services using the `data` volume here # ... define other services using the `data` volume here
backup: backup:
image: offen/docker-volume-backup:latest image: offen/docker-volume-backup:v2
environment: environment:
AWS_BUCKET_NAME: backup-bucket AWS_S3_BUCKET_NAME: backup-bucket
AWS_ACCESS_KEY_ID: AKIAIOSFODNN7EXAMPLE AWS_ACCESS_KEY_ID: AKIAIOSFODNN7EXAMPLE
AWS_SECRET_ACCESS_KEY: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY AWS_SECRET_ACCESS_KEY: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
GPG_PASSPHRASE: somesecretstring GPG_PASSPHRASE: somesecretstring
@@ -896,7 +962,7 @@ services:
volumes: volumes:
- app_data:/tmp/dumps - app_data:/tmp/dumps
backup: backup:
image: offen/docker-volume-backup:latest image: offen/docker-volume-backup:v2
environment: environment:
BACKUP_FILENAME: db.tar.gz BACKUP_FILENAME: db.tar.gz
BACKUP_CRON_EXPRESSION: "0 2 * * *" BACKUP_CRON_EXPRESSION: "0 2 * * *"
@@ -917,10 +983,10 @@ version: '3'
services: services:
# ... define other services using the `data_1` and `data_2` volumes here # ... define other services using the `data_1` and `data_2` volumes here
backup_1: &backup_service backup_1: &backup_service
image: offen/docker-volume-backup:latest image: offen/docker-volume-backup:v2
environment: &backup_environment environment: &backup_environment
BACKUP_CRON_EXPRESSION: "0 2 * * *" BACKUP_CRON_EXPRESSION: "0 2 * * *"
AWS_BUCKET_NAME: backup-bucket AWS_S3_BUCKET_NAME: backup-bucket
AWS_ACCESS_KEY_ID: AKIAIOSFODNN7EXAMPLE AWS_ACCESS_KEY_ID: AKIAIOSFODNN7EXAMPLE
AWS_SECRET_ACCESS_KEY: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY AWS_SECRET_ACCESS_KEY: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
# Label the container using the `data_1` volume as `docker-volume-backup.stop-during-backup=service1` # Label the container using the `data_1` volume as `docker-volume-backup.stop-during-backup=service1`

View File

@@ -18,7 +18,7 @@ import (
"strings" "strings"
) )
func createArchive(inputFilePath, outputFilePath string) error { func createArchive(inputFilePath, outputFilePath string, options createArchiveOptions) error {
inputFilePath = stripTrailingSlashes(inputFilePath) inputFilePath = stripTrailingSlashes(inputFilePath)
inputFilePath, outputFilePath, err := makeAbsolute(inputFilePath, outputFilePath) inputFilePath, outputFilePath, err := makeAbsolute(inputFilePath, outputFilePath)
if err != nil { if err != nil {
@@ -28,7 +28,7 @@ func createArchive(inputFilePath, outputFilePath string) error {
return fmt.Errorf("createArchive: error creating output file path: %w", err) return fmt.Errorf("createArchive: error creating output file path: %w", err)
} }
if err := compress(inputFilePath, outputFilePath, filepath.Dir(inputFilePath)); err != nil { if err := compress(inputFilePath, outputFilePath, filepath.Dir(inputFilePath), options); err != nil {
return fmt.Errorf("createArchive: error creating archive: %w", err) return fmt.Errorf("createArchive: error creating archive: %w", err)
} }
@@ -52,7 +52,7 @@ func makeAbsolute(inputFilePath, outputFilePath string) (string, string, error)
return inputFilePath, outputFilePath, err return inputFilePath, outputFilePath, err
} }
func compress(inPath, outFilePath, subPath string) error { func compress(inPath, outFilePath, subPath string, options createArchiveOptions) error {
file, err := os.Create(outFilePath) file, err := os.Create(outFilePath)
if err != nil { if err != nil {
return fmt.Errorf("compress: error creating out file: %w", err) return fmt.Errorf("compress: error creating out file: %w", err)
@@ -71,7 +71,7 @@ func compress(inPath, outFilePath, subPath string) error {
} }
for _, p := range paths { for _, p := range paths {
if err := writeTarGz(p, tarWriter, prefix); err != nil { if err := writeTarGz(p, tarWriter, prefix, options.format); err != nil {
return fmt.Errorf("compress error writing %s to archive: %w", p, err) return fmt.Errorf("compress error writing %s to archive: %w", p, err)
} }
} }
@@ -94,7 +94,7 @@ func compress(inPath, outFilePath, subPath string) error {
return nil return nil
} }
func writeTarGz(path string, tarWriter *tar.Writer, prefix string) error { func writeTarGz(path string, tarWriter *tar.Writer, prefix string, format tar.Format) error {
fileInfo, err := os.Lstat(path) fileInfo, err := os.Lstat(path)
if err != nil { if err != nil {
return fmt.Errorf("writeTarGz: error getting file infor for %s: %w", path, err) return fmt.Errorf("writeTarGz: error getting file infor for %s: %w", path, err)
@@ -113,6 +113,10 @@ func writeTarGz(path string, tarWriter *tar.Writer, prefix string) error {
} }
header, err := tar.FileInfoHeader(fileInfo, link) header, err := tar.FileInfoHeader(fileInfo, link)
if format >= 0 {
header.Format = format
}
if err != nil { if err != nil {
return fmt.Errorf("writeTarGz: error getting file info header: %w", err) return fmt.Errorf("writeTarGz: error getting file info header: %w", err)
} }
@@ -140,3 +144,7 @@ func writeTarGz(path string, tarWriter *tar.Writer, prefix string) error {
return nil return nil
} }
type createArchiveOptions struct {
format tar.Format
}

View File

@@ -3,7 +3,11 @@
package main package main
import "time" import (
"archive/tar"
"fmt"
"time"
)
// Config holds all configuration values that are expected to be set // Config holds all configuration values that are expected to be set
// by users. // by users.
@@ -41,4 +45,31 @@ type Config struct {
WebdavPassword string `split_words:"true"` WebdavPassword string `split_words:"true"`
ExecLabel string `split_words:"true"` ExecLabel string `split_words:"true"`
ExecForwardOutput bool `split_words:"true"` ExecForwardOutput bool `split_words:"true"`
LockTimeout time.Duration `split_words:"true" default:"60m"`
TarArchiveHeaderFormat TarFormat `split_words:"true"`
}
type TarFormat tar.Format
func (t *TarFormat) Decode(value string) error {
switch value {
case "PAX":
*t = TarFormat(tar.FormatPAX)
return nil
case "USTAR":
*t = TarFormat(tar.FormatUSTAR)
return nil
case "GNU":
*t = TarFormat(tar.FormatGNU)
return nil
case "":
*t = TarFormat(-1)
return nil
default:
return fmt.Errorf("tarFormat: unknown format %s", value)
}
}
func (t *TarFormat) Format() tar.Format {
return tar.Format(*t)
} }

View File

@@ -1,6 +1,9 @@
// Copyright 2022 - Offen Authors <hioffen@posteo.de> // Copyright 2022 - Offen Authors <hioffen@posteo.de>
// SPDX-License-Identifier: MPL-2.0 // SPDX-License-Identifier: MPL-2.0
// Portions of this file are taken and adapted from `moby`, Copyright 2012-2017 Docker, Inc.
// Licensed under the Apache 2.0 License: https://github.com/moby/moby/blob/8e610b2b55bfd1bfa9436ab110d311f5e8a74dcb/LICENSE
package main package main
import ( import (
@@ -10,12 +13,12 @@ import (
"io/ioutil" "io/ioutil"
"os" "os"
"strings" "strings"
"sync"
"github.com/cosiner/argv" "github.com/cosiner/argv"
"github.com/docker/docker/api/types" "github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/filters" "github.com/docker/docker/api/types/filters"
"github.com/docker/docker/pkg/stdcopy" "github.com/docker/docker/pkg/stdcopy"
"golang.org/x/sync/errgroup"
) )
func (s *script) exec(containerRef string, command string) ([]byte, []byte, error) { func (s *script) exec(containerRef string, command string) ([]byte, []byte, error) {
@@ -94,29 +97,27 @@ func (s *script) runLabeledCommands(label string) error {
return nil return nil
} }
wg := sync.WaitGroup{} g := new(errgroup.Group)
wg.Add(len(containersWithCommand))
var cmdErrors []error
for _, container := range containersWithCommand { for _, container := range containersWithCommand {
go func(c types.Container) { c := container
g.Go(func() error {
cmd, _ := c.Labels[label] cmd, _ := c.Labels[label]
s.logger.Infof("Running %s command %s for container %s", label, cmd, strings.TrimPrefix(c.Names[0], "/")) s.logger.Infof("Running %s command %s for container %s", label, cmd, strings.TrimPrefix(c.Names[0], "/"))
stdout, stderr, err := s.exec(c.ID, cmd) stdout, stderr, err := s.exec(c.ID, cmd)
if err != nil {
cmdErrors = append(cmdErrors, err)
}
if s.c.ExecForwardOutput { if s.c.ExecForwardOutput {
os.Stderr.Write(stderr) os.Stderr.Write(stderr)
os.Stdout.Write(stdout) os.Stdout.Write(stdout)
} }
wg.Done() if err != nil {
}(container) return fmt.Errorf("runLabeledCommands: error executing command: %w", err)
}
return nil
})
} }
wg.Wait() if err := g.Wait(); err != nil {
if len(cmdErrors) != 0 { return fmt.Errorf("runLabeledCommands: error from errgroup: %w", err)
return join(cmdErrors...)
} }
return nil return nil
} }

58
cmd/backup/lock.go Normal file
View File

@@ -0,0 +1,58 @@
// Copyright 2022 - Offen Authors <hioffen@posteo.de>
// SPDX-License-Identifier: MPL-2.0
package main
import (
"errors"
"fmt"
"time"
"github.com/gofrs/flock"
)
// lock opens a lockfile at the given location, keeping it locked until the
// caller invokes the returned release func. In case the lock is currently blocked
// by another execution, it will repeatedly retry until the lock is available
// or the given timeout is exceeded.
func (s *script) lock(lockfile string) (func() error, error) {
start := time.Now()
defer func() {
s.stats.LockedTime = time.Now().Sub(start)
}()
retry := time.NewTicker(5 * time.Second)
defer retry.Stop()
deadline := time.NewTimer(s.c.LockTimeout)
defer deadline.Stop()
fileLock := flock.New(lockfile)
for {
acquired, err := fileLock.TryLock()
if err != nil {
return noop, fmt.Errorf("lock: error trying lock: %w", err)
}
if acquired {
if s.encounteredLock {
s.logger.Info("Acquired exclusive lock on subsequent attempt, ready to continue.")
}
return fileLock.Unlock, nil
}
if !s.encounteredLock {
s.logger.Infof(
"Exclusive lock was not available on first attempt. Will retry until it becomes available or the timeout of %s is exceeded.",
s.c.LockTimeout,
)
s.encounteredLock = true
}
select {
case <-retry.C:
continue
case <-deadline.C:
return noop, errors.New("lock: timed out waiting for lockfile to become available")
}
}
}

View File

@@ -8,14 +8,15 @@ import (
) )
func main() { func main() {
unlock := lock("/var/lock/dockervolumebackup.lock")
defer unlock()
s, err := newScript() s, err := newScript()
if err != nil { if err != nil {
panic(err) panic(err)
} }
unlock, err := s.lock("/var/lock/dockervolumebackup.lock")
defer unlock()
s.must(err)
defer func() { defer func() {
if pArg := recover(); pArg != nil { if pArg := recover(); pArg != nil {
if err, ok := pArg.(error); ok { if err, ok := pArg.(error); ok {

View File

@@ -12,6 +12,7 @@ import (
"os" "os"
"path" "path"
"path/filepath" "path/filepath"
"strings"
"text/template" "text/template"
"time" "time"
@@ -46,6 +47,8 @@ type script struct {
file string file string
stats *Stats stats *Stats
encounteredLock bool
c *Config c *Config
} }
@@ -353,6 +356,12 @@ func (s *script) takeBackup() error {
backupSources := s.c.BackupSources backupSources := s.c.BackupSources
if s.c.BackupFromSnapshot { if s.c.BackupFromSnapshot {
s.logger.Warn(
"Using BACKUP_FROM_SNAPSHOT has been deprecated and will be removed in the next major version.",
)
s.logger.Warn(
"Please use `exec-pre` and `exec-post` commands to prepare your backup sources. Refer to the README for an upgrade guide.",
)
backupSources = filepath.Join("/tmp", s.c.BackupSources) backupSources = filepath.Join("/tmp", s.c.BackupSources)
// copy before compressing guard against a situation where backup folder's content are still growing. // copy before compressing guard against a situation where backup folder's content are still growing.
s.registerHook(hookLevelPlumbing, func(error) error { s.registerHook(hookLevelPlumbing, func(error) error {
@@ -379,7 +388,9 @@ func (s *script) takeBackup() error {
s.logger.Infof("Removed tar file `%s`.", tarFile) s.logger.Infof("Removed tar file `%s`.", tarFile)
return nil return nil
}) })
if err := createArchive(backupSources, tarFile); err != nil { if err := createArchive(backupSources, tarFile, createArchiveOptions{
format: s.c.TarArchiveHeaderFormat.Format(),
}); err != nil {
return fmt.Errorf("takeBackup: error compressing backup folder: %w", err) return fmt.Errorf("takeBackup: error compressing backup folder: %w", err)
} }
@@ -527,7 +538,8 @@ func (s *script) pruneBackups() error {
if s.minioClient != nil { if s.minioClient != nil {
candidates := s.minioClient.ListObjects(context.Background(), s.c.AwsS3BucketName, minio.ListObjectsOptions{ candidates := s.minioClient.ListObjects(context.Background(), s.c.AwsS3BucketName, minio.ListObjectsOptions{
WithMetadata: true, WithMetadata: true,
Prefix: s.c.BackupPruningPrefix, Prefix: filepath.Join(s.c.AwsS3Path, s.c.BackupPruningPrefix),
Recursive: true,
}) })
var matches []minio.ObjectInfo var matches []minio.ObjectInfo
@@ -580,6 +592,9 @@ func (s *script) pruneBackups() error {
var matches []fs.FileInfo var matches []fs.FileInfo
var lenCandidates int var lenCandidates int
for _, candidate := range candidates { for _, candidate := range candidates {
if !strings.HasPrefix(candidate.Name(), s.c.BackupPruningPrefix) {
continue
}
lenCandidates++ lenCandidates++
if candidate.ModTime().Before(deadline) { if candidate.ModTime().Before(deadline) {
matches = append(matches, candidate) matches = append(matches, candidate)

View File

@@ -42,6 +42,7 @@ type Stats struct {
StartTime time.Time StartTime time.Time
EndTime time.Time EndTime time.Time
TookTime time.Duration TookTime time.Duration
LockedTime time.Duration
LogOutput *bytes.Buffer LogOutput *bytes.Buffer
Containers ContainersStats Containers ContainersStats
BackupFile BackupFileStats BackupFile BackupFileStats

View File

@@ -10,27 +10,10 @@ import (
"io" "io"
"os" "os"
"strings" "strings"
"github.com/gofrs/flock"
) )
var noop = func() error { return nil } var noop = func() error { return nil }
// lock opens a lockfile at the given location, keeping it locked until the
// caller invokes the returned release func. When invoked while the file is
// still locked the function panics.
func lock(lockfile string) func() error {
fileLock := flock.New(lockfile)
acquired, err := fileLock.TryLock()
if err != nil {
panic(err)
}
if !acquired {
panic("unable to acquire file lock")
}
return fileLock.Unlock
}
// copy creates a copy of the file located at `dst` at `src`. // copy creates a copy of the file located at `dst` at `src`.
func copyFile(src, dst string) error { func copyFile(src, dst string) error {
in, err := os.Open(src) in, err := os.Open(src)

View File

@@ -13,6 +13,7 @@ Here is a list of all data passed to the template:
* `StartTime`: time when the script started execution * `StartTime`: time when the script started execution
* `EndTime`: time when the backup has completed successfully (after pruning) * `EndTime`: time when the backup has completed successfully (after pruning)
* `TookTime`: amount of time it took for the backup to run. (equal to `EndTime - StartTime`) * `TookTime`: amount of time it took for the backup to run. (equal to `EndTime - StartTime`)
* `LockedTime`: amount of time it took for the backup to acquire the exclusive lock
* `LogOutput`: full log of the application * `LogOutput`: full log of the application
* `Containers`: object containing stats about the docker containers * `Containers`: object containing stats about the docker containers
* `All`: total number of containers * `All`: total number of containers

3
go.mod
View File

@@ -1,6 +1,6 @@
module github.com/offen/docker-volume-backup module github.com/offen/docker-volume-backup
go 1.17 go 1.18
require ( require (
github.com/containrrr/shoutrrr v0.5.2 github.com/containrrr/shoutrrr v0.5.2
@@ -14,6 +14,7 @@ require (
github.com/sirupsen/logrus v1.8.1 github.com/sirupsen/logrus v1.8.1
github.com/studio-b12/gowebdav v0.0.0-20211109083228-3f8721cd4b6f github.com/studio-b12/gowebdav v0.0.0-20211109083228-3f8721cd4b6f
golang.org/x/crypto v0.0.0-20210817164053-32db794688a5 golang.org/x/crypto v0.0.0-20210817164053-32db794688a5
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c
) )
require ( require (

3
go.sum
View File

@@ -430,7 +430,6 @@ github.com/klauspost/compress v1.13.5/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47e
github.com/klauspost/compress v1.13.6 h1:P76CopJELS0TiO2mebmnzgWaajssP/EszplttgQxcgc= github.com/klauspost/compress v1.13.6 h1:P76CopJELS0TiO2mebmnzgWaajssP/EszplttgQxcgc=
github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
github.com/klauspost/cpuid v1.2.3/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek= github.com/klauspost/cpuid v1.2.3/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek=
github.com/klauspost/cpuid v1.3.1 h1:5JNjFYYQrZeKRJ0734q51WCEEn2huer72Dc7K+R/b6s=
github.com/klauspost/cpuid v1.3.1/go.mod h1:bYW4mA6ZgKPob1/Dlai2LviZJO7KGI3uoWLd42rAQw4= github.com/klauspost/cpuid v1.3.1/go.mod h1:bYW4mA6ZgKPob1/Dlai2LviZJO7KGI3uoWLd42rAQw4=
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.0.4/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.0.4/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
@@ -806,6 +805,8 @@ golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=

2
test/confd/02backup.env Normal file
View File

@@ -0,0 +1,2 @@
BACKUP_FILENAME="other.tar.gz"
BACKUP_CRON_EXPRESSION="*/1 * * * *"

View File

@@ -7,8 +7,9 @@ services:
volumes: volumes:
- ./local:/archive - ./local:/archive
- app_data:/backup/app_data:ro - app_data:/backup/app_data:ro
- ./backup.env:/etc/dockervolumebackup/conf.d/00backup.env - ./01backup.env:/etc/dockervolumebackup/conf.d/01backup.env
- ./never.env:/etc/dockervolumebackup/conf.d/10never.env - ./02backup.env:/etc/dockervolumebackup/conf.d/02backup.env
- ./03never.env:/etc/dockervolumebackup/conf.d/03never.env
- /var/run/docker.sock:/var/run/docker.sock - /var/run/docker.sock:/var/run/docker.sock
offen: offen:

View File

@@ -19,6 +19,12 @@ if [ ! -f ./local/conf.tar.gz ]; then
fi fi
echo "[TEST:PASS] Config from file was used." echo "[TEST:PASS] Config from file was used."
if [ ! -f ./local/other.tar.gz ]; then
echo "[TEST:FAIL] Run on same schedule did not succeed."
exit 1
fi
echo "[TEST:PASS] Run on same schedule succeeded."
if [ -f ./local/never.tar.gz ]; then if [ -f ./local/never.tar.gz ]; then
echo "[TEST:FAIL] Unexpected file was found." echo "[TEST:FAIL] Unexpected file was found."
exit 1 exit 1

View File

@@ -43,6 +43,8 @@ services:
image: offen/offen:latest image: offen/offen:latest
labels: labels:
- docker-volume-backup.stop-during-backup=true - docker-volume-backup.stop-during-backup=true
healthcheck:
disable: true
deploy: deploy:
replicas: 2 replicas: 2
restart_policy: restart_policy: