Article View

Scroll down to read the full article.

The Phantom ENOTFOUND: Node.js, Alpine, and the Ghostly DNS Cache in Kubernetes

calendar_month July 15, 2026 |
Quick Summary: Battling sporadic Node.js ENOTFOUND errors in Alpine Kubernetes pods? Uncover the obscure musl libc DNS caching flaw and implement a robust fix. E...

The Phantom ENOTFOUND: Node.js, Alpine, and the Ghostly DNS Cache in Kubernetes

Alright, listen up. If you've spent more than a week in SRE, you know the absolute hell of DNS issues. They're insidious, transient, and nearly impossible to reproduce consistently. This one? This one is a special kind of nightmare, surfacing only under specific conditions in Kubernetes with Node.js on Alpine Linux. You'll be tearing your hair out, blaming the network, blaming Kubernetes, blaming your developers, until you finally pinpoint the true, obscure culprit: musl libc's DNS resolver and its peculiar interaction with Node.js.

Tangled wires in a server rack
Visual representation

The Problem: Sporadic ENOTFOUND After Service Restarts

Picture this: You have a Node.js microservice (let's call it api-consumer) running in Kubernetes. It needs to talk to another internal Kubernetes service (data-provider). Everything's fine, humming along. Then data-provider gets redeployed – a routine update. Suddenly, api-consumer starts throwing getaddrinfo ENOTFOUND data-provider errors. Not every request, mind you, just sporadically. And it only lasts for a few minutes, then magically resolves itself. Rinse, repeat.

Your dashboards are red, alerts are firing, and your boss is asking why 'basic service discovery' is failing. You check the Pod IPs, the Service definitions, kubectl exec into the container and ping works fine! nslookup works fine! But your Node.js app is screaming ENOTFOUND. Frustrating, right?

The Symptoms You're Seeing

  • Intermittent ENOTFOUND: The errors are not constant; they come and go.
  • Post-Redeploy Trigger: The problem reliably appears shortly after the target service (the one being looked up) is redeployed or its endpoints change.
  • Alpine Specific: This is the crucial differentiator. Your Debian-based Node.js containers? They're laughing, completely unaffected.
  • Self-Resolving: After 30 seconds to a few minutes, the errors stop without any manual intervention.

The Susceptible Environment

This phantom ENOTFOUND is a nasty combination of specific components. Don't waste time looking elsewhere if you're not in this ballpark.

Component Version/Type Notes
Operating System Alpine Linux (3.12 - 3.19) Docker images like node:lts-alpine, node:20-alpine
Node.js 14.x, 16.x, 18.x, 20.x Affects various LTS and current versions.
libc Implementation musl libc The core difference from glibc-based systems (Debian, Ubuntu, CentOS).
Orchestration Kubernetes (1.18+) Any standard Kubernetes cluster using CoreDNS.

The Root Cause

Here's the deal: musl libc's DNS resolver is simpler and more strict than glibc's, and Node.js's dns.lookup function, backed by libuv, doesn't always handle this elegantly. When a Kubernetes service's underlying Pods change (e.g., during a redeploy), CoreDNS quickly updates its records. Kubernetes then pushes a new resolv.conf to your Pods, which includes updated DNS server information or search domains. A typical glibc system, especially with an active nscd or similar caching daemon, would handle these updates and refresh its cache fairly aggressively, picking up the new entries or invalidating stale ones. However, musl libc, by design, has a very minimal resolver with a more aggressive negative caching strategy and a less sophisticated mechanism for expiring cached positive entries, especially for internal, short-TTL records. This isn't the same as the EAI_AGAIN ghost, but it's another reminder that Node.js's interaction with libc DNS can be... finicky. Node.js's internal libuv layer, which performs the actual getaddrinfo calls, can sometimes hold onto stale or negatively cached responses from musl for longer than expected, particularly when dealing with qualified vs. unqualified domain names in search paths.

A complex diagram of network packets flowing through a series of filters and caches
Visual representation

The Step-by-Step Fix: Prioritize resolv.conf Verbatim

The core of the problem lies in how libuv (and thus Node.js) interprets DNS resolution order and caching with musl. We need to force Node.js to strictly adhere to the search paths and DNS server order specified in /etc/resolv.conf, and to do so without relying on musl's potentially problematic internal caching behavior for getaddrinfo. Essentially, we make it re-evaluate every time, like a high-frequency trading system needs to re-evaluate market data, but for DNS.

This isn't about setting NODE_DNS_SERVER to bypass resolv.conf entirely, which has its own issues. It's about how libuv (the underlying asynchronous I/O library Node.js uses) processes name lookups.

The Solution: The NODE_DNS_ORDER Environment Variable

Add the following environment variable to your Node.js application's Kubernetes Deployment or Dockerfile:


NODE_DNS_ORDER=VERBATIM

Let's break that down:

  1. In Kubernetes Deployment Manifest:
    Add this under env for your container:
    
            containers:
              - name: api-consumer
                image: node:20-alpine
                env:
                  - name: NODE_DNS_ORDER
                    value: "VERBATIM"
            
  2. In Dockerfile (if you build your own custom image):
    Add before your CMD or ENTRYPOINT:
    
            FROM node:20-alpine
            ENV NODE_DNS_ORDER=VERBATIM
            WORKDIR /app
            COPY package*.json ./
            RUN npm install
            COPY . .
            CMD ["node", "src/index.js"]
            

Explanation

By default, Node.js (via libuv) uses a heuristic approach for DNS lookups. It tries to resolve the name as provided, then potentially appends search domains from resolv.conf. The problem is that with musl's peculiar caching, this heuristic can lead to ENOTFOUND if the unqualified name hits a negatively cached entry before the search domains are properly applied and re-queried against a fresh CoreDNS entry.

Setting NODE_DNS_ORDER=VERBATIM changes this behavior. It forces Node.js to pass the name directly to the underlying OS getaddrinfo function (which is musl's in Alpine) and process the results in the order returned. More importantly, it ensures that if the system's resolv.conf specifies search domains, these are applied strictly by getaddrinfo itself, forcing a full resolution attempt including search path expansion and a fresh query to CoreDNS, bypassing any stale internal musl negative cache entries that might have lingered for the unqualified name.

Testing Your Fix

After applying this change, redeploy your api-consumer service. Then, trigger a redeploy of data-provider. Monitor your logs and metrics for api-consumer. You should see a dramatic reduction, if not complete elimination, of the sporadic ENOTFOUND errors that previously plagued your system after the target service restarts. The resolution should be immediate and consistent.

Final Thoughts

This issue highlights the subtle, often infuriating differences between glibc and musl libc in a containerized, highly dynamic environment like Kubernetes. While Alpine offers smaller image sizes, these kinds of obscure runtime behaviors can make you question the trade-offs. Always be aware of your base image's libc implementation when debugging network-related issues in Node.js. Sometimes, a tiny environment variable change is all it takes to slay a dragon.

Read Next