package size calculator
This commit is contained in:
104
runner.py
104
runner.py
@@ -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)}
|
||||
|
||||
Reference in New Issue
Block a user