Files
pikasTech-unidesk/scripts/src/gc-remote-memory-distribution.py
T

538 lines
24 KiB
Python

def memory_distribution_config():
config = MEMORY_CONFIG.get("attribution")
if not isinstance(config, dict):
raise RuntimeError("required YAML memory attribution policy is missing")
required_integers = {
"cgroupScanLimit": (1, 1000000),
"topLevelLimit": (1, 100),
"systemServiceLimit": (1, 100),
"podLimit": (1, 100),
"processLimit": (1, 100),
"processPssLimit": (1, 50),
"processSwapLimit": (1, 100),
"commandTimeoutSeconds": (1, 60),
"targetMemAvailableBytes": (1, 1 << 50),
"namespaceCountConcurrency": (1, 32),
}
for key, (minimum, maximum) in required_integers.items():
value = config.get(key)
if isinstance(value, bool) or not isinstance(value, int) or value < minimum or value > maximum:
raise RuntimeError("invalid YAML memory attribution integer: %s" % key)
cgroup_root = config.get("cgroupRoot")
kubeconfig = config.get("kubeconfigPath")
resources = config.get("objectCountResources")
namespaces = config.get("namespaceCountNamespaces")
if not isinstance(cgroup_root, str) or not cgroup_root.startswith("/") or os.path.normpath(cgroup_root) != cgroup_root:
raise RuntimeError("invalid YAML memory attribution cgroupRoot")
if not isinstance(kubeconfig, str) or not kubeconfig.startswith("/") or os.path.normpath(kubeconfig) != kubeconfig:
raise RuntimeError("invalid YAML memory attribution kubeconfigPath")
if not isinstance(resources, list) or not resources or any(
not isinstance(item, str) or not re.match(r"^[a-z0-9.-]{1,100}$", item) for item in resources
):
raise RuntimeError("invalid YAML memory attribution objectCountResources")
if not isinstance(namespaces, list) or not namespaces or any(
not isinstance(item, str) or not re.match(r"^[a-z0-9]([-a-z0-9]*[a-z0-9])?$", item) for item in namespaces
):
raise RuntimeError("invalid YAML memory attribution namespaceCountNamespaces")
return config
def read_memory_distribution_key_values(path, multiplier=1):
result = {}
try:
with open(path, "r", encoding="utf-8") as handle:
for line in handle:
parts = line.replace(":", "", 1).split()
if len(parts) < 2:
continue
try:
result[parts[0]] = int(parts[1]) * multiplier
except ValueError:
continue
except OSError:
return {}
return result
def read_memory_distribution_integer(path):
try:
with open(path, "r", encoding="utf-8") as handle:
return int(handle.read().strip())
except (OSError, ValueError):
return None
def memory_distribution_cgroup_row(root, path):
stat = read_memory_distribution_key_values(os.path.join(path, "memory.stat"))
file_bytes = safe_int(stat.get("file"))
slab_reclaimable = safe_int(stat.get("slab_reclaimable"))
return {
"path": os.path.relpath(path, root),
"currentBytes": read_memory_distribution_integer(os.path.join(path, "memory.current")),
"swapCurrentBytes": read_memory_distribution_integer(os.path.join(path, "memory.swap.current")),
"anonBytes": stat.get("anon"),
"fileBytes": stat.get("file"),
"inactiveAnonBytes": stat.get("inactive_anon"),
"inactiveFileBytes": stat.get("inactive_file"),
"reclaimableFileSlabUpperBoundBytes": file_bytes + slab_reclaimable,
}
def memory_distribution_limit(config, key):
configured = safe_int(config.get(key))
return min(100, configured * 5) if bool(OPTIONS.get("full")) else configured
def memory_distribution_top(rows, limit):
return sorted(rows, key=lambda item: safe_int(item.get("currentBytes")), reverse=True)[:limit]
def memory_distribution_command_summary(result):
return {
"ok": result.get("exitCode") == 0,
"exitCode": result.get("exitCode"),
"timedOut": result.get("timedOut") is True,
"durationMs": result.get("durationMs"),
}
def memory_distribution_runtime_maps(config):
timeout = safe_int(config.get("commandTimeoutSeconds"))
docker = command(["docker", "ps", "--no-trunc", "--format", "{{.ID}}\t{{.Names}}"], timeout)
docker_names = {}
if docker.get("exitCode") == 0:
for line in docker.get("stdout", "").splitlines():
parts = line.split("\t", 1)
if len(parts) == 2 and re.match(r"^[a-f0-9]{64}$", parts[0]):
docker_names[parts[0]] = parts[1]
cri = command(["k3s", "crictl", "pods", "-o", "json"], timeout)
pod_names = {}
if cri.get("exitCode") == 0:
try:
payload = json.loads(cri.get("stdout") or "{}")
for item in payload.get("items") or []:
metadata = item.get("metadata") or {}
uid = str(metadata.get("uid") or "")
if uid:
pod_names[uid] = {
"namespace": metadata.get("namespace"),
"name": metadata.get("name"),
"state": item.get("state"),
}
except (TypeError, ValueError, json.JSONDecodeError):
pass
return docker_names, pod_names, {
"docker": memory_distribution_command_summary(docker),
"cri": memory_distribution_command_summary(cri),
}
def collect_memory_distribution_cgroups(config, docker_names, pod_names):
root = config.get("cgroupRoot")
scan_limit = safe_int(config.get("cgroupScanLimit"))
top_level = []
system_services = []
pods = []
scanned = 0
truncated = False
pod_pattern = re.compile(r"pod([0-9a-f_]{36})\.slice$")
docker_pattern = re.compile(r"docker-([a-f0-9]{64})\.scope$")
for current, directories, files in os.walk(root, topdown=True, followlinks=False):
directories[:] = sorted(name for name in directories if not os.path.islink(os.path.join(current, name)))
scanned += 1
if scanned > scan_limit:
truncated = True
break
if "memory.current" not in files:
continue
row = memory_distribution_cgroup_row(root, current)
parent = os.path.dirname(current)
if parent == root:
top_level.append(row)
if parent == os.path.join(root, "system.slice"):
match = docker_pattern.search(os.path.basename(current))
if match:
row["owner"] = {"kind": "docker", "name": docker_names.get(match.group(1)), "id": match.group(1)}
system_services.append(row)
match = pod_pattern.search(os.path.basename(current))
if match:
uid = match.group(1).replace("_", "-")
row["owner"] = {"kind": "kubernetes-pod", "uid": uid, **(pod_names.get(uid) or {})}
pods.append(row)
directories[:] = []
return {
"scan": {"root": root, "scannedCount": scanned, "limit": scan_limit, "truncated": truncated},
"topLevel": memory_distribution_top(top_level, memory_distribution_limit(config, "topLevelLimit")),
"systemServices": memory_distribution_top(system_services, memory_distribution_limit(config, "systemServiceLimit")),
"pods": memory_distribution_top(pods, memory_distribution_limit(config, "podLimit")),
"podCgroupCount": len(pods),
}
def memory_distribution_cgroup_for_pid(pid):
try:
with open("/proc/%s/cgroup" % int(pid), "r", encoding="utf-8") as handle:
for line in handle:
parts = line.rstrip("\n").split(":", 2)
if len(parts) == 3 and parts[0] == "0" and parts[1] == "":
return parts[2]
except (OSError, ValueError):
pass
return None
def memory_distribution_pss(pid):
values = read_memory_distribution_key_values("/proc/%s/smaps_rollup" % int(pid), 1024)
if not values:
return None
return {
"pssBytes": values.get("Pss"),
"pssAnonBytes": values.get("Pss_Anon"),
"pssFileBytes": values.get("Pss_File"),
"swapPssBytes": values.get("SwapPss"),
}
def memory_distribution_process_swap(pid):
values = read_memory_distribution_key_values("/proc/%s/status" % int(pid), 1024)
return values.get("VmSwap")
def memory_distribution_process_owner(cgroup_path, docker_names, pod_names):
if not isinstance(cgroup_path, str):
return None
docker_match = re.search(r"docker-([a-f0-9]{64})\.scope(?:/|$)", cgroup_path)
if docker_match is not None:
container_id = docker_match.group(1)
return {"kind": "docker", "id": container_id, "name": docker_names.get(container_id)}
match = re.search(r"pod([0-9a-f_]{36})\.slice(?:/|$)", cgroup_path)
if match is None:
return None
uid = match.group(1).replace("_", "-")
return {"kind": "kubernetes-pod", "uid": uid, **(pod_names.get(uid) or {})}
def collect_memory_distribution_processes(config, docker_names, pod_names):
result = command(["ps", "-eo", "pid=,ppid=,rss=,stat=,comm=,args="], safe_int(config.get("commandTimeoutSeconds")))
if result.get("exitCode") != 0:
return {"ok": False, "command": memory_distribution_command_summary(result), "top": [], "zombies": {}}
rows = []
zombie_by_comm = {}
zombie_by_parent = {}
for line in result.get("stdout", "").splitlines():
parts = line.strip().split(None, 5)
if len(parts) < 5:
continue
pid = safe_int(parts[0])
ppid = safe_int(parts[1])
rss = safe_int(parts[2]) * 1024
state = parts[3]
comm = parts[4]
args = parts[5] if len(parts) > 5 else comm
row = {
"pid": pid,
"ppid": ppid,
"comm": comm,
"state": state,
"rssBytes": rss,
"swapBytes": memory_distribution_process_swap(pid),
"commandPreview": redact_command_preview(args)[:120],
}
rows.append(row)
if state.startswith("Z"):
zombie_by_comm[comm] = zombie_by_comm.get(comm, 0) + 1
zombie_by_parent[ppid] = zombie_by_parent.get(ppid, 0) + 1
by_pid = {item.get("pid"): item for item in rows}
top = sorted(rows, key=lambda item: safe_int(item.get("rssBytes")), reverse=True)[:memory_distribution_limit(config, "processLimit")]
top_swap = sorted(rows, key=lambda item: safe_int(item.get("swapBytes")), reverse=True)[:memory_distribution_limit(config, "processSwapLimit")]
pss_limit = memory_distribution_limit(config, "processPssLimit")
for index, item in enumerate(top):
item["cgroupPath"] = memory_distribution_cgroup_for_pid(item.get("pid"))
item["owner"] = memory_distribution_process_owner(item.get("cgroupPath"), docker_names, pod_names)
if index < pss_limit:
item.update(memory_distribution_pss(item.get("pid")) or {})
for item in top_swap:
item["cgroupPath"] = memory_distribution_cgroup_for_pid(item.get("pid"))
item["owner"] = memory_distribution_process_owner(item.get("cgroupPath"), docker_names, pod_names)
parent_rows = []
for ppid, count in sorted(zombie_by_parent.items(), key=lambda item: item[1], reverse=True)[:5]:
parent = by_pid.get(ppid) or {}
cgroup_path = memory_distribution_cgroup_for_pid(ppid)
owner = memory_distribution_process_owner(cgroup_path, docker_names, pod_names)
parent_rows.append({
"ppid": ppid,
"parentComm": parent.get("comm"),
"zombieCount": count,
"cgroupPath": cgroup_path,
"owner": owner,
"classification": "unreaped-child-process-leak",
"remediation": "fix-child-reaping-and-rollout-owner",
})
return {
"ok": True,
"command": memory_distribution_command_summary(result),
"processCount": len(rows),
"rssTotalBytes": sum(safe_int(item.get("rssBytes")) for item in rows),
"swapTotalBytes": sum(safe_int(item.get("swapBytes")) for item in rows),
"top": top,
"topSwap": top_swap,
"zombies": {
"count": sum(zombie_by_comm.values()),
"byComm": [{"comm": comm, "count": count} for comm, count in sorted(zombie_by_comm.items(), key=lambda item: item[1], reverse=True)[:5]],
"byParent": parent_rows,
},
}
def parse_memory_distribution_pressure(path):
result = {}
try:
with open(path, "r", encoding="utf-8") as handle:
for line in handle:
parts = line.split()
if not parts:
continue
result[parts[0]] = {
key: float(value) if key != "total" else safe_int(value)
for key, value in (item.split("=", 1) for item in parts[1:] if "=" in item)
}
except OSError:
pass
return result
def collect_memory_distribution_metrics(config):
timeout = safe_int(config.get("commandTimeoutSeconds"))
kubeconfig = config.get("kubeconfigPath")
result = command(["kubectl", "--kubeconfig", kubeconfig, "get", "--raw", "/metrics"], timeout)
selected = set(config.get("objectCountResources") or [])
objects = {}
runtime = {}
if result.get("exitCode") == 0:
object_pattern = re.compile(r'^apiserver_storage_objects\{resource="([^"]+)"\}\s+([0-9.eE+-]+)$')
runtime_names = set([
"process_resident_memory_bytes", "process_virtual_memory_bytes", "go_goroutines",
"go_memstats_heap_alloc_bytes", "go_memstats_heap_inuse_bytes", "go_memstats_heap_sys_bytes",
])
for line in result.get("stdout", "").splitlines():
match = object_pattern.match(line)
if match and match.group(1) in selected:
objects[match.group(1)] = int(float(match.group(2)))
continue
parts = line.split()
if len(parts) == 2 and parts[0] in runtime_names:
try:
runtime[parts[0]] = float(parts[1])
except ValueError:
pass
return {"command": memory_distribution_command_summary(result), "objects": objects, "runtime": runtime}
def memory_distribution_namespace_resource_path(resource, namespace):
encoded_namespace = urllib.parse.quote(namespace, safe="")
paths = {
"pods": "/api/v1/namespaces/%s/pods?limit=1" % encoded_namespace,
"events": "/api/v1/namespaces/%s/events?limit=1" % encoded_namespace,
"secrets": "/api/v1/namespaces/%s/secrets?limit=1" % encoded_namespace,
"persistentvolumeclaims": "/api/v1/namespaces/%s/persistentvolumeclaims?limit=1" % encoded_namespace,
"pipelineruns.tekton.dev": "/apis/tekton.dev/v1/namespaces/%s/pipelineruns?limit=1" % encoded_namespace,
"taskruns.tekton.dev": "/apis/tekton.dev/v1/namespaces/%s/taskruns?limit=1" % encoded_namespace,
}
return paths.get(resource)
def memory_distribution_namespace_count(config, namespace, resource):
path = memory_distribution_namespace_resource_path(resource, namespace)
if path is None:
return namespace, resource, None, "unsupported-resource"
result = command([
"kubectl", "--kubeconfig", config.get("kubeconfigPath"), "get", "--raw", path,
], safe_int(config.get("commandTimeoutSeconds")))
if result.get("exitCode") != 0:
return namespace, resource, None, "read-failed"
try:
payload = json.loads(result.get("stdout") or "{}")
metadata = payload.get("metadata") or {}
count = len(payload.get("items") or []) + safe_int(metadata.get("remainingItemCount"))
return namespace, resource, count, None
except (TypeError, ValueError, json.JSONDecodeError):
return namespace, resource, None, "parse-failed"
def collect_memory_distribution_namespace_counts(config):
import concurrent.futures
namespaces = list(config.get("namespaceCountNamespaces") or [])
resources = [
item for item in (config.get("objectCountResources") or [])
if memory_distribution_namespace_resource_path(item, namespaces[0]) is not None
]
jobs = [(namespace, resource) for namespace in namespaces for resource in resources]
rows = {namespace: {"namespace": namespace} for namespace in namespaces}
errors = []
with concurrent.futures.ThreadPoolExecutor(max_workers=safe_int(config.get("namespaceCountConcurrency"))) as pool:
futures = [pool.submit(memory_distribution_namespace_count, config, namespace, resource) for namespace, resource in jobs]
for future in concurrent.futures.as_completed(futures):
namespace, resource, count, error = future.result()
if error is None:
rows[namespace][resource] = count
else:
errors.append({"namespace": namespace, "resource": resource, "error": error})
ordered = sorted(
rows.values(),
key=lambda item: sum(safe_int(value) for key, value in item.items() if key != "namespace"),
reverse=True,
)
return {"rows": ordered, "errors": errors, "complete": not errors}
def compact_memory_distribution_cgroup(item):
compact = {
key: item.get(key)
for key in ["currentBytes", "swapCurrentBytes", "anonBytes"]
if item.get(key) is not None
}
owner = item.get("owner") if isinstance(item.get("owner"), dict) else None
if owner is not None:
compact["owner"] = {
key: owner.get(key)
for key in ["kind", "namespace", "name"]
if owner.get(key) is not None
}
else:
compact["path"] = item.get("path")
return compact
def compact_memory_distribution_process(item):
compact = {
key: item.get(key)
for key in ["pid", "comm", "rssBytes", "swapBytes", "pssBytes"]
if item.get(key) is not None
}
owner = item.get("owner") if isinstance(item.get("owner"), dict) else None
if owner is not None:
compact["owner"] = {
key: owner.get(key)
for key in ["kind", "namespace", "name"]
if owner.get(key) is not None
}
else:
compact["cgroupPath"] = item.get("cgroupPath")
return compact
def compact_memory_distribution_pressure(pressure):
return {
level: {
key: values.get(key)
for key in ["avg10", "avg60"]
if values.get(key) is not None
}
for level, values in pressure.items()
if isinstance(values, dict)
}
def compact_memory_distribution_zombies(zombies):
by_parent = []
for item in zombies.get("byParent") or []:
compact = {
key: item.get(key)
for key in ["ppid", "parentComm", "zombieCount", "classification", "remediation"]
if item.get(key) is not None
}
owner = item.get("owner") if isinstance(item.get("owner"), dict) else None
if owner is not None:
compact["owner"] = {
key: owner.get(key)
for key in ["kind", "namespace", "name"]
if owner.get(key) is not None
}
by_parent.append(compact)
return {
"count": zombies.get("count"),
"byComm": (zombies.get("byComm") or [])[:3],
"byParent": by_parent[:3],
}
def collect_memory_distribution(observed_at):
try:
config = memory_distribution_config()
except RuntimeError as exc:
return {"ok": False, "action": "gc remote memory-distribution", "providerId": PROVIDER_ID, "dryRun": True, "mutation": False, "error": str(exc)}
started = time.time()
docker_names, pod_names, runtime_commands = memory_distribution_runtime_maps(config)
host_snapshot = collect_memory_snapshot()
meminfo = read_memory_distribution_key_values("/proc/meminfo", 1024)
cgroups = collect_memory_distribution_cgroups(config, docker_names, pod_names)
processes = collect_memory_distribution_processes(config, docker_names, pod_names)
metrics = collect_memory_distribution_metrics(config)
namespace_counts = collect_memory_distribution_namespace_counts(config)
available = safe_int((host_snapshot.get("memory") or {}).get("availableBytes"))
target = safe_int(config.get("targetMemAvailableBytes"))
cached = safe_int(meminfo.get("Cached")) + safe_int(meminfo.get("Buffers"))
slab_reclaimable = safe_int(meminfo.get("SReclaimable"))
payload = {
"ok": cgroups.get("scan", {}).get("truncated") is not True,
"action": "gc remote memory-distribution",
"providerId": PROVIDER_ID,
"dryRun": True,
"mutation": False,
"observedAt": observed_at,
"durationMs": int((time.time() - started) * 1000),
"summary": {
"metric": "MemAvailable",
"source": "/proc/meminfo",
"swapExcludedFromAvailability": True,
"availableBytes": available,
"targetAvailableBytes": target,
"targetGapBytes": max(0, target - available + 1),
"anonPagesBytes": meminfo.get("AnonPages"),
"cachedAndBuffersBytes": cached,
"slabBytes": meminfo.get("Slab"),
"reclaimableFileSlabUpperBoundBytes": cached + slab_reclaimable,
"swapFreeBytes": safe_int((host_snapshot.get("swap") or {}).get("freeBytes")),
"memoryPsiFullAvg60": (parse_memory_distribution_pressure("/proc/pressure/memory").get("full") or {}).get("avg60"),
},
"host": {
"snapshot": host_snapshot,
"pages": {key: meminfo.get(key) for key in [
"MemFree", "Buffers", "Cached", "Active", "Inactive", "Active(anon)", "Inactive(anon)",
"Active(file)", "Inactive(file)", "AnonPages", "Mapped", "Shmem", "Slab", "SReclaimable",
"SUnreclaim", "KernelStack", "PageTables", "SwapCached",
]},
"pressure": {resource: parse_memory_distribution_pressure("/proc/pressure/%s" % resource) for resource in ["memory", "io", "cpu"]},
},
"cgroups": cgroups,
"processes": processes,
"kubernetes": {**metrics, "namespaceCounts": namespace_counts},
"collection": {
"configSource": "config/unidesk-cli.yaml#gc.remote.targets.%s.memoryPressure.attribution" % PROVIDER_ID,
"runtimeMaps": runtime_commands,
"podRuntimeMapCount": len(pod_names),
"dockerRuntimeMapCount": len(docker_names),
"full": bool(OPTIONS.get("full")),
},
"policy": {
"analysisOnly": True,
"next": "Use this distribution before designing or running memory-pressure GC candidates.",
},
}
if bool(OPTIONS.get("full")):
return payload
payload["host"] = {
"pressure": {"memory": compact_memory_distribution_pressure(parse_memory_distribution_pressure("/proc/pressure/memory"))},
}
payload["cgroups"] = {
"scan": cgroups.get("scan"),
"podCgroupCount": cgroups.get("podCgroupCount"),
"topLevel": [compact_memory_distribution_cgroup(item) for item in cgroups.get("topLevel") or []],
"systemServices": [compact_memory_distribution_cgroup(item) for item in cgroups.get("systemServices") or []],
"pods": [compact_memory_distribution_cgroup(item) for item in cgroups.get("pods") or []],
}
payload["processes"] = {
"ok": processes.get("ok"),
"processCount": processes.get("processCount"),
"rssTotalBytes": processes.get("rssTotalBytes"),
"swapTotalBytes": processes.get("swapTotalBytes"),
"top": [compact_memory_distribution_process(item) for item in processes.get("top") or []],
"topSwap": [compact_memory_distribution_process(item) for item in processes.get("topSwap") or []],
"zombies": compact_memory_distribution_zombies(processes.get("zombies") or {}),
}
payload["kubernetes"] = {
"objects": metrics.get("objects"),
"namespaceCounts": namespace_counts,
}
if (metrics.get("command", {}).get("ok") is not True):
payload["kubernetes"]["commandError"] = metrics.get("command")
payload["collection"] = {
"configSource": payload.get("collection", {}).get("configSource"),
"podRuntimeMapCount": len(pod_names),
"dockerRuntimeMapCount": len(docker_names),
"runtimeMapErrors": {
key: value for key, value in runtime_commands.items()
if value.get("ok") is not True
},
"full": False,
}
return payload