Skip to content

Commit d85518f

Browse files
committed
Merge pull request #8 from OceanCodes/master
docker-rotate 2.0
2 parents a1bc7cf + 76ef316 commit d85518f

File tree

7 files changed

+225
-189
lines changed

7 files changed

+225
-189
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,3 +55,4 @@ target/
5555

5656
cover
5757
.eggs
58+
.venv*

README.md

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,24 @@
11
docker-rotate
22
=============
33

4-
In a continuously deployed environment, old and unused docker images accumulate and use up space.
5-
`docker-rotate` helps remove the K oldest images of each type.
4+
In a continuously deployed environment, old and unused docker images and containers accumulate and use up space.
5+
`docker-rotate` helps remove the K oldest images of each type and remove non-running containers.
66

77
[![Build Status](https://travis-ci.org/locationlabs/docker-rotate.png)](https://travis-ci.org/locationlabs/docker-rotate)
88

99
Usage:
1010

11-
# delete all but the three oldest images of each type
12-
docker-rotate --clean-images --keep 3
11+
# delete all but the three most recent images of each type
12+
docker-rotate images --keep 3
1313

14-
# only target one type of image (by name)
15-
docker-rotate --clean-images --keep 3 --only organization/image
14+
# only target one type of image but don't remove latest
15+
docker-rotate images --keep 3 --image "organization/image" "~:latest"
1616

1717
# don't actualy delete anything
18-
docker-rotate --clean-images --keep 3 --dry-run
18+
docker-rotate --dry-run images --keep 3
1919

20-
# also delete exited containers (except those with volumes)
21-
docker-rotate --clean-images --clean-containers --keep 3
20+
# delete containers exited more than an hour ago
21+
docker-rotate containers --exited 1h
2222

2323
By default, `docker-rotate` connects to the local Unix socket; the usual environment variables will
2424
be respected if the `--use-env` flag is given.

dockerrotate/containers.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
from datetime import datetime, timedelta
2+
from dateutil import parser
3+
from dateutil.tz import tzutc
4+
import re
5+
6+
from docker.errors import APIError
7+
8+
from dockerrotate.filter import include_image
9+
10+
11+
TIME_REGEX = re.compile(r'((?P<days>\d+?)d)?((?P<hours>\d+?)h)?((?P<minutes>\d+?)m)?((?P<seconds>\d+?)s)?') # noqa
12+
13+
14+
def parse_time(time_str):
15+
"""
16+
Parse a human readable time delta string.
17+
"""
18+
parts = TIME_REGEX.match(time_str)
19+
if not parts:
20+
raise Exception("Invalid time delta format '{}'".format(time_str))
21+
parts = parts.groupdict()
22+
time_params = {}
23+
for (name, param) in parts.iteritems():
24+
if param:
25+
time_params[name] = int(param)
26+
return timedelta(**time_params)
27+
28+
29+
def include_container(container, args):
30+
"""
31+
Return truthy if container should be removed.
32+
"""
33+
inspect_data = args.client.inspect_container(container["Id"])
34+
status = inspect_data["State"]["Status"]
35+
36+
if status == "exited":
37+
finished_at = parser.parse(inspect_data["State"]["FinishedAt"])
38+
if (args.now - finished_at) < args.exited_ts:
39+
return False
40+
elif status == "created":
41+
created_at = parser.parse(inspect_data["Created"])
42+
if (args.now - created_at) < args.created_ts:
43+
return False
44+
else:
45+
return False
46+
47+
return include_image([container["Image"]], args)
48+
49+
50+
def clean_containers(args):
51+
"""
52+
Delete non-running containers.
53+
54+
Images cannot be deleted if in use. Deleting dead containers allows
55+
more images to be cleaned.
56+
"""
57+
args.exited_ts = parse_time(args.exited)
58+
args.created_ts = parse_time(args.created)
59+
args.now = datetime.now(tzutc())
60+
61+
containers = [
62+
container for container in args.client.containers(all=True)
63+
if include_container(container, args)
64+
]
65+
66+
for container in containers:
67+
print "Removing container ID: {}, Name: {}, Image: {}".format(
68+
container["Id"],
69+
(container.get("Names") or ["N/A"])[0],
70+
container["Image"],
71+
)
72+
73+
if args.dry_run:
74+
continue
75+
76+
try:
77+
args.client.remove_container(container["Id"])
78+
except APIError as error:
79+
print "Unable to remove container: {}: {}".format(
80+
container["Id"],
81+
error,
82+
)

dockerrotate/filter.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import re
2+
3+
4+
def include_image(image_tags, args):
5+
"""
6+
Return truthy if image should be considered for removal.
7+
"""
8+
if not args.images:
9+
return True
10+
11+
return all(regex_match(pattern, tag)
12+
for pattern in args.images
13+
for tag in image_tags)
14+
15+
16+
def regex_match(pattern, tag):
17+
"""
18+
Perform a regex match on the tag.
19+
"""
20+
if pattern[0] == '~':
21+
return not re.search(pattern[1:], tag)
22+
return re.search(pattern, tag)

dockerrotate/images.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
from collections import defaultdict
2+
3+
from docker.errors import APIError
4+
5+
from dockerrotate.filter import include_image
6+
7+
8+
def clean_images(args):
9+
"""
10+
Delete old images keeping the most recent N images by tag.
11+
"""
12+
# should not need to inspect all images; only intermediate images should appear
13+
# when all is true; these should be deleted along with dependent images
14+
images = [image
15+
for image in args.client.images(all=False)
16+
if include_image(image["RepoTags"], args)]
17+
18+
# index by id
19+
images_by_id = {
20+
image["Id"]: image for image in images
21+
}
22+
23+
# group by name
24+
images_by_name = defaultdict(set)
25+
for image in images:
26+
for tag in image["RepoTags"]:
27+
image_name = normalize_tag_name(tag)
28+
images_by_name[image_name].add(image["Id"])
29+
30+
for image_name, image_ids in images_by_name.items():
31+
# sort/keep
32+
images_to_delete = sorted([
33+
images_by_id[image_id] for image_id in image_ids],
34+
key=lambda image: -image["Created"],
35+
)[args.keep:]
36+
37+
# delete
38+
for image in images_to_delete:
39+
print "Removing image ID: {}, Tags: {}".format(
40+
image["Id"],
41+
", ".join(image["RepoTags"])
42+
)
43+
44+
if args.dry_run:
45+
continue
46+
47+
try:
48+
args.client.remove_image(image["Id"], force=True, noprune=False)
49+
except APIError as error:
50+
print error.message
51+
52+
53+
def normalize_tag_name(tag):
54+
"""
55+
docker-py provides image names with tags as a single string.
56+
57+
We want:
58+
59+
some.domain.com/organization/image:tag -> organization/image
60+
organization/image:tag -> organization/image
61+
image:tag -> image
62+
"""
63+
return "/".join(tag.rsplit(":", 1)[0].split("/")[-2:])

0 commit comments

Comments
 (0)