whyfull 0.1.0__py3-none-any.whl
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/__init__.py +3 -0
- whyfull/__main__.py +5 -0
- whyfull/analyze.py +167 -0
- whyfull/checks/__init__.py +92 -0
- whyfull/checks/containers.py +94 -0
- whyfull/checks/crashes.py +31 -0
- whyfull/checks/deleted_open.py +125 -0
- whyfull/checks/journald.py +33 -0
- whyfull/checks/kernels.py +82 -0
- whyfull/checks/logs.py +81 -0
- whyfull/checks/packages.py +32 -0
- whyfull/checks/snaps.py +46 -0
- whyfull/checks/space.py +50 -0
- whyfull/checks/tmpfiles.py +55 -0
- whyfull/checks/usercaches.py +63 -0
- whyfull/cli.py +126 -0
- whyfull/model.py +102 -0
- whyfull/mounts.py +94 -0
- whyfull/render.py +194 -0
- whyfull/system.py +166 -0
- whyfull/units.py +53 -0
- whyfull/walk.py +171 -0
- whyfull-0.1.0.dist-info/METADATA +148 -0
- whyfull-0.1.0.dist-info/RECORD +27 -0
- whyfull-0.1.0.dist-info/WHEEL +4 -0
- whyfull-0.1.0.dist-info/entry_points.txt +2 -0
- whyfull-0.1.0.dist-info/licenses/LICENSE +21 -0
whyfull/__init__.py
ADDED
whyfull/__main__.py
ADDED
whyfull/analyze.py
ADDED
|
@@ -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)
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Docker images, build cache, stopped containers, volumes and container logs."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
|
|
6
|
+
from whyfull.checks import check
|
|
7
|
+
from whyfull.model import CAUTION, REVIEW, SAFE, Finding
|
|
8
|
+
from whyfull.units import human, parse_size
|
|
9
|
+
|
|
10
|
+
MIN_RECLAIM = 200 * 10 ** 6
|
|
11
|
+
BIG_LOG = 500 * 10 ** 6
|
|
12
|
+
|
|
13
|
+
# docker system df row type -> (risk, title, fix, detail)
|
|
14
|
+
DF_ROWS = {
|
|
15
|
+
"Build Cache": (
|
|
16
|
+
SAFE, "Docker build cache", ["docker builder prune -f"],
|
|
17
|
+
"Layers cached by docker build. They are rebuilt on the next build that needs them.",
|
|
18
|
+
),
|
|
19
|
+
"Images": (
|
|
20
|
+
REVIEW, "Docker images not used by any container", ["docker image prune -a -f"],
|
|
21
|
+
"Images with no container using them. They are pulled again if something needs them later.",
|
|
22
|
+
),
|
|
23
|
+
"Containers": (
|
|
24
|
+
REVIEW, "Stopped Docker containers", ["docker container prune -f"],
|
|
25
|
+
"Writable layers of containers that have exited. Check nothing needs to be restarted from them.",
|
|
26
|
+
),
|
|
27
|
+
"Local Volumes": (
|
|
28
|
+
CAUTION, "Docker volumes not used by any container", ["docker volume ls -f dangling=true # look first",
|
|
29
|
+
"docker volume prune -f"],
|
|
30
|
+
"Volumes hold data such as databases. Unused ones may still matter: inspect before pruning.",
|
|
31
|
+
),
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def docker_root(ctx):
|
|
36
|
+
out = ctx.system.run(["docker", "info", "--format", "{{.DockerRootDir}}"])
|
|
37
|
+
root = (out or "").strip()
|
|
38
|
+
return root or "/var/lib/docker"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@check("docker", "Docker images, build cache, containers and volumes")
|
|
42
|
+
def docker(ctx):
|
|
43
|
+
root = docker_root(ctx)
|
|
44
|
+
if not ctx.system.exists(root) or not ctx.on_target(root):
|
|
45
|
+
return
|
|
46
|
+
dev = ctx.dev_of(root)
|
|
47
|
+
out = ctx.system.run(["docker", "system", "df", "--format", "{{json .}}"], timeout=60)
|
|
48
|
+
if out is None:
|
|
49
|
+
ctx.note("Docker data lives on this disk, but `docker system df` didn't run; "
|
|
50
|
+
"run whyfull with sudo or as a member of the docker group to include it.")
|
|
51
|
+
else:
|
|
52
|
+
for line in out.splitlines():
|
|
53
|
+
try:
|
|
54
|
+
row = json.loads(line)
|
|
55
|
+
except ValueError:
|
|
56
|
+
continue
|
|
57
|
+
spec = DF_ROWS.get(row.get("Type"))
|
|
58
|
+
if spec is None:
|
|
59
|
+
continue
|
|
60
|
+
reclaim = parse_size(str(row.get("Reclaimable", "")).split(" (")[0])
|
|
61
|
+
if not reclaim or reclaim < MIN_RECLAIM:
|
|
62
|
+
continue
|
|
63
|
+
risk, title, fix, detail = spec
|
|
64
|
+
yield Finding("docker", row["Type"].lower().replace(" ", "-"), title, reclaim, risk, dev,
|
|
65
|
+
detail=detail, fix=fix,
|
|
66
|
+
evidence=["%s total, %s in use" % (row.get("TotalCount", "?"), row.get("Active", "?"))])
|
|
67
|
+
|
|
68
|
+
# Container logs are not in `docker system df`; read them from disk.
|
|
69
|
+
logs = []
|
|
70
|
+
containers = os.path.join(root, "containers")
|
|
71
|
+
for cid in ctx.system.listdir(containers):
|
|
72
|
+
log = os.path.join(containers, cid, cid + "-json.log")
|
|
73
|
+
st = ctx.system.stat(log)
|
|
74
|
+
if st is None or st.st_size < BIG_LOG:
|
|
75
|
+
continue
|
|
76
|
+
name = cid[:12]
|
|
77
|
+
cfg = ctx.system.read_text(os.path.join(containers, cid, "config.v2.json"))
|
|
78
|
+
if cfg:
|
|
79
|
+
try:
|
|
80
|
+
name = json.loads(cfg).get("Name", "").lstrip("/") or name
|
|
81
|
+
except ValueError:
|
|
82
|
+
pass
|
|
83
|
+
logs.append((st.st_blocks * 512, name, log))
|
|
84
|
+
logs.sort(reverse=True)
|
|
85
|
+
for size, name, _log in logs[:5]:
|
|
86
|
+
yield Finding(
|
|
87
|
+
"docker", "log:" + name, "Docker log of container %s is %s" % (name, human(size)),
|
|
88
|
+
size, CAUTION, dev,
|
|
89
|
+
detail=("The json-file log driver keeps everything a container prints unless "
|
|
90
|
+
"max-size is set. Truncating loses that history; set rotation so it doesn't grow back."),
|
|
91
|
+
fix=["sudo truncate -s 0 \"$(docker inspect --format '{{.LogPath}}' %s)\"" % name,
|
|
92
|
+
"# rotate future logs (applies to new containers): add to /etc/docker/daemon.json",
|
|
93
|
+
'# {"log-driver": "json-file", "log-opts": {"max-size": "50m", "max-file": "3"}}'],
|
|
94
|
+
)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Crash reports and core dumps."""
|
|
2
|
+
|
|
3
|
+
from whyfull.checks import check
|
|
4
|
+
from whyfull.model import REVIEW, Finding
|
|
5
|
+
|
|
6
|
+
MIN_BYTES = 100 * 10 ** 6
|
|
7
|
+
|
|
8
|
+
PLACES = [
|
|
9
|
+
("/var/lib/systemd/coredump", "systemd-coredump core dumps",
|
|
10
|
+
["sudo find /var/lib/systemd/coredump -type f -delete",
|
|
11
|
+
"# cap them: set MaxUse=1G in /etc/systemd/coredump.conf"]),
|
|
12
|
+
("/var/crash", "crash reports in /var/crash", ["sudo find /var/crash -type f -delete"]),
|
|
13
|
+
("/var/lib/apport/coredump", "apport core dumps", ["sudo find /var/lib/apport/coredump -type f -delete"]),
|
|
14
|
+
("/var/spool/abrt", "ABRT crash reports", ["sudo abrt-cli remove /var/spool/abrt/*"]),
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@check("crashes", "Core dumps and crash reports")
|
|
19
|
+
def run(ctx):
|
|
20
|
+
for path, title, fix in PLACES:
|
|
21
|
+
if not ctx.system.exists(path) or not ctx.on_target(path):
|
|
22
|
+
continue
|
|
23
|
+
size = ctx.size(path)
|
|
24
|
+
if size < MIN_BYTES:
|
|
25
|
+
continue
|
|
26
|
+
yield Finding(
|
|
27
|
+
"crashes", path, title, size, REVIEW, ctx.dev_of(path),
|
|
28
|
+
detail=("Memory dumps from crashed programs. Useful for debugging a recent crash; "
|
|
29
|
+
"otherwise safe to delete."),
|
|
30
|
+
fix=fix,
|
|
31
|
+
)
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Files that were deleted while a process still had them open.
|
|
2
|
+
|
|
3
|
+
The classic "df says full, du says empty" problem: deleting a log file that a
|
|
4
|
+
service is writing to removes its name, but the space is only released when
|
|
5
|
+
the last process closes it.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
|
|
11
|
+
from whyfull.checks import check
|
|
12
|
+
from whyfull.model import CAUTION, Finding
|
|
13
|
+
from whyfull.walk import allocated
|
|
14
|
+
|
|
15
|
+
MIN_BYTES = 1 << 20 # ignore groups smaller than 1 MiB
|
|
16
|
+
|
|
17
|
+
_UNIT_RE = re.compile(r"/([^/]+\.service)(?:/|$)")
|
|
18
|
+
_DOCKER_RE = re.compile(r"docker[-/]([0-9a-f]{12,64})")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _read(path):
|
|
22
|
+
try:
|
|
23
|
+
with open(path) as f:
|
|
24
|
+
return f.read()
|
|
25
|
+
except OSError:
|
|
26
|
+
return ""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def owner_of(proc, pid):
|
|
30
|
+
"""('unit', 'nginx.service') / ('container', 'abc123...') / ('process', 'name')."""
|
|
31
|
+
cgroup = _read(os.path.join(proc, pid, "cgroup"))
|
|
32
|
+
m = _DOCKER_RE.search(cgroup)
|
|
33
|
+
if m:
|
|
34
|
+
return "container", m.group(1)[:12]
|
|
35
|
+
units = _UNIT_RE.findall(cgroup)
|
|
36
|
+
# Skip per-user manager units; user@1000.service is not what holds the file.
|
|
37
|
+
units = [u for u in units if not u.startswith("user@")]
|
|
38
|
+
if units:
|
|
39
|
+
return "unit", units[-1]
|
|
40
|
+
comm = _read(os.path.join(proc, pid, "comm")).strip() or "pid " + pid
|
|
41
|
+
return "process", comm
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def scan_proc(proc):
|
|
45
|
+
"""Yield (pid, fd, target, stat) for every open file that has been deleted."""
|
|
46
|
+
try:
|
|
47
|
+
pids = [p for p in os.listdir(proc) if p.isdigit()]
|
|
48
|
+
except OSError:
|
|
49
|
+
return
|
|
50
|
+
for pid in pids:
|
|
51
|
+
fd_dir = os.path.join(proc, pid, "fd")
|
|
52
|
+
try:
|
|
53
|
+
fds = os.listdir(fd_dir)
|
|
54
|
+
except OSError:
|
|
55
|
+
continue
|
|
56
|
+
for fd in fds:
|
|
57
|
+
fd_path = os.path.join(fd_dir, fd)
|
|
58
|
+
try:
|
|
59
|
+
target = os.readlink(fd_path)
|
|
60
|
+
except OSError:
|
|
61
|
+
continue
|
|
62
|
+
if not target.endswith(" (deleted)") or not target.startswith("/"):
|
|
63
|
+
continue
|
|
64
|
+
if target.startswith("/memfd:"):
|
|
65
|
+
continue
|
|
66
|
+
try:
|
|
67
|
+
st = os.stat(fd_path)
|
|
68
|
+
except OSError:
|
|
69
|
+
continue
|
|
70
|
+
if st.st_nlink != 0:
|
|
71
|
+
continue
|
|
72
|
+
yield pid, fd, target[: -len(" (deleted)")], st
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@check("deleted-open", "Deleted files still held open by a process")
|
|
76
|
+
def run(ctx):
|
|
77
|
+
groups = {} # (dev, kind, name) -> dict
|
|
78
|
+
seen = set()
|
|
79
|
+
for pid, fd, path, st in scan_proc(ctx.system.proc):
|
|
80
|
+
if st.st_dev not in ctx.target_devs:
|
|
81
|
+
continue
|
|
82
|
+
kind, name = owner_of(ctx.system.proc, pid)
|
|
83
|
+
g = groups.setdefault((st.st_dev, kind, name), {"bytes": 0, "files": {}, "fds": []})
|
|
84
|
+
g["fds"].append((pid, fd))
|
|
85
|
+
key = (st.st_dev, st.st_ino)
|
|
86
|
+
if key in seen:
|
|
87
|
+
continue
|
|
88
|
+
seen.add(key)
|
|
89
|
+
size = allocated(st)
|
|
90
|
+
g["bytes"] += size
|
|
91
|
+
g["files"][path] = g["files"].get(path, 0) + size
|
|
92
|
+
|
|
93
|
+
if not ctx.system.is_root:
|
|
94
|
+
ctx.note("Run with sudo to see deleted files held open by other users' processes.")
|
|
95
|
+
|
|
96
|
+
for (dev, kind, name), g in groups.items():
|
|
97
|
+
ctx.deleted_open_bytes[dev] = ctx.deleted_open_bytes.get(dev, 0) + g["bytes"]
|
|
98
|
+
if g["bytes"] < MIN_BYTES:
|
|
99
|
+
continue
|
|
100
|
+
nfiles = len(g["files"])
|
|
101
|
+
noun = "file" if nfiles == 1 else "files"
|
|
102
|
+
if kind == "unit":
|
|
103
|
+
short = name[: -len(".service")]
|
|
104
|
+
title = "%d deleted %s still open by %s" % (nfiles, noun, name)
|
|
105
|
+
fix = ["sudo systemctl restart %s" % short]
|
|
106
|
+
elif kind == "container":
|
|
107
|
+
title = "%d deleted %s still open in container %s" % (nfiles, noun, name)
|
|
108
|
+
fix = ["docker restart %s" % name]
|
|
109
|
+
else:
|
|
110
|
+
title = "%d deleted %s still open by %s" % (nfiles, noun, name)
|
|
111
|
+
fix = ["# restart %s, or free the space without a restart:" % name]
|
|
112
|
+
# Truncating through /proc frees the space immediately and keeps the process running.
|
|
113
|
+
truncate = ["sudo truncate -s 0 /proc/%s/fd/%s" % (p, f) for p, f in sorted(set(g["fds"]))[:5]]
|
|
114
|
+
if kind != "process":
|
|
115
|
+
fix.append("# or free the space without a restart:")
|
|
116
|
+
fix.extend(truncate)
|
|
117
|
+
biggest = sorted(g["files"].items(), key=lambda kv: -kv[1])[:5]
|
|
118
|
+
evidence = ["%s (deleted)" % (ctx.system.display(p) if p.startswith(ctx.system.root + "/") else p)
|
|
119
|
+
for p, _ in biggest]
|
|
120
|
+
yield Finding(
|
|
121
|
+
"deleted-open", "%s:%s" % (kind, name), title, g["bytes"], CAUTION, dev,
|
|
122
|
+
detail=("These files were deleted, but the process writing to them never closed "
|
|
123
|
+
"them, so the space is still in use. du can't see it; df can."),
|
|
124
|
+
fix=fix, evidence=evidence,
|
|
125
|
+
)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""The systemd journal."""
|
|
2
|
+
|
|
3
|
+
from whyfull.checks import check
|
|
4
|
+
from whyfull.model import REVIEW, Finding
|
|
5
|
+
from whyfull.units import human
|
|
6
|
+
|
|
7
|
+
KEEP = 200 * 10 ** 6 # what we suggest vacuuming down to
|
|
8
|
+
MIN_RECLAIM = 100 * 10 ** 6
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@check("journald", "systemd journal")
|
|
12
|
+
def run(ctx):
|
|
13
|
+
for path in ("/var/log/journal", "/run/log/journal"):
|
|
14
|
+
if not ctx.system.exists(path) or not ctx.on_target(path):
|
|
15
|
+
continue
|
|
16
|
+
size = ctx.size(path)
|
|
17
|
+
reclaim = size - KEEP
|
|
18
|
+
if reclaim < MIN_RECLAIM:
|
|
19
|
+
continue
|
|
20
|
+
yield Finding(
|
|
21
|
+
"journald", path, "systemd journal in %s is %s" % (path, human(size)),
|
|
22
|
+
reclaim, REVIEW, ctx.dev_of(path),
|
|
23
|
+
detail=("journald keeps old logs until it reaches its size cap (by default 10%% of "
|
|
24
|
+
"the filesystem). Vacuuming keeps the newest %s; capping it stops it "
|
|
25
|
+
"growing back." % human(KEEP)),
|
|
26
|
+
fix=[
|
|
27
|
+
"sudo journalctl --vacuum-size=200M",
|
|
28
|
+
"# keep it capped:",
|
|
29
|
+
"sudo mkdir -p /etc/systemd/journald.conf.d",
|
|
30
|
+
"printf '[Journal]\\nSystemMaxUse=200M\\n' | sudo tee /etc/systemd/journald.conf.d/size.conf",
|
|
31
|
+
"sudo systemctl restart systemd-journald",
|
|
32
|
+
],
|
|
33
|
+
)
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Old kernels: their modules under /lib/modules and their images in /boot."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
|
|
6
|
+
from whyfull.checks import check
|
|
7
|
+
from whyfull.model import REVIEW, Finding
|
|
8
|
+
from whyfull.units import human
|
|
9
|
+
|
|
10
|
+
MIN_BYTES = 50 * 10 ** 6
|
|
11
|
+
BOOT_PATTERNS = ["vmlinuz-%s", "initrd.img-%s", "initramfs-%s.img", "initramfs-%skdump.img",
|
|
12
|
+
"System.map-%s", "config-%s", "abi-%s", "retpoline-%s"]
|
|
13
|
+
|
|
14
|
+
FIXES = {
|
|
15
|
+
"debian": ["sudo apt autoremove --purge",
|
|
16
|
+
"# if old kernels remain, purge them by name, e.g.: sudo apt purge linux-image-%(v)s"],
|
|
17
|
+
"rhel": ["sudo dnf remove --oldinstallonly --setopt installonly_limit=2 kernel"],
|
|
18
|
+
"suse": ["sudo zypper purge-kernels"],
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def version_key(v):
|
|
23
|
+
return [int(p) if p.isdigit() else p for p in re.split(r"(\d+)", v)]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def installed(ctx):
|
|
27
|
+
"""Kernel versions with a modules directory, and where each directory is."""
|
|
28
|
+
found = {}
|
|
29
|
+
for base in ("/lib/modules", "/usr/lib/modules"):
|
|
30
|
+
for v in ctx.system.listdir(base):
|
|
31
|
+
path = os.path.join(base, v)
|
|
32
|
+
local = ctx.system.path(path)
|
|
33
|
+
if not os.path.isdir(local):
|
|
34
|
+
continue
|
|
35
|
+
real = os.path.realpath(local)
|
|
36
|
+
if any(os.path.realpath(ctx.system.path(p)) == real for p in found.values()):
|
|
37
|
+
continue
|
|
38
|
+
found.setdefault(v, path)
|
|
39
|
+
return found
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@check("kernels", "Old kernels")
|
|
43
|
+
def run(ctx):
|
|
44
|
+
kernels = installed(ctx)
|
|
45
|
+
if len(kernels) < 2:
|
|
46
|
+
return
|
|
47
|
+
running = ctx.system.kernel_release()
|
|
48
|
+
newest = max(kernels, key=version_key)
|
|
49
|
+
old = sorted((v for v in kernels if v not in (running, newest)), key=version_key)
|
|
50
|
+
if not old:
|
|
51
|
+
return
|
|
52
|
+
|
|
53
|
+
per_dev = {} # dev -> [bytes, versions]
|
|
54
|
+
for v in old:
|
|
55
|
+
parts = [kernels[v]] + ["/boot/" + p % v for p in BOOT_PATTERNS]
|
|
56
|
+
for path in parts:
|
|
57
|
+
if not ctx.system.exists(path) or not ctx.on_target(path):
|
|
58
|
+
continue
|
|
59
|
+
entry = per_dev.setdefault(ctx.dev_of(path), [0, set()])
|
|
60
|
+
entry[0] += ctx.size(path)
|
|
61
|
+
entry[1].add(v)
|
|
62
|
+
|
|
63
|
+
family = ctx.system.distro_family()
|
|
64
|
+
for dev, (nbytes, versions) in per_dev.items():
|
|
65
|
+
if nbytes < MIN_BYTES:
|
|
66
|
+
continue
|
|
67
|
+
versions = sorted(versions, key=version_key)
|
|
68
|
+
fs = ctx.filesystem(dev)
|
|
69
|
+
where = " in /boot" if fs is not None and fs.mountpoint == "/boot" else ""
|
|
70
|
+
fix = [f % {"v": versions[0]} for f in FIXES.get(family, [])] or \
|
|
71
|
+
["# remove the kernel packages for: " + ", ".join(versions)]
|
|
72
|
+
yield Finding(
|
|
73
|
+
"kernels", str(dev), "%d old kernel%s%s" % (len(versions), "" if len(versions) == 1 else "s", where),
|
|
74
|
+
nbytes, REVIEW, dev,
|
|
75
|
+
detail=("Kernels other than the running one (%s) and the newest (%s). Package managers keep "
|
|
76
|
+
"them as fallbacks; one spare is plenty. A full /boot can make kernel updates fail."
|
|
77
|
+
% (running, newest)),
|
|
78
|
+
fix=fix,
|
|
79
|
+
evidence=["%s" % v for v in versions[:8]],
|
|
80
|
+
)
|
|
81
|
+
if per_dev and not any(b >= MIN_BYTES for b, _ in per_dev.values()):
|
|
82
|
+
ctx.note("Old kernels found but they are small (%s)." % human(sum(b for b, _ in per_dev.values())))
|