diff --git a/.gitignore b/.gitignore index 28a584d..e5e8c15 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ build/ __pycache__ *.egg-info/ +.venv diff --git a/docker_custodian/docker_gc.py b/docker_custodian/docker_gc.py index dffcd03..a74e106 100644 --- a/docker_custodian/docker_gc.py +++ b/docker_custodian/docker_gc.py @@ -7,6 +7,7 @@ import fnmatch import logging import sys +import time import dateutil.parser import docker @@ -137,7 +138,13 @@ def get_dangling_volumes(client): return volumes -def cleanup_images(client, max_image_age, dry_run, exclude_set): +def cleanup_images( + client, + max_image_age, + recent_image_age, + dry_run, + exclude_set +): # re-fetch container list so that we don't include removed containers containers = get_all_containers(client) @@ -151,10 +158,35 @@ def cleanup_images(client, max_image_age, dry_run, exclude_set): images = filter_images_in_use_by_id(images, image_ids_in_use) images = filter_excluded_images(images, exclude_set) + if recent_image_age is not None: + images = without_recently_used_images(client, images, recent_image_age) + for image_summary in reversed(list(images)): remove_image(client, image_summary, max_image_age, dry_run) +def without_recently_used_images(client, images, recent_image_age): + exclude = set() + for event in client.events(since=recent_image_age, + until=int(time.time()), + decode=True): + status = event.get('status', '') + if status != 'create' and not status.startswith('exec_start'): + continue + image_ref = event.get('from') + if image_ref is None: + continue + exclude.add(image_ref) + return [ + image for image in images + if exclude.intersection( + [image.get('Id')] + + image.get('RepoTags', []) + + image.get('RepoDigests', []) + ) + ] + + def filter_excluded_images(images, exclude_set): def include_image(image_summary): image_tags = image_summary.get('RepoTags') @@ -314,7 +346,13 @@ def main(): exclude_set = build_exclude_set( args.exclude_image, args.exclude_image_file) - cleanup_images(client, args.max_image_age, args.dry_run, exclude_set) + cleanup_images( + client, + args.max_image_age, + args.recent_image_age, + args.dry_run, + exclude_set + ) if args.dangling_volumes: cleanup_volumes(client, args.dry_run) @@ -334,6 +372,11 @@ def get_args(args=None): help="Maxium age for an image. Images older than this age will be " "removed. Age can be specified in any pytimeparse supported " "format.") + parser.add_argument( + '--recent-image-age', + type=timedelta_type, default=None, + help="Images used within specified time interval will not be removed. " + "Age can be specified in any pytimeparse supported format.") parser.add_argument( '--dangling-volumes', action="store_true", diff --git a/tests/docker_gc_test.py b/tests/docker_gc_test.py index 445301f..fa051ce 100644 --- a/tests/docker_gc_test.py +++ b/tests/docker_gc_test.py @@ -124,12 +124,30 @@ def test_cleanup_images(mock_client, now): ] mock_client.inspect_image.side_effect = iter(mock_images) - docker_gc.cleanup_images(mock_client, max_image_age, False, set()) + docker_gc.cleanup_images(mock_client, max_image_age, None, False, set()) assert mock_client.remove_image.mock_calls == [ mock.call(image=image['Id']) for image in reversed(images) ] +def test_without_recently_used_images(mock_client, now): + images = [ + dict(Id='gone'), + dict(Id='a'), + dict(RepoTags=['b']), + dict(RepoDigests=['c']) + ] + mock_events = [ + {'status': 'create', 'from': 'a'}, + {'status': 'exec_start', 'from': 'b'}, + {'status': 'create', 'from': 'c'} + ] + mock_client.events.return_value = mock_events + filtered_images = docker_gc.without_recently_used_images( + mock_client, images, 0) + assert filtered_images == images[1:] + + def test_cleanup_volumes(mock_client): mock_client.volumes.return_value = volumes = { 'Volumes': [ @@ -238,7 +256,7 @@ def test_filter_images_in_use_by_id(mock_client, now): 'Id': image, 'Created': '2014-01-01T01:01:01Z' } - docker_gc.cleanup_images(mock_client, now, False, set()) + docker_gc.cleanup_images(mock_client, now, None, False, set()) assert mock_client.remove_image.mock_calls == [ mock.call(image=id_) for id_ in ['6', '5', '4', '3'] ]