├── Gomfile ├── Gemfile ├── extra └── logo.psd ├── Gomfile.lock ├── images.go ├── .gitignore ├── help.go ├── Makefile ├── README.md ├── Gemfile.lock ├── LICENSE ├── main.go └── drydock.go /Gomfile: -------------------------------------------------------------------------------- 1 | gom 'github.com/fsouza/go-dockerclient' 2 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gem "fpm" 4 | -------------------------------------------------------------------------------- /extra/logo.psd: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gocardless/drydock/HEAD/extra/logo.psd -------------------------------------------------------------------------------- /Gomfile.lock: -------------------------------------------------------------------------------- 1 | gom 'github.com/fsouza/go-dockerclient', :commit => '9df1f25d542e79d7909ef321b5c13c5d34ea7f1d' 2 | -------------------------------------------------------------------------------- /images.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | // array of image ids 4 | type Images []string 5 | 6 | // check if an image id exists in images list 7 | func (imgs Images) Exist(id string) bool { 8 | for _, img := range imgs { 9 | if img == id { 10 | return true 11 | } 12 | } 13 | 14 | return false 15 | } 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled Object files, Static and Dynamic libs (Shared Objects) 2 | *.o 3 | *.a 4 | *.so 5 | drydock 6 | 7 | # Folders 8 | _obj 9 | _test 10 | _workspace 11 | 12 | # Architecture specific extensions/prefixes 13 | *.[568vq] 14 | [568vq].out 15 | 16 | *.cgo1.go 17 | *.cgo2.c 18 | _cgo_defun.c 19 | _cgo_gotypes.go 20 | _cgo_export.* 21 | 22 | _testmain.go 23 | 24 | *.exe 25 | *.test 26 | *.prof 27 | 28 | drydock.linux_amd64 29 | cover.out 30 | _vendor 31 | .bundle 32 | *.deb 33 | -------------------------------------------------------------------------------- /help.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | const VERSION = "0.0.4" 8 | 9 | func usage() { 10 | fmt.Printf("DryDock %s\n", VERSION) 11 | fmt.Printf("usage: drydock [options]\n\n") 12 | 13 | fmt.Printf("Options:\n") 14 | fmt.Printf(" --dry-run don't delete images\n") 15 | fmt.Printf(" --age <48h> delete images older than age\n") 16 | fmt.Printf(" --keep <10> keep at least this many images\n") 17 | fmt.Printf(" --pattern <^.*$> pattern for images to be deleted\n") 18 | fmt.Printf(" --docker docker host endpoint\n") 19 | } 20 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | PREFIX=/usr/local 2 | VERSION=0.0.4 3 | BUILD_COMMAND=gom build -ldflags "-X main.version=$(VERSION)" 4 | 5 | .PHONY: build clean 6 | 7 | build: 8 | $(BUILD_COMMAND) -o drydock *.go 9 | 10 | test: 11 | test -z "$(golint ./... | tee /dev/stderr)" || exit 1 12 | gom tool vet *.go || exit 1 13 | gom test -race -test.v . || exit 1 14 | 15 | build-production: test 16 | GOOS=linux GOARCH=amd64 $(BUILD_COMMAND) -o drydock.linux_amd64 *.go 17 | 18 | deb: build-production 19 | bundle exec fpm -s dir -t $@ -n drydock -v $(VERSION) \ 20 | --description "Docker image cleaner" \ 21 | --maintainer "GoCardless Engineering " \ 22 | drydock.linux_amd64=$(PREFIX)/bin/drydock 23 | 24 | clean: 25 | -rm -f drydock drydock.linux_amd64 cover.out drydock.test 26 | -rm -f drydock_${VERSION}_amd64.deb 27 | 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | DryDock 2 | 3 | DryDock 4 | ======= 5 | 6 | DryDock is a utility, intended to be run as a cron job, to clean up old and unused docker images. This is useful on build servers and deployment nodes where image turnover can be high. 7 | 8 | Usage 9 | ----- 10 | 11 | $ drydock --help 12 | DryDock 0.0.1 13 | usage: drydock [options] 14 | 15 | Options: 16 | --dry-run don't delete images 17 | --age <48h> delete images older than age 18 | --pattern <^.*$> pattern for images to be deleted 19 | --docker docker host endpoint 20 | 21 | Development 22 | ----------- 23 | 24 | ``` 25 | $ gom install # install dependencies 26 | $ make # build 27 | $ make deb # build debian package 28 | ``` 29 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: https://rubygems.org/ 3 | specs: 4 | archive-tar-minitar (0.5.2) 5 | arr-pm (0.0.10) 6 | cabin (> 0) 7 | backports (3.6.8) 8 | cabin (0.8.1) 9 | childprocess (0.5.9) 10 | ffi (~> 1.0, >= 1.0.11) 11 | clamp (1.0.0) 12 | ffi (1.9.10) 13 | fpm (1.6.0) 14 | archive-tar-minitar 15 | arr-pm (~> 0.0.10) 16 | backports (>= 2.6.2) 17 | cabin (>= 0.6.0) 18 | childprocess 19 | clamp (~> 1.0.0) 20 | ffi 21 | json (>= 1.7.7) 22 | pleaserun (~> 0.0.24) 23 | ruby-xz 24 | insist (1.0.0) 25 | io-like (0.3.0) 26 | json (1.8.3) 27 | mustache (0.99.8) 28 | pleaserun (0.0.24) 29 | cabin (> 0) 30 | clamp 31 | insist 32 | mustache (= 0.99.8) 33 | stud 34 | ruby-xz (0.2.3) 35 | ffi (~> 1.9) 36 | io-like (~> 0.3) 37 | stud (0.0.22) 38 | 39 | PLATFORMS 40 | ruby 41 | 42 | DEPENDENCIES 43 | fpm 44 | 45 | BUNDLED WITH 46 | 1.12.3 47 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015 GoCardless 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "log" 6 | "os" 7 | "regexp" 8 | "time" 9 | ) 10 | 11 | var version string 12 | 13 | // list images older than age matching match 14 | // remove images older than age that aren't running 15 | 16 | var ( 17 | DryRun = flag.Bool("dry-run", false, "don't run delete functions") 18 | ImageAge = flag.String("age", "48h", "delete images older than age") 19 | ImagesToKeep = flag.Int("keep", 10, "keep at least this many images") 20 | ImagePattern = flag.String("pattern", "^.*$", "match image names") 21 | DockerHost = flag.String("docker", "tcp://127.0.0.1:2375", "docker endpoint") 22 | Version = flag.Bool("version", false, "version") 23 | ) 24 | 25 | func main() { 26 | flag.Usage = usage 27 | flag.Parse() 28 | 29 | logOut := log.New(os.Stdout, "", log.LstdFlags) 30 | 31 | if *Version { 32 | logOut.Println("drydock version " + version) 33 | os.Exit(0) 34 | } 35 | 36 | if *DryRun { 37 | logOut.Println("[INFO] dry run enabled") 38 | } 39 | 40 | drydock, err := NewDryDock(*DockerHost) 41 | if err != nil { 42 | log.Fatalf("[FATAL] drydock: %s\n", err) 43 | } 44 | 45 | drydock.Age, err = time.ParseDuration(*ImageAge) 46 | if err != nil { 47 | log.Fatalf("[FATAL] age: %s\n", err) 48 | } 49 | 50 | drydock.Keep = *ImagesToKeep 51 | 52 | drydock.Pattern, err = regexp.Compile(*ImagePattern) 53 | if err != nil { 54 | log.Fatalf("[FATAL] pattern: %s\n", err) 55 | } 56 | 57 | images, err := drydock.ListImages() 58 | if err != nil { 59 | log.Fatalf("[FATAL] images: %s\n", err) 60 | } 61 | 62 | imagesInUse, err := drydock.ListInUseImages() 63 | if err != nil { 64 | log.Fatalf("[FATAL] images in use: %s\n", err) 65 | } 66 | 67 | logOut.Printf("[INFO] %d images scheduled for deletion\n", len(images)) 68 | for _, image := range images { 69 | if imagesInUse.Exist(image) { 70 | log.Printf("[WARN] skipping %s, in use\n", image) 71 | continue 72 | } 73 | 74 | if !*DryRun { 75 | err := drydock.RemoveImage(image) 76 | if err != nil { 77 | log.Fatalf("[FATAL] remove image: %s\n", err) 78 | } 79 | } 80 | 81 | logOut.Printf("[INFO] deleted image %s\n", image) 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /drydock.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "os" 6 | "regexp" 7 | "sort" 8 | "time" 9 | 10 | "github.com/fsouza/go-dockerclient" 11 | ) 12 | 13 | type DryDock struct { 14 | Age time.Duration 15 | Keep int 16 | Pattern *regexp.Regexp 17 | docker *docker.Client 18 | logOut *log.Logger 19 | } 20 | 21 | // create a new DryDock assignment connected to Docker server 22 | func NewDryDock(endpoint string) (*DryDock, error) { 23 | client, err := docker.NewClient(endpoint) 24 | if err != nil { 25 | return nil, err 26 | } 27 | 28 | return &DryDock{ 29 | Age: 48 * time.Hour, 30 | Pattern: regexp.MustCompile(`^.*$`), 31 | 32 | docker: client, 33 | logOut: log.New(os.Stdout, "", log.LstdFlags), 34 | }, nil 35 | } 36 | 37 | // Sorts docker.APIImages, newest first 38 | type byCreatedNewestFirst []docker.APIImages 39 | 40 | func (a byCreatedNewestFirst) Len() int { return len(a) } 41 | func (a byCreatedNewestFirst) Swap(i, j int) { a[i], a[j] = a[j], a[i] } 42 | func (a byCreatedNewestFirst) Less(i, j int) bool { return a[i].Created > a[j].Created } 43 | 44 | // list images older than Age, excluding the newest Keep images 45 | func (dd DryDock) ListImages() (Images, error) { 46 | images, err := dd.docker.ListImages(docker.ListImagesOptions{All: false}) 47 | if err != nil { 48 | return nil, err 49 | } 50 | 51 | var imagesMatchingFilter []docker.APIImages 52 | for _, image := range images { 53 | if dd.matchRepoTags(image) { 54 | imagesMatchingFilter = append(imagesMatchingFilter, image) 55 | } 56 | } 57 | dd.logOut.Printf("[INFO] %d images matched pattern", len(imagesMatchingFilter)) 58 | 59 | sort.Sort(byCreatedNewestFirst(imagesMatchingFilter)) 60 | 61 | cutoff := time.Now().Add(-(dd.Age)) 62 | var deleteStartingAtIndex int 63 | for i, image := range imagesMatchingFilter { 64 | created := time.Unix(image.Created, 0) 65 | 66 | if i >= dd.Keep && created.Before(cutoff) { 67 | deleteStartingAtIndex = i 68 | break 69 | } 70 | 71 | if i == len(imagesMatchingFilter)-1 { 72 | dd.logOut.Printf("[INFO] No images met keep/age criteria") 73 | return Images{}, nil 74 | } 75 | } 76 | 77 | imagesForDeletion := imagesMatchingFilter[deleteStartingAtIndex:len(imagesMatchingFilter)] 78 | dd.logOut.Printf("[INFO] %d images met keep/age criteria", len(imagesForDeletion)) 79 | 80 | var imageIds Images 81 | for _, image := range imagesForDeletion { 82 | imageIds = append(imageIds, image.ID) 83 | } 84 | 85 | return imageIds, nil 86 | } 87 | 88 | // list images used by containers 89 | func (dd DryDock) ListInUseImages() (Images, error) { 90 | containers, err := dd.docker.ListContainers(docker.ListContainersOptions{All: true}) 91 | if err != nil { 92 | return nil, err 93 | } 94 | 95 | var imgs Images 96 | for _, container := range containers { 97 | image, err := dd.docker.InspectImage(container.Image) 98 | if err != nil { 99 | return nil, err 100 | } 101 | 102 | imgs = append(imgs, image.ID) 103 | } 104 | 105 | return imgs, nil 106 | } 107 | 108 | // remote an image by id 109 | func (dd DryDock) RemoveImage(id string) error { 110 | return dd.docker.RemoveImage(id) 111 | } 112 | 113 | // match an images repo tags 114 | func (dd DryDock) matchRepoTags(image docker.APIImages) bool { 115 | for _, tag := range image.RepoTags { 116 | if dd.Pattern.MatchString(tag) { 117 | return true 118 | } 119 | } 120 | 121 | return false 122 | } 123 | --------------------------------------------------------------------------------