fix(deployment): give the tile server a resource budget, cap allocator arenas, and spread its replicas#114
Open
arnoldcastro5000 wants to merge 1 commit into
Conversation
…tile server replicas - Production overlay: requests cpu 100m + memory 768Mi, memory limit 1536Mi, baselined from July 2026 production measurements (issue mapnik#315 investigation; cpu steady state measured 52-62 millicores). Without a request the pods are BestEffort: the kubelet evicts them first under node memory pressure and the scheduler keeps placing replacements on the starved node, which produced a ~90 pod eviction storm on July 2. With a memory limit, a leaking pod is OOM restarted cleanly in place instead of taking the whole node down. No cpu limit on purpose: throttling slows renders even on idle nodes, stacking up concurrent requests and raising the memory spikes this change exists to contain. - Production overlay: MALLOC_ARENA_MAX=2 (verified unset in prod; glibc default is 8 arenas per cpu = 16 here). The render path churns 300-450MB native alloc/free cycles; many arenas fragment under that churn and RSS ratchets up over days. Capping to 2 is the standard mitigation and doubles as the production test of the ratchet hypothesis: if the slow creep flattens, mechanism found. - Base: soft podAntiAffinity so the two replicas prefer different nodes and one bad node cannot take out every tile server at once. - The existing nodeAffinity pin to microservices-node-pool is kept; all 5 pool nodes have headroom for the new requests. - The V8 heap is already capped (--max-old-space-size=1024 in the start script since 2021); 1024Mi heap + ~500Mi native headroom is the budget behind the 1536Mi limit. For Greenstand/treetracker-infrastructure#315.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Part of Greenstand/treetracker-infrastructure#315. Companion to #113 (code fixes); this PR is deployment configuration only, no code changes.
Background: what went wrong on July 2
The tile server pods currently declare no resource requests or limits. In Kubernetes terms they are "BestEffort" pods, which has two consequences, and we watched both happen in production on July 2:
Change 1: memory request and limit (production overlay)
These numbers come from measurement campaigns against production on July 2 and 3 (over 1,000 one-minute samples; details in issue #315):
node --max-old-space-size=1024since 2021, so 1024Mi of JavaScript heap plus ~500Mi of native rendering headroom is what 1536Mi represents.One honest note for reviewers: the busier pod spends roughly 9 to 12% of its time above 768Mi depending on the day (9.4% in the final campaign, all of it transient spikes). That is expected and fine for a Burstable pod. If the team prefers a more conservative reservation, 896Mi also fits the node math.
Change 2: cpu request, and deliberately no cpu limit (production overlay)
Measured baseline from a 6-hour campaign on July 3 (359 one-minute intervals per pod): mean 43 to 46 millicores, median 27 to 30, p90 100 to 106, p99 234 to 253, single worst minute 398. So the request sits right at the measured p90 and at roughly twice the mean: without any request a pod gets almost no CPU weight when the node is contended, and slow renders stack up concurrent requests, which raises the very memory spikes this PR contains.
No cpu limit on purpose: cpu limits work by throttling, and they throttle even when the node has idle CPU. For a render server that means longer requests, deeper queues, and higher peak memory. Bursts above the request are short and modest (about 10% of intervals exceeded 100 millicores, only 3 or 4 of 359 exceeded 250, none reached 500) and are absorbed by spare node capacity instead: the two pool nodes averaged 213 and 311 millicores of their 2000 allocatable during the campaign.
Pre-warm forecast: when Airflow's Redis is repaired (issue #321) the map pre-warm job resumes at 182 requests per 5 minutes; the measured CPU cost per rendered tile (46 core-milliseconds, from 41,616 served requests) predicts that adds about 28 millicores average, well within the budget.
Change 3: cap the memory allocator's arenas (production overlay)
The measurements found that the multi-GB growth is mostly NOT JavaScript memory (the JS heap is capped at 1 GB and the killed pods held 2.0 to 2.5 GB). The best-fit explanation is fragmentation in glibc's memory allocator. Plain-language version: the allocator keeps up to 8 memory pools per CPU (16 on our 2-CPU nodes), and memory freed into one pool cannot be reused by the others, and is almost never returned to the operating system. The tile renderer allocates and frees 300 to 450 MB per render burst, all day, scattered across those 16 pools, so each pool slowly grows toward its own record peak. The process looks like it frees everything (it does, internally) while its footprint ratchets upward for days. Capping to 2 pools makes each burst reuse the previous burst's freed memory instead of growing a fresh pool. This is a standard, widely used setting for exactly this workload shape; the cost is a tiny amount of lock contention between the handful of rendering threads.
We verified on July 3 that no MALLOC setting exists in production today (checked the live pods directly). This change doubles as the experiment that confirms the mechanism: if the slow memory creep flattens after this deploys, we have found the ratchet. If it does not, we have cheaply eliminated a suspect.
Change 4: prefer separate nodes for the replicas (base)
There are 2 replicas but nothing today prevents both landing on the same node, and this service's history shows why that is risky. The measurements add a concrete reason: the load balancer spreads each browsing session across both replicas, so their memory spikes are simultaneous. During the July 3 campaign both pods hit their spike peaks in the same minute (one at 996 MB and the other at 959 MB at 21:04 UTC). On separate nodes that is fine; on one shared 4 GB node it would be a combined 2 GB surge on top of everything else. The added
podAntiAffinityis a soft preference: spread across nodes when possible, still schedule if only one node has room. It changes nothing for the single-replica development overlay.What this PR deliberately does not change
microservices-node-poolstays exactly as it is. Capacity check: all 5 pool nodes have room for the new requests today (four with ~2.3 GB unreserved, one with ~1.0 GB; cpu allocated is ~592m of ~1900m).Deploy notes
Validation
Manifests were structurally validated (both files parse, the patch container name matches the base so the strategic merge binds, env lists merge by name, values and affinity land where expected). Reviewer with kubectl handy:
kubectl kustomize deployment/overlays/productionshould render clean; that is the one check the draft environment could not run.