├── .github ├── FUNDING.yml └── workflows │ ├── build.yml │ ├── goreleaser.yml │ └── lint.yml ├── .gitignore ├── .golangci.yml ├── .goreleaser.yml ├── LICENSE ├── README.md ├── backup.go ├── go.mod ├── go.sum ├── main.go └── restore.go /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: muesli 2 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | on: [push, pull_request] 3 | 4 | jobs: 5 | build: 6 | strategy: 7 | matrix: 8 | go-version: [~1.11, ^1] 9 | os: [ubuntu-latest, macos-latest] 10 | runs-on: ${{ matrix.os }} 11 | env: 12 | GO111MODULE: "on" 13 | steps: 14 | - name: Install Go 15 | uses: actions/setup-go@v2 16 | with: 17 | go-version: ${{ matrix.go-version }} 18 | 19 | - name: Checkout code 20 | uses: actions/checkout@v2 21 | 22 | - name: Download Go modules 23 | run: go mod download 24 | 25 | - name: Build 26 | run: go build -v ./... 27 | 28 | - name: Test 29 | run: go test ./... 30 | -------------------------------------------------------------------------------- /.github/workflows/goreleaser.yml: -------------------------------------------------------------------------------- 1 | name: goreleaser 2 | 3 | on: 4 | pull_request: 5 | push: 6 | 7 | jobs: 8 | goreleaser: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - 12 | name: Checkout 13 | uses: actions/checkout@v2 14 | with: 15 | fetch-depth: 0 16 | - 17 | name: Set up Go 18 | uses: actions/setup-go@v2 19 | - 20 | name: Run GoReleaser 21 | uses: goreleaser/goreleaser-action@v2 22 | with: 23 | version: latest 24 | args: release --snapshot --skip-publish --skip-sign --rm-dist 25 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: lint 2 | on: 3 | push: 4 | pull_request: 5 | 6 | jobs: 7 | golangci: 8 | name: lint 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v2 12 | - name: golangci-lint 13 | uses: golangci/golangci-lint-action@v2 14 | with: 15 | # Required: the version of golangci-lint is required and must be specified without patch version: we always use the latest patch version. 16 | version: v1.37 17 | # Optional: golangci-lint command line arguments. 18 | args: --issues-exit-code=0 19 | # Optional: working directory, useful for monorepos 20 | # working-directory: somedir 21 | # Optional: show only new issues if it's a pull request. The default value is `false`. 22 | only-new-issues: true 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, build with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | # Builds 15 | docker-backup 16 | /dist 17 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | run: 2 | tests: false 3 | 4 | issues: 5 | max-issues-per-linter: 0 6 | max-same-issues: 0 7 | 8 | linters: 9 | enable: 10 | - bodyclose 11 | - dupl 12 | - exportloopref 13 | - goconst 14 | - godot 15 | - godox 16 | - goimports 17 | - goprintffuncname 18 | - gosec 19 | - misspell 20 | - prealloc 21 | - rowserrcheck 22 | - sqlclosecheck 23 | - unconvert 24 | - unparam 25 | - whitespace 26 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | env: 2 | - GO111MODULE=on 3 | - CGO_ENABLED=0 4 | before: 5 | hooks: 6 | - go mod tidy 7 | builds: 8 | - 9 | binary: docker-backup 10 | ldflags: -s -w -X main.Version={{ .Version }} -X main.CommitSHA={{ .Commit }} 11 | goos: 12 | - linux 13 | - darwin 14 | goarch: 15 | - amd64 16 | - arm64 17 | - 386 18 | - arm 19 | goarm: 20 | - 6 21 | - 7 22 | 23 | archives: 24 | - 25 | replacements: 26 | darwin: Darwin 27 | linux: Linux 28 | windows: Windows 29 | 386: i386 30 | amd64: x86_64 31 | 32 | signs: 33 | - artifacts: checksum 34 | 35 | checksum: 36 | name_template: 'checksums.txt' 37 | snapshot: 38 | name_template: "{{ .Tag }}-next" 39 | changelog: 40 | sort: asc 41 | filters: 42 | exclude: 43 | - '^docs:' 44 | - '^test:' 45 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Christian Muehlhaeuser 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | docker-backup 2 | ============= 3 | 4 | [![Latest Release](https://img.shields.io/github/release/muesli/docker-backup.svg)](https://github.com/muesli/docker-backup/releases) 5 | [![Build Status](https://github.com/muesli/docker-backup/workflows/build/badge.svg)](https://github.com/muesli/docker-backup/actions) 6 | [![Go ReportCard](https://goreportcard.com/badge/muesli/docker-backup)](https://goreportcard.com/report/muesli/docker-backup) 7 | [![GoDoc](https://godoc.org/github.com/golang/gddo?status.svg)](https://pkg.go.dev/github.com/muesli/docker-backup) 8 | 9 | A tool to create & restore complete, self-contained backups of Docker containers 10 | 11 | # What's the issue 12 | 13 | Docker services usually have a bunch of volatile data volumes that need to be 14 | backed up. Backing up an entire (file)system is easy, but often enough you just 15 | want to create a backup of a single (or a few) containers, maybe to restore them 16 | on another system later. 17 | 18 | Some services, such as databases, also need to be aware (flushed/synced/paused) 19 | of an impending backup. The backup should be run on the Docker host, as you 20 | don't want to have a backup client configured & running in every single 21 | container either, since this would add a lot of maintenance & administration 22 | overhead. 23 | 24 | `docker-backup` directly connects to Docker, analyzes a container's mounts & 25 | volumes, and generates a list of dirs & files that need to be backed up on the 26 | host system. This also collects all the metadata information associated with a 27 | container, so it can be restored or cloned on a different host, including its 28 | port-mappings and data volumes. 29 | 30 | The generated list can either be fed to an existing backup solution or 31 | `docker-backup` can directly create a `.tar` image of your container, so you can 32 | simply copy it to another machine. 33 | 34 | ## Installation 35 | 36 | `docker-backup` requires Go 1.11 or higher. Make sure you have a working Go 37 | environment. See the [install instructions](https://golang.org/doc/install.html). 38 | 39 | `docker-backup` works with Docker hosts running Docker 18.02 (API version 1.36) 40 | and newer. 41 | 42 | ### Packages 43 | 44 | - Arch Linux: [docker-backup](https://aur.archlinux.org/packages/docker-backup/) 45 | 46 | ### From source 47 | 48 | git clone https://github.com/muesli/docker-backup.git 49 | cd docker-backup 50 | go build 51 | 52 | Run `docker-backup --help` to see a full list of options. 53 | 54 | ## Usage 55 | 56 | ### Creating a Backup 57 | 58 | To backup a single container start `docker-backup` with the `backup` command and 59 | supply the ID of the container: 60 | 61 | docker-backup backup 62 | 63 | This will create a `.json` file with the container's metadata, as well as a file 64 | containing all the volumes that need to be backed up with an external tool like 65 | [restic](https://restic.net/) or [borgbackup](https://www.borgbackup.org/). 66 | 67 | If you want to directly create a `.tar` file containing all the container's 68 | data, simply run: 69 | 70 | docker-backup backup --tar 71 | 72 | You can also backup all running containers on the host with the `--all` flag: 73 | 74 | docker-backup backup --all 75 | 76 | To backup all containers (regardless of their current running state), run: 77 | 78 | docker-backup backup --all --stopped 79 | 80 | With the help of `--launch` you can directly launch a backup program with the 81 | generated file-list supplied as an argument: 82 | 83 | docker-backup backup --all --launch "restic -r /dest backup --password-file pwfile --tag %tag --files-from %list" 84 | 85 | ### Restoring a Backup 86 | 87 | To restore a container, run `docker-backup` with the `restore` command: 88 | 89 | docker-backup restore 90 | 91 | `docker-backup` will automatically detect whether you supplied a `.tar` or 92 | `.json` file and restore the container, including all its port-mappings and data 93 | volumes. 94 | 95 | If you want to start the container once the restore has finished, add the 96 | `--start` flag: 97 | 98 | docker-backup restore --start 99 | -------------------------------------------------------------------------------- /backup.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "archive/tar" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "io/ioutil" 9 | "os" 10 | "os/exec" 11 | "path/filepath" 12 | "strings" 13 | "syscall" 14 | "time" 15 | 16 | "github.com/docker/docker/api/types" 17 | "github.com/docker/docker/api/types/container" 18 | "github.com/docker/go-connections/nat" 19 | "github.com/kennygrant/sanitize" 20 | "github.com/spf13/cobra" 21 | ) 22 | 23 | // Backup is used to gather all of a container's metadata, so we can encode it 24 | // as JSON and store it 25 | type Backup struct { 26 | Name string 27 | Config *container.Config 28 | PortMap nat.PortMap 29 | Mounts []types.MountPoint 30 | } 31 | 32 | var ( 33 | optLaunch = "" 34 | optTar = false 35 | optAll = false 36 | optStopped = false 37 | optVerbose = false 38 | 39 | paths []string 40 | tw *tar.Writer 41 | 42 | backupCmd = &cobra.Command{ 43 | Use: "backup [container-id]", 44 | Short: "creates a backup of a container", 45 | RunE: func(cmd *cobra.Command, args []string) error { 46 | if optAll { 47 | return backupAll() 48 | } 49 | 50 | if len(args) < 1 { 51 | return fmt.Errorf("backup requires the ID of a container") 52 | } 53 | return backup(args[0]) 54 | }, 55 | } 56 | ) 57 | 58 | func collectFile(path string, info os.FileInfo, err error) error { 59 | if err != nil { 60 | return err 61 | } 62 | 63 | if optVerbose { 64 | fmt.Println("Adding", path) 65 | } 66 | 67 | paths = append(paths, path) 68 | return nil 69 | } 70 | 71 | func collectFileTar(path string, info os.FileInfo, err error) error { 72 | if err != nil { 73 | return err 74 | } 75 | if info.Mode()&os.ModeSocket != 0 { 76 | // ignore sockets 77 | return nil 78 | } 79 | 80 | if optVerbose { 81 | fmt.Println("Adding", path) 82 | } 83 | 84 | th, err := tar.FileInfoHeader(info, path) 85 | if err != nil { 86 | return err 87 | } 88 | 89 | th.Name = path 90 | if si, ok := info.Sys().(*syscall.Stat_t); ok { 91 | th.Uid = int(si.Uid) 92 | th.Gid = int(si.Gid) 93 | } 94 | 95 | if err := tw.WriteHeader(th); err != nil { 96 | return err 97 | } 98 | 99 | if !info.Mode().IsRegular() { 100 | return nil 101 | } 102 | if info.Mode().IsDir() { 103 | return nil 104 | } 105 | 106 | file, err := os.Open(path) 107 | if err != nil { 108 | return err 109 | } 110 | 111 | _, err = io.Copy(tw, file) 112 | return err 113 | } 114 | 115 | func backupTar(filename string, backup Backup) error { 116 | b, err := json.MarshalIndent(backup, "", " ") 117 | if err != nil { 118 | return err 119 | } 120 | // fmt.Println(string(b)) 121 | 122 | tarfile, err := os.Create(filename + ".tar") 123 | if err != nil { 124 | return err 125 | } 126 | tw = tar.NewWriter(tarfile) 127 | 128 | th := &tar.Header{ 129 | Name: "container.json", 130 | Size: int64(len(b)), 131 | ModTime: time.Now(), 132 | AccessTime: time.Now(), 133 | ChangeTime: time.Now(), 134 | Mode: 0600, 135 | } 136 | 137 | if err := tw.WriteHeader(th); err != nil { 138 | return err 139 | } 140 | if _, err := tw.Write(b); err != nil { 141 | return err 142 | } 143 | 144 | for _, m := range backup.Mounts { 145 | // fmt.Printf("Mount (type %s) %s -> %s\n", m.Type, m.Source, m.Destination) 146 | 147 | err := filepath.Walk(m.Source, collectFileTar) 148 | if err != nil { 149 | return err 150 | } 151 | } 152 | 153 | tw.Close() 154 | fmt.Println("Created backup:", filename+".tar") 155 | return nil 156 | } 157 | 158 | func getFullImageName(imageName string) (string, error) { 159 | // If the image already specifies a tag we can safely use as-is 160 | if strings.Contains(imageName, ":") { 161 | return imageName, nil 162 | } 163 | 164 | // If the used image doesn't include tag information try to find one (if it exists). 165 | images, err := cli.ImageList(ctx, types.ImageListOptions{}) 166 | if err != nil { 167 | // Couldn't get image list, abort 168 | return imageName, err 169 | } 170 | 171 | for _, image := range images { 172 | if (!strings.Contains(imageName, image.ID)) || len(image.RepoTags) == 0 { 173 | // unrelated image or image entry doesn't have any tags, move on 174 | continue 175 | } 176 | 177 | for _, tag := range image.RepoTags { 178 | // use closer matching tag if it exists 179 | if !strings.Contains(tag, imageName) { 180 | continue 181 | } 182 | return tag, nil 183 | } 184 | // If none of the tags matches the base image name, return the first tag 185 | return image.RepoTags[0], nil 186 | } 187 | 188 | // There is no tag on the matching image, just have to go with what was provided 189 | return imageName, nil 190 | } 191 | 192 | func backup(ID string) error { 193 | conf, err := cli.ContainerInspect(ctx, ID) 194 | if err != nil { 195 | return err 196 | } 197 | fmt.Printf("Creating backup of %s (%s, %s)\n", conf.Name[1:], conf.Config.Image, conf.ID[:12]) 198 | 199 | paths = []string{} 200 | 201 | conf.Config.Image, err = getFullImageName(conf.Config.Image) 202 | if err != nil { 203 | return err 204 | } 205 | 206 | backup := Backup{ 207 | Name: conf.Name, 208 | PortMap: conf.HostConfig.PortBindings, 209 | Config: conf.Config, 210 | Mounts: conf.Mounts, 211 | } 212 | 213 | filename := sanitize.Path(fmt.Sprintf("%s-%s", conf.Config.Image, ID)) 214 | filename = strings.Replace(filename, "/", "_", -1) 215 | if optTar { 216 | return backupTar(filename, backup) 217 | } 218 | 219 | b, err := json.MarshalIndent(backup, "", " ") 220 | if err != nil { 221 | return err 222 | } 223 | // fmt.Println(string(b)) 224 | 225 | err = ioutil.WriteFile(filename+".backup.json", b, 0600) 226 | if err != nil { 227 | return err 228 | } 229 | 230 | for _, m := range conf.Mounts { 231 | // fmt.Printf("Mount (type %s) %s -> %s\n", m.Type, m.Source, m.Destination) 232 | err := filepath.Walk(m.Source, collectFile) 233 | if err != nil { 234 | return err 235 | } 236 | } 237 | 238 | filelist, err := os.Create(filename + ".backup.files") 239 | if err != nil { 240 | return err 241 | } 242 | defer filelist.Close() 243 | 244 | _, err = filelist.WriteString(filename + ".backup.json\n") 245 | if err != nil { 246 | return err 247 | } 248 | for _, s := range paths { 249 | _, err := filelist.WriteString(s + "\n") 250 | if err != nil { 251 | return err 252 | } 253 | } 254 | 255 | fmt.Println("Created backup:", filename+".backup.json") 256 | 257 | if optLaunch != "" { 258 | ol := strings.Replace(optLaunch, "%tag", filename, -1) 259 | ol = strings.Replace(ol, "%list", filename+".backup.files", -1) 260 | 261 | fmt.Println("Launching external command and waiting for it to finish:") 262 | fmt.Println(ol) 263 | 264 | l := strings.Split(ol, " ") 265 | cmd := exec.Command(l[0], l[1:]...) 266 | return cmd.Run() 267 | } 268 | 269 | return nil 270 | } 271 | 272 | func backupAll() error { 273 | containers, err := cli.ContainerList(ctx, types.ContainerListOptions{ 274 | All: optStopped, 275 | }) 276 | if err != nil { 277 | panic(err) 278 | } 279 | 280 | for _, container := range containers { 281 | err := backup(container.ID) 282 | if err != nil { 283 | return err 284 | } 285 | } 286 | 287 | return nil 288 | } 289 | 290 | func init() { 291 | backupCmd.Flags().StringVarP(&optLaunch, "launch", "l", "", "launch external program with file-list as argument") 292 | backupCmd.Flags().BoolVarP(&optTar, "tar", "t", false, "create tar backups") 293 | backupCmd.Flags().BoolVarP(&optAll, "all", "a", false, "backup all running containers") 294 | backupCmd.Flags().BoolVarP(&optStopped, "stopped", "s", false, "in combination with --all: also backup stopped containers") 295 | backupCmd.Flags().BoolVarP(&optVerbose, "verbose", "v", false, "print detailed backup progress") 296 | RootCmd.AddCommand(backupCmd) 297 | } 298 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/muesli/docker-backup 2 | 3 | go 1.12 4 | 5 | require ( 6 | github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78 // indirect 7 | github.com/Microsoft/go-winio v0.4.16 // indirect 8 | github.com/docker/distribution v2.7.1+incompatible // indirect 9 | github.com/docker/docker v0.7.3-0.20190503020752-619df5a8f60f 10 | github.com/docker/go-connections v0.4.0 11 | github.com/docker/go-units v0.4.0 // indirect 12 | github.com/gogo/protobuf v1.2.1 // indirect 13 | github.com/google/go-cmp v0.5.5 // indirect 14 | github.com/gorilla/mux v1.8.0 // indirect 15 | github.com/inconshreveable/mousetrap v1.0.0 // indirect 16 | github.com/kennygrant/sanitize v1.2.4 17 | github.com/morikuni/aec v1.0.0 // indirect 18 | github.com/opencontainers/go-digest v1.0.0-rc1 // indirect 19 | github.com/opencontainers/image-spec v1.0.1 // indirect 20 | github.com/spf13/cobra v0.0.3 21 | github.com/spf13/pflag v1.0.3 // indirect 22 | golang.org/x/net v0.0.0-20190502183928-7f726cade0ab // indirect 23 | golang.org/x/time v0.0.0-20210220033141-f8bda1e9f3ba // indirect 24 | google.golang.org/grpc v1.20.1 // indirect 25 | gotest.tools v2.2.0+incompatible // indirect 26 | ) 27 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= 2 | github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78 h1:w+iIsaOQNcT7OZ575w+acHgRric5iCyQh+xv+KJ4HB8= 3 | github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78/go.mod h1:LmzpDX56iTiv29bbRTIsUNlaFfuhWRQBWjQdVyAevI8= 4 | github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 5 | github.com/Microsoft/go-winio v0.4.16 h1:FtSW/jqD+l4ba5iPBj9CODVtgfYAD8w2wS923g/cFDk= 6 | github.com/Microsoft/go-winio v0.4.16/go.mod h1:XB6nPKklQyQ7GC9LdcBEcBl8PF76WugXOPRXwdLnMv0= 7 | github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= 8 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 9 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 10 | github.com/docker/distribution v2.7.1+incompatible h1:a5mlkVzth6W5A4fOsS3D2EO5BUmsJpcB+cRlLU7cSug= 11 | github.com/docker/distribution v2.7.1+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= 12 | github.com/docker/docker v0.7.3-0.20190503020752-619df5a8f60f h1:Dtk1lVB9XfLuYUW+4mkWslWOBexBdVHD6IlsWu9R4nE= 13 | github.com/docker/docker v0.7.3-0.20190503020752-619df5a8f60f/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= 14 | github.com/docker/go-connections v0.4.0 h1:El9xVISelRB7BuFusrZozjnkIM5YnzCViNKohAFqRJQ= 15 | github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec= 16 | github.com/docker/go-units v0.4.0 h1:3uh0PgVws3nIA0Q+MwDC8yjEPf9zjRfZZWXZYDct3Tw= 17 | github.com/docker/go-units v0.4.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= 18 | github.com/gogo/protobuf v1.2.1 h1:/s5zKNz0uPFCZ5hddgPdo2TK2TVrUNMn0OOX8/aZMTE= 19 | github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4= 20 | github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= 21 | github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= 22 | github.com/golang/protobuf v1.2.0 h1:P3YflyNX/ehuJFLhxviNdFxQPkGK5cDcApsge1SqnvM= 23 | github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 24 | github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= 25 | github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 26 | github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= 27 | github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= 28 | github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM= 29 | github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= 30 | github.com/kennygrant/sanitize v1.2.4 h1:gN25/otpP5vAsO2djbMhF/LQX6R7+O1TB4yv8NzpJ3o= 31 | github.com/kennygrant/sanitize v1.2.4/go.mod h1:LGsjYYtgxbetdg5owWB2mpgUL6e2nfw2eObZ0u0qvak= 32 | github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q= 33 | github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= 34 | github.com/konsorten/go-windows-terminal-sequences v1.0.1 h1:mweAR1A6xJ3oS2pRaGiHgQ4OO8tzTaLawm8vnODuwDk= 35 | github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= 36 | github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= 37 | github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= 38 | github.com/opencontainers/go-digest v1.0.0-rc1 h1:WzifXhOVOEOuFYOJAW6aQqW0TooG2iki3E3Ii+WN7gQ= 39 | github.com/opencontainers/go-digest v1.0.0-rc1/go.mod h1:cMLVZDEM3+U2I4VmLI6N8jQYUd2OVphdqWwCJHrFt2s= 40 | github.com/opencontainers/image-spec v1.0.1 h1:JMemWkRwHx4Zj+fVxWoMCFm/8sYGGrUVojFA6h/TRcI= 41 | github.com/opencontainers/image-spec v1.0.1/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0= 42 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 43 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 44 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 45 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 46 | github.com/sirupsen/logrus v1.4.1 h1:GL2rEmy6nsikmW0r8opw9JIRScdMF5hA8cOYLH7In1k= 47 | github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= 48 | github.com/spf13/cobra v0.0.3 h1:ZlrZ4XsMRm04Fr5pSFxBgfND2EBVa1nLpiy1stUsX/8= 49 | github.com/spf13/cobra v0.0.3/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ= 50 | github.com/spf13/pflag v1.0.3 h1:zPAT6CGy6wXeQ7NtTnaTerfKOsV6V6F8agHXFiazDkg= 51 | github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= 52 | github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 53 | github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w= 54 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 55 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 56 | golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 57 | golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 58 | golang.org/x/net v0.0.0-20190502183928-7f726cade0ab h1:9RfW3ktsOZxgo9YNbBAjq1FWzc/igwEcUzZz8IXgSbk= 59 | golang.org/x/net v0.0.0-20190502183928-7f726cade0ab/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 60 | golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= 61 | golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f h1:wMNYb4v58l5UBM7MYRLPG6ZhfOqbKu7X5eyFl8ZhKvA= 62 | golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 63 | golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 64 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 65 | golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3 h1:7TYNF4UdlohbFwpNH04CoPMp1cHUZgO1Ebq5r2hIjfo= 66 | golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 67 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 68 | golang.org/x/time v0.0.0-20210220033141-f8bda1e9f3ba h1:O8mE0/t419eoIwhTFpKVkHiTs/Igowgfkj25AcZrtiE= 69 | golang.org/x/time v0.0.0-20210220033141-f8bda1e9f3ba/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= 70 | golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 71 | golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 72 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= 73 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 74 | google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= 75 | google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8 h1:Nw54tB0rB7hY/N0NQvRW8DG4Yk3Q6T9cu9RcFQDu1tc= 76 | google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= 77 | google.golang.org/grpc v1.20.1 h1:Hz2g2wirWK7H0qIIhGIqRGTuMwTE8HEKFnDZZ7lm9NU= 78 | google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= 79 | gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo= 80 | gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw= 81 | honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= 82 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | /* 2 | * A tool to create & restore full backups of Docker containers 3 | * Copyright (c) 2019, Christian Muehlhaeuser 4 | * 5 | * For license see LICENSE 6 | */ 7 | 8 | package main 9 | 10 | import ( 11 | "context" 12 | "fmt" 13 | "os" 14 | 15 | "github.com/docker/docker/client" 16 | "github.com/spf13/cobra" 17 | ) 18 | 19 | var ( 20 | cli *client.Client 21 | ctx = context.Background() 22 | 23 | // RootCmd is the core command used for cli-arg parsing 24 | RootCmd = &cobra.Command{ 25 | Use: "docker-backup", 26 | Short: "docker-backup creates or restores backups of Docker containers", 27 | SilenceErrors: true, 28 | SilenceUsage: true, 29 | } 30 | ) 31 | 32 | func main() { 33 | var err error 34 | // cli, err = client.NewEnvClient() 35 | // cli, err = client.NewClientWithOpts(client.FromEnv) 36 | cli, err = client.NewClientWithOpts(client.WithVersion("1.36")) 37 | if err != nil { 38 | panic(err) 39 | } 40 | 41 | if err := RootCmd.Execute(); err != nil { 42 | fmt.Println(err) 43 | os.Exit(-1) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /restore.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "archive/tar" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "io/ioutil" 9 | "os" 10 | "strings" 11 | 12 | "github.com/docker/docker/api/types" 13 | "github.com/docker/docker/api/types/container" 14 | "github.com/spf13/cobra" 15 | ) 16 | 17 | var ( 18 | optStart = false 19 | 20 | restoreCmd = &cobra.Command{ 21 | Use: "restore ", 22 | Short: "restores a backup of a container", 23 | RunE: func(cmd *cobra.Command, args []string) error { 24 | if len(args) < 1 { 25 | return fmt.Errorf("restore requires a .json or .tar backup") 26 | } 27 | 28 | if strings.HasSuffix(args[0], ".json") { 29 | return restore(args[0]) 30 | } else if strings.HasSuffix(args[0], ".tar") { 31 | return restoreTar(args[0]) 32 | } 33 | 34 | return fmt.Errorf("Unknown file type, please provide a .tar or .json file") 35 | }, 36 | } 37 | ) 38 | 39 | func restoreTar(filename string) error { 40 | tarfile, err := os.Open(filename) 41 | if err != nil { 42 | return err 43 | } 44 | defer tarfile.Close() 45 | 46 | tr := tar.NewReader(tarfile) 47 | var b []byte 48 | for { 49 | th, err := tr.Next() 50 | if err == io.EOF { 51 | break 52 | } 53 | if err != nil { 54 | return err 55 | } 56 | switch th.Name { 57 | case "container.json": 58 | var err error 59 | b, err = ioutil.ReadAll(tr) 60 | if err != nil { 61 | return err 62 | } 63 | } 64 | } 65 | 66 | var backup Backup 67 | err = json.Unmarshal(b, &backup) 68 | if err != nil { 69 | return err 70 | } 71 | 72 | id, err := createContainer(backup) 73 | if err != nil { 74 | return err 75 | } 76 | 77 | conf, err := cli.ContainerInspect(ctx, id) 78 | if err != nil { 79 | return err 80 | } 81 | 82 | tt := map[string]string{} 83 | for _, oldPath := range backup.Mounts { 84 | for _, hostPath := range conf.Mounts { 85 | if oldPath.Destination == hostPath.Destination { 86 | tt[oldPath.Source] = hostPath.Source 87 | break 88 | } 89 | } 90 | } 91 | 92 | if _, err := tarfile.Seek(0, 0); err != nil { 93 | return err 94 | } 95 | tr = tar.NewReader(tarfile) 96 | for { 97 | th, err := tr.Next() 98 | if err == io.EOF { 99 | break 100 | } 101 | if err != nil { 102 | return err 103 | } 104 | if th.Name == "container.json" { 105 | continue 106 | } 107 | 108 | path := th.Name 109 | fmt.Println("Restoring:", path) 110 | for k, v := range tt { 111 | if strings.HasPrefix(path, k) { 112 | path = v + path[len(k):] 113 | } 114 | } 115 | 116 | if th.Typeflag == tar.TypeDir { 117 | if err := os.MkdirAll(path, os.FileMode(th.Mode)); err != nil { 118 | return err 119 | } 120 | } else { 121 | file, err := os.Create(path) 122 | if err != nil { 123 | return err 124 | } 125 | if _, err := io.Copy(file, tr); err != nil { 126 | return err 127 | } 128 | file.Close() 129 | } 130 | if err := os.Chmod(path, os.FileMode(th.Mode)); err != nil { 131 | return err 132 | } 133 | if err := os.Chown(path, th.Uid, th.Gid); err != nil { 134 | return err 135 | } 136 | fmt.Println("Created as:", path) 137 | } 138 | 139 | if optStart { 140 | return startContainer(id) 141 | } 142 | return nil 143 | } 144 | 145 | func restore(filename string) error { 146 | var backup Backup 147 | b, err := ioutil.ReadFile(filename) 148 | if err != nil { 149 | return err 150 | } 151 | err = json.Unmarshal(b, &backup) 152 | if err != nil { 153 | return err 154 | } 155 | 156 | id, err := createContainer(backup) 157 | if err != nil { 158 | return err 159 | } 160 | 161 | if optStart { 162 | return startContainer(id) 163 | } 164 | return nil 165 | } 166 | 167 | func createContainer(backup Backup) (string, error) { 168 | nameparts := strings.Split(backup.Name, "/") 169 | name := nameparts[len(nameparts)-1] 170 | fmt.Println("Restoring Container:", name) 171 | 172 | _, _, err := cli.ImageInspectWithRaw(ctx, backup.Config.Image) 173 | if err != nil { 174 | fmt.Println("Pulling Image:", backup.Config.Image) 175 | _, err := cli.ImagePull(ctx, backup.Config.Image, types.ImagePullOptions{}) 176 | if err != nil { 177 | return "", err 178 | } 179 | } 180 | // io.Copy(os.Stdout, reader) 181 | 182 | resp, err := cli.ContainerCreate(ctx, backup.Config, &container.HostConfig{ 183 | PortBindings: backup.PortMap, 184 | }, nil, name) 185 | if err != nil { 186 | return "", err 187 | } 188 | fmt.Println("Created Container with ID:", resp.ID) 189 | 190 | for _, m := range backup.Mounts { 191 | fmt.Printf("Old Mount (type %s) %s -> %s\n", m.Type, m.Source, m.Destination) 192 | } 193 | 194 | conf, err := cli.ContainerInspect(ctx, resp.ID) 195 | if err != nil { 196 | return "", err 197 | } 198 | for _, m := range conf.Mounts { 199 | fmt.Printf("New Mount (type %s) %s -> %s\n", m.Type, m.Source, m.Destination) 200 | } 201 | 202 | return resp.ID, nil 203 | } 204 | 205 | func startContainer(id string) error { 206 | fmt.Println("Starting container:", id[:12]) 207 | 208 | err := cli.ContainerStart(ctx, id, types.ContainerStartOptions{}) 209 | if err != nil { 210 | return err 211 | } 212 | 213 | /* 214 | statusCh, errCh := cli.ContainerWait(ctx, id, container.WaitConditionNotRunning) 215 | select { 216 | case err := <-errCh: 217 | if err != nil { 218 | return err 219 | } 220 | case <-statusCh: 221 | } 222 | 223 | out, err := cli.ContainerLogs(ctx, id, types.ContainerLogsOptions{ShowStdout: true}) 224 | if err != nil { 225 | return err 226 | } 227 | io.Copy(os.Stdout, out) 228 | */ 229 | 230 | return nil 231 | } 232 | 233 | func init() { 234 | restoreCmd.Flags().BoolVarP(&optStart, "start", "s", false, "start restored container") 235 | RootCmd.AddCommand(restoreCmd) 236 | } 237 | --------------------------------------------------------------------------------