Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,4 @@ target/

cover
.eggs
.venv*
18 changes: 9 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,24 +1,24 @@
docker-rotate
=============

In a continuously deployed environment, old and unused docker images accumulate and use up space.
`docker-rotate` helps remove the K oldest images of each type.
In a continuously deployed environment, old and unused docker images and containers accumulate and use up space.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are you removing containers? are you not naming them properly?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's what docker-rotate is doing - clearing out unused images and non-running containers to clear up space on the host

`docker-rotate` helps remove the K oldest images of each type and remove non-running containers.

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

Usage:

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

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

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

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

By default, `docker-rotate` connects to the local Unix socket; the usual environment variables will
be respected if the `--use-env` flag is given.
82 changes: 82 additions & 0 deletions dockerrotate/containers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
from datetime import datetime, timedelta
from dateutil import parser
from dateutil.tz import tzutc
import re

from docker.errors import APIError

from dockerrotate.filter import include_image


TIME_REGEX = re.compile(r'((?P<days>\d+?)d)?((?P<hours>\d+?)h)?((?P<minutes>\d+?)m)?((?P<seconds>\d+?)s)?') # noqa


def parse_time(time_str):
"""
Parse a human readable time delta string.
"""
parts = TIME_REGEX.match(time_str)
if not parts:
raise Exception("Invalid time delta format '{}'".format(time_str))
parts = parts.groupdict()
time_params = {}
for (name, param) in parts.iteritems():
if param:
time_params[name] = int(param)
return timedelta(**time_params)


def include_container(container, args):
"""
Return truthy if container should be removed.
"""
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only exited containers or containers that have been created but not started are considered for removal.

Note that I removed the support for data containers.
In latest docker versions data volumes are first class objects and should be created with docker volume command instead of creating data containers. This removes the guesswork we had here before.

inspect_data = args.client.inspect_container(container["Id"])
status = inspect_data["State"]["Status"]

if status == "exited":
finished_at = parser.parse(inspect_data["State"]["FinishedAt"])
if (args.now - finished_at) < args.exited_ts:
return False
elif status == "created":
created_at = parser.parse(inspect_data["Created"])
if (args.now - created_at) < args.created_ts:
return False
else:
return False

return include_image([container["Image"]], args)


def clean_containers(args):
"""
Delete non-running containers.
Images cannot be deleted if in use. Deleting dead containers allows
more images to be cleaned.
"""
args.exited_ts = parse_time(args.exited)
args.created_ts = parse_time(args.created)
args.now = datetime.now(tzutc())

containers = [
container for container in args.client.containers(all=True)
if include_container(container, args)
]

for container in containers:
print "Removing container ID: {}, Name: {}, Image: {}".format(
container["Id"],
(container.get("Names") or ["N/A"])[0],
container["Image"],
)

if args.dry_run:
continue

try:
args.client.remove_container(container["Id"])
except APIError as error:
print "Unable to remove container: {}: {}".format(
container["Id"],
error,
)
22 changes: 22 additions & 0 deletions dockerrotate/filter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import re


def include_image(image_tags, args):
"""
Return truthy if image should be considered for removal.
"""
if not args.images:
return True

return all(regex_match(pattern, tag)
for pattern in args.images
for tag in image_tags)


def regex_match(pattern, tag):
"""
Perform a regex match on the tag.
"""
if pattern[0] == '~':
return not re.search(pattern[1:], tag)
return re.search(pattern, tag)
63 changes: 63 additions & 0 deletions dockerrotate/images.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
from collections import defaultdict

from docker.errors import APIError

from dockerrotate.filter import include_image


def clean_images(args):
"""
Delete old images keeping the most recent N images by tag.
"""
# should not need to inspect all images; only intermediate images should appear
# when all is true; these should be deleted along with dependent images
images = [image
for image in args.client.images(all=False)
if include_image(image["RepoTags"], args)]

# index by id
images_by_id = {
image["Id"]: image for image in images
}

# group by name
images_by_name = defaultdict(set)
for image in images:
for tag in image["RepoTags"]:
image_name = normalize_tag_name(tag)
images_by_name[image_name].add(image["Id"])

for image_name, image_ids in images_by_name.items():
# sort/keep
images_to_delete = sorted([
images_by_id[image_id] for image_id in image_ids],
key=lambda image: -image["Created"],
)[args.keep:]

# delete
for image in images_to_delete:
print "Removing image ID: {}, Tags: {}".format(
image["Id"],
", ".join(image["RepoTags"])
)

if args.dry_run:
continue

try:
args.client.remove_image(image["Id"], force=True, noprune=False)
except APIError as error:
print error.message


def normalize_tag_name(tag):
"""
docker-py provides image names with tags as a single string.

We want:

some.domain.com/organization/image:tag -> organization/image
organization/image:tag -> organization/image
image:tag -> image
"""
return "/".join(tag.rsplit(":", 1)[0].split("/")[-2:])
Loading