Compare commits

..

9 Commits

Author SHA1 Message Date
Frederik Ring
d7ccdd79fc Merge pull request #26 from offen/instance-profile
Allow s3 authentication via IAM role
2021-09-30 19:32:54 +02:00
Frederik Ring
bd73a2b5e4 allow s3 authentication via IAM role 2021-09-30 19:24:43 +02:00
Frederik Ring
6cf5cf47e7 Merge pull request #25 from offen/delete-on-failure
Ensure script always tries to remove local artifacts even when backup failed
2021-09-13 09:33:12 +02:00
Frederik Ring
53c257065e ensure script always tries to remove local artifacts even when backup failed 2021-09-12 10:48:19 +02:00
Frederik Ring
184b7a1e18 add docs on one off backups using docker cli 2021-09-11 11:21:48 +02:00
Frederik Ring
69a94f226b tweak configuration reference for email settings 2021-09-10 11:58:33 +02:00
Frederik Ring
160a47e90b allow registering hooks at different levels 2021-09-09 16:55:49 +02:00
Frederik Ring
59660ec5c7 include exit log message in notification 2021-09-09 11:08:05 +02:00
Frederik Ring
af3e69b7a8 fix typo in README 2021-09-09 09:19:37 +02:00
3 changed files with 118 additions and 42 deletions

View File

@@ -3,11 +3,13 @@
Backup Docker volumes locally or to any S3 compatible storage.
The [offen/docker-volume-backup](https://hub.docker.com/r/offen/docker-volume-backup) Docker image can be used as a lightweight (below 15MB) sidecar container to an existing Docker setup.
It handles __recurring or one-off backups of Docker volumes__ to a __local directory__ or __any S3 compatible storage__ (or both), and __rotates away old backups__ if configured. It also supports __encrypting your backups using GPG__.
It handles __recurring or one-off backups of Docker volumes__ to a __local directory__ or __any S3 compatible storage__ (or both), and __rotates away old backups__ if configured. It also supports __encrypting your backups using GPG__ and __sending notifications for failed backup runs__.
<!-- MarkdownTOC -->
- [Quickstart](#quickstart)
- [Recurring backups in a compose setup](#recurring-backups-in-a-compose-setup)
- [One-off backups using Docker CLI](#one-off-backups-using-docker-cli)
- [Configuration reference](#configuration-reference)
- [How to](#how-to)
- [Stopping containers during backup](#stopping-containers-during-backup)
@@ -38,6 +40,8 @@ Code and documentation for `v1` versions are found on [this branch][v1-branch].
## Quickstart
### Recurring backups in a compose setup
Add a `backup` service to your compose setup and mount the volumes you would like to see backed up:
```yml
@@ -78,6 +82,22 @@ volumes:
data:
```
### One-off backups using Docker CLI
To run a one time backup, mount the volume you would like to see backed up into a container and run the `backup` command:
```console
docker run --rm \
-v data:/backup/data \
--env AWS_ACCESS_KEY_ID="<xxx>" \
--env AWS_SECRET_ACCESS_KEY="<xxx>" \
--env AWS_S3_BUCKET_NAME="<xxx>" \
--entrypoint backup \
offen/docker-volume-backup:latest
```
Alternatively, pass a `--env-file` in order to use a full config as described below.
## Configuration reference
Backup targets, schedule and retention are configured in environment variables.
@@ -114,6 +134,13 @@ You can populate below template according to your requirements and use it as you
# AWS_ACCESS_KEY_ID="<xxx>"
# AWS_SECRET_ACCESS_KEY="<xxx>"
# Instead of providing static credentials, you can also use IAM instance profiles
# or similar to provide authentication. Some possible configuration options on AWS:
# - EC2: http://169.254.169.254
# - ECS: http://169.254.170.2
# AWS_IAM_ROLE_ENDPOINT="http://169.254.169.254"
# This is the FQDN of your storage server, e.g. `storage.example.com`.
# Do not set this when working against AWS S3 (the default value is
# `s3.amazonaws.com`). If you need to set a specific (non-https) protocol, you
@@ -210,20 +237,12 @@ You can populate below template according to your requirements and use it as you
# EMAIL_NOTIFICATION_SENDER="no-reply@example.com"
# The hostname of your SMTP server.
# Configuration and credentials for the SMTP server to be used.
# EMAIL_SMTP_PORT defaults to 587.
# EMAIL_SMTP_HOST="posteo.de"
# The SMTP password.
# EMAIL_SMTP_PASSWORD="<xxx>"
# The SMTP username.
# EMAIL_SMTP_USERNAME="no-reply@example.com"
The port used when communicating with the server. Defaults to 587.
# EMAIL_SMTP_PORT="<port>"
```

View File

@@ -39,6 +39,15 @@ func main() {
panic(err)
}
defer func() {
if err := recover(); err != nil {
if e, ok := err.(error); ok && strings.Contains(e.Error(), msgBackupFailed) {
os.Exit(1)
}
panic(err)
}
}()
s.must(func() error {
restartContainers, err := s.stopContainers()
defer func() {
@@ -50,9 +59,14 @@ func main() {
return s.takeBackup()
}())
s.must(s.encryptBackup())
s.must(s.copyBackup())
s.must(s.removeArtifacts())
s.must(func() error {
defer func() {
s.must(s.removeArtifacts())
}()
s.must(s.encryptBackup())
return s.copyBackup()
}())
s.must(s.pruneOldBackups())
s.logger.Info("Finished running backup tasks.")
}
@@ -60,10 +74,10 @@ func main() {
// script holds all the stateful information required to orchestrate a
// single backup run.
type script struct {
cli *client.Client
mc *minio.Client
logger *logrus.Logger
errorHooks []errorHook
cli *client.Client
mc *minio.Client
logger *logrus.Logger
hooks []hook
start time.Time
file string
@@ -72,8 +86,6 @@ type script struct {
c *config
}
type errorHook func(err error, start time.Time, logOutput string) error
type config struct {
BackupSources string `split_words:"true" default:"/backup"`
BackupFilename string `split_words:"true" default:"backup-%Y-%m-%dT%H-%M-%S.tar.gz"`
@@ -88,6 +100,7 @@ type config struct {
AwsEndpointInsecure bool `split_words:"true"`
AwsAccessKeyID string `envconfig:"AWS_ACCESS_KEY_ID"`
AwsSecretAccessKey string `split_words:"true"`
AwsIamRoleEndpoint string `split_words:"true"`
GpgPassphrase string `split_words:"true"`
EmailNotificationRecipient string `split_words:"true"`
EmailNotificationSender string `split_words:"true" default:"noreply@nohost"`
@@ -97,6 +110,8 @@ type config struct {
EmailSMTPPassword string `envconfig:"EMAIL_SMTP_PASSWORD"`
}
var msgBackupFailed = "backup run failed"
// newScript creates all resources needed for the script to perform actions against
// remote resources like the Docker engine or remote storage locations. All
// reading from env vars or other configuration sources is expected to happen
@@ -131,12 +146,21 @@ func newScript() (*script, error) {
}
if s.c.AwsS3BucketName != "" {
mc, err := minio.New(s.c.AwsEndpoint, &minio.Options{
Creds: credentials.NewStaticV4(
var creds *credentials.Credentials
if s.c.AwsAccessKeyID != "" && s.c.AwsSecretAccessKey != "" {
creds = credentials.NewStaticV4(
s.c.AwsAccessKeyID,
s.c.AwsSecretAccessKey,
"",
),
)
} else if s.c.AwsIamRoleEndpoint != "" {
creds = credentials.NewIAM(s.c.AwsIamRoleEndpoint)
} else {
return nil, errors.New("newScript: AWS_S3_BUCKET_NAME is defined, but no credentials were provided")
}
mc, err := minio.New(s.c.AwsEndpoint, &minio.Options{
Creds: creds,
Secure: !s.c.AwsEndpointInsecure && s.c.AwsEndpointProto == "https",
})
if err != nil {
@@ -146,7 +170,7 @@ func newScript() (*script, error) {
}
if s.c.EmailNotificationRecipient != "" {
s.errorHooks = append(s.errorHooks, func(err error, start time.Time, logOutput string) error {
s.hooks = append(s.hooks, hook{hookLevelFailure, func(err error, start time.Time, logOutput string) error {
mailer := gomail.NewDialer(
s.c.EmailSMTPHost, s.c.EmailSMTPPort, s.c.EmailSMTPUsername, s.c.EmailSMTPPassword,
)
@@ -155,7 +179,7 @@ func newScript() (*script, error) {
"Failure running docker-volume-backup at %s", start.Format(time.RFC3339),
)
body := fmt.Sprintf(
"Running docker-volume-backup failed with error: %s\n\nLog output before the error occurred:\n\n%s\n", err, logOutput,
"Running docker-volume-backup failed with error: %s\n\nLog output of the failed run was:\n\n%s\n", err, logOutput,
)
message := gomail.NewMessage()
@@ -164,7 +188,7 @@ func newScript() (*script, error) {
message.SetHeader("Subject", subject)
message.SetBody("text/plain", body)
return mailer.DialAndSend(message)
})
}})
}
return s, nil
@@ -362,10 +386,17 @@ func (s *script) copyBackup() error {
// removeArtifacts removes the backup file from disk.
func (s *script) removeArtifacts() error {
if err := os.Remove(s.file); err != nil {
return fmt.Errorf("removeArtifacts: error removing file: %w", err)
_, err := os.Stat(s.file)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return fmt.Errorf("removeArtifacts: error calling stat on file %s: %w", s.file, err)
}
s.logger.Info("Removed local artifacts.")
if err := os.Remove(s.file); err != nil {
return fmt.Errorf("removeArtifacts: error removing file %s: %w", s.file, err)
}
s.logger.Infof("Removed local artifacts %s.", s.file)
return nil
}
@@ -504,17 +535,35 @@ func (s *script) pruneOldBackups() error {
return nil
}
// must exits the script run non-zero and prematurely in case the given error
// is non-nil. If error hooks are present on the script object, they
// runHooks runs all hooks that have been registered using the
// given level. In case executing a hook returns an error, the following
// hooks will still be run before the function returns an error.
func (s *script) runHooks(err error, targetLevel string) error {
var actionErrors []error
for _, hook := range s.hooks {
if hook.level != targetLevel {
continue
}
if err := hook.action(err, s.start, s.output.String()); err != nil {
actionErrors = append(actionErrors, err)
}
}
if len(actionErrors) != 0 {
return join(actionErrors...)
}
return nil
}
// must exits the script run prematurely in case the given error
// is non-nil. If failure hooks have been registered on the script object, they
// will be called, passing the failure and previous log output.
func (s *script) must(err error) {
if err != nil {
for _, hook := range s.errorHooks {
if hookErr := hook(err, s.start, s.output.String()); hookErr != nil {
s.logger.Errorf("An error occurred calling an error hook: %s", hookErr)
}
s.logger.Errorf("Fatal error running backup: %s", err)
if hookErr := s.runHooks(err, hookLevelFailure); hookErr != nil {
s.logger.Errorf("An error occurred calling the registered failure hooks: %s", hookErr)
}
s.logger.Fatalf("Fatal error running backup: %s", err)
panic(errors.New(msgBackupFailed))
}
}
@@ -587,3 +636,14 @@ func (b *bufferingWriter) Write(p []byte) (n int, err error) {
}
return b.writer.Write(p)
}
// hook contains a queued action that can be trigger them when the script
// reaches a certain point (e.g. unsuccessful backup)
type hook struct {
level string
action func(err error, start time.Time, logOutput string) error
}
const (
hookLevelFailure = "failure"
)

View File

@@ -29,8 +29,7 @@ docker run -d \
sleep 10
docker run -d \
--name backup \
docker run --rm \
--network test_network \
-v app_data:/backup/app_data \
-v /var/run/docker.sock:/var/run/docker.sock \
@@ -40,18 +39,16 @@ docker run -d \
--env AWS_ENDPOINT_PROTO=http \
--env AWS_S3_BUCKET_NAME=backup \
--env BACKUP_FILENAME=test.tar.gz \
--env BACKUP_CRON_EXPRESSION="0 0 5 31 2 ?" \
--entrypoint backup \
offen/docker-volume-backup:$TEST_VERSION
docker exec backup backup
docker run --rm -it \
-v backup_data:/data alpine \
ash -c 'tar -xvf /data/backup/test.tar.gz && test -f /backup/app_data/offen.db'
echo "[TEST:PASS] Found relevant files in untared backup."
if [ "$(docker ps -q | wc -l)" != "3" ]; then
if [ "$(docker ps -q | wc -l)" != "2" ]; then
echo "[TEST:FAIL] Expected all containers to be running post backup, instead seen:"
docker ps
exit 1