<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: jiankunking</title>
    <description>The latest articles on DEV Community by jiankunking (@jiankunking).</description>
    <link>https://dev.to/jiankunking</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4194196%2F85a22323-cc20-4a1c-b09f-ac937626f641.png</url>
      <title>DEV Community: jiankunking</title>
      <link>https://dev.to/jiankunking</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/jiankunking"/>
    <language>en</language>
    <item>
      <title>From Model Management to an AI Gateway: Tying Assets, Traffic and Cost into a Closed Loop</title>
      <dc:creator>jiankunking</dc:creator>
      <pubDate>Tue, 27 Oct 2026 12:30:07 +0000</pubDate>
      <link>https://dev.to/jiankunking/from-model-management-to-an-ai-gateway-tying-assets-traffic-and-cost-into-a-closed-loop-3205</link>
      <guid>https://dev.to/jiankunking/from-model-management-to-an-ai-gateway-tying-assets-traffic-and-cost-into-a-closed-loop-3205</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;This system has two main lines: one is model management and model assets, answering "who can use what and how much"; the other is the AI gateway, enforcing those decisions on every request. This article follows "the life of a model" and "the life of a request" in turn, and finally sees where the two lines mesh.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  1. Assets and traffic
&lt;/h2&gt;

&lt;p&gt;The core object of traditional API management is the interface; the core object of AI platform management is the model. Both appear to handle HTTP requests, yet what they care about differs:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What interface management cares about&lt;/th&gt;
&lt;th&gt;What model management additionally cares about&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Address, protocol, authentication, routing&lt;/td&gt;
&lt;td&gt;Model listing, selection, permissions, quotas, unit price, retirement&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Whether the request succeeded&lt;/td&gt;
&lt;td&gt;How many input and output tokens were consumed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Whether the upstream is healthy&lt;/td&gt;
&lt;td&gt;Whether instances are healthy, whether quota remains, whether the provider needs to be switched&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Who the caller is&lt;/td&gt;
&lt;td&gt;Which application, user and cost center the call belongs to&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The left column is what the traffic side cares about; the right column is what model management adds. Instance health and remaining quota are ultimately executed on the data plane — the asset side states the requirements and consumes the results. If the people who manage assets and the people who manage traffic were two separate systems, each holding half the picture, they would drift apart: approvals, permissions and quotas would stay inside the asset system and fail to constrain real requests; the gateway would forward traffic without knowing why a model was listed, who is allowed to use it, or whose cost the call books to.&lt;/p&gt;

&lt;p&gt;Their real relationship is a loop:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Model listing → selection and application → authorization and quota generation
        → gateway authentication, rate limiting, routing, rewriting
        → usage and status flow back → cost allocation, scale-out, migration and retirement
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The loop runs in two directions: decisions flow down — models, permissions, quotas and routing rules enter the gateway through the configuration plane; results flow up — token usage, callers and call outcomes return from the gateway to the asset side.&lt;/p&gt;

&lt;p&gt;Three planes keep the loop turning: the &lt;strong&gt;business plane&lt;/strong&gt; makes decisions and owns model assets and usage rules; the &lt;strong&gt;configuration plane&lt;/strong&gt; translates and delivers those decisions, turning business rules into router, instance and plugin configuration the gateway can execute; the &lt;strong&gt;data plane&lt;/strong&gt; executes decisions and reports results back — built on an AI gateway, it is responsible only for authentication, rate limiting, routing, rewriting, forwarding and recording along the request path. Each plane keeps to its boundary: the business plane does not touch inference traffic, the data plane does not hold asset rules, and the configuration plane only translates and delivers.&lt;/p&gt;

&lt;p&gt;From here on, this article uses two words: the side that manages the rules is the &lt;strong&gt;asset side&lt;/strong&gt;, corresponding to the business plane of the three planes; the side that sends requests is the &lt;strong&gt;caller&lt;/strong&gt; — both applications such as low-code agent platforms and microservices, and direct personal office use.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4wqj8jcezmqkmvbdmnqd.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4wqj8jcezmqkmvbdmnqd.png" alt="Figure 1 · The three planes" width="800" height="567"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The hard part is here: the same model, token and caller must point to one and the same thing in both the asset system and the traffic system. Next, two lines: first the life of a model, then the life of a request.&lt;/p&gt;




&lt;h2&gt;
  
  
  2. Model management and model assets: the life of a model
&lt;/h2&gt;

&lt;p&gt;One running example throughout: the customer support team wants to connect a new inference model to their low-code agent platform. From arrival to exit, the model passes through six gates:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6q6lsyvgt6maggs2axe5.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6q6lsyvgt6maggs2axe5.png" alt="Figure 2 · Model lifecycle" width="800" height="135"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  2.1 Selection: verify first, then add to the asset catalog
&lt;/h3&gt;

&lt;p&gt;They first tried several candidates side by side in the model marketplace: tuning &lt;code&gt;Temperature&lt;/code&gt;, &lt;code&gt;Top_p&lt;/code&gt; and &lt;code&gt;Max_tokens&lt;/code&gt; one by one to see how the parameters shape the output; once the parameters were settled they were frozen, and the models were swapped while re-running the same set of prompts — keeping the model as the only variable — to compare the differences.&lt;/p&gt;

&lt;p&gt;Selection is not just picking a model name; it also means judging whether reasoning capability is needed, how high the quality bar is, and whether there are security risks. Security risk is especially decisive — it directly determines whether to go with a private deployment or a cloud service, and the later application and routing approach follow from it.&lt;/p&gt;

&lt;h3&gt;
  
  
  2.2 Application: establish ownership first, then open production access
&lt;/h3&gt;

&lt;p&gt;The application form must state the usage mode (low-code agent platform, microservice calls, or personal office use) and the environment (test or production).&lt;/p&gt;

&lt;p&gt;Production access must be bound to an explicit scenario. Without a scenario, calls stop at the token and never reach the application or cost center, so auditing, cost attribution and problem diagnosis are limited to that coarse layer. So what this step really establishes is the three-way relationship between model, caller, and cost ownership.&lt;/p&gt;

&lt;h3&gt;
  
  
  2.3 Tokens: the carrier of permission, not an isolated key
&lt;/h3&gt;

&lt;p&gt;One token can carry permissions for multiple models — you do not need a separate key per model. The deletion boundary for tokens is deliberately tight: delete the last model permission and the token is invalidated with it; if an agent still references the token, deletion is refused. &lt;strong&gt;A token that is "still valid but whose user is unknown" must never exist in the system.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Once the application is approved, the rules continue down the chain. The enterprise/application path issues a token, creates a Consumer (the gateway-side object representing the caller, holding its credentials and plugin configuration), writes model permissions and quota, and adds the route to a whitelist; the personal path only issues a token, with no Consumer — authentication and quota checks happen in real time on the asset side. Until this step is complete, "authorized" in the asset system cannot become "may call" in the request path.&lt;/p&gt;

&lt;h3&gt;
  
  
  2.4 Invocation: asset relationships carried into the request
&lt;/h3&gt;

&lt;p&gt;Once real calls begin, the SDK documentation is only the surface; underneath it, model management rules are expressed on the request side:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Exceeding either RPM or TPM causes a rejection, constraining request frequency, model throughput and the cost budget;&lt;/li&gt;
&lt;li&gt;Keeping &lt;code&gt;X-Session-ID&lt;/code&gt; constant within a conversation lets consistent hashing land requests on the same instance as much as possible, reusing the KV cache;&lt;/li&gt;
&lt;li&gt;Passing user information via &lt;code&gt;baggage: sentry-user&lt;/code&gt; (&lt;code&gt;baggage&lt;/code&gt; is the standard request header OpenTelemetry uses to propagate key-value context along a trace) lets the call be correlated with the log field &lt;code&gt;caller_user&lt;/code&gt; when the caller carries it as agreed, enabling end-to-end attribution.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These request headers are not business decoration; they are the carriers through which session, user and cost ownership enter the data plane.&lt;/p&gt;

&lt;h3&gt;
  
  
  2.5 Billing: usage must flow back to the asset relationship
&lt;/h3&gt;

&lt;p&gt;A single user interaction can trigger several model calls; counting by interactions or requests alone cannot explain real consumption, and capacity and cost have to be measured in tokens.&lt;/p&gt;

&lt;p&gt;Token-based measurement supports three things: capacity sizing — production statistics yield the average token consumption per interaction (P95 as well; the mean hides the long tail of multi-turn loops), which is fed into load tests to verify the RPM / TPM, concurrency and latency ceilings so that business volume can be converted into resource requirements; cost allocation aggregates by token to the application or sub-product, drillable down to the user; cost estimation uses &lt;code&gt;DAU × interactions per user per day × average tokens per interaction × unit price&lt;/code&gt; to project daily cost — for the latter two, DAU and interaction counts come from product-side instrumentation, while the gateway supplies only the token and unit-price bases, with input and output tokens priced separately.&lt;/p&gt;

&lt;p&gt;The gateway is the feedback entry point here: it parses upstream usage, records input and output tokens, model instance, caller and trace information, and hands them to quota settlement, detail queries and cost allocation.&lt;/p&gt;

&lt;h3&gt;
  
  
  2.6 Retirement: remove references first, then delete the asset
&lt;/h3&gt;

&lt;p&gt;A model cannot be retired by deleting it from a list. Published routes, issued tokens, agent references and in-flight migrations must all be handled in sync. The system uses a migration protection list to block new routes being created against the old model, and route creation also validates service template compatibility.&lt;/p&gt;

&lt;p&gt;The correct order is: switch the calling relationships first, then reclaim tokens and routes, and only then remove the model asset. Doing it in reverse does not work: delete the asset before cutting traffic, and the asset relationships drift apart from real traffic.&lt;/p&gt;

&lt;p&gt;The six stages do not really end here. The three dashed lines in Figure 2 are the feedback flow: usage, quality and cost feedback supports scale-out plans and provider trade-offs; permission and quota adjustments flow back to the application step; and the slot freed by retirement is filled by a replacement model, re-entering selection evaluation.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. The AI gateway: the life of a request
&lt;/h2&gt;

&lt;p&gt;The asset relationships established in Chapter 2 finally take effect in real requests — some as short as milliseconds, others as long as several minutes.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7m3bkszw78evr0yk1fc0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7m3bkszw78evr0yk1fc0.png" alt="Figure 3 · Request execution pipeline" width="800" height="214"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Once the low-code agent platform is officially connected, every request on the enterprise path enters the gateway, and the gateway must answer five questions in order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Who are you&lt;/strong&gt;: verify the enterprise credentials.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;May you call this model&lt;/strong&gt;: check model permissions and the routing whitelist.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;May this request be admitted&lt;/strong&gt;: run security validation plus the admission checks on RPM, concurrency and input tokens.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Where does this request go&lt;/strong&gt;: filter out unhealthy or quota-exhausted candidate instances to complete routing, then re-check instance-level quota.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;How much was actually used&lt;/strong&gt;: parse the response usage, write logs, and report back to the asset side.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Figure 3 expands these five questions into eight stages; the extra request rewriting and forwarding are execution details. Identity and permission must be confirmed first, the admission check comes before routing, and actual usage can only be settled after the response — implementations may vary; this order cannot.&lt;/p&gt;

&lt;p&gt;Behind all five questions are the decisions from Section 2: who you are comes from the ownership established at application time; whether you may call comes from token permissions; whether it is admitted comes from the quota verdict; where it goes comes from the instance list determined by selection and retirement; how much was used returns to the billing basis. The two lines mesh right here.&lt;/p&gt;

&lt;h3&gt;
  
  
  3.1 Two kinds of callers, two paths
&lt;/h3&gt;

&lt;p&gt;The low-code agent platform takes the enterprise path; personal office use takes the personal path. The two kinds of callers have different data sources and do not fit into one authentication model:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fz8vgsrep5pvpqs5ji05q.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fz8vgsrep5pvpqs5ji05q.png" alt="Figure 4 · Different scenario domains map to two invocation paths" width="800" height="150"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Enterprise/application path&lt;/strong&gt;: the token is bound to a fixed caller, and the gateway completes identity, model permission and quota checks locally.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Personal path&lt;/strong&gt;: no fixed consumer object is created; the asset side validates token and quota in real time, and actual usage is reclaimed after the request ends.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The two kinds of calls use different scenario domains. By the time a request reaches the gateway, the domain has already determined whether it hits the enterprise route or the personal route — it is not a choice made by route labels after entry.&lt;/p&gt;

&lt;h3&gt;
  
  
  3.2 Usage is parsed exactly once
&lt;/h3&gt;

&lt;p&gt;The gateway parses upstream response usage exactly once, and hands the same result to token quota settlement, personal usage reporting, logs and metrics, avoiding the inconsistent accounting that arises when each of them computes it separately.&lt;/p&gt;

&lt;p&gt;This step turns "the request succeeded" into operational information: not only whether the call completed, but which instance served it, how many tokens it burned, and whose account it books to.&lt;/p&gt;

&lt;p&gt;Request and response bodies may contain prompts, business data, and even personal information; "can be recorded" does not mean "retained in full by default". In production, sampling decisions must also be made per scenario, together with the corresponding masking, access control and retention periods.&lt;/p&gt;

&lt;h3&gt;
  
  
  3.3 Instance pool
&lt;/h3&gt;

&lt;p&gt;One model may map to self-hosted instances, a cloud service and a backup provider at the same time. Before forwarding, health status, remaining quota and alert-based demotion jointly decide whether an instance may enter the candidate set; after forwarding, an upstream 429/5xx triggers candidate switching according to the retry conditions. These conditions must converge into a single routing-and-retry chain, otherwise an instance just removed by health checks may be re-selected by another logic path, and a high-priority instance that has exhausted its quota may keep receiving traffic.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fy54ivp3kojcph02vosne.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fy54ivp3kojcph02vosne.png" alt="Figure 5 · Fallback's filtering and retry chain" width="800" height="130"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  3.4 A real request
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fjiankunking.com%2Fimages%2Ffrom-model-management-to-ai-gateway%2Fai-request-complete-journey-diagram-hd-v2-en.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fjiankunking.com%2Fimages%2Ffrom-model-management-to-ai-gateway%2Fai-request-complete-journey-diagram-hd-v2-en.gif" alt="Figure 6 · A complete request: from entry to settlement" width="760" height="502"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;In the figure, the red-flagged "unavailable" instance was removed from the candidates after failing an active health check. This request first reached the preferred instance, which returned 429 — excluded only for this request, marked "tried this round" in the figure, and not written into health-check state. Fallback is on, the time since the last failure is still within the retry window, and retries remain, so the router then switched to backup one. The low-code agent platform notices nothing: no retry of its own, no need to change address or credentials.&lt;/p&gt;

&lt;p&gt;Request-level exclusion, health-check removal, quota filtering and alert-based demotion — four mechanisms that do not interfere with one another, yet all act on the same candidate pool. Only after the preferred instance keeps failing past a threshold is it demoted by alert integration, and it re-enters the candidate set according to policy once probing recovers.&lt;/p&gt;




&lt;h2&gt;
  
  
  4. Where the two lines mesh
&lt;/h2&gt;

&lt;p&gt;The asset side makes decisions and the traffic side executes them, connected by quota and configuration; the capability map fixes the boundary between the two sides.&lt;/p&gt;

&lt;h3&gt;
  
  
  4.1 Quota: computed on the asset side, executed on the data plane
&lt;/h3&gt;

&lt;p&gt;The asset side holds model permissions, plans and cost rules, so it is the right place to answer "how much should be granted"; the data plane sees every real request, so it is the right place to answer "how much has been used". The two are not interchangeable.&lt;/p&gt;

&lt;p&gt;The enterprise/application path connects them with a five-step chain (the "Business side" in the figure is this article's asset side):&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fizypi3d5syl18hk9hxvp.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fizypi3d5syl18hk9hxvp.png" alt="Figure 7 · The five-step chain of quota generation and execution" width="800" height="361"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The core boundary in one sentence: &lt;strong&gt;on the enterprise path, the gateway does not hold quota rules — only the computed verdict.&lt;/strong&gt; The personal path goes further: authentication and quota checks are all done in real time on the asset side.&lt;/p&gt;

&lt;p&gt;Token rate limiting has two stages: check remaining headroom before forwarding, then book against real usage after the response. That way a call can be rejected before it happens and settled accurately afterward. The cost is that concurrent in-flight requests have no reserved quota, so actual usage can briefly exceed the threshold.&lt;/p&gt;

&lt;p&gt;To users, this implementation ultimately surfaces only as the rate-limit dimensions RPM / TPM, with exact values on the model marketplace detail page; the performance tiers (standard mode / high-performance mode, etc.) are separate information annotated on the model marketplace page.&lt;/p&gt;

&lt;p&gt;Time-window rate limiting follows the same logic: the time window is a variable read when the request arrives, so busy/off-peak quotas switch at runtime under the same rules, with no daily batch edit of live configuration.&lt;/p&gt;

&lt;h3&gt;
  
  
  4.2 Configuration delivery
&lt;/h3&gt;

&lt;p&gt;Model permissions, quotas and instance state all change, but changes cannot be made by overwriting the gateway configuration wholesale — a single business-side delivery could wipe out manual operations adjustments.&lt;/p&gt;

&lt;p&gt;So the configuration stage enforces exactly two boundaries. &lt;strong&gt;Source-of-truth boundary&lt;/strong&gt;: managed plugins treat the database as the single source of truth; unmanaged plugins keep their current data-plane state; the gateway is called only after the database transaction commits, and external IO stays out of the transaction. &lt;strong&gt;Failure boundary&lt;/strong&gt;: a failed delivery records status, alerts and retries — a database rollback is never used to paper over "not delivered"; if a rollback is genuinely needed, restore the gateway first, then roll back the database.&lt;/p&gt;

&lt;p&gt;The configuration plane only changes target fields and reads back to confirm after delivery. The goal is not that a given call never fails, but that after a failure you still know where the source of truth is and the data plane can re-converge.&lt;/p&gt;

&lt;h3&gt;
  
  
  4.3 Capability map
&lt;/h3&gt;

&lt;p&gt;The asset side and the traffic side each hold a segment — where does the boundary lie? The starting point was a list of 13 capability categories:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Management · Traffic governance · Connections and timeouts · Routing and load balancing · Request rewriting · Observability
Health checks · Model management · Security · Multi-region · Protocol conversion · Extensibility · High availability
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Laid item by item against a traditional API gateway, the differences cluster in governance and operations, not in forwarding — and among the 13 categories, observability and request rewriting were the densest. Grouped by where the capability finally executes, they fall into 8 domains:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8l8a8bbvunoqujoiuye9.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8l8a8bbvunoqujoiuye9.png" alt="Figure 8 · Full capability map (8 domains)" width="799" height="393"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The most important thing about this map is not the number of features but the boundaries: A–G decide how a request executes; H decides why it executes that way. Traditional API gateways mainly cover the former; to make models first-class citizens, the latter must be supplied by model management and the business control plane.&lt;/p&gt;




&lt;h2&gt;
  
  
  5. How feedback becomes decisions
&lt;/h2&gt;

&lt;p&gt;Operational results flow back from three places, carrying the traffic side's execution results back to the asset side:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Feedback trigger&lt;/th&gt;
&lt;th&gt;Who catches it&lt;/th&gt;
&lt;th&gt;What decision it changes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Persistent upstream 429 / 5xx triggering an alert&lt;/td&gt;
&lt;td&gt;Data-plane alert integration demotes priority&lt;/td&gt;
&lt;td&gt;The instance's routing priority, directly affecting every subsequent routing decision&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Token usage and call details&lt;/td&gt;
&lt;td&gt;Quota and analytics layer&lt;/td&gt;
&lt;td&gt;Cost allocation aggregates to the application or sub-product (drillable to the user), and supports the capacity and cost basis (2.5)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Instance health status and usage growth trends&lt;/td&gt;
&lt;td&gt;Operations and model operations&lt;/td&gt;
&lt;td&gt;Scale out instances, degrade, or add providers — eventually entering the retirement and migration of 2.6&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The first row needs no human: once the state changes, routing priority changes with it. The latter two need a human decision: data first becomes bills and dashboards, and only then does someone decide to scale out, switch providers, or retire.&lt;/p&gt;




&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Model management decides who can use what and how much; the AI gateway is responsible for enforcing those decisions on every request; and after each request, usage, callers and outcomes flow back into model management to support cost allocation, scale-out, migration and retirement.&lt;/p&gt;

&lt;p&gt;This is also where the two main lines intersect: the life of a model establishes and changes asset relationships, and the life of a request executes and verifies them. Without the first, the gateway does not know why it routes the way it does; without the second, permissions and quotas in model management are just rules on paper.&lt;/p&gt;

&lt;p&gt;Connect these things, and model assets, traffic governance and cost management finally form a genuine closed loop.&lt;/p&gt;




&lt;h2&gt;
  
  
  A note from the author
&lt;/h2&gt;

&lt;p&gt;I drove this system end to end — from the capability inventory and three-plane architecture design, through custom plugins, the full model lifecycle, rate-limiting migration, and the operations console.&lt;/p&gt;

&lt;p&gt;If you are also working on model platforms, AI gateways or LLMOps, I would be glad to compare notes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Further reading&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Selection criteria: &lt;a href="https://jiankunking.com/how-to-judge-an-ai-gateway-from-boundaries-to-scorecard.html" rel="noopener noreferrer"&gt;What Kind of Gateway Makes a Good AI Gateway?&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Another rate-limiting shape: &lt;a href="https://jiankunking.com/llm-gateway-rate-limiting-with-litetopic.html" rel="noopener noreferrer"&gt;Rethinking LLM Gateway Rate Limiting: From an In-Process Leaky Bucket to a Distributed Leaky-Bucket Matrix&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Protocol conversion: &lt;a href="https://jiankunking.com/efficient-design-and-development-in-the-ai-era-practice-of-plugin-development-for-converting-anthropic-requests-to-an-openai-compatible-backend.html" rel="noopener noreferrer"&gt;Efficient Design and Development in the AI Era: Building an Anthropic → OpenAI Protocol Conversion Plugin&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Production troubleshooting: &lt;a href="https://jiankunking.com/one-upgrade-two-independent-bugs-api7-gateway.html" rel="noopener noreferrer"&gt;One Upgrade, Two Independent Bugs: Tracing Streaming 500s and CPU Pegged at 100% Under Low Traffic in an API7 AI Gateway&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;This article is the English edition of a post originally published in &lt;a href="https://jiankunking.com/from-model-management-to-ai-gateway.html" rel="noopener noreferrer"&gt;Chinese&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>aiops</category>
      <category>apigateway</category>
    </item>
    <item>
      <title>API7 Gateway Upgrade Postmortem: Streaming 500s and Low-Traffic CPU Saturation From Two Independent Bugs</title>
      <dc:creator>jiankunking</dc:creator>
      <pubDate>Wed, 21 Oct 2026 13:13:12 +0000</pubDate>
      <link>https://dev.to/jiankunking/api7-gateway-upgrade-postmortem-streaming-500s-and-low-traffic-cpu-saturation-from-two-1m2c</link>
      <guid>https://dev.to/jiankunking/api7-gateway-upgrade-postmortem-streaming-500s-and-low-traffic-cpu-saturation-from-two-1m2c</guid>
      <description>&lt;p&gt;After upgrading our production AI gateway (API7 Enterprise, a commercial distribution of Apache APISIX) from 3.9.14 to 3.9.19-patch.1, two problems surfaced in quick succession: streaming requests intermittently returned 500 with the client receiving nothing at all, and multiple OpenResty workers on node 56 repeatedly ran near 100% CPU even though traffic had not visibly grown. A single upgrade tripped two mutually independent software defects — and on the most visibly affected node, a CPU frequency-governor difference stacked on top.&lt;/p&gt;

&lt;p&gt;I will give the conclusions first, then walk back through the investigation. Hypotheses I later discarded are marked along the way so they do not blur into the final findings.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Conclusions first&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Problem 1: intermittent 500s on streaming requests&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Root cause: while an SSE message was being reassembled from chunks, the old logic still flagged "needs flush." When the background timer fired, the send interface returned &lt;code&gt;nothing to flush&lt;/code&gt; (which only means there is nothing to flush &lt;em&gt;right now&lt;/em&gt;), but the code read it as a client disconnect and proactively stopped reading from upstream. The error occurred before any response bytes went out, so the client received nothing at all.&lt;/li&gt;
&lt;li&gt;Trigger: it depends on the relative order of chunk arrival and the periodic flush, not on response length — which is why it comes and goes.&lt;/li&gt;
&lt;li&gt;Handling: temporarily set &lt;code&gt;streaming_flush_interval_ms&lt;/code&gt; to &lt;code&gt;0&lt;/code&gt; to bypass the background flush path; the temporary setting was removed after patch.3 fixed the state check.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Problem 2: high CPU on multiple workers at low traffic&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Root cause: an index-sync regression introduced when &lt;code&gt;nginx-lua-prometheus-api7&lt;/code&gt; was bumped to 1.0.0-1. Re-registering an expired metric changes the global &lt;code&gt;delete_count&lt;/code&gt;, and the next time other workers sync they perform an O(key_count) full slot scan. The &lt;code&gt;get&lt;/code&gt; and &lt;code&gt;ttl&lt;/code&gt; calls inside the scan repeatedly contend for the shared dict's global lock, burning user CPU. &lt;code&gt;key_count&lt;/code&gt; only ever grows, so CPU consumption tracks historically allocated slots rather than traffic — low QPS alone is enough to peg the CPU.&lt;/li&gt;
&lt;li&gt;Trigger: high-cardinality labels plus metrics with &lt;code&gt;expire: 1800&lt;/code&gt; constantly being created, expiring, and re-registering; the more workers, the more visible the contention.&lt;/li&gt;
&lt;li&gt;Handling: rebuilding the Pods to zero the accumulated index only stopped the bleeding. The final fix rolled &lt;code&gt;nginx-lua-prometheus-api7&lt;/code&gt; back to 0.20250302-1, the version shipped with 3.9.14, delivered via a new patch while the gateway itself stayed on 3.9.19.&lt;/li&gt;
&lt;li&gt;Impact: apart from the streaming 500s in Problem 1, no increase in 5xx was observed during the CPU anomalies; the main user-visible effect was slightly higher response times, which we did not quantify separately.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One-line lesson: a restart clears state; it does not fix a bug. And a software bug cannot explain "only one machine alerts" — that is an infrastructure difference at another layer, which needs its own investigation.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Background: the request path and the environment
&lt;/h2&gt;

&lt;p&gt;The path is short:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Client (Codex, curl, application code) → API7 gateway (3 nodes) → Azure (GPT-series models)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The three nodes are 10.200.76.54, .55, and .56, 16 cores each. Both problems appeared after the upgrade; below I investigate them one at a time, and then look at the node-to-node difference.&lt;/p&gt;

&lt;h2&gt;
  
  
  A few terms that will keep coming up
&lt;/h2&gt;

&lt;p&gt;Readers familiar with OpenResty can skip this section.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;shared dict and its lock&lt;/strong&gt;: &lt;code&gt;ngx.shared.DICT&lt;/code&gt; is a memory dictionary shared by all workers. To keep concurrent access consistent, every read and write goes through the same mutex (&lt;code&gt;ngx_shmtx_lock&lt;/code&gt;) — pure reads included. When multiple workers hit the same dict at high frequency, lock contention and spin-backoff burn user CPU directly; it never shows up as iowait.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;KeyIndex&lt;/code&gt; and three counters&lt;/strong&gt;: &lt;code&gt;KeyIndex&lt;/code&gt; is the slot-index cache inside &lt;code&gt;nginx-lua-prometheus-api7&lt;/code&gt;, one copy per worker, used to avoid walking the dict from scratch on every lookup. It involves three numbers:

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;key_count&lt;/code&gt;: the number of slots ever allocated historically; it only increases;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;delete_count&lt;/code&gt;: a global deletion counter; incremented whenever an expired metric slot is reclaimed and then re-registered;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;self.deleted&lt;/code&gt;: the worker-local snapshot of &lt;code&gt;delete_count&lt;/code&gt;; if it disagrees with the global value, a full rescan is triggered.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;expire&lt;/code&gt; and &lt;code&gt;remove_expired_keys_interval&lt;/code&gt;&lt;/strong&gt;: the first is how long a single entry lives (1800 seconds in this deployment); the second is how often the cleanup job runs (library default: 3600 seconds).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;cpufreq governor&lt;/strong&gt;: the kernel's frequency-scaling policy. &lt;code&gt;performance&lt;/code&gt; tries to stay at high frequency; &lt;code&gt;powersave&lt;/code&gt; prefers dropping frequency to save power (behavior differs under &lt;code&gt;intel_pstate&lt;/code&gt;, covered later). The same user-CPU work takes longer at a lower frequency and is more likely to trip an alert threshold.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Problem 1: streaming requests return 500
&lt;/h2&gt;

&lt;p&gt;Conclusion first: patch.1 reworked the common streaming path, and the cooperation between message reassembly and the periodic flush turned out to be defective. Combined, they make the code &lt;strong&gt;misread "nothing to flush" as a client disconnect&lt;/strong&gt;, aborting the upstream read. Because the error happens before the response is emitted, the symptom is a client that receives nothing and a 500 in the logs.&lt;/p&gt;

&lt;h3&gt;
  
  
  Symptom and three controlled comparisons
&lt;/h3&gt;

&lt;p&gt;After configuring Codex to use the GPT-series models proxied through API7, errors started appearing in normal use — the client received nothing. The gateway access logs recorded 500 for the corresponding requests. I ran three comparisons first:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Comparison&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;th&gt;What it narrowed down at the time&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Same request body sent straight to Azure&lt;/td&gt;
&lt;td&gt;Works&lt;/td&gt;
&lt;td&gt;Reduces suspicion of upstream trouble or request-format problems; focus on the gateway&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Original request with &lt;code&gt;tools&lt;/code&gt; removed, still through the gateway&lt;/td&gt;
&lt;td&gt;Works&lt;/td&gt;
&lt;td&gt;Suggests the request shape affects the trigger, but does not prove &lt;code&gt;tools&lt;/code&gt; was invalid&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Original request with &lt;code&gt;stream&lt;/code&gt; set to &lt;code&gt;false&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Works&lt;/td&gt;
&lt;td&gt;Focus on the streaming processing path&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I took a detour here: the second comparison sent me down the wrong path for a while — I suspected some non-standard parameter inside &lt;code&gt;tools&lt;/code&gt;. Only later did I learn it had merely avoided the trigger. Change the request shape and the relative order of chunk arrival and periodic flush changes with it, so whether you hit the bug is purely a timing question; &lt;code&gt;tools&lt;/code&gt; itself was never invalid. Controlled comparisons narrow the search space, but the root cause still requires reproduction and code confirmation.&lt;/p&gt;

&lt;h3&gt;
  
  
  Reproduction
&lt;/h3&gt;

&lt;p&gt;At first, switching upstreams failed to reproduce it. Once I had the full curl command, gateway logs, route configuration, and Azure streaming response, and replayed them against the same version image (3.9.19-patch.1), the problem appeared immediately. &lt;strong&gt;The key is preserving the original request and environment&lt;/strong&gt; — omit either one and it may not reproduce.&lt;/p&gt;

&lt;h3&gt;
  
  
  Root cause: a new change tripping an old defect
&lt;/h3&gt;

&lt;p&gt;Two parts.&lt;/p&gt;

&lt;p&gt;One part: patch.1, to fix content moderation, changed the common streaming path. Some models' streamed answers do not carry usage information, which could leave the complete answer unmoderated, so the gateway was changed to first collect one complete SSE message, then hand it to the downstream plugins for processing and sending. Requests with content moderation disabled also pass through this logic.&lt;/p&gt;

&lt;p&gt;The other part: message reassembly and the periodic flush cooperate badly. A bit of background first — a single SSE message may arrive at the gateway split across multiple TCP chunks; incomplete content is buffered until the whole message is assembled. But the old logic still flags "needs flush." The background timer fires at that moment, and the send interface returns &lt;code&gt;nothing to flush&lt;/code&gt;. That return value &lt;strong&gt;only means there is nothing to flush right now; it is not sufficient evidence that the connection has dropped&lt;/strong&gt;. The old code, however, treated it as a client disconnect and proactively stopped reading from Azure.&lt;/p&gt;

&lt;p&gt;The full error chain is shown below: a background flush fires while the SSE message is still being buffered, &lt;code&gt;nothing to flush&lt;/code&gt; is misread as a disconnect, and the chain is cut before any response content goes out.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjvgxykd1do46q9jcfi06.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjvgxykd1do46q9jcfi06.png" alt=" " width="800" height="669"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Whether it triggers depends on the relative order of chunk arrival and the periodic flush; long responses are not a precondition, which is why it comes and goes.&lt;/p&gt;

&lt;p&gt;There is one more trap that inflated the debugging cost: the misjudged-disconnect record is logged at INFO level, while production is configured at WARN, so no corresponding log line was visible. The eventual diagnosis rested on the reproduction result compared against the streaming-path code, not on logs. &lt;strong&gt;The absence of an error log does not mean the error path did not run.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Mitigation and fix
&lt;/h3&gt;

&lt;p&gt;The temporary mitigation was to set &lt;code&gt;streaming_flush_interval_ms&lt;/code&gt; to &lt;code&gt;0&lt;/code&gt; on the affected routes. It proved effective in production, with no need to drop &lt;code&gt;tools&lt;/code&gt; or disable streaming.&lt;/p&gt;

&lt;p&gt;The parameter belongs to the &lt;code&gt;ai-proxy&lt;/code&gt; plugin; &lt;code&gt;ai-proxy-multi&lt;/code&gt; exposes it as well. The &lt;a href="https://apisix.apache.org/docs/apisix/plugins/ai-proxy/" rel="noopener noreferrer"&gt;official documentation&lt;/a&gt; lists a default of &lt;code&gt;10&lt;/code&gt;, with values of 0 or greater. The two flush paths involved here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Greater than 0&lt;/strong&gt;: a background timer calls &lt;code&gt;ngx.flush(false)&lt;/code&gt; every N milliseconds, batching prepared output into periodic flushes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Equal to 0&lt;/strong&gt;: the background flush timer is not enabled; flushing goes through the synchronous &lt;code&gt;ngx.flush(true)&lt;/code&gt; path instead.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Setting 0 avoids the problematic background path, but giving up batching may increase send overhead, so actual CPU changes need watching. &lt;code&gt;ngx.flush(true)&lt;/code&gt; suspends the current request's coroutine while waiting, not the whole Nginx worker; a slow client can still delay subsequent upstream reads for the current request — see &lt;a href="https://blog.openresty.com/en/stream-resp/" rel="noopener noreferrer"&gt;OpenResty's notes on streaming responses&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The rollout touched only the affected routes, watching 5xx, 499, client time-to-first-byte, worker CPU, and upstream latency. Once patch.3, which contains the code fix, passed validation, the temporary setting was removed and the default of 10 milliseconds restored.&lt;/p&gt;

&lt;p&gt;The code fix has two parts: schedule a flush only after content has been handed to the downstream response handler, and when the flush reports "nothing to flush," keep waiting instead of treating it as a disconnect. The SSE reassembly logic remains; the key to the fix is &lt;strong&gt;distinguishing "temporarily nothing to flush" from "the client is gone."&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 2: multiple workers pegged while traffic stayed low
&lt;/h2&gt;

&lt;p&gt;The root cause of Problem 2 is an index-sync regression in &lt;code&gt;nginx-lua-prometheus-api7&lt;/code&gt; 1.0.0-1: a change in &lt;code&gt;delete_count&lt;/code&gt; triggers an O(key_count) full slot scan, and the &lt;code&gt;get&lt;/code&gt; and &lt;code&gt;ttl&lt;/code&gt; calls in the scan contend for the shared dict's global lock, burning user CPU. &lt;code&gt;key_count&lt;/code&gt; is a historical cumulative value — &lt;strong&gt;it reached 570,000 in production at one point — which is why current traffic is irrelevant&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The hourly &lt;code&gt;flush_expired()&lt;/code&gt; is a separate path. The counterexample from monitoring (below) shows that the amount reclaimed is unrelated to CPU; the cleanup is more like the trigger for short hourly spikes than the CPU cost itself. Rebuilding the Pods to zero the index state counts as stopping the bleeding; the real cure was rolling back that dependency.&lt;/p&gt;

&lt;h3&gt;
  
  
  Symptom and what we ruled out
&lt;/h3&gt;

&lt;p&gt;After upgrading to patch.1, node 56 began showing CPU spikes of one or two minutes every hour, later escalating into dense clusters of repeated multi-worker saturation. In that snapshot, 9 of the OpenResty workers were above 99% and the 10th around 38%, for about 9.3 cores total — not a brief blip in a single worker, but 9 of 10 workers pegged simultaneously, nearly hitting the 10-core ceiling the license allows.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhlugr70cn2lzued7x0tr.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhlugr70cn2lzued7x0tr.png" alt="OpenResty CPU on node 56 versus the other two nodes (2026-09-20 08:45–11:45; red line is node 56, yellow and light blue are 54 and 55)" width="800" height="436"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A traffic surge was the first hypothesis. But at peak, QPS on the three nodes was 77.5, 74.6, and 52.3 — node 56 had the &lt;em&gt;lowest&lt;/em&gt; traffic yet drove CPU to about 9 cores (red line above), approaching the 10-core license ceiling, while the other two sat at 1.1 to 1.4 cores. The Elasticsearch logs showed no surge in request count, streaming requests, bytes in and out, or tokens either.&lt;/p&gt;

&lt;p&gt;No matching anomaly in network, memory, or disk: soft interrupts were low, no packet loss or queue overflows, memory was plentiful with no swap, and iowait was near zero. During the anomalous window, roughly 97%–98% of CPU consumption was in user mode, so the investigation turned to OpenResty's user-space execution path.&lt;/p&gt;

&lt;p&gt;Nothing on the application side failed along with it: apart from the streaming 500s in Problem 1, no increase in 5xx was observed during the CPU spikes; the main user-visible impact was slightly higher response times, which we did not quantify separately. The symptom showed up as latency, not as errors — and precisely because nothing throws errors, problems like this are mostly discoverable only through CPU alerts and latency curves.&lt;/p&gt;

&lt;p&gt;At this point I could only say that &lt;strong&gt;the current request volume cannot explain the CPU difference between nodes&lt;/strong&gt; — but that was not yet grounds to declare it "completely unrelated to the workload."&lt;/p&gt;

&lt;h3&gt;
  
  
  A monitoring counterexample: reclaimed volume does not track CPU
&lt;/h3&gt;

&lt;p&gt;The most obvious suspect was that hourly cleanup timer. Test it first: is the CPU spike really caused by &lt;code&gt;flush_expired()&lt;/code&gt;?&lt;/p&gt;

&lt;p&gt;Aggregating two days of monitoring by minute shows that the CPU curve actually has two distinct shapes:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Comparison&lt;/th&gt;
&lt;th&gt;Pattern ①: short spikes at a fixed hourly time&lt;/th&gt;
&lt;th&gt;Pattern ②: dense spike clusters&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Shape&lt;/td&gt;
&lt;td&gt;short spikes at a fixed time each hour, one to two minutes each&lt;/td&gt;
&lt;td&gt;not a sustained plateau, but bursts of high-CPU multi-worker spikes, each lasting a few seconds to tens of seconds, with dips in between&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;When it appears&lt;/td&gt;
&lt;td&gt;at the same time as the hourly cleanup; looking closely, the spike peaks 30–60 seconds after the cleanup and settles about 90 seconds in&lt;/td&gt;
&lt;td&gt;wave after wave; the start and end of each affected block falls between two timer firings&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Can the scheduled cleanup explain it?&lt;/td&gt;
&lt;td&gt;only "why at this moment," not the magnitude&lt;/td&gt;
&lt;td&gt;not at all&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Why is the cleanup time fixed? Each worker runs cleanup every 3600 seconds from the moment its process starts, so it fires at the same second every hour — for example, 25 minutes and 30 seconds past the hour. A CPU spike can only be called cleanup-related if it appears at that same moment; the dense clusters clearly do not line up.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhhbg2hp6h3e6dv99ckk2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhhbg2hp6h3e6dv99ckk2.png" alt=" " width="800" height="402"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Now compare reclaimed volume against CPU: neither direction lines up. Reclaimed volume is the single-step drop in shared-dict occupied space; CPU is the node peak within ten minutes of the reclaim (rate over a 1-minute window, 30-second step; recomputing with a finer step gives the same result, so smoothing did not erase the spikes):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Time (node 56)&lt;/th&gt;
&lt;th&gt;Reclaimed&lt;/th&gt;
&lt;th&gt;Node CPU peak&lt;/th&gt;
&lt;th&gt;Highest single worker at the time&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;09-16 14:18 (cleanup time)&lt;/td&gt;
&lt;td&gt;8.63 MiB&lt;/td&gt;
&lt;td&gt;4.74 cores&lt;/td&gt;
&lt;td&gt;1.00 core, 4 workers near saturation simultaneously&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;09-16 23:22 (cleanup time)&lt;/td&gt;
&lt;td&gt;12.53 MiB&lt;/td&gt;
&lt;td&gt;0.78 cores&lt;/td&gt;
&lt;td&gt;0.14 cores&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;09-20 12:25 (cleanup time)&lt;/td&gt;
&lt;td&gt;17.07 MiB&lt;/td&gt;
&lt;td&gt;1.35 cores&lt;/td&gt;
&lt;td&gt;0.18 cores&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;09-20 14:45 (not a cleanup time)&lt;/td&gt;
&lt;td&gt;5.30 MiB&lt;/td&gt;
&lt;td&gt;7.35 cores&lt;/td&gt;
&lt;td&gt;0.76 cores, all 10 workers above 0.5 cores simultaneously&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The first two rows come from the 5-worker era; on the evening of September 17 the license per node was raised from 5 cores to 10 and the worker count went up to 10, so the last two rows are from the 10-worker era.&lt;/p&gt;

&lt;p&gt;A large reclaim can cost almost no CPU. The 12:25:30 hourly cleanup ran as scheduled and shared-dict occupancy dropped a step — &lt;strong&gt;17.07 MiB reclaimed in a single pass, the largest scheduled cleanup of the day, yet the node CPU peak was only 1.35 cores, with every worker under 0.2 cores&lt;/strong&gt;. The same day at 12:22 and 12:29, the other two nodes reclaimed 19.75 and 18.82 MiB respectively, with CPU peaks of only 0.96 and 0.72 cores. A small reclaim can be expensive: at 14:45:00 only 5.30 MiB was reclaimed, yet node CPU shot to 7.35 cores.&lt;/p&gt;

&lt;p&gt;Treating "5.5–21.8 MiB reclaimed per hour accompanied by 2.4–4.7 cores" on September 16 daytime as a rule means mistaking coincidence at the same moment for causation: later that night, at 22:22 and 23:22, two comparable reclaims of 8.14 and 12.53 MiB produced CPU of only 0.75 and 0.78 cores.&lt;/p&gt;

&lt;p&gt;Tables alone can look cherry-picked, so here is node 56's entire day on September 20 plotted on a single timeline: node CPU on top, each reclaim's time and size below. Reclaims range from 1 MiB to 18 MiB and occur almost every hour outside the 3 a.m.–9 a.m. gap; yet the dense spike clusters appear only in the two blocks after 10 a.m. and after 1 p.m. The 17.07 MiB reclaim at 12:25 falls right in a quiet stretch, and the evening's 8–18 MiB reclaims raised no spikes either. The orange reclaims in the chart — those outside the cleanup schedule — all sit &lt;em&gt;under&lt;/em&gt; the spike clusters instead. Finally, pooling all 522 reclaim events of ≥1 MiB across the three nodes from 9/14 to 9/22, the correlation between reclaimed volume and the CPU peak within ±10 minutes is only 0.18.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fiqzx6arolb7oauqy85tx.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fiqzx6arolb7oauqy85tx.png" alt=" " width="799" height="484"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;It is worth separating the sources as well: "space reclaimed" in monitoring does not come from a single path. What lands on the cleanup's timestamp is &lt;code&gt;flush_expired()&lt;/code&gt;'s batch cleanup; reclaims off that schedule (like the 14:45:00 one) are more likely LRU eviction from the write path — two sides of the same churn that produces the dense spikes. Mix them into one metric and you get the odd result of "same reclaim volume, completely different CPU."&lt;/p&gt;

&lt;p&gt;So "how much was reclaimed" is not the independent variable: the scheduled cleanup at best explains &lt;em&gt;when&lt;/em&gt; short spikes happen, not their magnitude, and certainly not the dense clusters. A second trigger path had to be found.&lt;/p&gt;

&lt;h3&gt;
  
  
  Dependency diff: two commits, two paths
&lt;/h3&gt;

&lt;p&gt;Next, diff the full change surface of the upgrade — indirect dependencies included:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;3.9.14                    nginx-lua-prometheus-api7 0.20250302-1
3.9.19-patch.1, patch.3   nginx-lua-prometheus-api7 1.0.0-1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The leads landed on two commits between those versions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;845084f 2026-06-23  bump the global delete_count in KeyIndex:add() when an expired metric reappears
5f68f6c 2026-07-27  add self.dict:flush_expired() at the end of remove_expired_keys()
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;First: the full scan after a counter change. The key branch of &lt;code&gt;KeyIndex:sync()&lt;/code&gt; is below (simplified excerpt, keeping only the branches relevant to the incident; for the full implementation see upstream &lt;a href="https://github.com/knyar/nginx-lua-prometheus/blob/main/prometheus_keys.lua" rel="noopener noreferrer"&gt;&lt;code&gt;prometheus_keys.lua&lt;/code&gt;'s &lt;code&gt;KeyIndex&lt;/code&gt;&lt;/a&gt;; API7's fork is published as the LuaRocks package &lt;a href="https://luarocks.org/modules/membphis/nginx-lua-prometheus-api7" rel="noopener noreferrer"&gt;&lt;code&gt;nginx-lua-prometheus-api7&lt;/code&gt;&lt;/a&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight lua"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- N is the current upper bound of known slots; it grows with key_count&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;deleted&lt;/span&gt; &lt;span class="o"&gt;~=&lt;/span&gt; &lt;span class="n"&gt;delete_count&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;
    &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;sync_range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;N&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;         &lt;span class="c1"&gt;-- rescan all historical slots, O(key_count)&lt;/span&gt;
    &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;deleted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;delete_count&lt;/span&gt;
&lt;span class="k"&gt;elseif&lt;/span&gt; &lt;span class="n"&gt;N&lt;/span&gt; &lt;span class="o"&gt;~=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;last&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;
    &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;sync_range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;last&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;N&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;-- sync only newly added slots&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The trigger chain: a metric slot configured with &lt;code&gt;expire: 1800&lt;/code&gt; expires and is physically reclaimed, but some worker's local index still holds it; when a later request tries to renew it, &lt;code&gt;dict:expire()&lt;/code&gt; returns &lt;code&gt;not found&lt;/code&gt;, so &lt;code&gt;add()&lt;/code&gt; re-registers it and the global &lt;code&gt;delete_count&lt;/code&gt; increments. The next time other workers run &lt;code&gt;sync()&lt;/code&gt; and find their local &lt;code&gt;self.deleted&lt;/code&gt; out of sync, they execute &lt;code&gt;sync_range(0, N)&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight lua"&gt;&lt;code&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;N&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="kd"&gt;local&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;key_prefix&lt;/span&gt; &lt;span class="o"&gt;..&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;      &lt;span class="c1"&gt;-- at least one get per historical slot&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;
        &lt;span class="kd"&gt;local&lt;/span&gt; &lt;span class="n"&gt;ttl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;ttl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;key_prefix&lt;/span&gt; &lt;span class="o"&gt;..&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;-- plus a ttl for slots that still hold values&lt;/span&gt;
        &lt;span class="c1"&gt;-- remaining sync logic omitted&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every historical slot needs at least one shared-dict &lt;code&gt;get&lt;/code&gt;; slots that still hold values need a &lt;code&gt;ttl&lt;/code&gt; as well. The loop contains no yield point — other requests on the same worker, including streaming responses that are mid-output, have to wait for the scan to finish.&lt;/p&gt;

&lt;p&gt;The trap here: &lt;strong&gt;&lt;code&gt;key_count&lt;/code&gt; is the upper bound of slots ever allocated, and in this implementation it only ever grows; the scan cost depends on historical slots, not on the number of samples currently in &lt;code&gt;/metrics&lt;/code&gt;&lt;/strong&gt;. That is why low traffic can still peg a worker.&lt;/p&gt;

&lt;p&gt;Second path: the unbounded hourly cleanup:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight lua"&gt;&lt;code&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;KeyIndex&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;remove_expired_keys&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="c1"&gt;-- logic for self.expire_keys omitted&lt;/span&gt;
    &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;flush_expired&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;   &lt;span class="c1"&gt;-- no max_count passed, no cap on how many keys are cleaned&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This call walks the LRU queue while holding the shared dict's mutex. Each worker builds its own cleanup timer, using the library default &lt;code&gt;remove_expired_keys_interval&lt;/code&gt; of 3600 seconds, which this version of APISIX does not override. So it can pull multiple workers in at the same moment every hour.&lt;/p&gt;

&lt;p&gt;Compare the two paths side by side:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Comparison&lt;/th&gt;
&lt;th&gt;Path A: full scan after &lt;code&gt;delete_count&lt;/code&gt; changes&lt;/th&gt;
&lt;th&gt;Path B: hourly &lt;code&gt;flush_expired()&lt;/code&gt;
&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Trigger&lt;/td&gt;
&lt;td&gt;any request that causes an expired metric to re-register&lt;/td&gt;
&lt;td&gt;the cleanup timer every 3600 seconds&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CPU shape&lt;/td&gt;
&lt;td&gt;Pattern ②: dense spike clusters&lt;/td&gt;
&lt;td&gt;Pattern ① lands on its schedule, but see attribution below&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;perf evidence&lt;/td&gt;
&lt;td&gt;shared-dict lock hotspots in 17 of 23 profiles&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;flush_expired&lt;/code&gt; never captured in any of the 23&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;monitoring evidence&lt;/td&gt;
&lt;td&gt;the controlled reproduction's worker signature matches both patterns&lt;/td&gt;
&lt;td&gt;same-magnitude reclaims stay flat throughout (17.07 MiB → 1.35 cores, 12.53 MiB → 0.78 cores); the cleanup itself costs almost no CPU&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;verdict&lt;/td&gt;
&lt;td&gt;the main cause of this batch of spikes&lt;/td&gt;
&lt;td&gt;trigger and amplifier; insufficient evidence for an independent CPU source&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Pattern ①'s attribution needs one more check. Although it lands on the cleanup's timestamp, three details do not fit "the cleanup itself burns CPU": the spike peaks only 30–60 seconds after the cleanup and settles about 90 seconds in; the magnitude is independent of the reclaimed volume; and the worker distribution is 4 workers near a full core each with the rest low — exactly the Path A signature seen later in the controlled reproduction (a &lt;em&gt;signature&lt;/em&gt; is the characteristic distribution of per-worker CPU usage, which can be used to infer the failure mechanism).&lt;/p&gt;

&lt;p&gt;A more consistent reading: the timer's batch reclaim merely pulls the trigger, and the CPU is actually spent on what follows — "expired metric re-register → &lt;code&gt;delete_count&lt;/code&gt; changes → other workers do a full scan." During the day, requests keep touching expired metrics, so re-registration follows immediately after cleanup; at night with no requests, a reclaim of the same size costs almost nothing.&lt;/p&gt;

&lt;p&gt;The boundary should be stated too: all 23 perf profiles were taken during dense-spike windows where a single worker held ≥80% continuously; Pattern ① has no independent perf evidence, so that layer of attribution is an inference from timing and signatures.&lt;/p&gt;

&lt;p&gt;Production configuration made the problem more likely: &lt;code&gt;http_status&lt;/code&gt;, &lt;code&gt;http_latency&lt;/code&gt;, and &lt;code&gt;bandwidth&lt;/code&gt; are all configured with &lt;code&gt;expire: 1800&lt;/code&gt;, and some metrics carry high-cardinality labels like &lt;code&gt;app&lt;/code&gt;, &lt;code&gt;project&lt;/code&gt;, and &lt;code&gt;request_host&lt;/code&gt;; histograms further expand each label set into multiple buckets. Large numbers of time series are constantly created, expired, and re-registered. The &lt;code&gt;pid&lt;/code&gt; label added during troubleshooting is used by only one metric, adds limited series, and is not a major factor.&lt;/p&gt;

&lt;p&gt;Going from 5 to 10 workers may also amplify the concurrent contention.&lt;/p&gt;

&lt;h3&gt;
  
  
  perf evidence: the hotspot is the shared-dict lock
&lt;/h3&gt;

&lt;p&gt;A script on the node sampled worker CPU every second; once it saw sustained ≥80%, it automatically captured a system snapshot and ran &lt;code&gt;perf record&lt;/code&gt;, ultimately producing 23 profiles.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;17 of them showed a shared-dict lock hotspot&lt;/strong&gt;: median Self CPU for &lt;code&gt;ngx_shmtx_lock&lt;/code&gt; was &lt;strong&gt;66.47%&lt;/strong&gt;, median Children CPU for &lt;code&gt;ngx_meta_lua_ffi_shdict_get&lt;/code&gt; was &lt;strong&gt;73.99%&lt;/strong&gt;, and &lt;code&gt;get_ttl&lt;/code&gt; was clearly lower. Self and Children are measured differently and must not be added together.&lt;/p&gt;

&lt;p&gt;None of the 23 profiles captured the &lt;code&gt;flush_expired&lt;/code&gt; symbol. &lt;strong&gt;Not captured does not mean never executed&lt;/strong&gt; — but combined with the spikes appearing in clusters and not matching the timer's fixed schedule, its likelihood as the main cause of this batch dropped substantially; the hourly cleanup's risk is still on the books.&lt;/p&gt;

&lt;p&gt;In the perf output, &lt;code&gt;ngx_meta_lua_ffi_shdict_get&lt;/code&gt;, &lt;code&gt;ngx_meta_lua_shdict_lookup&lt;/code&gt;, and &lt;code&gt;ngx_meta_lua_ffi_shdict_get_ttl&lt;/code&gt; correspond to &lt;code&gt;get&lt;/code&gt;, dict lookup, and &lt;code&gt;ttl&lt;/code&gt;. &lt;code&gt;get&lt;/code&gt; and &lt;code&gt;ttl&lt;/code&gt; each need to acquire the same lock, and the spin-backoff on acquisition failure consumes CPU. This is not one worker holding the lock while scanning the whole index; it is multiple workers each scanning and contending for the same lock as they repeatedly touch the shared dict — a different contention pattern from &lt;code&gt;flush_expired()&lt;/code&gt; holding the lock through a single cleanup walk.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffswsojlrcu43i7nj9mra.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffswsojlrcu43i7nj9mra.png" alt=" " width="800" height="418"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;High &lt;code&gt;get&lt;/code&gt; and low &lt;code&gt;ttl&lt;/code&gt; fit the model of "many historical slots already invalid," but slot counts cannot be inferred from that alone. When the dense high-CPU clusters hit, 9 to 10 workers repeatedly fall into the hotspot at the same time.&lt;/p&gt;

&lt;h3&gt;
  
  
  Internal counters and controlled reproduction
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;key_count&lt;/code&gt; and &lt;code&gt;delete_count&lt;/code&gt; were not exposed as metrics at the time; I read them through a temporary diagnostic endpoint restricted to operations-only access. Reading them required distinguishing the global counters from worker-local caches, and aligning the readings with each worker's CPU and the shared dict's free space on one timeline. Such endpoints should be deleted once troubleshooting ends.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;key_count&lt;/code&gt; on node 56 reached &lt;strong&gt;570,000&lt;/strong&gt; before the instance was rebuilt and dropped to &lt;strong&gt;just over 10,000&lt;/strong&gt; afterward. The former means one full sync has to scan hundreds of thousands of historical slots. &lt;code&gt;key_count&lt;/code&gt; indicates scan size; changes in &lt;code&gt;delete_count&lt;/code&gt; indicate that the next sync may take the full-scan path.&lt;/p&gt;

&lt;p&gt;The controlled experiment used the same-version image with 5 workers: first construct already-reclaimed slots, then trigger one counter change. It reproduced the production pattern: &lt;strong&gt;the worker that initiated the change stayed at low CPU while the other 4 simultaneously approached a full core each&lt;/strong&gt; (why the initiator stays low was not pursued further). The reproduction still worked with external metric scraping removed, showing scraping is not a necessary condition.&lt;/p&gt;

&lt;p&gt;Condensed into minimal reproduction steps (these describe the necessary conditions and their order; exact commands and configuration vary a bit by version):&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Start an instance with the same-version image (&lt;code&gt;nginx-lua-prometheus-api7 1.0.0-1&lt;/code&gt;), 5 workers;&lt;/li&gt;
&lt;li&gt;Configure metrics with &lt;code&gt;expire: 1800&lt;/code&gt;, preferably with high-cardinality labels, to create continuous slot allocation and expiry;&lt;/li&gt;
&lt;li&gt;Wait for slots to be physically reclaimed while each worker's local &lt;code&gt;KeyIndex&lt;/code&gt; still holds the old indexes;&lt;/li&gt;
&lt;li&gt;Trigger one "expired metric re-registration" so the global &lt;code&gt;delete_count&lt;/code&gt; changes;&lt;/li&gt;
&lt;li&gt;Observe: the worker that initiated the change stays low while the rest simultaneously approach a full core each, for a few seconds to tens of seconds; an &lt;code&gt;ngx_shmtx_lock&lt;/code&gt; hotspot is visible in perf;&lt;/li&gt;
&lt;li&gt;Still reproducible with external Prometheus scraping removed.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The dependency change provided the trigger mechanism, perf pointed to lock contention, the internal counters explained the scan size, and the controlled experiment reproduced the multi-worker high CPU. The vendor's final report (API7) also confirmed the root cause — only at that point did all the evidence lines truly line up.&lt;/p&gt;

&lt;h3&gt;
  
  
  Stopping the bleeding, configuration mitigation, and the code fix
&lt;/h3&gt;

&lt;p&gt;First, stop the bleeding: after reading the counters on all three nodes, I deleted and recreated the gateway Pods one at a time. An ordinary reload preserves the shared dict; rebuilding the instance cleared the accumulated index state, and the CPU spikes temporarily stopped. &lt;strong&gt;But this only zeroes state; it does not fix the bug.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If the code fix cannot be delivered right away, configuration changes can reduce the trigger and amplification conditions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Tame high-cardinality labels&lt;/strong&gt;: prioritize metrics that carry an expiry, and remove non-essential &lt;code&gt;app&lt;/code&gt;, &lt;code&gt;project&lt;/code&gt;, &lt;code&gt;request_host&lt;/code&gt; dimensions and ad-hoc troubleshooting labels.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Revisit the expiry policy&lt;/strong&gt;: extending expiry reduces short-cycle reclaim and re-registration; disabling expiry requires evaluating time-series count and shared-dict capacity first — do not trade a state-growth problem for a capacity problem.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Distinguish the two paths&lt;/strong&gt;: raising &lt;code&gt;remove_expired_keys_interval&lt;/code&gt; only lowers how often the scheduled cleanup runs; it does not fix the full scan triggered by a &lt;code&gt;delete_count&lt;/code&gt; change. Whether it can even be tuned depends on whether that version exposes a configuration entry.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rolling instance rebuild&lt;/strong&gt;: clears inflated historical state, but cannot substitute for the algorithmic fix.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Why we chose a wholesale rollback
&lt;/h3&gt;

&lt;p&gt;Three options were on the table: keep patching the two defects on &lt;code&gt;nginx-lua-prometheus-api7&lt;/code&gt; 1.0.0-1, revert only those two related commits, or roll the dependency back wholesale to 0.20250302-1, the version shipped with 3.9.14. We chose the third — the gateway stays on 3.9.19, keeping its other features and fixes, with only the Prometheus dependency rolled back to the 3.9.14 version, delivered via a new patch. &lt;strong&gt;This was not a downgrade of the whole gateway stack.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The rollback was chosen mainly for its certainty: the target is behavior that ran in production on 3.9.14 for a long time — a return to a known state. Continuing to patch on 1.0.0 means stacking patches on a new version, with a much higher regression-verification cost. Scope of change was also a consideration: both defects live inside the LuaRocks dependency, one in index sync, one in cleanup logic, and whether reverting a single commit works cleanly depends on whether later commits depend on earlier changes. The cost is equally clear: we give up whatever else changed between 1.0.0 and 0.20250302-1, and any future re-upgrade must first address these two commits, with acceptance testing covering the process of the index state re-accumulating.&lt;/p&gt;

&lt;p&gt;Plainly put: the trade-off was not about which option was more elegant, but which one got us back to verified behavior fastest.&lt;/p&gt;

&lt;h2&gt;
  
  
  Node difference: why it was always node 56
&lt;/h2&gt;

&lt;p&gt;While investigating the CPU issue, there was one more thing: same code, no obvious traffic surge, yet alerts almost all landed on node 56. A software bug cannot explain a severity difference like that; beyond current QPS, I had to compare process uptime, &lt;code&gt;key_count&lt;/code&gt;, the rate of &lt;code&gt;delete_count&lt;/code&gt; changes, and machine configuration.&lt;/p&gt;

&lt;p&gt;Real machine-side differences did show up: all three run the Ubuntu 5.4.0 kernel but at different patch levels — 56 is &lt;code&gt;5.4.0-135-generic&lt;/code&gt; (built November 2022), while 54 and 55 are both &lt;code&gt;5.4.0-90-generic&lt;/code&gt; (built October 2021), so 56's kernel is actually about a year newer. The CPU model is the same on all three, Xeon Silver 4110, but 56 shows a lower CPU MHz in &lt;code&gt;lscpu&lt;/code&gt;. Checking the governor:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;cat /sys/devices/system/cpu/cpu0/cpufreq/scaling_governor

56: powersave
55: performance
54: performance
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;powersave&lt;/code&gt; behaves differently across drivers and modes, so one cannot conclude the CPU was pinned at a low frequency from this alone — see the &lt;a href="https://www.kernel.org/doc/html/latest/admin-guide/pm/intel_pstate.html" rel="noopener noreferrer"&gt;intel_pstate documentation&lt;/a&gt;. A single &lt;code&gt;lscpu&lt;/code&gt; reading is also not representative of the whole incident window.&lt;/p&gt;

&lt;p&gt;After unifying the governor, the effect was clear: comparing the same time window on the days before and after, node 56's total CPU consumption curve moved down noticeably, and the gap between nodes narrowed with it. Node 56 was still slightly higher than the other two, though. Note also that this comparison did not separately control for historical index state and workload — it is an observational before/after in production, not a controlled experiment.&lt;/p&gt;

&lt;p&gt;So the frequency-governor difference is best characterized as: &lt;strong&gt;a genuine amplifying factor, with a clear drop in the production before/after comparison&lt;/strong&gt;, but its independent contribution was not quantified, and the remaining difference still has to be sought in kernel minor version, accumulated index state, and load distribution. &lt;strong&gt;The confirmed CPU root cause remains the Prometheus index-sync defect.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Outcomes and validation boundaries
&lt;/h2&gt;

&lt;p&gt;These actions addressed different layers and cannot be uniformly summarized as "it got better after the upgrade":&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Problem&lt;/th&gt;
&lt;th&gt;Action taken and observed&lt;/th&gt;
&lt;th&gt;Boundary of the conclusion&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Streaming 500&lt;/td&gt;
&lt;td&gt;The set-to-0 workaround was effective in production; after patch.3 passed validation and was upgraded, the temporary setting was removed&lt;/td&gt;
&lt;td&gt;Bypassing the background path and fixing the state check are two different measures&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Multi-worker high CPU&lt;/td&gt;
&lt;td&gt;After rebuild, cumulative counters dropped and CPU spikes have not recurred; the Prometheus dependency rollback (&lt;code&gt;nginx-lua-prometheus-api7&lt;/code&gt; back to 0.20250302-1) ships via a new patch&lt;/td&gt;
&lt;td&gt;Rebuilding clears state (stop-the-bleeding); the dependency rollback targets the regression path&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Node difference&lt;/td&gt;
&lt;td&gt;After unifying the governor, CPU dropped noticeably and the inter-node gap narrowed, but 56 is still slightly higher&lt;/td&gt;
&lt;td&gt;A production before/after comparison; the frequency-governor contribution cannot be quantified separately; the remaining difference is unexplained&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Pitfalls and lessons
&lt;/h2&gt;

&lt;p&gt;The easiest traps to fall into, together with a few judgment calls worth keeping.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No error log does not mean the error path didn't run.&lt;/strong&gt; The misjudged disconnect was logged at INFO; configured for WARN in production, it is completely invisible.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;nothing to flush&lt;/code&gt; is not a disconnect signal&lt;/strong&gt; — it only means there is temporarily nothing to flush. Reasoning about network state from it will kill upstream reads that should have continued.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Restarts and instance rebuilds only zero state; they don't fix bugs.&lt;/strong&gt; The accumulated index in the shared dict can only be cleared by rebuilding the instance, but the regression remains, and the problem returns once state re-accumulates.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CPU consumption depends on historically accumulated slots, not on current traffic or active metric count.&lt;/strong&gt; &lt;code&gt;key_count&lt;/code&gt; only grows; 570,000 historical slots are enough to peg the CPU at low QPS.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reads on a shared dict also take the lock.&lt;/strong&gt; The mass of &lt;code&gt;get&lt;/code&gt; and &lt;code&gt;ttl&lt;/code&gt; calls in a loop can burn just as much user CPU, showing up as high CPU rather than iowait.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Only one machine alerts" points to another layer.&lt;/strong&gt; A software bug cannot explain severity differences between nodes; dig into kernel version, frequency scaling, power policy. But finding one configuration difference does not mean you have explained the difference — keep comparing other variables.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Resource saturation does not mean application errors.&lt;/strong&gt; During the dense CPU spikes there were no 5xx, only slightly higher response times; watching only the error rate misses this class of failure — resource alerts and latency curves matter too.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Include indirect dependencies in the change surface.&lt;/strong&gt; Diffing only the gateway code would have missed this Prometheus regression.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Let counterexamples change your hypothesis — and re-check the measurement basis of your comparisons.&lt;/strong&gt; Reclaiming 17.07 MiB at 1.35 cores while 5.30 MiB came with 7.35 cores shows "reclaimed volume" was never the independent variable of CPU; what I saw during the day on September 16 was coincidence, not causation. perf and the controlled reproduction narrowed the range further.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two things to do earlier next time: capture perf immediately when dense clusters of multi-worker high-CPU spikes appear; and when reproducing, assemble the complete request, the original response, and a same-version environment in one go.&lt;/p&gt;

&lt;h2&gt;
  
  
  Still unverified and open questions
&lt;/h2&gt;

&lt;p&gt;These conclusion boundaries need to be kept, to avoid reading "suspected" as "proven":&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The independent contribution of &lt;code&gt;powersave&lt;/code&gt; is not quantified&lt;/strong&gt;: all we confirmed is that 56 was on &lt;code&gt;powersave&lt;/code&gt; with a lower frequency; after unifying the governor, CPU dropped noticeably and the node gap narrowed, but 56 is still slightly higher; the remaining difference is unexplained, and no controlled comparison was run. To verify, first hold version, worker count, index state, and load constant, then switch governors on the same node and compare frequency, CPU, and request latency. What we do in the meantime: add kernel version, frequency scaling, and power policy to the node baseline checks, while keeping checks on application accumulated state.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Long-term effect not quantified&lt;/strong&gt;: we have no quantitative evidence for how well the patch that fixed the CPU problem performs long-term — we did not record the patch's full build number or observation duration, nor do we have pre/post error-rate and latency comparisons. So we can only say the fix has shipped and has not recurred in the short term; no conclusion on the long-term effect.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Acceptance isn't closed out yet&lt;/strong&gt;: subsequent acceptance must cover normal traffic together with metric scraping, hourly cycles, and the re-accumulation of index state — not just the brief calm right after a rebuild.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Disclosure: how tooling was used
&lt;/h2&gt;

&lt;p&gt;Most of the perf-report aggregation, monitoring correlation, and source-code triage in this investigation was done with automated tooling, and this postmortem itself was drafted and polished with an AI assistant (mimo-v2.6-pro). None of that changes the epistemics: judgment had to come back to the evidence on the ground, and no plausible-sounding explanation was allowed to stand when a counterexample contradicted it. For a fuller account of how tooling participated in the analysis, see &lt;a href="https://jiankunking.com/runtime-behavior-blind-spots-an-ai-assisted-post-mortem-on-api7-ai-gateway-cpu-saturation-bugs.html" rel="noopener noreferrer"&gt;Runtime Behavior Blind Spots: An AI-Assisted Postmortem of the API7 AI Gateway CPU Saturation Failures&lt;/a&gt; (in Chinese).&lt;/p&gt;

&lt;h2&gt;
  
  
  Related posts
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://jiankunking.com/en/apisix-504-15-seconds.html" rel="noopener noreferrer"&gt;APISIX Gateway 504s After Exactly 15 Seconds: Tracing It to tcp_syn_retries&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://jiankunking.com/runtime-behavior-blind-spots-an-ai-assisted-post-mortem-on-api7-ai-gateway-cpu-saturation-bugs.html" rel="noopener noreferrer"&gt;Runtime Behavior Blind Spots: An AI-Assisted Postmortem of the API7 AI Gateway CPU Saturation Failures&lt;/a&gt; (in Chinese)&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://jiankunking.com/how-to-judge-an-ai-gateway-from-boundaries-to-scorecard.html" rel="noopener noreferrer"&gt;What Makes a Good AI Gateway? From Boundaries to a Scorecard&lt;/a&gt; (in Chinese)&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://jiankunking.com/from-model-management-to-ai-gateway.html" rel="noopener noreferrer"&gt;From Model Management to an AI Gateway: Tying Assets, Traffic, and Cost into a Closed Loop&lt;/a&gt; (in Chinese)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;This article is the English edition of a post originally published in &lt;a href="https://jiankunking.com/one-upgrade-two-independent-bugs-api7-gateway.html" rel="noopener noreferrer"&gt;Chinese&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>debugging</category>
      <category>performance</category>
      <category>ai</category>
      <category>gateway</category>
    </item>
    <item>
      <title>APISIX Gateway 504s After Exactly 15 Seconds: Tracing It to tcp_syn_retries</title>
      <dc:creator>jiankunking</dc:creator>
      <pubDate>Tue, 20 Oct 2026 13:58:56 +0000</pubDate>
      <link>https://dev.to/jiankunking/apisix-gateway-504s-after-exactly-15-seconds-tracing-it-to-tcpsynretries-645</link>
      <guid>https://dev.to/jiankunking/apisix-gateway-504s-after-exactly-15-seconds-tracing-it-to-tcpsynretries-645</guid>
      <description>&lt;p&gt;Our production API gateway (API7 Enterprise, a commercial distribution of Apache APISIX) started returning intermittent 504s. Every single one of those requests took exactly 15 seconds. Not 14, not 16 — 15.&lt;/p&gt;

&lt;p&gt;The plugin timeout was configured at 600 seconds, so the application layer had no reason to give up that fast. Where does 15 seconds come from? This post walks through how we narrowed it down to &lt;code&gt;tcp_syn_retries=3&lt;/code&gt; and a SYN retransmission timeout — and why we deliberately did &lt;strong&gt;not&lt;/strong&gt; ship the obvious fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  Following the timeout through the code
&lt;/h2&gt;

&lt;p&gt;First step: verify that the 600-second timeout actually reaches the bottom of the stack. Tracing the call chain from the plugin configuration, through the Lua layer, through the FFI boundary, into the C module:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight lua"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- ai-transport/http.lua:145&lt;/span&gt;
&lt;span class="n"&gt;httpc&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;set_timeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;-- resty/ngx_http_ffi_client.lua&lt;/span&gt;
&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;set_timeouts&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;600000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;600000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;600000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;-- ngx_http_ffi_client_request.c:2238&lt;/span&gt;
&lt;span class="n"&gt;op&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;connect_timeout&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;600000&lt;/span&gt;
&lt;span class="n"&gt;ngx_add_timer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;write&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;op&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;connect_timeout&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The 600s value is passed down correctly, and the C module really does arm a 600s timer. So the timeout is not coming from the application. Something else owns the 15-second deadline.&lt;/p&gt;

&lt;h2&gt;
  
  
  Suspecting the kernel
&lt;/h2&gt;

&lt;p&gt;15 seconds is a suspicious number. It smells like a kernel TCP constant rather than an application default. Checking the kernel parameters on the node running the gateway pod:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;sysctl net.ipv4.tcp_syn_retries
&lt;span class="c"&gt;# output: net.ipv4.tcp_syn_retries = 3&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The kernel computes the total SYN timeout as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;timeout = ((2 &amp;lt;&amp;lt; boundary) - 1) * rto_base
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With &lt;code&gt;tcp_syn_retries=3&lt;/code&gt; and a 1-second base RTO:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;timeout = ((2 &amp;lt;&amp;lt; 3) - 1) * 1 = 15 seconds
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An exact match. The retransmission timeline:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1st SYN:        sent t=0s,  times out t=1s   (cumulative 1s)
1st retransmit: sent t=1s,  times out t=3s   (cumulative 3s)
2nd retransmit: sent t=3s,  times out t=7s   (cumulative 7s)
3rd retransmit: sent t=7s,  times out t=15s  (cumulative 15s) &amp;lt;- kernel gives up
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is no fifth attempt. &lt;code&gt;tcp_syn_retries=3&lt;/code&gt; means at most 3 retransmissions, 4 attempts total, and then the kernel declares the connection dead.&lt;/p&gt;

&lt;h2&gt;
  
  
  Eliminating DNS
&lt;/h2&gt;

&lt;p&gt;Could this be DNS resolution failing slowly? Looking at APISIX's DNS resolution code for the error message formats:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight lua"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- resolver.lua:83&lt;/span&gt;
&lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"failed to parse domain: "&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;", error: "&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;-- resty/ngx_http_ffi_client.lua&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;host&lt;/span&gt; &lt;span class="o"&gt;..&lt;/span&gt; &lt;span class="s2"&gt;" could not be resolved ("&lt;/span&gt; &lt;span class="o"&gt;..&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="s2"&gt;"no address"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;..&lt;/span&gt; &lt;span class="s2"&gt;")"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Searching the gateway logs for these strings did turn up DNS errors:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;2026/09/07 08:59:15 [warn] base.lua:255: failed to send request to LLM server:
connect: failed to parse domain: failed to query the DNS server:
dns server error: 2 server failure
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But when we pulled the &lt;code&gt;request_id&lt;/code&gt;s of those DNS errors from our log store and compared the two populations, a clear difference emerged:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;DNS errors&lt;/th&gt;
&lt;th&gt;SYN timeouts&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;count&lt;/td&gt;
&lt;td&gt;21&lt;/td&gt;
&lt;td&gt;750&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;avg duration&lt;/td&gt;
&lt;td&gt;2.25s&lt;/td&gt;
&lt;td&gt;15.38s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;status code&lt;/td&gt;
&lt;td&gt;500&lt;/td&gt;
&lt;td&gt;504&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;DNS failures return immediately — they never sit for 15 seconds. Two independent problems that merely look similar.&lt;/p&gt;

&lt;h2&gt;
  
  
  Packet capture
&lt;/h2&gt;

&lt;p&gt;Theory is nice; packets are better. Capturing traffic on the gateway node and filtering for handshakes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# find SYN packets&lt;/span&gt;
tcpdump &lt;span class="nt"&gt;-r&lt;/span&gt; capture.pcap &lt;span class="s1"&gt;'tcp[tcpflags] &amp;amp; (tcp-syn) != 0'&lt;/span&gt; &lt;span class="nt"&gt;-nn&lt;/span&gt;

&lt;span class="c"&gt;# find SYN retransmissions (multiple SYNs from the same port)&lt;/span&gt;
tcpdump &lt;span class="nt"&gt;-r&lt;/span&gt; capture.pcap &lt;span class="s1"&gt;'tcp[tcpflags] &amp;amp; (tcp-syn) != 0'&lt;/span&gt; &lt;span class="nt"&gt;-nn&lt;/span&gt; | &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'{print $3, $5}'&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt; | &lt;span class="nb"&gt;uniq&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We captured a complete retransmission sequence from a single connection:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;10.200.76.55.18494 -&amp;gt; 39.96.198.249.443:
  initial SYN:  15:27:20.096
  retransmit 1: 15:27:21.117 (gap 1.0s)
  retransmit 2: 15:27:23.133 (gap 3.0s)
  retransmit 3: 15:27:27.229 (gap 7.1s)
  -&amp;gt; kernel gives up, connection fails
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Distribution of the gaps across all captured failures:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;gap&lt;/th&gt;
&lt;th&gt;count&lt;/th&gt;
&lt;th&gt;theory&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1.0s&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3.0s&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7.1s&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The wire-level evidence matches the kernel parameter calculation exactly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three layers of evidence
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;layer&lt;/th&gt;
&lt;th&gt;evidence&lt;/th&gt;
&lt;th&gt;conclusion&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;application&lt;/td&gt;
&lt;td&gt;717 requests with request_time = 15s&lt;/td&gt;
&lt;td&gt;timeout is pinned at exactly 15s, not at 600s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;code&lt;/td&gt;
&lt;td&gt;source trace: 600s propagates correctly&lt;/td&gt;
&lt;td&gt;not an application timeout&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;network&lt;/td&gt;
&lt;td&gt;PCAP: SYN retransmit gaps of 1s, 3s, 7s&lt;/td&gt;
&lt;td&gt;kernel SYN retransmission timeout&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;All three layers point to the same root cause: &lt;code&gt;tcp_syn_retries=3&lt;/code&gt; combined with SYNs that never got answered.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix we did not ship
&lt;/h2&gt;

&lt;p&gt;The tempting fix — bumping &lt;code&gt;tcp_syn_retries&lt;/code&gt; to 5 — was rejected on purpose.&lt;/p&gt;

&lt;p&gt;It treats the symptom, not the disease:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The root cause stays.&lt;/strong&gt; Why are the SYNs not getting through, or not getting answers? Something at the network level is broken. Retrying more often does not repair it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Failure just takes longer.&lt;/strong&gt; &lt;code&gt;tcp_syn_retries=5&lt;/code&gt; stretches the total timeout from 15s to 63s. Users now wait over a minute to learn the same thing. That is a worse experience, not a better one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It hides the real problem.&lt;/strong&gt; A longer timeout makes the symptom recede from dashboards and alerts while the underlying connectivity issue keeps getting worse.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The correct directions: fix connectivity at the network layer, or implement retry/fallback in the application layer where you control backoff, budgets and error semantics — rather than depending on kernel-level retries you cannot observe or tune per-request.&lt;/p&gt;

&lt;p&gt;We followed up by pulling in the corporate network team for a deeper investigation of the network layer. That is where the actual fix lives.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Odd, round timeout values are a fingerprint.&lt;/strong&gt; 15s, 31s, 63s are almost never application configuration. They are the kernel telling you its retry budget expired. &lt;code&gt;tcp_syn_retries=N&lt;/code&gt; yields total timeouts of 1s, 3s, 7s, 15s, 31s, 63s for N = 0…5.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cross-validate across layers.&lt;/strong&gt; Logs alone suggested DNS. Source code alone exonerated the application. Packet capture alone showed retransmissions but not their business impact. Only the combination — request-duration histograms from logs, source-level timeout tracing, and PCAP — pinned the root cause.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Similar-looking errors can be unrelated.&lt;/strong&gt; The DNS failures and the SYN timeouts appeared in the same gateway, in the same period, but had completely different duration profiles. Always compare distributions, not anecdotes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A retry knob is not a fix.&lt;/strong&gt; If you cannot explain why packets are being dropped, more retries just convert a fast failure into a slow one.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;The gateway in this incident is API7 Enterprise, a commercial distribution of Apache APISIX. The fix discussed above was deliberately not applied; connectivity was escalated to the network team instead.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This article is the English edition of a post originally published in &lt;a href="https://jiankunking.com/APISIX-504-troubleshooting.html" rel="noopener noreferrer"&gt;Chinese&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>apisix</category>
      <category>gateway</category>
      <category>debugging</category>
    </item>
  </channel>
</rss>
