mirror of
https://github.com/offen/docker-volume-backup.git
synced 2025-12-05 09:08:02 +01:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9ebb9e14e | ||
|
|
6e1b8553e6 | ||
|
|
5ec2b2c3ff | ||
|
|
3bbeba5b83 | ||
|
|
9155b4d130 | ||
|
|
2a17e84ab6 | ||
|
|
00f2359461 | ||
|
|
0504a92a1f | ||
|
|
3ded77448c | ||
|
|
58b42b9036 | ||
|
|
180438f1fc | ||
|
|
30265c14ba |
@@ -3,7 +3,7 @@ version: 2.1
|
||||
jobs:
|
||||
canary:
|
||||
machine:
|
||||
image: ubuntu-1604:202007-01
|
||||
image: ubuntu-2004:202201-02
|
||||
working_directory: ~/docker-volume-backup
|
||||
steps:
|
||||
- checkout
|
||||
@@ -19,6 +19,7 @@ jobs:
|
||||
name: Run tests
|
||||
working_directory: ~/docker-volume-backup/test
|
||||
command: |
|
||||
export GPG_TTY=$(tty)
|
||||
./test.sh canary
|
||||
|
||||
build:
|
||||
|
||||
@@ -14,7 +14,7 @@ FROM alpine:3.15
|
||||
|
||||
WORKDIR /root
|
||||
|
||||
RUN apk add --update ca-certificates
|
||||
RUN apk add --no-cache ca-certificates
|
||||
|
||||
COPY --from=builder /app/cmd/backup/backup /usr/bin/backup
|
||||
|
||||
|
||||
183
README.md
183
README.md
@@ -16,16 +16,19 @@ It handles __recurring or one-off backups of Docker volumes__ to a __local direc
|
||||
- [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)
|
||||
- [Stop containers during backup](#stop-containers-during-backup)
|
||||
- [Automatically pruning old backups](#automatically-pruning-old-backups)
|
||||
- [Send email notifications on failed backup runs](#send-email-notifications-on-failed-backup-runs)
|
||||
- [Customize notifications](#customize-notifications)
|
||||
- [Run custom commands before / after backup](#run-custom-commands-before--after-backup)
|
||||
- [Encrypting your backup using GPG](#encrypting-your-backup-using-gpg)
|
||||
- [Restoring a volume from a backup](#restoring-a-volume-from-a-backup)
|
||||
- [Set the timezone the container runs in](#set-the-timezone-the-container-runs-in)
|
||||
- [Using with Docker Swarm](#using-with-docker-swarm)
|
||||
- [Manually triggering a backup](#manually-triggering-a-backup)
|
||||
- [Update deprecated email configuration](#update-deprecated-email-configuration)
|
||||
- [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)
|
||||
- [Recipes](#recipes)
|
||||
- [Backing up to AWS S3](#backing-up-to-aws-s3)
|
||||
- [Backing up to Filebase](#backing-up-to-filebase)
|
||||
@@ -36,6 +39,7 @@ It handles __recurring or one-off backups of Docker volumes__ to a __local direc
|
||||
- [Running on a custom cron schedule](#running-on-a-custom-cron-schedule)
|
||||
- [Rotating away backups that are older than 7 days](#rotating-away-backups-that-are-older-than-7-days)
|
||||
- [Encrypting your backups using GPG](#encrypting-your-backups-using-gpg)
|
||||
- [Using mysqldump to prepare the backup](#using-mysqldump-to-prepare-the-backup)
|
||||
- [Running multiple instances in the same setup](#running-multiple-instances-in-the-same-setup)
|
||||
- [Differences to `futurice/docker-volume-backup`](#differences-to-futuricedocker-volume-backup)
|
||||
|
||||
@@ -80,7 +84,8 @@ services:
|
||||
- data:/backup/my-app-backup:ro
|
||||
# Mounting the Docker socket allows the script to stop and restart
|
||||
# the container during backup. You can omit this if you don't want
|
||||
# to stop the container
|
||||
# to stop the container. In case you need to proxy the socket, you can
|
||||
# also provide a location by setting `DOCKER_HOST` in the container
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
# If you mount a local directory or volume to `/archive` a local
|
||||
# copy of the backup will be stored there. You can override the
|
||||
@@ -277,6 +282,27 @@ You can populate below template according to your requirements and use it as you
|
||||
|
||||
# BACKUP_STOP_CONTAINER_LABEL="service1"
|
||||
|
||||
########### EXECUTING COMMANDS IN CONTAINERS PRE/POST BACKUP
|
||||
|
||||
# It is possible to define commands to be run in any container before and after
|
||||
# a backup is conducted. The commands themselves are defined in labels like
|
||||
# `docker-volume-backup.exec-pre=/bin/sh -c 'mysqldump [options] > dump.sql'.
|
||||
# Several options exist for controlling this feature:
|
||||
|
||||
# By default, any output of such a command is suppressed. If this value
|
||||
# is configured to be "true", command execution output will be forwarded to
|
||||
# the backup container's stdout and stderr.
|
||||
|
||||
# EXEC_FORWARD_OUTPUT="true"
|
||||
|
||||
# Without any further configuration, all commands defined in labels will be
|
||||
# run before and after a backup. If you need more fine grained control, you
|
||||
# can use this option to set a label that will be used for narrowing down
|
||||
# the set of eligible containers. When set, an eligible container will also need
|
||||
# to be labeled as `docker-volume-backup.exec-label=database`.
|
||||
|
||||
# EXEC_LABEL="database"
|
||||
|
||||
########### NOTIFICATIONS
|
||||
|
||||
# Notifications (email, Slack, etc.) can be sent out when a backup run finishes.
|
||||
@@ -297,6 +323,13 @@ You can populate below template according to your requirements and use it as you
|
||||
|
||||
# NOTIFICATION_LEVEL="error"
|
||||
|
||||
########### DOCKER HOST
|
||||
|
||||
# If you are interfacing with Docker via TCP you can set the Docker host here
|
||||
# instead of mounting the Docker socket as a volume. This is unset by default.
|
||||
|
||||
# DOCKER_HOST="tcp://docker_socket_proxy:2375"
|
||||
|
||||
########### EMAIL NOTIFICATIONS
|
||||
|
||||
# ************************************************************************
|
||||
@@ -335,7 +368,7 @@ You can work around this by either updating `docker-compose` or unquoting your c
|
||||
|
||||
## How to
|
||||
|
||||
### Stopping containers during backup
|
||||
### Stop containers during backup
|
||||
|
||||
In many cases, it will be desirable to stop the services that are consuming the volume you want to backup in order to ensure data integrity.
|
||||
This image can automatically stop and restart containers and services (in case you are running Docker in Swarm mode).
|
||||
@@ -435,6 +468,63 @@ Overridable template names are: `title_success`, `body_success`, `title_failure`
|
||||
|
||||
For a full list of available variables and functions, see [this page](https://github.com/offen/docker-volume-backup/blob/master/docs/NOTIFICATION-TEMPLATES.md).
|
||||
|
||||
### Run custom commands before / after backup
|
||||
|
||||
In certain scenarios it can be required to run specific commands before and after a backup is taken (e.g. dumping a database).
|
||||
When mounting the Docker socket into the `docker-volume-backup` container, you can define pre- and post-commands that will be run in the context of the target container.
|
||||
Such commands are defined by specifying the command in a `docker-volume-backup.exec-[pre|post]` label.
|
||||
|
||||
Taking a database dump using `mysqldump` would look like this:
|
||||
|
||||
```yml
|
||||
version: '3'
|
||||
|
||||
services:
|
||||
# ... define other services using the `data` volume here
|
||||
database:
|
||||
image: mariadb
|
||||
volumes:
|
||||
- backup_data:/tmp/backups
|
||||
labels:
|
||||
- docker-volume-backup.exec-pre=/bin/sh -c 'mysqldump --all-databases > /backups/dump.sql'
|
||||
|
||||
volumes:
|
||||
backup_data:
|
||||
```
|
||||
|
||||
Due to Docker limitations, you currently cannot use any kind of redirection in these commands unless you pass the command to `/bin/sh -c` or similar.
|
||||
I.e. instead of using `echo "ok" > ok.txt` you will need to use `/bin/sh -c 'echo "ok" > ok.txt'`.
|
||||
|
||||
If you need fine grained control about which container's commands are run, you can use the `EXEC_LABEL` configuration on your `docker-volume-backup` container:
|
||||
|
||||
```yml
|
||||
version: '3'
|
||||
|
||||
services:
|
||||
database:
|
||||
image: mariadb
|
||||
volumes:
|
||||
- backup_data:/tmp/backups
|
||||
labels:
|
||||
- docker-volume-backup.exec-pre=/bin/sh -c 'mysqldump --all-databases > /tmp/volume/dump.sql'
|
||||
- docker-volume-backup.exec-label=database
|
||||
|
||||
backup:
|
||||
image: offen/docker-volume-backup:latest
|
||||
environment:
|
||||
EXEC_LABEL: database
|
||||
volumes:
|
||||
- data:/backup/dump:ro
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
|
||||
volumes:
|
||||
backup_data:
|
||||
```
|
||||
|
||||
|
||||
The backup procedure is guaranteed to wait for all `pre` commands to finish.
|
||||
However there are no guarantees about the order in which they are run, which could also happen concurrently.
|
||||
|
||||
### Encrypting your backup using GPG
|
||||
|
||||
The image supports encrypting backups using GPG out of the box.
|
||||
@@ -466,6 +556,26 @@ In case you need to restore a volume from a backup, the most straight forward pr
|
||||
|
||||
Depending on your setup and the application(s) you are running, this might involve other steps to be taken still.
|
||||
|
||||
---
|
||||
|
||||
If you want to rollback an entire volume to an earlier backup snapshot (recommended for database volumes):
|
||||
|
||||
- Trigger a manual backup if necessary (see `Manually triggering a backup`).
|
||||
- Stop the container(s) that are using the volume.
|
||||
- If volume was initially created using docker-compose, find out exact volume name using:
|
||||
```console
|
||||
docker volume ls
|
||||
```
|
||||
- Remove existing volume (the example assumes it's named `data`):
|
||||
```console
|
||||
docker volume rm data
|
||||
```
|
||||
- Create new volume with the same name and restore a snapshot:
|
||||
```console
|
||||
docker run --rm -it -v data:/backup/my-app-backup -v /path/to/local_backups:/archive:ro alpine tar -xvzf /archive/full_backup_filename.tar.gz
|
||||
```
|
||||
- Restart the container(s) that are using the volume.
|
||||
|
||||
### Set the timezone the container runs in
|
||||
|
||||
By default a container based on this image will run in the UTC timezone.
|
||||
@@ -535,6 +645,41 @@ After:
|
||||
NOTIFICATION_URLS=smtp://me:secret@posteo.de:587/?fromAddress=no-reply@example.com&toAddresses=you@example.com
|
||||
```
|
||||
|
||||
### Using a custom Docker host
|
||||
|
||||
If you are interfacing with Docker via TCP, set `DOCKER_HOST` to the correct URL.
|
||||
```ini
|
||||
DOCKER_HOST=tcp://docker_socket_proxy:2375
|
||||
```
|
||||
|
||||
In case you are using a socket proxy, it must support `GET` and `POST` requests to the `/containers` endpoint. If you are using Docker Swarm, it must also support the `/services` endpoint. If you are using pre/post backup commands, it must also support the `/exec` endpoint.
|
||||
|
||||
### Run multiple backup schedules in the same container
|
||||
|
||||
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`:
|
||||
|
||||
```
|
||||
version: '3'
|
||||
|
||||
services:
|
||||
# ... define other services using the `data` volume here
|
||||
backup:
|
||||
image: offen/docker-volume-backup:latest
|
||||
volumes:
|
||||
- data:/backup/my-app-backup:ro
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
- ./configuration:/etc/dockervolumebackup/conf.d
|
||||
|
||||
volumes:
|
||||
data:
|
||||
```
|
||||
|
||||
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.
|
||||
The `backup` command expects to run on an exclusive lock, so it is your responsibility to make sure the invocations do not overlap.
|
||||
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.
|
||||
|
||||
## Recipes
|
||||
|
||||
This section lists configuration for some real-world use cases that you can mix and match according to your needs.
|
||||
@@ -738,6 +883,32 @@ volumes:
|
||||
data:
|
||||
```
|
||||
|
||||
### Using mysqldump to prepare the backup
|
||||
|
||||
```yml
|
||||
version: '3'
|
||||
|
||||
services:
|
||||
database:
|
||||
image: mariadb:latest
|
||||
labels:
|
||||
- docker-volume-backup.exec-pre=/bin/sh -c 'mysqldump -psecret --all-databases > /tmp/dumps/dump.sql'
|
||||
volumes:
|
||||
- app_data:/tmp/dumps
|
||||
backup:
|
||||
image: offen/docker-volume-backup:latest
|
||||
environment:
|
||||
BACKUP_FILENAME: db.tar.gz
|
||||
BACKUP_CRON_EXPRESSION: "0 2 * * *"
|
||||
volumes:
|
||||
- ./local:/archive
|
||||
- data:/backup/data:ro
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
|
||||
volumes:
|
||||
data:
|
||||
```
|
||||
|
||||
### Running multiple instances in the same setup
|
||||
|
||||
```yml
|
||||
@@ -779,12 +950,12 @@ This image is heavily inspired by `futurice/docker-volume-backup`. We decided to
|
||||
|
||||
- The original image is based on `ubuntu` and requires additional tools, making it heavy.
|
||||
This version is roughly 1/25 in compressed size (it's ~12MB).
|
||||
- The original image uses a shell script, when this version is written in Go, which makes it easier to extend and maintain (more verbose too).
|
||||
- The original image uses a shell script, when this version is written in Go.
|
||||
- The original image proposed to handle backup rotation through AWS S3 lifecycle policies.
|
||||
This image adds the option to rotate away old backups through the same command so this functionality can also be offered for non-AWS storage backends like MinIO.
|
||||
Local copies of backups can also be pruned once they reach a certain age.
|
||||
- InfluxDB specific functionality from the original image was removed.
|
||||
- `arm64` and `arm/v7` architectures are supported.
|
||||
- Docker in Swarm mode is supported.
|
||||
- Notifications on failed backups are supported
|
||||
- IAM authentication through instance profiles is supported
|
||||
- Notifications on finished backups are supported.
|
||||
- IAM authentication through instance profiles is supported.
|
||||
|
||||
142
cmd/backup/archive.go
Normal file
142
cmd/backup/archive.go
Normal file
@@ -0,0 +1,142 @@
|
||||
// Copyright 2022 - Offen Authors <hioffen@posteo.de>
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
// Portions of this file are taken from package `targz`, Copyright (c) 2014 Fredrik Wallgren
|
||||
// Licensed under the MIT License: https://github.com/walle/targz/blob/57fe4206da5abf7dd3901b4af3891ec2f08c7b08/LICENSE
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"compress/gzip"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func createArchive(inputFilePath, outputFilePath string) error {
|
||||
inputFilePath = stripTrailingSlashes(inputFilePath)
|
||||
inputFilePath, outputFilePath, err := makeAbsolute(inputFilePath, outputFilePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("createArchive: error transposing given file paths: %w", err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(outputFilePath), 0755); err != nil {
|
||||
return fmt.Errorf("createArchive: error creating output file path: %w", err)
|
||||
}
|
||||
|
||||
if err := compress(inputFilePath, outputFilePath, filepath.Dir(inputFilePath)); err != nil {
|
||||
return fmt.Errorf("createArchive: error creating archive: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func stripTrailingSlashes(path string) string {
|
||||
if len(path) > 0 && path[len(path)-1] == '/' {
|
||||
path = path[0 : len(path)-1]
|
||||
}
|
||||
|
||||
return path
|
||||
}
|
||||
|
||||
func makeAbsolute(inputFilePath, outputFilePath string) (string, string, error) {
|
||||
inputFilePath, err := filepath.Abs(inputFilePath)
|
||||
if err == nil {
|
||||
outputFilePath, err = filepath.Abs(outputFilePath)
|
||||
}
|
||||
|
||||
return inputFilePath, outputFilePath, err
|
||||
}
|
||||
|
||||
func compress(inPath, outFilePath, subPath string) error {
|
||||
file, err := os.Create(outFilePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("compress: error creating out file: %w", err)
|
||||
}
|
||||
|
||||
prefix := path.Dir(outFilePath)
|
||||
gzipWriter := gzip.NewWriter(file)
|
||||
tarWriter := tar.NewWriter(gzipWriter)
|
||||
|
||||
var paths []string
|
||||
if err := filepath.WalkDir(inPath, func(path string, di fs.DirEntry, err error) error {
|
||||
paths = append(paths, path)
|
||||
return err
|
||||
}); err != nil {
|
||||
return fmt.Errorf("compress: error walking filesystem tree: %w", err)
|
||||
}
|
||||
|
||||
for _, p := range paths {
|
||||
if err := writeTarGz(p, tarWriter, prefix); err != nil {
|
||||
return fmt.Errorf("compress error writing %s to archive: %w", p, err)
|
||||
}
|
||||
}
|
||||
|
||||
err = tarWriter.Close()
|
||||
if err != nil {
|
||||
return fmt.Errorf("compress: error closing tar writer: %w", err)
|
||||
}
|
||||
|
||||
err = gzipWriter.Close()
|
||||
if err != nil {
|
||||
return fmt.Errorf("compress: error closing gzip writer: %w", err)
|
||||
}
|
||||
|
||||
err = file.Close()
|
||||
if err != nil {
|
||||
return fmt.Errorf("compress: error closing file: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeTarGz(path string, tarWriter *tar.Writer, prefix string) error {
|
||||
fileInfo, err := os.Lstat(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("writeTarGz: error getting file infor for %s: %w", path, err)
|
||||
}
|
||||
|
||||
if fileInfo.Mode()&os.ModeSocket == os.ModeSocket {
|
||||
return nil
|
||||
}
|
||||
|
||||
var link string
|
||||
if fileInfo.Mode()&os.ModeSymlink == os.ModeSymlink {
|
||||
var err error
|
||||
if link, err = os.Readlink(path); err != nil {
|
||||
return fmt.Errorf("writeTarGz: error resolving symlink %s: %w", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
header, err := tar.FileInfoHeader(fileInfo, link)
|
||||
if err != nil {
|
||||
return fmt.Errorf("writeTarGz: error getting file info header: %w", err)
|
||||
}
|
||||
header.Name = strings.TrimPrefix(path, prefix)
|
||||
|
||||
err = tarWriter.WriteHeader(header)
|
||||
if err != nil {
|
||||
return fmt.Errorf("writeTarGz: error writing file info header: %w", err)
|
||||
}
|
||||
|
||||
if !fileInfo.Mode().IsRegular() {
|
||||
return nil
|
||||
}
|
||||
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("writeTarGz: error opening %s: %w", path, err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
_, err = io.Copy(tarWriter, file)
|
||||
if err != nil {
|
||||
return fmt.Errorf("writeTarGz: error copying %s to tar writer: %w", path, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -39,4 +39,6 @@ type Config struct {
|
||||
WebdavPath string `split_words:"true" default:"/"`
|
||||
WebdavUsername string `split_words:"true"`
|
||||
WebdavPassword string `split_words:"true"`
|
||||
ExecLabel string `split_words:"true"`
|
||||
ExecForwardOutput bool `split_words:"true"`
|
||||
}
|
||||
|
||||
122
cmd/backup/exec.go
Normal file
122
cmd/backup/exec.go
Normal file
@@ -0,0 +1,122 @@
|
||||
// Copyright 2022 - Offen Authors <hioffen@posteo.de>
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/cosiner/argv"
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/docker/docker/api/types/filters"
|
||||
"github.com/docker/docker/pkg/stdcopy"
|
||||
)
|
||||
|
||||
func (s *script) exec(containerRef string, command string) ([]byte, []byte, error) {
|
||||
args, _ := argv.Argv(command, nil, nil)
|
||||
execID, err := s.cli.ContainerExecCreate(context.Background(), containerRef, types.ExecConfig{
|
||||
Cmd: args[0],
|
||||
AttachStdin: true,
|
||||
AttachStderr: true,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("exec: error creating container exec: %w", err)
|
||||
}
|
||||
|
||||
resp, err := s.cli.ContainerExecAttach(context.Background(), execID.ID, types.ExecStartCheck{})
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("exec: error attaching container exec: %w", err)
|
||||
}
|
||||
defer resp.Close()
|
||||
|
||||
var outBuf, errBuf bytes.Buffer
|
||||
outputDone := make(chan error)
|
||||
|
||||
go func() {
|
||||
_, err := stdcopy.StdCopy(&outBuf, &errBuf, resp.Reader)
|
||||
outputDone <- err
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-outputDone:
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("exec: error demultiplexing output: %w", err)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
stdout, err := ioutil.ReadAll(&outBuf)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("exec: error reading stdout: %w", err)
|
||||
}
|
||||
stderr, err := ioutil.ReadAll(&errBuf)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("exec: error reading stderr: %w", err)
|
||||
}
|
||||
|
||||
res, err := s.cli.ContainerExecInspect(context.Background(), execID.ID)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("exec: error inspecting container exec: %w", err)
|
||||
}
|
||||
|
||||
if res.ExitCode > 0 {
|
||||
return stdout, stderr, fmt.Errorf("exec: running command exited %d", res.ExitCode)
|
||||
}
|
||||
|
||||
return stdout, stderr, nil
|
||||
}
|
||||
|
||||
func (s *script) runLabeledCommands(label string) error {
|
||||
f := []filters.KeyValuePair{
|
||||
{Key: "label", Value: label},
|
||||
}
|
||||
if s.c.ExecLabel != "" {
|
||||
f = append(f, filters.KeyValuePair{
|
||||
Key: "label",
|
||||
Value: fmt.Sprintf("docker-volume-backup.exec-label=%s", s.c.ExecLabel),
|
||||
})
|
||||
}
|
||||
containersWithCommand, err := s.cli.ContainerList(context.Background(), types.ContainerListOptions{
|
||||
Quiet: true,
|
||||
Filters: filters.NewArgs(f...),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("runLabeledCommands: error querying for containers: %w", err)
|
||||
}
|
||||
|
||||
if len(containersWithCommand) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
wg := sync.WaitGroup{}
|
||||
wg.Add(len(containersWithCommand))
|
||||
|
||||
var cmdErrors []error
|
||||
for _, container := range containersWithCommand {
|
||||
go func(c types.Container) {
|
||||
cmd, _ := c.Labels[label]
|
||||
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)
|
||||
if err != nil {
|
||||
cmdErrors = append(cmdErrors, err)
|
||||
}
|
||||
if s.c.ExecForwardOutput {
|
||||
os.Stderr.Write(stderr)
|
||||
os.Stdout.Write(stdout)
|
||||
}
|
||||
wg.Done()
|
||||
}(container)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
if len(cmdErrors) != 0 {
|
||||
return join(cmdErrors...)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -38,6 +38,13 @@ func main() {
|
||||
}()
|
||||
|
||||
s.must(func() error {
|
||||
runPostCommands, err := s.runCommands()
|
||||
defer func() {
|
||||
s.must(runPostCommands())
|
||||
}()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
restartContainers, err := s.stopContainers()
|
||||
// The mechanism for restarting containers is not using hooks as it
|
||||
// should happen as soon as possible (i.e. before uploading backups or
|
||||
|
||||
@@ -23,7 +23,6 @@ import (
|
||||
"github.com/docker/docker/client"
|
||||
"github.com/kelseyhightower/envconfig"
|
||||
"github.com/leekchan/timeutil"
|
||||
"github.com/m90/targz"
|
||||
"github.com/minio/minio-go/v7"
|
||||
"github.com/minio/minio-go/v7/pkg/credentials"
|
||||
"github.com/otiai10/copy"
|
||||
@@ -73,7 +72,7 @@ func newScript() (*script, error) {
|
||||
|
||||
s.registerHook(hookLevelPlumbing, func(error) error {
|
||||
s.stats.EndTime = time.Now()
|
||||
s.stats.TookTime = s.stats.EndTime.Sub(s.stats.EndTime)
|
||||
s.stats.TookTime = s.stats.EndTime.Sub(s.stats.StartTime)
|
||||
return nil
|
||||
})
|
||||
|
||||
@@ -90,7 +89,8 @@ func newScript() (*script, error) {
|
||||
s.file = timeutil.Strftime(&s.stats.StartTime, s.file)
|
||||
|
||||
_, err := os.Stat("/var/run/docker.sock")
|
||||
if !os.IsNotExist(err) {
|
||||
_, dockerHostSet := os.LookupEnv("DOCKER_HOST")
|
||||
if !os.IsNotExist(err) || dockerHostSet {
|
||||
cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("newScript: failed to create docker client")
|
||||
@@ -212,6 +212,22 @@ func newScript() (*script, error) {
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *script) runCommands() (func() error, error) {
|
||||
if s.cli == nil {
|
||||
return noop, nil
|
||||
}
|
||||
|
||||
if err := s.runLabeledCommands("docker-volume-backup.exec-pre"); err != nil {
|
||||
return noop, fmt.Errorf("runCommands: error running pre commands: %w", err)
|
||||
}
|
||||
return func() error {
|
||||
if err := s.runLabeledCommands("docker-volume-backup.exec-post"); err != nil {
|
||||
return fmt.Errorf("runCommands: error running post commands: %w", err)
|
||||
}
|
||||
return nil
|
||||
}, nil
|
||||
}
|
||||
|
||||
// stopContainers stops all Docker containers that are marked as to being
|
||||
// stopped during the backup and returns a function that can be called to
|
||||
// restart everything that has been stopped.
|
||||
@@ -363,7 +379,7 @@ func (s *script) takeBackup() error {
|
||||
s.logger.Infof("Removed tar file `%s`.", tarFile)
|
||||
return nil
|
||||
})
|
||||
if err := targz.Compress(backupSources, tarFile); err != nil {
|
||||
if err := createArchive(backupSources, tarFile); err != nil {
|
||||
return fmt.Errorf("takeBackup: error compressing backup folder: %w", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -5,10 +5,21 @@
|
||||
|
||||
set -e
|
||||
|
||||
BACKUP_CRON_EXPRESSION="${BACKUP_CRON_EXPRESSION:-@daily}"
|
||||
if [ ! -d "/etc/dockervolumebackup/conf.d" ]; then
|
||||
BACKUP_CRON_EXPRESSION="${BACKUP_CRON_EXPRESSION:-@daily}"
|
||||
|
||||
echo "Installing cron.d entry with expression $BACKUP_CRON_EXPRESSION."
|
||||
echo "$BACKUP_CRON_EXPRESSION backup 2>&1" | crontab -
|
||||
echo "Installing cron.d entry with expression $BACKUP_CRON_EXPRESSION."
|
||||
echo "$BACKUP_CRON_EXPRESSION backup 2>&1" | crontab -
|
||||
else
|
||||
echo "/etc/dockervolumebackup/conf.d was found, using configuration files from this directory."
|
||||
|
||||
for file in /etc/dockervolumebackup/conf.d/*; do
|
||||
source $file
|
||||
BACKUP_CRON_EXPRESSION="${BACKUP_CRON_EXPRESSION:-@daily}"
|
||||
echo "Appending cron.d entry with expression $BACKUP_CRON_EXPRESSION and configuration file $file"
|
||||
(crontab -l; echo "$BACKUP_CRON_EXPRESSION /bin/sh -c 'set -a; source $file; set +a && backup' 2>&1") | crontab -
|
||||
done
|
||||
fi
|
||||
|
||||
echo "Starting cron in foreground."
|
||||
crond -f -l 8
|
||||
|
||||
2
go.mod
2
go.mod
@@ -4,11 +4,11 @@ go 1.17
|
||||
|
||||
require (
|
||||
github.com/containrrr/shoutrrr v0.5.2
|
||||
github.com/cosiner/argv v0.1.0
|
||||
github.com/docker/docker v20.10.11+incompatible
|
||||
github.com/gofrs/flock v0.8.1
|
||||
github.com/kelseyhightower/envconfig v1.4.0
|
||||
github.com/leekchan/timeutil v0.0.0-20150802142658-28917288c48d
|
||||
github.com/m90/targz v0.0.0-20220208141135-d3baeef59a97
|
||||
github.com/minio/minio-go/v7 v7.0.16
|
||||
github.com/otiai10/copy v1.7.0
|
||||
github.com/sirupsen/logrus v1.8.1
|
||||
|
||||
4
go.sum
4
go.sum
@@ -208,6 +208,8 @@ github.com/coreos/go-systemd/v22 v22.1.0/go.mod h1:xO0FLkIi5MaZafQlIrOotqXZ90ih+
|
||||
github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||
github.com/coreos/pkg v0.0.0-20160727233714-3ac0863d7acf/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA=
|
||||
github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA=
|
||||
github.com/cosiner/argv v0.1.0 h1:BVDiEL32lwHukgJKP87btEPenzrrHUjajs/8yzaqcXg=
|
||||
github.com/cosiner/argv v0.1.0/go.mod h1:EusR6TucWKX+zFgtdUsKT2Cvg45K5rtpCcWz4hK06d8=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
|
||||
github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY=
|
||||
@@ -450,8 +452,6 @@ github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/leekchan/timeutil v0.0.0-20150802142658-28917288c48d h1:2puqoOQwi3Ai1oznMOsFIbifm6kIfJaLLyYzWD4IzTs=
|
||||
github.com/leekchan/timeutil v0.0.0-20150802142658-28917288c48d/go.mod h1:hO90vCP2x3exaSH58BIAowSKvV+0OsY21TtzuFGHON4=
|
||||
github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII=
|
||||
github.com/m90/targz v0.0.0-20220208141135-d3baeef59a97 h1:Uc/WzUKI/zvhkqIzk5TyaPE6AY1SD1DWGc7RV7cky4s=
|
||||
github.com/m90/targz v0.0.0-20220208141135-d3baeef59a97/go.mod h1:YZK3bSO/oVlk9G+v00BxgzxW2Us4p/R4ysHOBjk0fJI=
|
||||
github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
|
||||
github.com/magiconair/properties v1.8.1/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
|
||||
github.com/mailru/easyjson v0.0.0-20190403194419-1ea4449da983/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
|
||||
|
||||
@@ -44,7 +44,7 @@ docker run --rm \
|
||||
--env BACKUP_FILENAME=test.tar.gz \
|
||||
--env "BACKUP_FROM_SNAPSHOT=true" \
|
||||
--entrypoint backup \
|
||||
offen/docker-volume-backup:$TEST_VERSION
|
||||
offen/docker-volume-backup:${TEST_VERSION:-canary}
|
||||
|
||||
docker run --rm -it \
|
||||
-v backup_data:/data alpine \
|
||||
|
||||
1
test/commands/.gitignore
vendored
Normal file
1
test/commands/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
local
|
||||
36
test/commands/docker-compose.yml
Normal file
36
test/commands/docker-compose.yml
Normal file
@@ -0,0 +1,36 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
database:
|
||||
image: mariadb:10.7
|
||||
deploy:
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
environment:
|
||||
MARIADB_ROOT_PASSWORD: test
|
||||
MARIADB_DATABASE: backup
|
||||
labels:
|
||||
- docker-volume-backup.exec-pre=/bin/sh -c 'mysqldump -ptest --all-databases > /tmp/volume/dump.sql'
|
||||
- docker-volume-backup.exec-post=/bin/sh -c 'echo "post" > /tmp/volume/post.txt'
|
||||
- docker-volume-backup.exec-label=test
|
||||
volumes:
|
||||
- app_data:/tmp/volume
|
||||
|
||||
backup:
|
||||
image: offen/docker-volume-backup:${TEST_VERSION:-canary}
|
||||
deploy:
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
environment:
|
||||
BACKUP_FILENAME: test.tar.gz
|
||||
BACKUP_CRON_EXPRESSION: 0 0 5 31 2 ?
|
||||
EXEC_LABEL: test
|
||||
EXEC_FORWARD_OUTPUT: "true"
|
||||
volumes:
|
||||
- archive:/archive
|
||||
- app_data:/backup/data:ro
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
|
||||
volumes:
|
||||
app_data:
|
||||
archive:
|
||||
62
test/commands/run.sh
Normal file
62
test/commands/run.sh
Normal file
@@ -0,0 +1,62 @@
|
||||
#!/bin/sh
|
||||
|
||||
set -e
|
||||
|
||||
cd $(dirname $0)
|
||||
|
||||
|
||||
docker-compose up -d
|
||||
sleep 30 # mariadb likes to take a bit before responding
|
||||
|
||||
docker-compose exec backup backup
|
||||
sudo cp -r $(docker volume inspect --format='{{ .Mountpoint }}' commands_archive) ./local
|
||||
|
||||
tar -xvf ./local/test.tar.gz
|
||||
if [ ! -f ./backup/data/dump.sql ]; then
|
||||
echo "[TEST:FAIL] Could not find file written by pre command."
|
||||
exit 1
|
||||
fi
|
||||
echo "[TEST:PASS] Found expected file."
|
||||
|
||||
if [ -f ./backup/data/post.txt ]; then
|
||||
echo "[TEST:FAIL] File created in post command was present in backup."
|
||||
exit 1
|
||||
fi
|
||||
echo "[TEST:PASS] Did not find unexpected file."
|
||||
|
||||
docker-compose down --volumes
|
||||
sudo rm -rf ./local
|
||||
|
||||
|
||||
echo "[TEST:INFO] Running commands test in swarm mode next."
|
||||
|
||||
docker swarm init
|
||||
|
||||
docker stack deploy --compose-file=docker-compose.yml test_stack
|
||||
|
||||
while [ -z $(docker ps -q -f name=backup) ]; do
|
||||
echo "[TEST:INFO] Backup container not ready yet. Retrying."
|
||||
sleep 1
|
||||
done
|
||||
|
||||
sleep 20
|
||||
|
||||
docker exec $(docker ps -q -f name=backup) backup
|
||||
|
||||
sudo cp -r $(docker volume inspect --format='{{ .Mountpoint }}' test_stack_archive) ./local
|
||||
|
||||
tar -xvf ./local/test.tar.gz
|
||||
if [ ! -f ./backup/data/dump.sql ]; then
|
||||
echo "[TEST:FAIL] Could not find file written by pre command."
|
||||
exit 1
|
||||
fi
|
||||
echo "[TEST:PASS] Found expected file."
|
||||
|
||||
if [ -f ./backup/data/post.txt ]; then
|
||||
echo "[TEST:FAIL] File created in post command was present in backup."
|
||||
exit 1
|
||||
fi
|
||||
echo "[TEST:PASS] Did not find unexpected file."
|
||||
|
||||
docker stack rm test_stack
|
||||
docker swarm leave --force
|
||||
@@ -21,8 +21,8 @@ services:
|
||||
volumes:
|
||||
- webdav_backup_data:/var/lib/dav
|
||||
|
||||
backup: &default_backup_service
|
||||
image: offen/docker-volume-backup:${TEST_VERSION}
|
||||
backup:
|
||||
image: offen/docker-volume-backup:${TEST_VERSION:-canary}
|
||||
hostname: hostnametoken
|
||||
depends_on:
|
||||
- minio
|
||||
|
||||
@@ -27,12 +27,12 @@ docker run --rm -it \
|
||||
-v compose_minio_backup_data:/minio_data \
|
||||
-v compose_webdav_backup_data:/webdav_data alpine \
|
||||
ash -c 'apk add gnupg && \
|
||||
echo 1234secret | gpg -d --pinentry-mode loopback --passphrase-fd 0 --yes /minio_data/backup/test-hostnametoken.tar.gz.gpg > /tmp/test-hostnametoken.tar.gz && tar -xf /tmp/test-hostnametoken.tar.gz -C /tmp && test -f /tmp/backup/app_data/offen.db && \
|
||||
echo 1234secret | gpg -d --pinentry-mode loopback --passphrase-fd 0 --yes /webdav_data/data/my/new/path/test-hostnametoken.tar.gz.gpg > /tmp/test-hostnametoken.tar.gz && tar -xf /tmp/test-hostnametoken.tar.gz -C /tmp && test -f /tmp/backup/app_data/offen.db'
|
||||
echo 1234secret | gpg -d --pinentry-mode loopback --passphrase-fd 0 --yes /minio_data/backup/test-hostnametoken.tar.gz.gpg > /tmp/test-hostnametoken.tar.gz && tar -xvf /tmp/test-hostnametoken.tar.gz -C /tmp && test -f /tmp/backup/app_data/offen.db && \
|
||||
echo 1234secret | gpg -d --pinentry-mode loopback --passphrase-fd 0 --yes /webdav_data/data/my/new/path/test-hostnametoken.tar.gz.gpg > /tmp/test-hostnametoken.tar.gz && tar -xvf /tmp/test-hostnametoken.tar.gz -C /tmp && test -f /tmp/backup/app_data/offen.db'
|
||||
|
||||
echo "[TEST:PASS] Found relevant files in decrypted and untared remote backups."
|
||||
|
||||
echo 1234secret | gpg -d --yes --passphrase-fd 0 ./local/test-hostnametoken.tar.gz.gpg > ./local/decrypted.tar.gz
|
||||
echo 1234secret | gpg -d --pinentry-mode loopback --yes --passphrase-fd 0 ./local/test-hostnametoken.tar.gz.gpg > ./local/decrypted.tar.gz
|
||||
tar -xf ./local/decrypted.tar.gz -C /tmp && test -f /tmp/backup/app_data/offen.db
|
||||
rm ./local/decrypted.tar.gz
|
||||
test -L /tmp/backup/app_data/db.link
|
||||
|
||||
1
test/confd/.gitignore
vendored
Normal file
1
test/confd/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
local
|
||||
2
test/confd/backup.env
Normal file
2
test/confd/backup.env
Normal file
@@ -0,0 +1,2 @@
|
||||
BACKUP_FILENAME="conf.tar.gz"
|
||||
BACKUP_CRON_EXPRESSION="*/1 * * * *"
|
||||
22
test/confd/docker-compose.yml
Normal file
22
test/confd/docker-compose.yml
Normal file
@@ -0,0 +1,22 @@
|
||||
version: '3'
|
||||
|
||||
services:
|
||||
backup:
|
||||
image: offen/docker-volume-backup:${TEST_VERSION:-canary}
|
||||
restart: always
|
||||
volumes:
|
||||
- ./local:/archive
|
||||
- app_data:/backup/app_data:ro
|
||||
- ./backup.env:/etc/dockervolumebackup/conf.d/00backup.env
|
||||
- ./never.env:/etc/dockervolumebackup/conf.d/10never.env
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
|
||||
offen:
|
||||
image: offen/offen:latest
|
||||
labels:
|
||||
- docker-volume-backup.stop-during-backup=true
|
||||
volumes:
|
||||
- app_data:/var/opt/offen
|
||||
|
||||
volumes:
|
||||
app_data:
|
||||
2
test/confd/never.env
Normal file
2
test/confd/never.env
Normal file
@@ -0,0 +1,2 @@
|
||||
BACKUP_FILENAME="never.tar.gz"
|
||||
BACKUP_CRON_EXPRESSION="0 0 5 31 2 ?"
|
||||
26
test/confd/run.sh
Executable file
26
test/confd/run.sh
Executable file
@@ -0,0 +1,26 @@
|
||||
#!/bin/sh
|
||||
|
||||
set -e
|
||||
|
||||
cd $(dirname $0)
|
||||
|
||||
mkdir -p local
|
||||
|
||||
docker-compose up -d
|
||||
|
||||
# sleep until a backup is guaranteed to have happened on the 1 minute schedule
|
||||
sleep 100
|
||||
|
||||
docker-compose down --volumes
|
||||
|
||||
if [ ! -f ./local/conf.tar.gz ]; then
|
||||
echo "[TEST:FAIL] Config from file was not used."
|
||||
exit 1
|
||||
fi
|
||||
echo "[TEST:PASS] Config from file was used."
|
||||
|
||||
if [ -f ./local/never.tar.gz ]; then
|
||||
echo "[TEST:FAIL] Unexpected file was found."
|
||||
exit 1
|
||||
fi
|
||||
echo "[TEST:PASS] Unexpected cron did not run."
|
||||
@@ -1,8 +1,8 @@
|
||||
version: '3'
|
||||
|
||||
services:
|
||||
backup: &default_backup_service
|
||||
image: offen/docker-volume-backup:${TEST_VERSION}
|
||||
backup:
|
||||
image: offen/docker-volume-backup:${TEST_VERSION:-canary}
|
||||
restart: always
|
||||
environment:
|
||||
BACKUP_FILENAME: test.tar.gz
|
||||
|
||||
1
test/ownership/.gitignore
vendored
Normal file
1
test/ownership/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
local
|
||||
27
test/ownership/docker-compose.yml
Normal file
27
test/ownership/docker-compose.yml
Normal file
@@ -0,0 +1,27 @@
|
||||
version: '3'
|
||||
|
||||
services:
|
||||
db:
|
||||
image: postgres:14-alpine
|
||||
restart: unless-stopped
|
||||
labels:
|
||||
- docker-volume-backup.stop-during-backup=true
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
environment:
|
||||
- POSTGRES_PASSWORD=1FHJMSwt0yhIN1zS7I4DilGUhThBKq0x
|
||||
- POSTGRES_USER=test
|
||||
- POSTGRES_DB=test
|
||||
|
||||
backup:
|
||||
image: offen/docker-volume-backup:${TEST_VERSION}
|
||||
restart: always
|
||||
environment:
|
||||
BACKUP_FILENAME: backup.tar.gz
|
||||
volumes:
|
||||
- postgres_data:/backup/postgres:ro
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
- ./local:/archive
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
28
test/ownership/run.sh
Normal file
28
test/ownership/run.sh
Normal file
@@ -0,0 +1,28 @@
|
||||
#!/bin/sh
|
||||
# This test refers to https://github.com/offen/docker-volume-backup/issues/71
|
||||
|
||||
set -e
|
||||
|
||||
cd $(dirname $0)
|
||||
|
||||
mkdir -p local
|
||||
|
||||
docker-compose up -d
|
||||
sleep 5
|
||||
|
||||
docker-compose exec backup backup
|
||||
|
||||
sudo tar --same-owner -xvf ./local/backup.tar.gz -C /tmp
|
||||
|
||||
sudo find /tmp/backup/postgres > /dev/null
|
||||
echo "[TEST:PASS] Backup contains files at expected location"
|
||||
|
||||
for file in $(sudo find /tmp/backup/postgres); do
|
||||
if [ "$(sudo stat -c '%u:%g' $file)" != "70:70" ]; then
|
||||
echo "[TEST:FAIL] Unexpected file ownership for $file: $(sudo stat -c '%u:%g' $file)"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
echo "[TEST:PASS] All files and directories in backup preserved their ownership."
|
||||
|
||||
docker-compose down --volumes
|
||||
@@ -18,8 +18,8 @@ services:
|
||||
volumes:
|
||||
- backup_data:/data
|
||||
|
||||
backup: &default_backup_service
|
||||
image: offen/docker-volume-backup:${TEST_VERSION}
|
||||
backup:
|
||||
image: offen/docker-volume-backup:${TEST_VERSION:-canary}
|
||||
depends_on:
|
||||
- minio
|
||||
deploy:
|
||||
@@ -49,7 +49,7 @@ services:
|
||||
condition: on-failure
|
||||
|
||||
pg:
|
||||
image: postgres:12.2-alpine
|
||||
image: postgres:14-alpine
|
||||
environment:
|
||||
POSTGRES_PASSWORD: example
|
||||
labels:
|
||||
|
||||
Reference in New Issue
Block a user