whyfull 0.1.0__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.
- whyfull-0.1.0/.gitignore +8 -0
- whyfull-0.1.0/CHANGELOG.md +10 -0
- whyfull-0.1.0/LICENSE +21 -0
- whyfull-0.1.0/PKG-INFO +148 -0
- whyfull-0.1.0/README.md +114 -0
- whyfull-0.1.0/pyproject.toml +68 -0
- whyfull-0.1.0/src/whyfull/__init__.py +3 -0
- whyfull-0.1.0/src/whyfull/__main__.py +5 -0
- whyfull-0.1.0/src/whyfull/analyze.py +167 -0
- whyfull-0.1.0/src/whyfull/checks/__init__.py +92 -0
- whyfull-0.1.0/src/whyfull/checks/containers.py +94 -0
- whyfull-0.1.0/src/whyfull/checks/crashes.py +31 -0
- whyfull-0.1.0/src/whyfull/checks/deleted_open.py +125 -0
- whyfull-0.1.0/src/whyfull/checks/journald.py +33 -0
- whyfull-0.1.0/src/whyfull/checks/kernels.py +82 -0
- whyfull-0.1.0/src/whyfull/checks/logs.py +81 -0
- whyfull-0.1.0/src/whyfull/checks/packages.py +32 -0
- whyfull-0.1.0/src/whyfull/checks/snaps.py +46 -0
- whyfull-0.1.0/src/whyfull/checks/space.py +50 -0
- whyfull-0.1.0/src/whyfull/checks/tmpfiles.py +55 -0
- whyfull-0.1.0/src/whyfull/checks/usercaches.py +63 -0
- whyfull-0.1.0/src/whyfull/cli.py +126 -0
- whyfull-0.1.0/src/whyfull/model.py +102 -0
- whyfull-0.1.0/src/whyfull/mounts.py +94 -0
- whyfull-0.1.0/src/whyfull/render.py +194 -0
- whyfull-0.1.0/src/whyfull/system.py +166 -0
- whyfull-0.1.0/src/whyfull/units.py +53 -0
- whyfull-0.1.0/src/whyfull/walk.py +171 -0
- whyfull-0.1.0/tests/conftest.py +136 -0
- whyfull-0.1.0/tests/test_checks.py +197 -0
- whyfull-0.1.0/tests/test_core.py +150 -0
- whyfull-0.1.0/tools/build_pyz.py +37 -0
- whyfull-0.1.0/tools/demo.py +165 -0
whyfull-0.1.0/.gitignore
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0
|
|
4
|
+
|
|
5
|
+
First release.
|
|
6
|
+
|
|
7
|
+
- Checks: deleted-but-open files, Docker (build cache, images, containers, volumes, container logs), systemd journal, rotated and oversized logs, old kernels, package manager caches, snap revisions, core dumps, old temp files, user build and package caches, inode exhaustion, and the ext root reserve.
|
|
8
|
+
- Full-filesystem scan with a time limit, largest directories, crowded directories, and a warning when `df` and the files on disk don't add up.
|
|
9
|
+
- Text, JSON and Markdown output; `--fail-above` for cron and monitoring.
|
|
10
|
+
- Runs on Python 3.6+ with no dependencies; also shipped as a single `whyfull.pyz` file.
|
whyfull-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ahmed Laban
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
whyfull-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: whyfull
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Find out why your Linux disk is full: deleted-but-open files, Docker, journald, old kernels, caches and more, each with the command that fixes it.
|
|
5
|
+
Project-URL: Homepage, https://github.com/amlaban65/whyfull
|
|
6
|
+
Project-URL: Issues, https://github.com/amlaban65/whyfull/issues
|
|
7
|
+
Project-URL: Changelog, https://github.com/amlaban65/whyfull/blob/main/CHANGELOG.md
|
|
8
|
+
Author: Ahmed Laban
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: cleanup,deleted files,devops,df,disk full,disk space,docker,du,inodes,journald,linux,lsof,no space left on device,sre,sysadmin
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Intended Audience :: System Administrators
|
|
16
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
17
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.6
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.7
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
24
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
25
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
26
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
27
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
28
|
+
Classifier: Topic :: System :: Filesystems
|
|
29
|
+
Classifier: Topic :: System :: Monitoring
|
|
30
|
+
Classifier: Topic :: System :: Systems Administration
|
|
31
|
+
Classifier: Topic :: Utilities
|
|
32
|
+
Requires-Python: >=3.6
|
|
33
|
+
Description-Content-Type: text/markdown
|
|
34
|
+
|
|
35
|
+
# whyfull
|
|
36
|
+
|
|
37
|
+
**Find out why your Linux disk is full, and get the command that fixes it.**
|
|
38
|
+
|
|
39
|
+
[](https://github.com/amlaban65/whyfull/actions/workflows/ci.yml)
|
|
40
|
+
[](https://pypi.org/project/whyfull/)
|
|
41
|
+
[](https://pypi.org/project/whyfull/)
|
|
42
|
+
[](https://github.com/amlaban65/whyfull/blob/main/LICENSE)
|
|
43
|
+
|
|
44
|
+
`df` says 100%. `du` doesn't add up. Something is failing with `No space left on device`.
|
|
45
|
+
|
|
46
|
+
whyfull checks the places where disk space usually goes on a Linux server: deleted files that a process still holds open, the systemd journal, Docker images and container logs, old kernels, package caches, core dumps, runaway logs and inode exhaustion. For each thing it finds, it prints how much space it takes, how risky it is to remove, and the exact command to get the space back.
|
|
47
|
+
|
|
48
|
+
It never deletes anything itself.
|
|
49
|
+
|
|
50
|
+
<p align="center">
|
|
51
|
+
<img src="https://raw.githubusercontent.com/amlaban65/whyfull/main/docs/demo.svg" alt="whyfull showing a full root filesystem: a 700 MB deleted nginx log still held open, a 560 MB Docker container log, unused images, old kernels and the systemd journal, each with the command that frees it" width="100%">
|
|
52
|
+
</p>
|
|
53
|
+
|
|
54
|
+
## Run it
|
|
55
|
+
|
|
56
|
+
A full disk may leave no room to install anything, so whyfull is a single 30 KB file with no dependencies. It runs on the Python 3 that already ships with nearly every Linux server (3.6 or newer). Download it to `/dev/shm`, which lives in memory:
|
|
57
|
+
|
|
58
|
+
```sh
|
|
59
|
+
curl -fsSL -o /dev/shm/whyfull https://github.com/amlaban65/whyfull/releases/latest/download/whyfull.pyz
|
|
60
|
+
sudo python3 /dev/shm/whyfull
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Or install it from PyPI:
|
|
64
|
+
|
|
65
|
+
```sh
|
|
66
|
+
pipx install whyfull # or: uv tool install whyfull, pip install --user whyfull
|
|
67
|
+
sudo "$(command -v whyfull)"
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
`uvx whyfull` and `pipx run whyfull` work for a one-off run.
|
|
71
|
+
|
|
72
|
+
Run it with `sudo`. Without root it can't measure files owned by other users and services, and it will tell you what it couldn't read.
|
|
73
|
+
|
|
74
|
+
## What it looks for
|
|
75
|
+
|
|
76
|
+
| Check | What it finds |
|
|
77
|
+
|---|---|
|
|
78
|
+
| `deleted-open` | Files that were deleted while a process still had them open. This is the classic "df says full but du says otherwise". whyfull names the systemd service or Docker container holding them and shows how to release the space with or without a restart. |
|
|
79
|
+
| `docker` | Build cache, unused images, stopped containers, dangling volumes, and container logs with no size limit. |
|
|
80
|
+
| `journald` | A systemd journal that has grown past a sensible size, with the commands to vacuum it and cap it. |
|
|
81
|
+
| `logs` | Rotated and compressed logs in `/var/log`, and single log files over 1 GB. |
|
|
82
|
+
| `kernels` | Installed kernels other than the running one and the newest one, with the right removal command for apt, dnf or zypper. |
|
|
83
|
+
| `packages` | apt, dnf, yum, pacman, zypper and apk caches. |
|
|
84
|
+
| `snaps` | Disabled snap revisions that snapd keeps around. |
|
|
85
|
+
| `crashes` | Core dumps from systemd-coredump, apport and ABRT, and `/var/crash`. |
|
|
86
|
+
| `tmp` | Files in `/tmp` and `/var/tmp` nobody has touched in a week. |
|
|
87
|
+
| `user-caches` | pip, uv, poetry, npm, yarn, pnpm, Go, Cargo, Maven, Gradle, Hugging Face, PyTorch and conda caches, and the Trash, for every user. |
|
|
88
|
+
| `inodes` | A filesystem that ran out of inodes (too many files) rather than bytes, and the directories with the most entries. |
|
|
89
|
+
| `reserved` | ext filesystems that look full only because of the 5% reserved for root. |
|
|
90
|
+
|
|
91
|
+
whyfull also scans the whole filesystem (with a time limit) and lists the largest directories. When the files it can see add up to much less than `df` reports, it says so, and points to where the rest usually is. Common causes are files hidden underneath a mount point and btrfs or ZFS snapshots.
|
|
92
|
+
|
|
93
|
+
`whyfull --list-checks` prints the list from your installed version.
|
|
94
|
+
|
|
95
|
+
### Risk levels
|
|
96
|
+
|
|
97
|
+
Every finding is labelled so you know what you're agreeing to:
|
|
98
|
+
|
|
99
|
+
- **safe**: caches that are rebuilt on demand. Removing them costs a slower next build or download.
|
|
100
|
+
- **review**: almost always fine to remove (old kernels, rotated logs, unused images), but take a look first.
|
|
101
|
+
- **caution**: can lose data or affect a running service, such as truncating a live log or pruning Docker volumes.
|
|
102
|
+
- **info**: explains what's going on, like inode exhaustion or the root reserve, without a direct cleanup.
|
|
103
|
+
|
|
104
|
+
## Usage
|
|
105
|
+
|
|
106
|
+
```sh
|
|
107
|
+
sudo whyfull # every filesystem that is 85% full or more
|
|
108
|
+
sudo whyfull /var # only the filesystem holding /var
|
|
109
|
+
sudo whyfull -t 70 # lower the "full" threshold to 70%
|
|
110
|
+
sudo whyfull -v # explain every finding, list all evidence
|
|
111
|
+
sudo whyfull --no-scan # skip the full scan, run only the checks
|
|
112
|
+
sudo whyfull --only docker,logs # run some checks; see --list-checks
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
If nothing is above the threshold, whyfull analyses `/` anyway and tells you the disks are fine.
|
|
116
|
+
|
|
117
|
+
### For incident tickets, scripts and monitoring
|
|
118
|
+
|
|
119
|
+
```sh
|
|
120
|
+
sudo whyfull --format markdown > disk.md # paste into a ticket or chat
|
|
121
|
+
sudo whyfull --json | jq '.targets[].findings[] | {title, bytes, risk}'
|
|
122
|
+
whyfull --fail-above 90 --no-scan # exits 1 when any filesystem is 90%+ full
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Exit codes: `0` when the run finished, `1` when `--fail-above` was exceeded, `2` for usage errors.
|
|
126
|
+
|
|
127
|
+
## How is this different from ncdu, du, dust or gdu?
|
|
128
|
+
|
|
129
|
+
Those are great for browsing a directory tree, and you should keep using them. They answer "which folder is big". whyfull answers "what is this space, and how do I get it back without breaking anything". It finds space that no directory browser can see (deleted files held open by a process). It knows that `/var/lib/docker/overlay2` is cleaned with `docker image prune`, not `rm`. It knows which kernels are safe to remove, and that a 2 GB `syslog.2.gz` can go while a live `syslog` should be truncated.
|
|
130
|
+
|
|
131
|
+
Run whyfull first. If something is still unexplained, open ncdu on the directory it points to.
|
|
132
|
+
|
|
133
|
+
## Safety
|
|
134
|
+
|
|
135
|
+
- whyfull only reads. It does not delete, truncate, prune or restart anything.
|
|
136
|
+
- The only commands it runs are read-only: `docker system df`, `docker info` and `snap list --all`.
|
|
137
|
+
- Every fix is printed for you to read and run yourself. Commands that can lose data are marked **caution** and explained.
|
|
138
|
+
- Scans stay on one filesystem (like `du -x`), count hard links once, and stop at a time limit, so they won't hang on a huge disk or wander into network mounts.
|
|
139
|
+
|
|
140
|
+
## Contributing
|
|
141
|
+
|
|
142
|
+
The most useful contribution is a new check: *"my disk filled up because of X, and here is how to spot it and clean it up."* Each check is one short file in [`src/whyfull/checks/`](https://github.com/amlaban65/whyfull/tree/main/src/whyfull/checks), and tests run against a fake filesystem, so you don't need a full disk to write one. [CONTRIBUTING.md](https://github.com/amlaban65/whyfull/blob/main/CONTRIBUTING.md) walks through it.
|
|
143
|
+
|
|
144
|
+
Some checks that would help a lot of people: containerd and CRI-O images (`crictl`), Podman, Kubernetes pod logs, PostgreSQL WAL, MySQL binlogs, Elasticsearch, LXD, Flatpak, Jenkins workspaces and GitLab Runner caches. If you have a war story about a disk that filled up in a strange way, [open an issue](https://github.com/amlaban65/whyfull/issues/new?template=new-check.yml) even if you don't plan to write the code.
|
|
145
|
+
|
|
146
|
+
## License
|
|
147
|
+
|
|
148
|
+
[MIT](https://github.com/amlaban65/whyfull/blob/main/LICENSE)
|
whyfull-0.1.0/README.md
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# whyfull
|
|
2
|
+
|
|
3
|
+
**Find out why your Linux disk is full, and get the command that fixes it.**
|
|
4
|
+
|
|
5
|
+
[](https://github.com/amlaban65/whyfull/actions/workflows/ci.yml)
|
|
6
|
+
[](https://pypi.org/project/whyfull/)
|
|
7
|
+
[](https://pypi.org/project/whyfull/)
|
|
8
|
+
[](https://github.com/amlaban65/whyfull/blob/main/LICENSE)
|
|
9
|
+
|
|
10
|
+
`df` says 100%. `du` doesn't add up. Something is failing with `No space left on device`.
|
|
11
|
+
|
|
12
|
+
whyfull checks the places where disk space usually goes on a Linux server: deleted files that a process still holds open, the systemd journal, Docker images and container logs, old kernels, package caches, core dumps, runaway logs and inode exhaustion. For each thing it finds, it prints how much space it takes, how risky it is to remove, and the exact command to get the space back.
|
|
13
|
+
|
|
14
|
+
It never deletes anything itself.
|
|
15
|
+
|
|
16
|
+
<p align="center">
|
|
17
|
+
<img src="https://raw.githubusercontent.com/amlaban65/whyfull/main/docs/demo.svg" alt="whyfull showing a full root filesystem: a 700 MB deleted nginx log still held open, a 560 MB Docker container log, unused images, old kernels and the systemd journal, each with the command that frees it" width="100%">
|
|
18
|
+
</p>
|
|
19
|
+
|
|
20
|
+
## Run it
|
|
21
|
+
|
|
22
|
+
A full disk may leave no room to install anything, so whyfull is a single 30 KB file with no dependencies. It runs on the Python 3 that already ships with nearly every Linux server (3.6 or newer). Download it to `/dev/shm`, which lives in memory:
|
|
23
|
+
|
|
24
|
+
```sh
|
|
25
|
+
curl -fsSL -o /dev/shm/whyfull https://github.com/amlaban65/whyfull/releases/latest/download/whyfull.pyz
|
|
26
|
+
sudo python3 /dev/shm/whyfull
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Or install it from PyPI:
|
|
30
|
+
|
|
31
|
+
```sh
|
|
32
|
+
pipx install whyfull # or: uv tool install whyfull, pip install --user whyfull
|
|
33
|
+
sudo "$(command -v whyfull)"
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
`uvx whyfull` and `pipx run whyfull` work for a one-off run.
|
|
37
|
+
|
|
38
|
+
Run it with `sudo`. Without root it can't measure files owned by other users and services, and it will tell you what it couldn't read.
|
|
39
|
+
|
|
40
|
+
## What it looks for
|
|
41
|
+
|
|
42
|
+
| Check | What it finds |
|
|
43
|
+
|---|---|
|
|
44
|
+
| `deleted-open` | Files that were deleted while a process still had them open. This is the classic "df says full but du says otherwise". whyfull names the systemd service or Docker container holding them and shows how to release the space with or without a restart. |
|
|
45
|
+
| `docker` | Build cache, unused images, stopped containers, dangling volumes, and container logs with no size limit. |
|
|
46
|
+
| `journald` | A systemd journal that has grown past a sensible size, with the commands to vacuum it and cap it. |
|
|
47
|
+
| `logs` | Rotated and compressed logs in `/var/log`, and single log files over 1 GB. |
|
|
48
|
+
| `kernels` | Installed kernels other than the running one and the newest one, with the right removal command for apt, dnf or zypper. |
|
|
49
|
+
| `packages` | apt, dnf, yum, pacman, zypper and apk caches. |
|
|
50
|
+
| `snaps` | Disabled snap revisions that snapd keeps around. |
|
|
51
|
+
| `crashes` | Core dumps from systemd-coredump, apport and ABRT, and `/var/crash`. |
|
|
52
|
+
| `tmp` | Files in `/tmp` and `/var/tmp` nobody has touched in a week. |
|
|
53
|
+
| `user-caches` | pip, uv, poetry, npm, yarn, pnpm, Go, Cargo, Maven, Gradle, Hugging Face, PyTorch and conda caches, and the Trash, for every user. |
|
|
54
|
+
| `inodes` | A filesystem that ran out of inodes (too many files) rather than bytes, and the directories with the most entries. |
|
|
55
|
+
| `reserved` | ext filesystems that look full only because of the 5% reserved for root. |
|
|
56
|
+
|
|
57
|
+
whyfull also scans the whole filesystem (with a time limit) and lists the largest directories. When the files it can see add up to much less than `df` reports, it says so, and points to where the rest usually is. Common causes are files hidden underneath a mount point and btrfs or ZFS snapshots.
|
|
58
|
+
|
|
59
|
+
`whyfull --list-checks` prints the list from your installed version.
|
|
60
|
+
|
|
61
|
+
### Risk levels
|
|
62
|
+
|
|
63
|
+
Every finding is labelled so you know what you're agreeing to:
|
|
64
|
+
|
|
65
|
+
- **safe**: caches that are rebuilt on demand. Removing them costs a slower next build or download.
|
|
66
|
+
- **review**: almost always fine to remove (old kernels, rotated logs, unused images), but take a look first.
|
|
67
|
+
- **caution**: can lose data or affect a running service, such as truncating a live log or pruning Docker volumes.
|
|
68
|
+
- **info**: explains what's going on, like inode exhaustion or the root reserve, without a direct cleanup.
|
|
69
|
+
|
|
70
|
+
## Usage
|
|
71
|
+
|
|
72
|
+
```sh
|
|
73
|
+
sudo whyfull # every filesystem that is 85% full or more
|
|
74
|
+
sudo whyfull /var # only the filesystem holding /var
|
|
75
|
+
sudo whyfull -t 70 # lower the "full" threshold to 70%
|
|
76
|
+
sudo whyfull -v # explain every finding, list all evidence
|
|
77
|
+
sudo whyfull --no-scan # skip the full scan, run only the checks
|
|
78
|
+
sudo whyfull --only docker,logs # run some checks; see --list-checks
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
If nothing is above the threshold, whyfull analyses `/` anyway and tells you the disks are fine.
|
|
82
|
+
|
|
83
|
+
### For incident tickets, scripts and monitoring
|
|
84
|
+
|
|
85
|
+
```sh
|
|
86
|
+
sudo whyfull --format markdown > disk.md # paste into a ticket or chat
|
|
87
|
+
sudo whyfull --json | jq '.targets[].findings[] | {title, bytes, risk}'
|
|
88
|
+
whyfull --fail-above 90 --no-scan # exits 1 when any filesystem is 90%+ full
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Exit codes: `0` when the run finished, `1` when `--fail-above` was exceeded, `2` for usage errors.
|
|
92
|
+
|
|
93
|
+
## How is this different from ncdu, du, dust or gdu?
|
|
94
|
+
|
|
95
|
+
Those are great for browsing a directory tree, and you should keep using them. They answer "which folder is big". whyfull answers "what is this space, and how do I get it back without breaking anything". It finds space that no directory browser can see (deleted files held open by a process). It knows that `/var/lib/docker/overlay2` is cleaned with `docker image prune`, not `rm`. It knows which kernels are safe to remove, and that a 2 GB `syslog.2.gz` can go while a live `syslog` should be truncated.
|
|
96
|
+
|
|
97
|
+
Run whyfull first. If something is still unexplained, open ncdu on the directory it points to.
|
|
98
|
+
|
|
99
|
+
## Safety
|
|
100
|
+
|
|
101
|
+
- whyfull only reads. It does not delete, truncate, prune or restart anything.
|
|
102
|
+
- The only commands it runs are read-only: `docker system df`, `docker info` and `snap list --all`.
|
|
103
|
+
- Every fix is printed for you to read and run yourself. Commands that can lose data are marked **caution** and explained.
|
|
104
|
+
- Scans stay on one filesystem (like `du -x`), count hard links once, and stop at a time limit, so they won't hang on a huge disk or wander into network mounts.
|
|
105
|
+
|
|
106
|
+
## Contributing
|
|
107
|
+
|
|
108
|
+
The most useful contribution is a new check: *"my disk filled up because of X, and here is how to spot it and clean it up."* Each check is one short file in [`src/whyfull/checks/`](https://github.com/amlaban65/whyfull/tree/main/src/whyfull/checks), and tests run against a fake filesystem, so you don't need a full disk to write one. [CONTRIBUTING.md](https://github.com/amlaban65/whyfull/blob/main/CONTRIBUTING.md) walks through it.
|
|
109
|
+
|
|
110
|
+
Some checks that would help a lot of people: containerd and CRI-O images (`crictl`), Podman, Kubernetes pod logs, PostgreSQL WAL, MySQL binlogs, Elasticsearch, LXD, Flatpak, Jenkins workspaces and GitLab Runner caches. If you have a war story about a disk that filled up in a strange way, [open an issue](https://github.com/amlaban65/whyfull/issues/new?template=new-check.yml) even if you don't plan to write the code.
|
|
111
|
+
|
|
112
|
+
## License
|
|
113
|
+
|
|
114
|
+
[MIT](https://github.com/amlaban65/whyfull/blob/main/LICENSE)
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "whyfull"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Find out why your Linux disk is full: deleted-but-open files, Docker, journald, old kernels, caches and more, each with the command that fixes it."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
authors = [{ name = "Ahmed Laban" }]
|
|
12
|
+
requires-python = ">=3.6"
|
|
13
|
+
dependencies = []
|
|
14
|
+
keywords = [
|
|
15
|
+
"disk full", "disk space", "no space left on device", "df", "du", "linux", "sysadmin",
|
|
16
|
+
"devops", "sre", "docker", "journald", "inodes", "deleted files", "lsof", "cleanup",
|
|
17
|
+
]
|
|
18
|
+
classifiers = [
|
|
19
|
+
"Development Status :: 4 - Beta",
|
|
20
|
+
"Environment :: Console",
|
|
21
|
+
"Intended Audience :: System Administrators",
|
|
22
|
+
"Intended Audience :: Developers",
|
|
23
|
+
"License :: OSI Approved :: MIT License",
|
|
24
|
+
"Operating System :: POSIX :: Linux",
|
|
25
|
+
"Programming Language :: Python :: 3",
|
|
26
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
27
|
+
"Programming Language :: Python :: 3.6",
|
|
28
|
+
"Programming Language :: Python :: 3.7",
|
|
29
|
+
"Programming Language :: Python :: 3.8",
|
|
30
|
+
"Programming Language :: Python :: 3.9",
|
|
31
|
+
"Programming Language :: Python :: 3.10",
|
|
32
|
+
"Programming Language :: Python :: 3.11",
|
|
33
|
+
"Programming Language :: Python :: 3.12",
|
|
34
|
+
"Programming Language :: Python :: 3.13",
|
|
35
|
+
"Topic :: System :: Filesystems",
|
|
36
|
+
"Topic :: System :: Monitoring",
|
|
37
|
+
"Topic :: System :: Systems Administration",
|
|
38
|
+
"Topic :: Utilities",
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
[project.urls]
|
|
42
|
+
Homepage = "https://github.com/amlaban65/whyfull"
|
|
43
|
+
Issues = "https://github.com/amlaban65/whyfull/issues"
|
|
44
|
+
Changelog = "https://github.com/amlaban65/whyfull/blob/main/CHANGELOG.md"
|
|
45
|
+
|
|
46
|
+
[project.scripts]
|
|
47
|
+
whyfull = "whyfull.cli:main"
|
|
48
|
+
|
|
49
|
+
[tool.hatch.version]
|
|
50
|
+
path = "src/whyfull/__init__.py"
|
|
51
|
+
|
|
52
|
+
[tool.hatch.build.targets.sdist]
|
|
53
|
+
include = ["src", "tests", "tools", "README.md", "LICENSE", "CHANGELOG.md"]
|
|
54
|
+
|
|
55
|
+
[tool.hatch.build.targets.wheel]
|
|
56
|
+
packages = ["src/whyfull"]
|
|
57
|
+
|
|
58
|
+
[tool.pytest.ini_options]
|
|
59
|
+
testpaths = ["tests"]
|
|
60
|
+
|
|
61
|
+
[tool.ruff]
|
|
62
|
+
line-length = 120
|
|
63
|
+
target-version = "py37" # ruff's oldest target; vermin in CI checks 3.6
|
|
64
|
+
|
|
65
|
+
[tool.ruff.lint]
|
|
66
|
+
select = ["E", "F", "W", "B", "UP"]
|
|
67
|
+
# Keep the code runnable on Python 3.6: no f-strings, no newer syntax.
|
|
68
|
+
ignore = ["UP021", "UP022", "UP004", "UP008", "UP009", "UP010", "UP015", "UP025", "UP031", "UP032", "B904"]
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Run the checks against the chosen filesystems and assemble a report."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
from whyfull import __version__
|
|
6
|
+
from whyfull.checks import Context, load
|
|
7
|
+
from whyfull.model import CAUTION, INFO, REVIEW, SAFE, Finding
|
|
8
|
+
from whyfull.mounts import containing, filesystems
|
|
9
|
+
from whyfull.units import human
|
|
10
|
+
from whyfull.walk import Budget, scan
|
|
11
|
+
|
|
12
|
+
DEFAULT_THRESHOLD = 85.0
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Target(object):
|
|
16
|
+
"""One filesystem under analysis and everything found about it."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, fs):
|
|
19
|
+
self.fs = fs
|
|
20
|
+
self.findings = []
|
|
21
|
+
self.tree = None
|
|
22
|
+
self.largest = [] # [(display path, bytes, [(child path, bytes)])]
|
|
23
|
+
self.crowded = [] # [(entries, display path)]
|
|
24
|
+
|
|
25
|
+
def reclaimable(self):
|
|
26
|
+
out = {SAFE: 0, REVIEW: 0, CAUTION: 0}
|
|
27
|
+
for f in self.findings:
|
|
28
|
+
if f.risk in out:
|
|
29
|
+
out[f.risk] += f.bytes
|
|
30
|
+
out["total"] = out[SAFE] + out[REVIEW] + out[CAUTION]
|
|
31
|
+
return out
|
|
32
|
+
|
|
33
|
+
def largest_dirs(self, system, top=6, children=3, min_share=0.01):
|
|
34
|
+
"""[(display path, bytes, [(child display path, bytes)])] from the scan."""
|
|
35
|
+
if self.tree is None:
|
|
36
|
+
return []
|
|
37
|
+
root = system.path(self.fs.mountpoint).rstrip("/") or "/"
|
|
38
|
+
floor = self.fs.used * min_share
|
|
39
|
+
|
|
40
|
+
def kids(parent):
|
|
41
|
+
out = [(p, b) for p, b in self.tree.sizes.items()
|
|
42
|
+
if os.path.dirname(p) == parent and p != parent and b >= floor]
|
|
43
|
+
return sorted(out, key=lambda pb: -pb[1])
|
|
44
|
+
|
|
45
|
+
result = []
|
|
46
|
+
for path, nbytes in kids(root)[:top]:
|
|
47
|
+
sub = [(system.display(p), b) for p, b in kids(path)[:children]]
|
|
48
|
+
result.append((system.display(path), nbytes, sub))
|
|
49
|
+
return result
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class Report(object):
|
|
53
|
+
def __init__(self):
|
|
54
|
+
self.version = __version__
|
|
55
|
+
self.filesystems = []
|
|
56
|
+
self.targets = []
|
|
57
|
+
self.threshold = DEFAULT_THRESHOLD
|
|
58
|
+
self.all_clear = False # no filesystem above the threshold
|
|
59
|
+
self.notes = []
|
|
60
|
+
self.errors = [] # (check id, message)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def choose_targets(system, all_fs, paths, threshold):
|
|
64
|
+
if paths:
|
|
65
|
+
chosen = []
|
|
66
|
+
for p in paths:
|
|
67
|
+
fs = containing(system, os.path.abspath(p) if system.root == "/" else p)
|
|
68
|
+
if fs is None:
|
|
69
|
+
raise ValueError("can't find the filesystem for %s" % p)
|
|
70
|
+
if fs.dev not in [t.dev for t in chosen]:
|
|
71
|
+
chosen.append(fs)
|
|
72
|
+
return chosen, False
|
|
73
|
+
full = [fs for fs in all_fs if fs.percent >= threshold]
|
|
74
|
+
if full:
|
|
75
|
+
return sorted(full, key=lambda f: -f.percent), False
|
|
76
|
+
root = containing(system, "/", all_fs) or containing(system, "/")
|
|
77
|
+
return ([root] if root else []), True
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def analyze(system, paths=None, threshold=DEFAULT_THRESHOLD, scan_seconds=30, check_seconds=60,
|
|
81
|
+
only=None, skip=None, do_scan=True):
|
|
82
|
+
report = Report()
|
|
83
|
+
report.threshold = threshold
|
|
84
|
+
all_fs = filesystems(system)
|
|
85
|
+
report.filesystems = all_fs
|
|
86
|
+
targets, report.all_clear = choose_targets(system, all_fs, paths, threshold)
|
|
87
|
+
report.targets = [Target(fs) for fs in targets]
|
|
88
|
+
if not targets:
|
|
89
|
+
return report
|
|
90
|
+
|
|
91
|
+
known = {fs.dev: fs for fs in all_fs}
|
|
92
|
+
for fs in targets:
|
|
93
|
+
known.setdefault(fs.dev, fs)
|
|
94
|
+
ctx = Context(system, targets, Budget(check_seconds), list(known.values()))
|
|
95
|
+
|
|
96
|
+
for chk in load():
|
|
97
|
+
if only and chk.id not in only:
|
|
98
|
+
continue
|
|
99
|
+
if skip and chk.id in skip:
|
|
100
|
+
continue
|
|
101
|
+
try:
|
|
102
|
+
found = list(chk.fn(ctx) or [])
|
|
103
|
+
except Exception as e: # a broken check must never hide the others
|
|
104
|
+
report.errors.append((chk.id, "%s: %s" % (type(e).__name__, e)))
|
|
105
|
+
continue
|
|
106
|
+
by_dev = {t.fs.dev: t for t in report.targets}
|
|
107
|
+
for f in found:
|
|
108
|
+
t = by_dev.get(f.dev)
|
|
109
|
+
if t is not None and (f.bytes > 0 or f.risk == INFO):
|
|
110
|
+
t.findings.append(f)
|
|
111
|
+
if ctx.budget.hit:
|
|
112
|
+
ctx.note("Some checks hit their time limit; sizes may be understated.")
|
|
113
|
+
|
|
114
|
+
for t in report.targets:
|
|
115
|
+
if do_scan:
|
|
116
|
+
t.tree = scan(system.path(t.fs.mountpoint), t.fs.dev, Budget(scan_seconds))
|
|
117
|
+
if not t.tree.complete:
|
|
118
|
+
ctx.note("The scan of %s stopped at its time limit; raise it with --scan-time." % t.fs.mountpoint)
|
|
119
|
+
if t.tree.denied and not system.is_root:
|
|
120
|
+
ctx.note("%d directories on %s couldn't be read; run with sudo for a complete picture."
|
|
121
|
+
% (t.tree.denied, t.fs.mountpoint))
|
|
122
|
+
t.largest = t.largest_dirs(system)
|
|
123
|
+
t.crowded = [(n, system.display(p)) for n, p in t.tree.crowded]
|
|
124
|
+
gap = unexplained(system, t, ctx.deleted_open_bytes.get(t.fs.dev, 0), all_fs)
|
|
125
|
+
if gap is not None:
|
|
126
|
+
t.findings.append(gap)
|
|
127
|
+
t.findings.sort(key=lambda f: (-f.priority, -f.bytes))
|
|
128
|
+
|
|
129
|
+
report.notes = ctx.notes
|
|
130
|
+
return report
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def unexplained(system, target, deleted_open, all_fs):
|
|
134
|
+
"""An INFO finding when df's 'used' is far above what the files add up to."""
|
|
135
|
+
tree, fs = target.tree, target.fs
|
|
136
|
+
if tree is None or not tree.complete or tree.denied:
|
|
137
|
+
return None
|
|
138
|
+
gap = fs.used - tree.total - deleted_open
|
|
139
|
+
if gap < max(512 * 2 ** 20, 0.05 * fs.used):
|
|
140
|
+
return None
|
|
141
|
+
root = fs.mountpoint.rstrip("/")
|
|
142
|
+
covering = sorted(m.mountpoint for m in all_fs
|
|
143
|
+
if m.dev != fs.dev and m.mountpoint != fs.mountpoint
|
|
144
|
+
and (m.mountpoint + "/").startswith(root + "/"))
|
|
145
|
+
fix = []
|
|
146
|
+
if covering:
|
|
147
|
+
fix = ["sudo mkdir -p /mnt/whyfull-under && sudo mount --bind %s /mnt/whyfull-under" % fs.mountpoint]
|
|
148
|
+
fix += ["sudo du -sh /mnt/whyfull-under%s" % m[len(root):] for m in covering[:6]]
|
|
149
|
+
fix += ["sudo umount /mnt/whyfull-under"]
|
|
150
|
+
if fs.fstype == "btrfs":
|
|
151
|
+
fix += ["sudo btrfs subvolume list -s %s # snapshots keep deleted data" % fs.mountpoint]
|
|
152
|
+
if fs.fstype == "zfs":
|
|
153
|
+
fix += ["zfs list -t snapshot -o name,used -s used"]
|
|
154
|
+
causes = []
|
|
155
|
+
if covering:
|
|
156
|
+
causes.append("files written into a directory before another disk was mounted over it "
|
|
157
|
+
"(%s): they still use space but are hidden" % ", ".join(covering[:4]))
|
|
158
|
+
if fs.fstype in ("btrfs", "zfs"):
|
|
159
|
+
causes.append("snapshots holding on to deleted data")
|
|
160
|
+
causes.append("filesystem metadata and journal")
|
|
161
|
+
return Finding(
|
|
162
|
+
"unexplained", fs.mountpoint,
|
|
163
|
+
"%s in use on %s isn't in any file whyfull can see" % (human(gap), fs.mountpoint),
|
|
164
|
+
gap, INFO, fs.dev, priority=10,
|
|
165
|
+
detail="Likely causes: " + "; ".join(causes) + ".",
|
|
166
|
+
fix=fix,
|
|
167
|
+
)
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Checks find specific, explainable causes of disk usage.
|
|
2
|
+
|
|
3
|
+
A check is a function decorated with @check. It receives a Context and
|
|
4
|
+
returns (or yields) Finding objects. Checks must never modify the system:
|
|
5
|
+
they read files and run read-only commands only.
|
|
6
|
+
|
|
7
|
+
To add one, create a module in this package, decorate a function with
|
|
8
|
+
@check("my-id", "What it looks for"), and add the module name to MODULES
|
|
9
|
+
below. See CONTRIBUTING.md for the full walkthrough.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import importlib
|
|
13
|
+
|
|
14
|
+
from whyfull.walk import du
|
|
15
|
+
|
|
16
|
+
REGISTRY = []
|
|
17
|
+
|
|
18
|
+
MODULES = [
|
|
19
|
+
"deleted_open",
|
|
20
|
+
"space",
|
|
21
|
+
"journald",
|
|
22
|
+
"logs",
|
|
23
|
+
"containers",
|
|
24
|
+
"packages",
|
|
25
|
+
"kernels",
|
|
26
|
+
"snaps",
|
|
27
|
+
"crashes",
|
|
28
|
+
"tmpfiles",
|
|
29
|
+
"usercaches",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Check(object):
|
|
34
|
+
def __init__(self, id, title, fn):
|
|
35
|
+
self.id = id
|
|
36
|
+
self.title = title
|
|
37
|
+
self.fn = fn
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def check(id, title):
|
|
41
|
+
def register(fn):
|
|
42
|
+
REGISTRY.append(Check(id, title, fn))
|
|
43
|
+
return fn
|
|
44
|
+
return register
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def load():
|
|
48
|
+
for name in MODULES:
|
|
49
|
+
importlib.import_module("whyfull.checks." + name)
|
|
50
|
+
return list(REGISTRY)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class Context(object):
|
|
54
|
+
"""What a check gets to work with.
|
|
55
|
+
|
|
56
|
+
system -- whyfull.system.System; use it for every file and command access
|
|
57
|
+
targets -- the Filesystem objects being analysed
|
|
58
|
+
budget -- shared time budget for size measurements
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
def __init__(self, system, targets, budget, all_filesystems=None):
|
|
62
|
+
self.system = system
|
|
63
|
+
self.targets = targets
|
|
64
|
+
self.budget = budget
|
|
65
|
+
self.all_filesystems = all_filesystems or targets
|
|
66
|
+
self.target_devs = set(fs.dev for fs in targets)
|
|
67
|
+
self.notes = []
|
|
68
|
+
self.deleted_open_bytes = {} # dev -> bytes, filled by the deleted_open check
|
|
69
|
+
self._seen = set()
|
|
70
|
+
|
|
71
|
+
def dev_of(self, path):
|
|
72
|
+
st = self.system.lstat(path)
|
|
73
|
+
return None if st is None else st.st_dev
|
|
74
|
+
|
|
75
|
+
def on_target(self, path):
|
|
76
|
+
return self.dev_of(path) in self.target_devs
|
|
77
|
+
|
|
78
|
+
def size(self, path):
|
|
79
|
+
"""Allocated bytes under path (on its own filesystem), or 0 if missing."""
|
|
80
|
+
local = self.system.path(path)
|
|
81
|
+
usage = du(local, budget=self.budget)
|
|
82
|
+
return usage.bytes
|
|
83
|
+
|
|
84
|
+
def filesystem(self, dev):
|
|
85
|
+
for fs in self.all_filesystems:
|
|
86
|
+
if fs.dev == dev:
|
|
87
|
+
return fs
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
def note(self, text):
|
|
91
|
+
if text not in self.notes:
|
|
92
|
+
self.notes.append(text)
|