Skip to content

ops(compose): add memory limits to api and worker containers#25

Open
yuzushi-dev wants to merge 3 commits into
mainfrom
fix/container-mem-limits
Open

ops(compose): add memory limits to api and worker containers#25
yuzushi-dev wants to merge 3 commits into
mainfrom
fix/container-mem-limits

Conversation

@yuzushi-dev

@yuzushi-dev yuzushi-dev commented Jul 18, 2026

Copy link
Copy Markdown
Owner

Problem

Prod (amber-u24, 15G RAM) has recurring host-level OOM kills (dmesg: constraint=CONSTRAINT_NONE, global_oom) — no container except minio had a memory limit. The kernel repeatedly kills amber2-api-1.

What 48h weekday profiling showed (updated)

A cgroup memory.current sampler (572 samples, 2026-07-20→22) found:

  • api-1 working set is HIGH but bounded: sits ~4.4G (peak 4423M), does not grow monotonically within a container life (no fast leak). Weekend-idle it was flat at 1.87G → growth is load-driven, not a time-based leak.
  • An OOM did occur in the window (RestartCount 6→7) and the container self-recovered (Docker restart: unless-stopped).
  • Root cause = capacity: the high working set collides with an undersized host (idle stack ~9G/15G, swap saturated) during the nightly ingestion burst (04:00 CEST sync_docs.py → graph_extraction/community_summary LLM fan-out on the workers).

Change

Add deploy.resources memory limits (same pattern as the existing minio block):

  • api: limit 6G / reservation 2G — a runaway/leak backstop set ABOVE the observed ~4.4G working set (kills a genuine runaway, does not crashloop normal ops). (The initial 4G was below the working set and would have crashlooped api — corrected after profiling.)
  • worker: limit 2.5G / reservation 1.25G per replica (idle ~1.7G each; prod scales to 3).

⚠️ Honest scope: this CONTAINS, it does not CURE

On a 15G host the sum of real working sets exceeds RAM (api ~4.4G + 3 workers + neo4j ~2G + milvus/pg/redis/etc). Memory limits alone cannot prevent a host-OOM when several containers peak together — they only make a single-container runaway cgroup-local and the victim predictable. The durable fix is capacity: add host RAM, or reduce worker replicas / lower CELERY_CONCURRENCY. Pair this PR with one of those.

Apply / risk

Requires docker compose up -d api worker (container recreate → brief api downtime + in-flight Celery tasks interrupted/retried, no data loss). Not applied to prod.

Validation

Valid YAML; deploy.resources confirmed (api: 6G/2G, worker: 2.5G/1.25G).

Prod recurring OOM was diagnosed as HOST-level (dmesg: constraint=CONSTRAINT_NONE,
global_oom), because no container except minio had a memory limit. On a 15G host
the idle stack already sits ~9.3G (3 worker replicas ×concurrency 2 ≈5G, neo4j
~2G, api ~1.1G) with swap chronically saturated, so any growth spike triggers a
global OOM — and the kernel has repeatedly killed amber2-api-1 (its RSS grows
over uptime to 3.5-5.5G).

Add deploy.resources memory limits (mirroring the existing minio block) so a
runaway becomes a cgroup-local kill of the offending container instead of a
host-wide OOM that can, in principle, hit any container:
- api: limit 4G / reservation 1.5G (normal RSS ~1.1G; catches the leak before host OOM)
- worker: limit 2.5G / reservation 1.25G PER REPLICA (idle ~1.7G each)

These are conservative initial ceilings — they contain the blast radius, they do
NOT fix the underlying api RSS growth (still to be profiled: leak vs unbounded
cache) and may need tuning against real ingestion peaks. Follow-ups: reduce
worker replicas/concurrency or add host RAM; profile api-1 RSS over 24h.

Not applied to prod — applying requires `docker compose up -d` (restart).
…~4.4G)

48h weekday RSS profiling: api-1 working set sits ~4.4G (peak 4423M), no fast
leak within a container life. The initial 4G cap was BELOW the working set and
would have crashlooped api. Raise to 6G (backstop above working set, kills a
genuine runaway, no crashloop). Comment now records that the cap does NOT
prevent host-OOM on a 15G host — sum of working sets exceeds RAM; real cure is
+RAM or fewer worker replicas / lower CELERY_CONCURRENCY.
…ed gaps

Adversarial review flagged the initial limits as containment-not-cure and, more
concretely, that most services stayed uncapped and the canary overlay was
unbounded. Re-sized from the 48h cgroup profiling (see ops notes):

- api 6G->5.5G limit, 2G->4.5G reservation. Working set plateaus at ~4.4G; the
  reservation guarantees it so api (the recurring OOM victim) isn't the one
  squeezed under host pressure.
- worker 2560M->2G limit, 1280M->1.5G reservation per replica (observed ~1.67G
  at CELERY_CONCURRENCY=2). 3 replicas => 6G tier ceiling.
- neo4j (was uncapped): 2.5G limit / 1.5G reservation — JVM heap is 1G but the
  page cache is off-heap and unbounded, a real host-OOM vector.
- milvus (was uncapped): 1.5G limit — segment/index cache grows with the set.
- celery worker_max_memory_per_child=1.5GiB: recycle a child before it reaches
  the 2G container cap, so gradual growth from ingestion fan-out is reclaimed by
  a clean restart instead of an OOM-kill + requeue (poison-pill) loop.
- canary overlay: api-canary/worker-canary were uncapped; add same ceilings with
  LOW reservations so a cutover window can't let the canary starve the live api.

Reservations now sum to ~10.75G < 15G (no scheduler overcommit); realistic
concurrent working set ~12.9G leaves ~2G headroom. postgres/redis/etcd left
uncapped (each <0.5G, negligible runaway risk).

Residual (NOT code, needs an ops decision): two full 5.5G stacks (live+canary)
plus the worker tier still cannot all peak simultaneously on 15G — a cutover
needs live workers scaled down or +host RAM. Applying these limits also requires
`docker compose up -d` (recreate), not `docker restart`; confirm per the prod
runbook before deploying. These changes are config-validated but NOT deploy-
tested (the local mirror runs a single stack, no canary / no replicas).
@yuzushi-dev

Copy link
Copy Markdown
Owner Author

Follow-up commit 13c58ff8 — re-sizes the limits from the 48h cgroup profiling and closes the uncapped gaps.

  • api 6G→5.5G limit / 2G→4.5G reservation (working set plateaus ~4.4G; reservation keeps the recurring OOM victim guaranteed).
  • worker 2560M→2G / res 1.5G per replica (observed ~1.67G @ concurrency 2).
  • neo4j (was uncapped): 2.5G — JVM heap is 1G but page cache is off-heap/unbounded, a real host-OOM vector.
  • milvus (was uncapped): 1.5G.
  • celery worker_max_memory_per_child=1.5GiB: recycle a child before the container cap → no OOM-kill + requeue (poison-pill) loop.
  • canary overlay was unbounded → same ceilings with low reservations.

Reservations now sum ~10.75G < 15G (no overcommit); realistic concurrent working set ~12.9G leaves ~2G headroom.

⚠️ Runbook collision — applying compose changes needs docker compose up -d (recreate), NOT docker restart. The prod runbook warns that up/recreate on api adopts amber2-api:latest, which may be deps-poorer than the running image (e.g. missing unstructured[docx]). Before deploying: docker compose build api + verify the baked deps, or pin the image tag. Also note two full 5.5G stacks (live+canary) + workers still don't all fit 15G — a cutover needs live workers scaled down or +host RAM. Config-validated, NOT deploy-tested (local mirror is single-stack).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant