203 lines
10 KiB
Python
203 lines
10 KiB
Python
"""只读核对 Linux 主机上的 Smail 资源隔离,不执行部署、重启或压测。"""
|
||
|
||
import argparse
|
||
from datetime import datetime, timezone
|
||
from decimal import Decimal
|
||
import json
|
||
import os
|
||
from pathlib import Path
|
||
import re
|
||
import subprocess
|
||
|
||
|
||
MIB = 1024 * 1024
|
||
PROPERTIES = (
|
||
'ActiveState', 'SubState', 'MainPID', 'InvocationID', 'ControlGroup', 'NRestarts',
|
||
'MemoryHigh', 'MemoryMax', 'MemorySwapMax', 'TasksMax', 'OOMPolicy', 'KillMode',
|
||
'Restart', 'RestartUSec', 'StartLimitIntervalUSec', 'StartLimitBurst', 'TimeoutStopUSec',
|
||
)
|
||
|
||
|
||
def systemd_state():
|
||
result = subprocess.run(
|
||
['systemctl', 'show', 'smail.service', '--property=' + ','.join(PROPERTIES)],
|
||
check=True, capture_output=True, text=True, timeout=10,
|
||
env={**os.environ, 'LC_ALL': 'C'},
|
||
)
|
||
state = dict(line.split('=', 1) for line in result.stdout.splitlines() if '=' in line)
|
||
if any(name not in state for name in PROPERTIES):
|
||
raise ValueError('服务管理器未提供完整资源属性')
|
||
return {name: state[name] for name in PROPERTIES}
|
||
|
||
|
||
def limit(value):
|
||
if value in ('max', 'infinity'):
|
||
return None
|
||
number = int(value)
|
||
if number < 0:
|
||
raise ValueError('资源限额不能是负数')
|
||
return number
|
||
|
||
|
||
def go_memory_limit(environment):
|
||
values = [item[len(b'GOMEMLIMIT='):] for item in environment.split(b'\0')
|
||
if item.startswith(b'GOMEMLIMIT=')]
|
||
if len(values) != 1:
|
||
return None
|
||
# 只允许合法的 Go 字节单位,不把其他环境变量或无效值写入报告。
|
||
match = re.fullmatch(rb'([0-9]+)(B|KiB|MiB|GiB|TiB)?', values[0])
|
||
if not match:
|
||
return None
|
||
units = {None: 1, b'B': 1, b'KiB': 1024, b'MiB': MIB,
|
||
b'GiB': 1024 ** 3, b'TiB': 1024 ** 4}
|
||
return int(match[1]) * units[match[2]]
|
||
|
||
|
||
def duration_usec(value):
|
||
if not re.fullmatch(r'(?:[0-9]+(?:\.[0-9]+)?(?:us|ms|s|min|h|d)\s*)+', value):
|
||
return None
|
||
units = {'us': 1, 'ms': 1000, 's': 1000000, 'min': 60000000,
|
||
'h': 3600000000, 'd': 86400000000}
|
||
return int(sum(Decimal(number) * units[unit] for number, unit in
|
||
re.findall(r'([0-9]+(?:\.[0-9]+)?)(us|ms|s|min|h|d)', value)))
|
||
|
||
|
||
def cpu_quota(value):
|
||
quota, period = value.split()
|
||
amount, interval = limit(quota), int(period)
|
||
if interval <= 0 or (amount is not None and amount <= 0):
|
||
raise ValueError('CPU 限额无效')
|
||
return None if amount is None else amount / interval
|
||
|
||
|
||
def collect():
|
||
before = systemd_state()
|
||
pid = int(before['MainPID'])
|
||
if before['ActiveState'] != 'active' or before['SubState'] != 'running' or pid <= 0:
|
||
raise ValueError('Smail 主服务未处于运行状态')
|
||
proc = Path('/proc') / str(pid)
|
||
base = Path('/sys/fs/cgroup').resolve(strict=True)
|
||
group_name = before['ControlGroup']
|
||
if not group_name.startswith('/') or '..' in Path(group_name).parts:
|
||
raise ValueError('服务控制组路径无效')
|
||
group = (base / group_name.lstrip('/')).resolve(strict=True)
|
||
if group == base or base not in group.parents:
|
||
raise ValueError('Smail 必须使用独立控制组')
|
||
if ('0::' + group_name) not in (proc / 'cgroup').read_text().splitlines():
|
||
raise ValueError('主进程没有加入预期的统一控制组')
|
||
files = ('memory.high', 'memory.max', 'memory.current', 'memory.swap.max',
|
||
'pids.max', 'pids.current', 'cpu.max')
|
||
kernel = {name: (group / name).read_text().strip() for name in files}
|
||
events = dict(line.split() for line in (group / 'memory.events').read_text().splitlines())
|
||
parents = []
|
||
parent = group.parent
|
||
while True:
|
||
limits = {name: (parent / name).read_text().strip() for name in files
|
||
if (parent / name).exists()}
|
||
parents.append({'path': str(parent.relative_to(base)), 'limits': limits})
|
||
if parent == base:
|
||
break
|
||
parent = parent.parent
|
||
memory = dict(line.split(':', 1) for line in Path('/proc/meminfo').read_text().splitlines())
|
||
snapshot = {
|
||
'time_utc': datetime.now(timezone.utc).isoformat(),
|
||
'service': before, 'kernel': kernel, 'parents': parents,
|
||
'oom': {name: int(events[name]) for name in ('oom', 'oom_kill')},
|
||
'go_memory_bytes': go_memory_limit((proc / 'environ').read_bytes()),
|
||
'host_available_bytes': int(memory['MemAvailable'].split()[0]) * 1024,
|
||
'host_total_bytes': int(memory['MemTotal'].split()[0]) * 1024,
|
||
}
|
||
# 读取期间若发生重启或资源属性变更,旧、新进程的数据不能拼成通过证据。
|
||
snapshot['stable'] = before == systemd_state()
|
||
return snapshot
|
||
|
||
|
||
def evaluate(snapshot, reserve_bytes):
|
||
failures = []
|
||
|
||
def require(condition, message):
|
||
if not condition:
|
||
failures.append(message)
|
||
|
||
service, kernel = snapshot['service'], snapshot['kernel']
|
||
high, maximum = limit(kernel['memory.high']), limit(kernel['memory.max'])
|
||
current, tasks = int(kernel['memory.current']), int(kernel['pids.current'])
|
||
task_maximum = limit(kernel['pids.max'])
|
||
go_limit = snapshot['go_memory_bytes']
|
||
require(reserve_bytes >= 128 * MIB, '必须明确预留至少 128 MiB 的主机及同机服务增长余量')
|
||
require(snapshot['stable'], '取证期间服务重启或资源配置发生变化,需要重新检查')
|
||
require(high is not None and maximum is not None and 0 < high < maximum,
|
||
'必须设置有限且递增的内存高水位与硬上限')
|
||
require(limit(kernel['memory.swap.max']) == 0, '交换空间未限制为零,不符合当前部署预算')
|
||
require(go_limit is not None and high is not None and 64 * MIB <= go_limit < high,
|
||
'主进程缺少有效 GOMEMLIMIT,或没有为非 Go 内存预留高水位余量')
|
||
require(high is not None and current < high, '当前内存已达到高水位或高水位未配置')
|
||
require(task_maximum is not None and 0 < tasks < task_maximum, '任务数上限无效或已经耗尽')
|
||
require(cpu_quota(kernel['cpu.max']) is not None, 'CPU 没有有限配额')
|
||
for property_name, file_name in (('MemoryHigh', 'memory.high'), ('MemoryMax', 'memory.max'),
|
||
('MemorySwapMax', 'memory.swap.max'), ('TasksMax', 'pids.max')):
|
||
require(limit(service[property_name]) == limit(kernel[file_name]),
|
||
property_name + ' 在服务管理器与实际控制组中不一致')
|
||
require(service['OOMPolicy'] in ('stop', 'kill'), 'OOM 后未设置整组停止策略')
|
||
require(service['KillMode'] == 'control-group', '停止服务时未覆盖整个进程组')
|
||
require((duration_usec(service['TimeoutStopUSec']) or 0) >= 90 * 1000000,
|
||
'停止等待不足 90 秒或未设置有效期限,无法覆盖队列及数据库收尾预算')
|
||
require(service['Restart'] == 'on-failure', '服务重启策略不符合故障恢复要求')
|
||
require((duration_usec(service['RestartUSec']) or 0) >= 5 * 1000000, '重启退避时间不足')
|
||
require((duration_usec(service['StartLimitIntervalUSec']) or 0) >= 60 * 1000000
|
||
and 1 <= int(service['StartLimitBurst']) <= 5, '缺少有效的启动频率限制')
|
||
require(all(snapshot['oom'][name] == 0 for name in ('oom', 'oom_kill')),
|
||
'当前服务控制组记录过 OOM,需要核查原因并重新验收')
|
||
growth = None if maximum is None else max(0, maximum - current)
|
||
if growth is not None:
|
||
require(maximum < snapshot['host_total_bytes'], '单服务硬上限已达到主机总内存')
|
||
require(snapshot['host_available_bytes'] - growth >= reserve_bytes,
|
||
'服务增长到硬上限后,主机剩余预算不足')
|
||
for parent in snapshot['parents']:
|
||
values = parent['limits']
|
||
for name in ('memory.high', 'memory.max'):
|
||
bound = limit(values.get(name, 'max'))
|
||
if bound is not None:
|
||
require(bound - int(values['memory.current']) - growth >= reserve_bytes,
|
||
'上级控制组 ' + parent['path'] + ' 的 ' + name + ' 余量不足')
|
||
bound = limit(values.get('pids.max', 'max'))
|
||
if bound is not None and task_maximum is not None:
|
||
require(bound - int(values['pids.current']) >= task_maximum - tasks,
|
||
'上级控制组 ' + parent['path'] + ' 的任务数余量不足')
|
||
parent_cpu = cpu_quota(values.get('cpu.max', 'max 100000'))
|
||
own_cpu = cpu_quota(kernel['cpu.max'])
|
||
if parent_cpu is not None and own_cpu is not None:
|
||
require(own_cpu <= parent_cpu, '上级控制组 ' + parent['path'] + ' 的 CPU 配额更小')
|
||
return {
|
||
'resource_checks_passed': not failures,
|
||
'production_approved': False,
|
||
'reserve_bytes': reserve_bytes,
|
||
'remaining_growth_bytes': growth,
|
||
'failures': failures,
|
||
'scope': '仅检查单次资源快照,不替代容量、持续监控及完整生产验收',
|
||
'snapshot': snapshot,
|
||
}
|
||
|
||
|
||
def main():
|
||
parser = argparse.ArgumentParser(description=__doc__)
|
||
parser.add_argument('--reserve-mib', required=True, type=int,
|
||
help='按同机服务增长与系统开销核算的保留内存,不少于 128 MiB')
|
||
args = parser.parse_args()
|
||
if args.reserve_mib < 128:
|
||
parser.error('保留内存必须不少于 128 MiB,不能用零预算规避检查')
|
||
try:
|
||
if os.name != 'posix' or os.geteuid() != 0:
|
||
raise ValueError('请在 Linux 部署主机使用 root 执行只读检查')
|
||
report = evaluate(collect(), args.reserve_mib * MIB)
|
||
except (OSError, ValueError, KeyError, subprocess.SubprocessError):
|
||
# 系统命令的错误输出可能携带部署信息,不原样打印环境或异常内容。
|
||
report = {'resource_checks_passed': False, 'production_approved': False,
|
||
'failures': ['无法获得完整资源证据,请检查服务、root 权限及 cgroup v2 支持']}
|
||
print(json.dumps(report, ensure_ascii=False, indent=2))
|
||
return 0 if report['resource_checks_passed'] else 1
|
||
|
||
|
||
if __name__ == '__main__':
|
||
raise SystemExit(main())
|