docker-image-cleanup 0.1.0__tar.gz → 0.1.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {docker_image_cleanup-0.1.0 → docker_image_cleanup-0.1.2}/PKG-INFO +42 -31
- {docker_image_cleanup-0.1.0 → docker_image_cleanup-0.1.2}/README.md +39 -29
- docker_image_cleanup-0.1.2/docker_image_cleanup/__init__.py +286 -0
- {docker_image_cleanup-0.1.0 → docker_image_cleanup-0.1.2}/pyproject.toml +10 -4
- docker_image_cleanup-0.1.0/docker_image_cleanup/__init__.py +0 -132
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: docker-image-cleanup
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.2
|
|
4
4
|
Summary: Clean up old Docker images from local storage with retention controls
|
|
5
5
|
Keywords: docker,cleanup,images,disk-space
|
|
6
6
|
Author: Michael Bianco
|
|
7
7
|
Author-email: Michael Bianco <mike@mikebian.co>
|
|
8
8
|
Requires-Dist: click>=8.3.0
|
|
9
9
|
Requires-Dist: docker>=7.1.0
|
|
10
|
-
Requires-Dist:
|
|
10
|
+
Requires-Dist: pydantic>=2.12.5
|
|
11
|
+
Requires-Dist: structlog-config>=0.6.0
|
|
11
12
|
Requires-Dist: whenever>=0.9.3
|
|
12
13
|
Requires-Python: >=3.11
|
|
13
14
|
Project-URL: Repository, https://github.com/iloveitaly/docker-image-cleanup
|
|
@@ -23,12 +24,6 @@ This tool is designed for managing images from self-hosted registries and probab
|
|
|
23
24
|
|
|
24
25
|
## Installation
|
|
25
26
|
|
|
26
|
-
```bash
|
|
27
|
-
pip install docker-image-cleanup
|
|
28
|
-
```
|
|
29
|
-
|
|
30
|
-
Or using `uv`:
|
|
31
|
-
|
|
32
27
|
```bash
|
|
33
28
|
uv tool install docker-image-cleanup
|
|
34
29
|
```
|
|
@@ -66,6 +61,45 @@ docker-image-cleanup --min-age-days 7 myrepo/myimage
|
|
|
66
61
|
docker-image-cleanup --num-recent 3 --min-age-days 7 myrepo/myimage
|
|
67
62
|
```
|
|
68
63
|
|
|
64
|
+
## Docker
|
|
65
|
+
|
|
66
|
+
This tool is available as a Docker image on GHCR. It's designed to run on a schedule using a built-in cron system.
|
|
67
|
+
|
|
68
|
+
### Environment Variables
|
|
69
|
+
|
|
70
|
+
- `TARGET_REPOS`: A space-separated list of image repositories to clean (e.g., `myrepo/image1 myrepo/image2`). **Required**.
|
|
71
|
+
- `SCHEDULE`: A cron expression for the cleanup schedule (default: `0 0 * * *` - daily at midnight).
|
|
72
|
+
- `NUM_RECENT`: Number of recent tags to keep (default: `5`).
|
|
73
|
+
- `MIN_AGE_DAYS`: Minimum age in days to keep tags (default: `30`).
|
|
74
|
+
|
|
75
|
+
### Docker Run
|
|
76
|
+
|
|
77
|
+
You must mount the Docker socket so the container can interact with your local Docker daemon:
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
docker run -d \
|
|
81
|
+
--name docker-image-cleanup \
|
|
82
|
+
-v /var/run/docker.sock:/var/run/docker.sock \
|
|
83
|
+
-e TARGET_REPOS="myrepo/image1 myrepo/image2" \
|
|
84
|
+
ghcr.io/iloveitaly/docker-image-cleanup:latest
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### Docker Compose
|
|
88
|
+
|
|
89
|
+
```yaml
|
|
90
|
+
services:
|
|
91
|
+
cleanup:
|
|
92
|
+
image: ghcr.io/iloveitaly/docker-image-cleanup:latest
|
|
93
|
+
restart: unless-stopped
|
|
94
|
+
volumes:
|
|
95
|
+
- /var/run/docker.sock:/var/run/docker.sock
|
|
96
|
+
environment:
|
|
97
|
+
- TARGET_REPOS=myrepo/image1 myrepo/image2
|
|
98
|
+
- SCHEDULE=0 0 * * *
|
|
99
|
+
- NUM_RECENT=5
|
|
100
|
+
- MIN_AGE_DAYS=30
|
|
101
|
+
```
|
|
102
|
+
|
|
69
103
|
## Features
|
|
70
104
|
|
|
71
105
|
- Removes old Docker images based on age and recency criteria
|
|
@@ -88,27 +122,4 @@ The cleanup process is straightforward:
|
|
|
88
122
|
|
|
89
123
|
Images with multiple tags are handled intelligently. If all tags on an image would be removed, the entire image is deleted. If only some tags would be removed, those tags are untagged but the image remains.
|
|
90
124
|
|
|
91
|
-
## Requirements
|
|
92
|
-
|
|
93
|
-
- Python 3.11+
|
|
94
|
-
- Docker daemon running locally
|
|
95
|
-
- Docker Python SDK
|
|
96
|
-
|
|
97
|
-
## Development
|
|
98
|
-
|
|
99
|
-
```bash
|
|
100
|
-
# Clone the repository
|
|
101
|
-
git clone https://github.com/iloveitaly/docker-image-cleanup
|
|
102
|
-
cd docker-image-cleanup
|
|
103
|
-
|
|
104
|
-
# Install dependencies
|
|
105
|
-
uv sync
|
|
106
|
-
|
|
107
|
-
# Run tests
|
|
108
|
-
pytest
|
|
109
|
-
|
|
110
|
-
# Run the CLI
|
|
111
|
-
uv run docker-image-cleanup --help
|
|
112
|
-
```
|
|
113
|
-
|
|
114
125
|
# [MIT License](LICENSE.md)
|
|
@@ -8,12 +8,6 @@ This tool is designed for managing images from self-hosted registries and probab
|
|
|
8
8
|
|
|
9
9
|
## Installation
|
|
10
10
|
|
|
11
|
-
```bash
|
|
12
|
-
pip install docker-image-cleanup
|
|
13
|
-
```
|
|
14
|
-
|
|
15
|
-
Or using `uv`:
|
|
16
|
-
|
|
17
11
|
```bash
|
|
18
12
|
uv tool install docker-image-cleanup
|
|
19
13
|
```
|
|
@@ -51,6 +45,45 @@ docker-image-cleanup --min-age-days 7 myrepo/myimage
|
|
|
51
45
|
docker-image-cleanup --num-recent 3 --min-age-days 7 myrepo/myimage
|
|
52
46
|
```
|
|
53
47
|
|
|
48
|
+
## Docker
|
|
49
|
+
|
|
50
|
+
This tool is available as a Docker image on GHCR. It's designed to run on a schedule using a built-in cron system.
|
|
51
|
+
|
|
52
|
+
### Environment Variables
|
|
53
|
+
|
|
54
|
+
- `TARGET_REPOS`: A space-separated list of image repositories to clean (e.g., `myrepo/image1 myrepo/image2`). **Required**.
|
|
55
|
+
- `SCHEDULE`: A cron expression for the cleanup schedule (default: `0 0 * * *` - daily at midnight).
|
|
56
|
+
- `NUM_RECENT`: Number of recent tags to keep (default: `5`).
|
|
57
|
+
- `MIN_AGE_DAYS`: Minimum age in days to keep tags (default: `30`).
|
|
58
|
+
|
|
59
|
+
### Docker Run
|
|
60
|
+
|
|
61
|
+
You must mount the Docker socket so the container can interact with your local Docker daemon:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
docker run -d \
|
|
65
|
+
--name docker-image-cleanup \
|
|
66
|
+
-v /var/run/docker.sock:/var/run/docker.sock \
|
|
67
|
+
-e TARGET_REPOS="myrepo/image1 myrepo/image2" \
|
|
68
|
+
ghcr.io/iloveitaly/docker-image-cleanup:latest
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### Docker Compose
|
|
72
|
+
|
|
73
|
+
```yaml
|
|
74
|
+
services:
|
|
75
|
+
cleanup:
|
|
76
|
+
image: ghcr.io/iloveitaly/docker-image-cleanup:latest
|
|
77
|
+
restart: unless-stopped
|
|
78
|
+
volumes:
|
|
79
|
+
- /var/run/docker.sock:/var/run/docker.sock
|
|
80
|
+
environment:
|
|
81
|
+
- TARGET_REPOS=myrepo/image1 myrepo/image2
|
|
82
|
+
- SCHEDULE=0 0 * * *
|
|
83
|
+
- NUM_RECENT=5
|
|
84
|
+
- MIN_AGE_DAYS=30
|
|
85
|
+
```
|
|
86
|
+
|
|
54
87
|
## Features
|
|
55
88
|
|
|
56
89
|
- Removes old Docker images based on age and recency criteria
|
|
@@ -73,27 +106,4 @@ The cleanup process is straightforward:
|
|
|
73
106
|
|
|
74
107
|
Images with multiple tags are handled intelligently. If all tags on an image would be removed, the entire image is deleted. If only some tags would be removed, those tags are untagged but the image remains.
|
|
75
108
|
|
|
76
|
-
## Requirements
|
|
77
|
-
|
|
78
|
-
- Python 3.11+
|
|
79
|
-
- Docker daemon running locally
|
|
80
|
-
- Docker Python SDK
|
|
81
|
-
|
|
82
|
-
## Development
|
|
83
|
-
|
|
84
|
-
```bash
|
|
85
|
-
# Clone the repository
|
|
86
|
-
git clone https://github.com/iloveitaly/docker-image-cleanup
|
|
87
|
-
cd docker-image-cleanup
|
|
88
|
-
|
|
89
|
-
# Install dependencies
|
|
90
|
-
uv sync
|
|
91
|
-
|
|
92
|
-
# Run tests
|
|
93
|
-
pytest
|
|
94
|
-
|
|
95
|
-
# Run the CLI
|
|
96
|
-
uv run docker-image-cleanup --help
|
|
97
|
-
```
|
|
98
|
-
|
|
99
109
|
# [MIT License](LICENSE.md)
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Cleans up old Docker images from local storage with retention controls.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from collections import defaultdict
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
import docker
|
|
10
|
+
import docker.errors
|
|
11
|
+
from pydantic import BaseModel, Field
|
|
12
|
+
from structlog_config import configure_logger
|
|
13
|
+
from whenever import Instant
|
|
14
|
+
|
|
15
|
+
log = configure_logger()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class CleanupConfig(BaseModel):
|
|
19
|
+
"""Configuration for cleaning up Docker images."""
|
|
20
|
+
|
|
21
|
+
num_recent: int = Field(5, description="Number of recent tags to keep.")
|
|
22
|
+
min_age_days: int = Field(30, description="Minimum age in days to keep tags.")
|
|
23
|
+
dry_run: bool = Field(False, description="Simulate removal without executing.")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ImageInfo(BaseModel):
|
|
27
|
+
"""Abstract representation of a Docker image for logic processing."""
|
|
28
|
+
|
|
29
|
+
id: str
|
|
30
|
+
tags: list[str]
|
|
31
|
+
created: Instant
|
|
32
|
+
size: int
|
|
33
|
+
is_in_use: bool = False
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def format_size(size_bytes: int) -> str:
|
|
37
|
+
"""Converts bytes to a human-readable string (KB, MB, GB, etc.)."""
|
|
38
|
+
size = float(size_bytes)
|
|
39
|
+
for unit in ["B", "KB", "MB", "GB", "TB"]:
|
|
40
|
+
if size < 1024:
|
|
41
|
+
return f"{size:.2f} {unit}"
|
|
42
|
+
size /= 1024
|
|
43
|
+
return f"{size:.2f} PB"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def parse_docker_image(
|
|
47
|
+
image_data: Any,
|
|
48
|
+
used_image_ids: set[
|
|
49
|
+
str
|
|
50
|
+
], # Use Any due to pyright resolution issues with docker library types
|
|
51
|
+
) -> ImageInfo:
|
|
52
|
+
"""Parses docker.models.images.Image into our ImageInfo model."""
|
|
53
|
+
created_instant = Instant.parse_iso(image_data.attrs["Created"])
|
|
54
|
+
image_id = image_data.id
|
|
55
|
+
|
|
56
|
+
return ImageInfo(
|
|
57
|
+
id=image_id,
|
|
58
|
+
tags=image_data.tags or [],
|
|
59
|
+
created=created_instant,
|
|
60
|
+
size=image_data.attrs.get("Size", 0),
|
|
61
|
+
is_in_use=image_id in used_image_ids,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def get_images_to_process(
|
|
66
|
+
client: docker.DockerClient, repo_name: str, used_image_ids: set[str]
|
|
67
|
+
) -> list[ImageInfo]:
|
|
68
|
+
"""Fetches and parses Docker images for a given repository."""
|
|
69
|
+
images = client.images.list(name=repo_name)
|
|
70
|
+
if not images:
|
|
71
|
+
log.info("no images found for repository", repo=repo_name)
|
|
72
|
+
return []
|
|
73
|
+
|
|
74
|
+
parsed_images = [parse_docker_image(img, used_image_ids) for img in images]
|
|
75
|
+
return sorted(parsed_images, key=lambda img: img.created, reverse=True)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def determine_cleanup_actions(
|
|
79
|
+
images: list[ImageInfo], config: CleanupConfig, now: Instant, repo_name: str
|
|
80
|
+
) -> dict[str, Any]:
|
|
81
|
+
"""Determines which images/tags to remove based on criteria."""
|
|
82
|
+
if not images:
|
|
83
|
+
return {"images_to_delete": [], "tags_to_remove": [], "total_size_saved": 0}
|
|
84
|
+
|
|
85
|
+
keep_tags: set[str] = set()
|
|
86
|
+
for img in images[: config.num_recent]:
|
|
87
|
+
keep_tags.update(img.tags)
|
|
88
|
+
|
|
89
|
+
min_age_threshold = now.subtract(hours=config.min_age_days * 24)
|
|
90
|
+
for img in images:
|
|
91
|
+
if img.created >= min_age_threshold:
|
|
92
|
+
keep_tags.update(img.tags)
|
|
93
|
+
|
|
94
|
+
images_to_delete_ids: set[str] = set()
|
|
95
|
+
tags_to_remove: list[str] = []
|
|
96
|
+
|
|
97
|
+
images_by_id: dict[str, list[ImageInfo]] = defaultdict(list)
|
|
98
|
+
for img in images:
|
|
99
|
+
images_by_id[img.id].append(img)
|
|
100
|
+
|
|
101
|
+
total_size_saved = 0
|
|
102
|
+
repo_prefix = repo_name + ":"
|
|
103
|
+
|
|
104
|
+
for img_id, img_list in images_by_id.items():
|
|
105
|
+
all_image_tags = set()
|
|
106
|
+
for img in img_list:
|
|
107
|
+
all_image_tags.update(img.tags)
|
|
108
|
+
|
|
109
|
+
current_image_repo_tags = {
|
|
110
|
+
tag for tag in all_image_tags if tag.startswith(repo_prefix)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
tags_for_this_image_to_remove = [
|
|
114
|
+
tag for tag in current_image_repo_tags if tag not in keep_tags
|
|
115
|
+
]
|
|
116
|
+
|
|
117
|
+
if tags_for_this_image_to_remove:
|
|
118
|
+
tags_to_remove.extend(tags_for_this_image_to_remove)
|
|
119
|
+
|
|
120
|
+
if set(tags_for_this_image_to_remove) == current_image_repo_tags:
|
|
121
|
+
img_info = img_list[0]
|
|
122
|
+
if not img_info.is_in_use:
|
|
123
|
+
images_to_delete_ids.add(img_id)
|
|
124
|
+
total_size_saved += img_info.size
|
|
125
|
+
else:
|
|
126
|
+
log.warning(
|
|
127
|
+
"skipped image in use",
|
|
128
|
+
image_id=img_id,
|
|
129
|
+
tags=tags_for_this_image_to_remove,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
"images_to_delete": list(images_to_delete_ids),
|
|
134
|
+
"tags_to_remove": list(set(tags_to_remove)),
|
|
135
|
+
"total_size_saved": total_size_saved,
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def execute_cleanup(
|
|
140
|
+
client: docker.DockerClient,
|
|
141
|
+
images_to_delete: list[str],
|
|
142
|
+
tags_to_remove: list[str],
|
|
143
|
+
total_size_saved: int,
|
|
144
|
+
dry_run: bool,
|
|
145
|
+
) -> int:
|
|
146
|
+
"""Executes the determined cleanup actions."""
|
|
147
|
+
executed_size_saved = 0
|
|
148
|
+
|
|
149
|
+
if dry_run:
|
|
150
|
+
if tags_to_remove:
|
|
151
|
+
log.info("would remove tags", tags=tags_to_remove)
|
|
152
|
+
if images_to_delete:
|
|
153
|
+
log.info("would remove images", image_ids=images_to_delete)
|
|
154
|
+
|
|
155
|
+
log.info(
|
|
156
|
+
"total space that would be saved",
|
|
157
|
+
bytes=total_size_saved,
|
|
158
|
+
human=format_size(total_size_saved),
|
|
159
|
+
)
|
|
160
|
+
return total_size_saved
|
|
161
|
+
|
|
162
|
+
for tag in tags_to_remove:
|
|
163
|
+
try:
|
|
164
|
+
client.images.remove(tag, force=False)
|
|
165
|
+
log.info("untagged image", tag=tag)
|
|
166
|
+
except docker.errors.APIError as e:
|
|
167
|
+
log.warning("skipped untagging", tag=tag, reason=str(e))
|
|
168
|
+
|
|
169
|
+
for img_id in images_to_delete:
|
|
170
|
+
try:
|
|
171
|
+
img = client.images.get(img_id)
|
|
172
|
+
size = img.attrs.get("Size", 0)
|
|
173
|
+
client.images.remove(img_id, force=False)
|
|
174
|
+
log.info("removed image", image_id=img_id, size=format_size(size))
|
|
175
|
+
executed_size_saved += size
|
|
176
|
+
except docker.errors.ImageNotFound:
|
|
177
|
+
log.warning("image already removed", image_id=img_id)
|
|
178
|
+
except docker.errors.APIError as e:
|
|
179
|
+
log.warning("skipped image removal", image_id=img_id, reason=str(e))
|
|
180
|
+
|
|
181
|
+
log.info(
|
|
182
|
+
"total space saved",
|
|
183
|
+
bytes=executed_size_saved,
|
|
184
|
+
human=format_size(executed_size_saved),
|
|
185
|
+
)
|
|
186
|
+
return executed_size_saved
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@click.command()
|
|
190
|
+
@click.argument("image_repos", nargs=-1, required=True)
|
|
191
|
+
@click.option(
|
|
192
|
+
"--num-recent",
|
|
193
|
+
default=5,
|
|
194
|
+
type=int,
|
|
195
|
+
help="Number of recent tags to keep.",
|
|
196
|
+
show_default=True,
|
|
197
|
+
)
|
|
198
|
+
@click.option(
|
|
199
|
+
"--min-age-days",
|
|
200
|
+
default=30,
|
|
201
|
+
type=int,
|
|
202
|
+
help="Minimum age in days to keep tags.",
|
|
203
|
+
show_default=True,
|
|
204
|
+
)
|
|
205
|
+
@click.option(
|
|
206
|
+
"--dry-run",
|
|
207
|
+
is_flag=True,
|
|
208
|
+
default=False,
|
|
209
|
+
help="Simulate removal without executing.",
|
|
210
|
+
show_default=True,
|
|
211
|
+
)
|
|
212
|
+
def main(
|
|
213
|
+
image_repos: tuple[str, ...], num_recent: int, min_age_days: int, dry_run: bool
|
|
214
|
+
):
|
|
215
|
+
"""
|
|
216
|
+
Cleans up old Docker images from local storage with retention controls.
|
|
217
|
+
|
|
218
|
+
IMAGE_REPOS are the repository names to clean (e.g., 'my/app', 'ubuntu').
|
|
219
|
+
"""
|
|
220
|
+
try:
|
|
221
|
+
client = docker.from_env()
|
|
222
|
+
now = Instant.now()
|
|
223
|
+
|
|
224
|
+
try:
|
|
225
|
+
all_containers = client.containers.list(all=True)
|
|
226
|
+
used_image_ids: set[str] = {
|
|
227
|
+
container.image.id
|
|
228
|
+
for container in all_containers
|
|
229
|
+
if container.image and container.image.id
|
|
230
|
+
}
|
|
231
|
+
log.debug("determined used image IDs", count=len(used_image_ids))
|
|
232
|
+
except docker.errors.APIError as e:
|
|
233
|
+
log.error(
|
|
234
|
+
"failed to list containers, cannot determine in-use images",
|
|
235
|
+
reason=str(e),
|
|
236
|
+
)
|
|
237
|
+
used_image_ids = set()
|
|
238
|
+
|
|
239
|
+
config = CleanupConfig(
|
|
240
|
+
num_recent=num_recent,
|
|
241
|
+
min_age_days=min_age_days,
|
|
242
|
+
dry_run=dry_run,
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
grand_total_saved = 0
|
|
246
|
+
for repo_name in image_repos:
|
|
247
|
+
log.info("processing repository", repo=repo_name)
|
|
248
|
+
|
|
249
|
+
images = get_images_to_process(client, repo_name, used_image_ids)
|
|
250
|
+
|
|
251
|
+
if not images:
|
|
252
|
+
continue
|
|
253
|
+
|
|
254
|
+
actions = determine_cleanup_actions(images, config, now, repo_name)
|
|
255
|
+
|
|
256
|
+
executed_bytes_saved = execute_cleanup(
|
|
257
|
+
client=client,
|
|
258
|
+
images_to_delete=actions["images_to_delete"],
|
|
259
|
+
tags_to_remove=actions["tags_to_remove"],
|
|
260
|
+
total_size_saved=actions["total_size_saved"],
|
|
261
|
+
dry_run=config.dry_run,
|
|
262
|
+
)
|
|
263
|
+
grand_total_saved += executed_bytes_saved
|
|
264
|
+
|
|
265
|
+
if len(image_repos) > 1:
|
|
266
|
+
result_msg = (
|
|
267
|
+
"grand total space that would be saved"
|
|
268
|
+
if dry_run
|
|
269
|
+
else "grand total space saved"
|
|
270
|
+
)
|
|
271
|
+
log.info(
|
|
272
|
+
result_msg,
|
|
273
|
+
bytes=grand_total_saved,
|
|
274
|
+
human=format_size(grand_total_saved),
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
except docker.errors.DockerException as e:
|
|
278
|
+
log.error("failed to connect to Docker daemon", reason=str(e))
|
|
279
|
+
exit(1)
|
|
280
|
+
except Exception:
|
|
281
|
+
log.exception("an unexpected error occurred")
|
|
282
|
+
exit(1)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
if __name__ == "__main__":
|
|
286
|
+
main()
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "docker-image-cleanup"
|
|
3
|
-
version = "0.1.
|
|
3
|
+
version = "0.1.2"
|
|
4
4
|
description = "Clean up old Docker images from local storage with retention controls"
|
|
5
5
|
keywords = ["docker", "cleanup", "images", "disk-space"]
|
|
6
6
|
readme = "README.md"
|
|
@@ -8,7 +8,8 @@ requires-python = ">=3.11"
|
|
|
8
8
|
dependencies = [
|
|
9
9
|
"click>=8.3.0",
|
|
10
10
|
"docker>=7.1.0",
|
|
11
|
-
"
|
|
11
|
+
"pydantic>=2.12.5",
|
|
12
|
+
"structlog-config>=0.6.0",
|
|
12
13
|
"whenever>=0.9.3",
|
|
13
14
|
]
|
|
14
15
|
authors = [{ name = "Michael Bianco", email = "mike@mikebian.co" }]
|
|
@@ -19,7 +20,7 @@ urls = { "Repository" = "https://github.com/iloveitaly/docker-image-cleanup" }
|
|
|
19
20
|
docker-image-cleanup = "docker_image_cleanup:main"
|
|
20
21
|
|
|
21
22
|
[build-system]
|
|
22
|
-
requires = ["uv_build>=0.
|
|
23
|
+
requires = ["uv_build>=0.9.0,<0.10.0"]
|
|
23
24
|
build-backend = "uv_build"
|
|
24
25
|
|
|
25
26
|
[tool.uv.build-backend]
|
|
@@ -27,5 +28,10 @@ build-backend = "uv_build"
|
|
|
27
28
|
module-root = ""
|
|
28
29
|
|
|
29
30
|
[dependency-groups]
|
|
30
|
-
dev = ["pytest>=8.3.3","pyright>=1.1.380","ruff>=0.8.0"]
|
|
31
|
+
dev = ["pytest>=8.3.3", "pyright[nodejs]>=1.1.380", "ruff>=0.8.0"]
|
|
31
32
|
|
|
33
|
+
[tool.pyright]
|
|
34
|
+
exclude = ["examples/", "playground/", "tmp/", ".venv/", "tests/"]
|
|
35
|
+
|
|
36
|
+
[tool.ruff]
|
|
37
|
+
extend-exclude = ["playground.py", "playground/"]
|
|
@@ -1,132 +0,0 @@
|
|
|
1
|
-
import click
|
|
2
|
-
import docker
|
|
3
|
-
import docker.errors
|
|
4
|
-
import structlog
|
|
5
|
-
from collections import defaultdict
|
|
6
|
-
from whenever import Instant
|
|
7
|
-
from structlog_config import configure_logger
|
|
8
|
-
|
|
9
|
-
configure_logger()
|
|
10
|
-
log = structlog.get_logger()
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
def human_size(size_bytes):
|
|
14
|
-
size = size_bytes
|
|
15
|
-
for unit in ["B", "KB", "MB", "GB", "TB"]:
|
|
16
|
-
if size < 1024:
|
|
17
|
-
return f"{size:.2f} {unit}"
|
|
18
|
-
size /= 1024
|
|
19
|
-
return f"{size:.2f} PB"
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
def clean_repo(client, image_repo, num_recent, min_age_days, dry_run, now):
|
|
23
|
-
images = client.images.list(name=image_repo)
|
|
24
|
-
if not images:
|
|
25
|
-
log.info("no images", repo=image_repo)
|
|
26
|
-
return 0
|
|
27
|
-
|
|
28
|
-
sorted_images = sorted(
|
|
29
|
-
images, key=lambda img: Instant.parse_iso(img.attrs["Created"]), reverse=True
|
|
30
|
-
)
|
|
31
|
-
|
|
32
|
-
keep_tags = set()
|
|
33
|
-
for img in sorted_images[:num_recent]:
|
|
34
|
-
keep_tags.update(img.tags)
|
|
35
|
-
|
|
36
|
-
min_age = now.subtract(hours=min_age_days * 24)
|
|
37
|
-
for img in images:
|
|
38
|
-
created = Instant.parse_iso(img.attrs["Created"])
|
|
39
|
-
log.debug("inspecting image", image_id=img.id, tags=img.tags, created=created)
|
|
40
|
-
if created >= min_age:
|
|
41
|
-
keep_tags.update(img.tags)
|
|
42
|
-
|
|
43
|
-
tags_to_remove = []
|
|
44
|
-
for img in images:
|
|
45
|
-
for tag in img.tags:
|
|
46
|
-
if tag not in keep_tags and tag.startswith(image_repo + ":"):
|
|
47
|
-
tags_to_remove.append((tag, img))
|
|
48
|
-
|
|
49
|
-
remove_by_id = defaultdict(list)
|
|
50
|
-
for tag, img in tags_to_remove:
|
|
51
|
-
remove_by_id[img.id].append(tag)
|
|
52
|
-
|
|
53
|
-
total_saved = 0
|
|
54
|
-
for img_id, remove_tags in remove_by_id.items():
|
|
55
|
-
img = client.images.get(img_id)
|
|
56
|
-
all_tags = img.tags
|
|
57
|
-
if set(remove_tags) == set(all_tags):
|
|
58
|
-
in_use = (
|
|
59
|
-
len(client.containers.list(all=True, filters={"ancestor": img_id})) > 0
|
|
60
|
-
)
|
|
61
|
-
if in_use:
|
|
62
|
-
log.warning("skipped image in use", image_id=img_id, tags=remove_tags)
|
|
63
|
-
continue
|
|
64
|
-
size = img.attrs.get("Size", 0)
|
|
65
|
-
if dry_run:
|
|
66
|
-
log.info(
|
|
67
|
-
"would remove image",
|
|
68
|
-
image_id=img_id,
|
|
69
|
-
tags=remove_tags,
|
|
70
|
-
size=human_size(size),
|
|
71
|
-
)
|
|
72
|
-
else:
|
|
73
|
-
for tag in remove_tags:
|
|
74
|
-
client.images.remove(tag, force=False)
|
|
75
|
-
log.info(
|
|
76
|
-
"removed image",
|
|
77
|
-
image_id=img_id,
|
|
78
|
-
tags=remove_tags,
|
|
79
|
-
size=human_size(size),
|
|
80
|
-
)
|
|
81
|
-
total_saved += size
|
|
82
|
-
else:
|
|
83
|
-
if dry_run:
|
|
84
|
-
log.info("would untag", tags=remove_tags)
|
|
85
|
-
else:
|
|
86
|
-
for tag in remove_tags:
|
|
87
|
-
try:
|
|
88
|
-
client.images.remove(tag, force=False)
|
|
89
|
-
log.info("untagged", tag=tag)
|
|
90
|
-
except docker.errors.APIError as e:
|
|
91
|
-
log.warning("skipped untag", tag=tag, reason=str(e))
|
|
92
|
-
|
|
93
|
-
msg = "total space that would be saved" if dry_run else "total space saved"
|
|
94
|
-
log.info(msg, bytes=total_saved, human=human_size(total_saved))
|
|
95
|
-
return total_saved
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
@click.command()
|
|
99
|
-
@click.argument("image_repos", nargs=-1, required=True)
|
|
100
|
-
@click.option(
|
|
101
|
-
"--num-recent", default=5, help="Number of recent tags to keep.", show_default=True
|
|
102
|
-
)
|
|
103
|
-
@click.option(
|
|
104
|
-
"--min-age-days",
|
|
105
|
-
default=30,
|
|
106
|
-
help="Minimum age in days to keep tags.",
|
|
107
|
-
show_default=True,
|
|
108
|
-
)
|
|
109
|
-
@click.option(
|
|
110
|
-
"--dry-run",
|
|
111
|
-
is_flag=True,
|
|
112
|
-
help="Simulate removal without executing.",
|
|
113
|
-
show_default=True,
|
|
114
|
-
)
|
|
115
|
-
def main(image_repos, num_recent, min_age_days, dry_run):
|
|
116
|
-
client = docker.from_env()
|
|
117
|
-
now = Instant.now()
|
|
118
|
-
|
|
119
|
-
grand_total = 0
|
|
120
|
-
for image_repo in image_repos:
|
|
121
|
-
log.info("cleaning repo", repo=image_repo)
|
|
122
|
-
grand_total += clean_repo(
|
|
123
|
-
client, image_repo, num_recent, min_age_days, dry_run, now
|
|
124
|
-
)
|
|
125
|
-
|
|
126
|
-
if len(image_repos) > 1:
|
|
127
|
-
msg = (
|
|
128
|
-
"grand total space that would be saved"
|
|
129
|
-
if dry_run
|
|
130
|
-
else "grand total space saved"
|
|
131
|
-
)
|
|
132
|
-
log.info(msg, bytes=grand_total, human=human_size(grand_total))
|