package size calculator

This commit is contained in:
2026-05-13 17:23:25 -03:00
parent 6652cb26e6
commit 29b095d583
7 changed files with 615 additions and 9 deletions

104
runner.py
View File

@@ -19,9 +19,11 @@ import os
import resource
import subprocess
import sys
import sysconfig
import time
import traceback
import uuid
import zipfile
from contextlib import redirect_stderr, redirect_stdout
from pathlib import Path
@@ -42,6 +44,7 @@ if SHARED_DIR.exists():
app = FastAPI(title="Lambda Local Runner")
_modules: dict = {} # name -> imported module (cache; presence = warm)
_module_deps: dict = {} # name -> set of sys.modules keys added during this function's init
_invocations: list[dict] = [] # newest last; capped at MAX_INVOCATIONS
@@ -156,6 +159,11 @@ def invoke(name: str, req: InvokeRequest):
f"functions.{name}.handler", target,
)
module = importlib.util.module_from_spec(spec)
# Snapshot sys.modules so /reset can pop the transitive deps this function
# pulled in (aioboto3 → aiobotocore → botocore, etc.). Without this the
# second cold start is fake — heavy imports stay cached in the long-running
# uvicorn process, and Force Cold reports unrealistically small numbers.
sys_modules_before = set(sys.modules)
t0 = time.monotonic()
try:
spec.loader.exec_module(module)
@@ -166,6 +174,7 @@ def invoke(name: str, req: InvokeRequest):
return _record(record)
init_duration_ms = (time.monotonic() - t0) * 1000
_modules[name] = module
_module_deps[name] = set(sys.modules) - sys_modules_before
record["metrics"]["init_duration_ms"] = round(init_duration_ms, 2)
module = _modules[name]
@@ -244,13 +253,19 @@ def clear_invocations():
@app.post("/reset")
def reset_modules():
"""Clear the module cache so the next invocation is cold. Useful for
A/B-ing cold-start cost without restarting the FastAPI process."""
"""Clear the module cache AND the transitive imports each function pulled in
during its init, so the next invocation pays a realistic cold-start cost
(re-importing aioboto3 → aiobotocore → botocore from disk, not a no-op
against an already-warm uvicorn process)."""
cleared = list(_modules.keys())
_modules.clear()
popped = 0
for name in cleared:
sys.modules.pop(name, None)
return {"cleared": cleared}
sys.modules.pop(f"functions.{name}.handler", None)
for dep in _module_deps.pop(name, ()):
if sys.modules.pop(dep, None) is not None:
popped += 1
_modules.clear()
return {"cleared": cleared, "transitive_modules_popped": popped}
@app.get("/functions/{name}/scripts")
@@ -291,6 +306,85 @@ def run_script(fn_name: str, script_name: str, req: ScriptRequest):
}
@app.get("/packaging")
def packaging():
"""Static sizing report — what each function would ship as a Lambda deployment zip,
what the shared layer would weigh, what the largest installed deps look like.
All computed against the live pod filesystem so numbers are real, not extrapolated."""
funcs = []
for d in sorted(FUNCTIONS_DIR.iterdir()):
if not d.is_dir() or d.name.startswith("_"):
continue
if not (d / "handler.py").exists():
continue
funcs.append({
"name": d.name,
"handler_bytes": (d / "handler.py").stat().st_size,
"folder_bytes": _dir_bytes(d),
"folder_zip_bytes": _zip_bytes(d),
})
site_packages = _site_packages_dir()
deps: list[dict] = []
if site_packages:
for child in sorted(site_packages.iterdir()):
if not child.is_dir():
continue
if child.name.startswith("_") or child.name.endswith(".dist-info"):
continue
b = _dir_bytes(child)
if b > 50_000:
deps.append({"name": child.name, "bytes": b})
deps.sort(key=lambda x: -x["bytes"])
shared_bytes = _dir_bytes(SHARED_DIR) if SHARED_DIR.exists() else 0
shared_zip = _zip_bytes(SHARED_DIR) if SHARED_DIR.exists() else 0
return {
"functions": funcs,
"dependencies": deps[:25],
"dependencies_total_bytes": sum(d["bytes"] for d in deps),
"shared_layer": {"bytes": shared_bytes, "zip_bytes": shared_zip},
"limits": {
"zip_upload_max": 50 * 1024 * 1024,
"unzipped_max": 250 * 1024 * 1024,
"container_image_max": 10 * 1024 * 1024 * 1024,
"tmp_default": 512 * 1024 * 1024,
"tmp_max": 10 * 1024 * 1024 * 1024,
"response_max": 6 * 1024 * 1024,
},
}
def _dir_bytes(path: Path) -> int:
total = 0
for p in path.rglob("*"):
if p.is_file():
try:
total += p.stat().st_size
except OSError:
pass
return total
def _zip_bytes(path: Path) -> int:
"""Compute deflate-zipped size without writing to disk."""
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
for p in path.rglob("*"):
if p.is_file():
try:
zf.write(p, p.relative_to(path))
except OSError:
pass
return buf.getbuffer().nbytes
def _site_packages_dir() -> Path | None:
purelib = sysconfig.get_paths().get("purelib")
return Path(purelib) if purelib and Path(purelib).exists() else None
@app.get("/health")
def health():
return {"ok": True, "loaded_modules": list(_modules.keys()), "invocations": len(_invocations)}