Files
VPSMaster/proxydoctor/resources.py
T

246 lines
12 KiB
Python

"""Read-only Linux resources; numeric results independent of terminal language."""
import math
import os
from pathlib import Path, PurePosixPath
import platform
import re
import time
from .reports import utc_now
def read_text(path):
try:
with Path(path).open(encoding='utf-8') as stream:
value = stream.read(262145)
return value if len(value) <= 262144 else None
except (OSError, UnicodeError):
return None
def number(value):
try:
parsed = int(value)
return parsed if parsed >= 0 else None
except (ValueError, TypeError):
return None
def meminfo(text):
result = {}
for line in (text or '').splitlines():
match = re.fullmatch(r'(MemTotal|MemAvailable|SwapTotal|SwapFree):\s+(\d+) kB', line)
if match:
result[match[1]] = int(match[2]) * 1024
return result
def pressure(text):
result = {}
for line in (text or '').splitlines():
fields = line.split()
if not fields or fields[0] not in ('some', 'full'):
continue
try:
values = dict(item.split('=', 1) for item in fields[1:])
row = {key: float(values[key]) for key in ('avg10','avg60','avg300')}
row['total_us'] = int(values['total'])
if all(math.isfinite(row[key]) and 0 <= row[key] <= 100 for key in ('avg10','avg60','avg300')) and row['total_us'] >= 0:
result[fields[0]] = row
except (KeyError, ValueError):
continue
return result or None
def cpu_ticks(text):
line = next((line for line in (text or '').splitlines() if line.startswith('cpu ')), '')
try:
values = [int(value) for value in line.split()[1:]]
# Guest and guest_nice are already included in user and nice.
return values[:8] if len(values) >= 8 and all(value >= 0 for value in values) else None
except ValueError:
return None
def steal_delta(before, after):
if before is None or after is None:
return None
deltas = [b-a for a,b in zip(before,after)]
total = sum(deltas)
return round(100*deltas[7]/total, 3) if total > 0 and all(value >= 0 for value in deltas) else None
def unescape_mount(value):
return re.sub(r'\\([0-7]{3})', lambda match: chr(int(match[1], 8)), value)
def cgroup_locations(membership, mounts, controller):
"""Map process membership onto visible mount roots, including ancestors.
Hidden namespace ancestors cannot be inspected; never infer unlimited host
resources from the visible root. Limit traversal to 64 visible ancestors.
"""
memberships = []
for line in (membership or '').splitlines():
parts = line.split(':', 2)
if len(parts) == 3 and (parts[0] == '0' or controller in parts[1].split(',')):
memberships.append((parts[0] == '0', parts[2]))
for line in (mounts or '').splitlines():
left, separator, right = line.partition(' - ')
fields, info = left.split(), right.split()
if not separator or len(fields) < 6 or len(info) < 3 or info[0] not in ('cgroup','cgroup2'):
continue
v2 = info[0] == 'cgroup2'
if not v2 and controller not in info[2].split(','):
continue
mount_root, mount = (PurePosixPath(unescape_mount(fields[index])) for index in (3,4))
for unified, member in memberships:
path = PurePosixPath(member)
if unified != v2 or not path.is_absolute() or '..' in path.parts or '..' in mount.parts:
continue
try:
relative = path.relative_to(mount_root)
except ValueError:
continue
current = mount / relative
for _ in range(64):
yield v2, str(current)
if current == mount:
break
current = current.parent
def cgroup_limits(read):
membership, mounts = read('/proc/self/cgroup'), read('/proc/self/mountinfo')
result = {'cpu_quota_cores': None, 'memory_limit_bytes': None,
'observations': [], 'scope': 'visible_cgroup_ancestors_only'}
for controller in ('cpu','memory'):
for v2, directory in cgroup_locations(membership, mounts, controller):
leaf = ('cpu.max' if v2 else 'cpu.cfs_quota_us') if controller == 'cpu' else ('memory.max' if v2 else 'memory.limit_in_bytes')
source = directory + '/' + leaf
raw = read(source)
if raw is None:
continue
value = None
state = 'unknown'
if controller == 'cpu':
tokens = raw.split()
quota = tokens[0] if tokens else ''
period = number(tokens[1] if v2 and len(tokens) == 2 else read(directory+'/cpu.cfs_period_us'))
if quota == ('max' if v2 else '-1'):
state = 'no_visible_limit'
elif number(quota) is not None and number(quota) > 0 and period:
value = int(quota)/period
state = 'measured'
key = 'cpu_quota_cores'
else:
value = number(raw.strip())
if raw.strip() == 'max' or (not v2 and value is not None and value >= 2**60):
value = None
state = 'no_visible_limit'
elif value is not None:
state = 'measured'
key = 'memory_limit_bytes'
result['observations'].append({'source': source, 'status': state, 'value': value,
'controller': controller, 'version': 2 if v2 else 1})
if value is not None:
prior = result[key]
result[key] = value if prior is None else min(value, prior)
return result
def collect(read=read_text, *, interval=0.2, sleep=time.sleep, clock=time.monotonic,
system=None, cpu_count=os.cpu_count, affinity=None):
if not 0.05 <= interval <= 1:
raise ValueError('Resource observation interval must be 0.05..1 seconds')
report = {'schema_version': 1, 'observed_at': utc_now(), 'method': 'linux_readonly_snapshot',
'status': 'unsupported', 'metrics': {}, 'limitations': [
'Visible resources and cgroup limits are not purchased specifications or dedicated capacity.',
'Hidden ancestor limits cannot be excluded; steal/PSI describe a short observation, not long-term contention.',
'Host-visible PSI/meminfo/CPU counters may differ from container scope.']}
if (system or platform.system()) != 'Linux':
return report
memory = meminfo(read('/proc/meminfo'))
limits = cgroup_limits(read)
container_markers = [path for path in ('/.dockerenv','/run/.containerenv','/run/systemd/container')
if read(path) is not None]
try:
visible = cpu_count()
except OSError:
visible = None
try:
allowed = len(affinity() if affinity else os.sched_getaffinity(0))
except (OSError, AttributeError):
allowed = None
bounds = [v for v in (visible, allowed, limits['cpu_quota_cores']) if v is not None and v > 0]
ram = [v for v in (memory.get('MemTotal'), limits['memory_limit_bytes']) if v is not None]
before = cpu_ticks(read('/proc/stat'))
started = clock()
sleep(interval) # Passive observation, no CPU workload or benchmark.
after = cpu_ticks(read('/proc/stat'))
elapsed = clock()-started
swap_total, swap_free = memory.get('SwapTotal'), memory.get('SwapFree')
metrics = {'logical_cpus': visible, 'affinity_cpus': allowed,
'visible_cpu_upper_bound_cores': min(bounds) if bounds else None,
'memory_total_bytes': memory.get('MemTotal'),
'memory_available_bytes': memory.get('MemAvailable'),
'visible_memory_upper_bound_bytes': min(ram) if ram else None,
'swap_total_bytes': swap_total, 'swap_free_bytes': swap_free,
'swap_used_bytes': swap_total-swap_free if swap_total is not None and swap_free is not None and swap_free <= swap_total else None,
'steal_percent': steal_delta(before, after), 'sample_elapsed_seconds': round(elapsed, 4),
'pressure': {kind: pressure(read('/proc/pressure/'+kind)) for kind in ('cpu','memory','io')}}
current = next((row for row in limits['observations'] if row['controller'] == 'memory'), None)
metrics['cgroup_memory_current_bytes'] = number(read(current['source'].rsplit('/',1)[0] +
('/memory.current' if current['version'] == 2 else '/memory.usage_in_bytes'))) if current else None
# Keep allocation evidence distinct from the underlying OS view. No local
# observation can certify provider-side CPU exclusivity or reservation.
report['allocation'] = {
'cpu_limit_cores': metrics['visible_cpu_upper_bound_cores'],
'cpu_basis': 'affinity_and_visible_quota' if limits['cpu_quota_cores'] is not None else 'visible_cpu_and_affinity_only',
'memory_limit_bytes': limits['memory_limit_bytes'] if limits['memory_limit_bytes'] is not None else
None if container_markers else memory.get('MemTotal'),
'memory_basis': 'visible_cgroup_limit' if limits['memory_limit_bytes'] is not None else
'unconfirmed_container_allocation' if container_markers else 'guest_or_os_visible_memory',
'dedicated_cpu': 'unknown', 'reserved_memory': 'unknown',
'container_evidence': container_markers,
}
report.update(status='partial', metrics=metrics, cgroup=limits,
sources=['/proc/meminfo','/proc/stat','/proc/pressure/{cpu,memory,io}',
'/proc/self/cgroup','/proc/self/mountinfo','sched_getaffinity'])
return report
def render(report, out):
"""Render numeric evidence without parsing translated human descriptions."""
tr = out.text
if report['status'] == 'unsupported':
out.row(tr('资源限制','Resource limits'),tr('当前系统不支持','Unsupported on this system'),'unknown')
return
metrics, limits = report['metrics'], report['cgroup']
def row(zh, en, value, suffix=''):
out.row(tr(zh,en), tr('未知','Unknown') if value is None else f'{value:g}{suffix}',
'unknown' if value is None else 'ok')
allocation = report['allocation']
row('本进程 CPU 上限','Process CPU limit',allocation['cpu_limit_cores'],' vCPU')
out.row(tr('CPU 额度依据','CPU limit basis'),tr('CPU 范围与可见配额','Affinity and visible quota')
if limits['cpu_quota_cores'] is not None else tr('仅系统可见 CPU/进程范围,配额未确认','OS-visible CPUs/affinity only; quota unconfirmed'),'unknown')
value = allocation['memory_limit_bytes']
row('内存额度/可见量','RAM limit/visible',None if value is None else value/1024**2,' MiB')
basis = allocation['memory_basis']
out.row(tr('内存额度依据','RAM limit basis'),
tr('可见 cgroup 限额(可能与同组进程共享)','Visible cgroup limit (may be shared within the group)') if basis == 'visible_cgroup_limit' else
tr('容器额度未确认;不展示宿主机内存作额度','Container allocation unknown; host RAM is not an allocation') if basis == 'unconfirmed_container_allocation' else
tr('虚拟机/系统可见量;独享保障未确认','Guest/OS-visible amount; reservation unconfirmed'),'unknown')
out.row(tr('CPU/内存独享性','CPU/RAM dedication'),tr('无法从本机确认','Cannot be verified locally'),'unknown')
if not allocation['container_evidence']:
for zh,en,key in [('Swap 总量','Swap total','swap_total_bytes'),('Swap 已用','Swap used','swap_used_bytes')]:
value = metrics[key]
row(zh,en,None if value is None else value/1024**2,' MiB')
row('CPU steal(短采样)','CPU steal (short)',metrics['steal_percent'],'%')
row('实际采样时间','Observation time',metrics['sample_elapsed_seconds'],' s')
for kind in ('cpu','memory','io'):
psi = metrics['pressure'][kind]
row(f'{kind} 压力 some/10s',f'{kind} PSI some/10s',psi.get('some',{}).get('avg10') if psi else None,'%')
out.row(tr('范围说明','Scope'),tr('可见配额;独享性未确认;压力为短采样',
'Visible quota; exclusivity unconfirmed; short pressure sample'),'unknown')