某个傍晚,开发环境一切正常。pnpm dev 跑得丝滑,作品数据从 API 流畅加载,页面骨架屏完整渲染。
Docker 构建。启动。页面空白。
数据库里有 8 条正常数据,后端 API 接口正常可用,但首页的"作品展示"板块就像被施了沉默魔法------既没有报错,也没有数据,只有一片骨架屏在风中飘荡。
最诡异的是:这个 bug 只在 Docker 生产构建下复现,本地开发模式完全正常。
环境
- 前端: Next.js 15 + TypeScript,
next build产出standalone构建 - 后端: Spring Boot 3.5.10,运行在独立容器中
- 网络: Docker Compose bridge network,前端通过容器名
api-service:8080访问后端 - 基础镜像:
node:22-alpine - 开发环境: Windows 宿主机,
pnpm dev
现象梳理
| 维度 | pnpm dev (开发模式) | Docker 生产构建 |
|---|---|---|
| 运行环境 | Windows 宿主机 | Alpine Linux 容器 |
| libc | glibc | musl |
| DNS 解析 | 宿主机 DNS / localhost | Docker DNS (127.0.0.11) |
| webpack 打包 | 即时编译,模块不打包 | next build 将 SSR 代码打包成 bundle |
| 网络拓扑 | 前后端都在宿主机 | 前后端分离容器,bridge 网络通信 |
这三个维度的差异,任何一个单独看都不致命,但组合在一起,就制造了一个只有在 Docker 生产构建下才能触发的问题。
剥洋葱:三层问题嵌套
第一层:Alpine musl 的 DNS 并发问题
Alpine Linux 使用 musl libc 替代 glibc。musl 的 getaddrinfo() 实现远不如 glibc 成熟。在与 Docker 内置 DNS 服务器(127.0.0.11)配合时,并发 DNS 请求 会频繁返回 EAI_AGAIN(临时解析失败)。
而 SSR(Server-Side Rendering)阶段,Next.js 在服务器端渲染页面时,多个组件可能同时向后端发起请求------这正是 musl 最不擅长的高并发场景。
第二层:webpack 打包将环境变量"焊死"
这是最隐蔽的问题。
Next.js 的 next build 在生产构建时,webpack 会通过 DefinePlugin 替换模块级的 process.env.X 引用:
typescript
// 源代码(你写的)
const BACKEND_HOST = process.env.BACKEND_HOST || 'localhost'
// 构建后的 bundle 中(webpack 替换后的)
const BACKEND_HOST = 'api-service' // 被内联成了构建时的字面量
这意味着即使容器启动后修改了 process.env.BACKEND_HOST,Bundle 内部的代码也看不到这些修改 ------因为它的 process.env 引用已经被编译掉了。
entrypoint 脚本通过 --require 预加载的方式修改 process.env,对 bundle 内的代码完全无效。
第三层:Server Component 没有"重试"机制
Next.js 的 Server Component 是 async 函数,在 SSR 阶段执行一次。如果期间 HTTP 请求因 DNS 失败:
typescript
async function ItemsSection() {
// 这里请求失败 → 抛异常
const data = await api.getItems()
return <Items data={data} />
}
- 异常被 catch → 组件不渲染数据
- 返回的 HTML 中没有作品数据
- 浏览器端也不重试------Server Component 只在 SSR 阶段执行一次
三层叠加的最终效果:
| musl DNS 并发失败 | + | webpack 内联 env | + | SSR 无重试 |
|---|---|---|---|---|
| 请求失败 | 修复无效 | 页面永久空白 |
修复思路:五层防御纵深
既然问题有三层,修复也必须有多层。
第一层(主防线):SSR 完全不发 HTTP 请求
将数据获取从 SSR 阶段移到浏览器端。新建 Client Component:
tsx
'use client'
export default function ItemsSection() {
const [items, setItems] = useState([])
const [loading, setLoading] = useState(true)
useEffect(() => {
// 数据请求在浏览器端发起
// 浏览器的 DNS 栈不受 Alpine musl 影响
itemService.getAll()
.then(res => setItems(res.data || []))
.finally(() => setLoading(false))
}, [])
if (loading) return <ItemsSkeleton />
return <Items items={items} />
}
原理: 浏览器有自己完整的 DNS 解析栈,根本不用 musl 的 getaddrinfo。
第二层:自定义 DNS 解析器
如果 SSR 场景仍然需要发起 HTTP 请求(比如其他页面),在 axios 实例中使用自定义 http.Agent:
typescript
if (typeof window === 'undefined') {
const http = require('http')
const dns = require('dns')
agent = new http.Agent({
lookup: (hostname, opts, cb) => {
// 使用 dns.resolve4(c-ares)替代 dns.lookup(getaddrinfo)
dns.resolve4(hostname, (err, addresses) => {
cb(err, addresses?.[0] || '', 4)
})
}
})
}
原理: dns.resolve4() 使用 Node.js 内部的 c-ares 库,完全不经过 libc 的 getaddrinfo。
第三层:动态读取环境变量
将模块级常量改为函数,每次请求时重新读取:
typescript
// ❌ 旧代码:模块级常量 → webpack 会内联
const API_BASE_URL = `${protocol}://${host}:${port}${API_PREFIX}`
// ✅ 新代码:函数级读取 → 每次调用都重新读 process.env
export function getApiBaseUrl(): string {
const host = process.env.BACKEND_HOST || 'localhost'
const port = process.env.BACKEND_PORT || '8080'
return `http://${host}:${port}/api/v1`
}
第四层:请求拦截器设置 baseURL
typescript
// axios 拦截器中动态设置,而不是在 create 时固定
client.interceptors.request.use((config) => {
config.baseURL = getApiBaseUrl()
return config
})
第五层:容器启动时预解析 IP
entrypoint 脚本在启动 Next.js 之前,将 hostname 解析为 IP 地址:
bash
# 尝试多种方法解析 backend hostname
RESOLVED_IP=$(getent hosts "$BACKEND_HOST" | awk '{print $1}')
# 或
RESOLVED_IP=$(node -e "dns.resolve4('$BACKEND_HOST',(e,a)=>process.stdout.write(a?.[0]||''))")
# 写入 preload 脚本
printf 'process.env.BACKEND_HOST = "%s";' "$RESOLVED_IP" > /app/preload.js
# 通过 --require 在 bundle 加载前注入
exec node --require /app/preload.js server.js
--require 在 Node.js 进程启动时最先执行,在 require 任何 bundle 之前就设置了正确的环境变量。这比等 bundle 加载后再修改要早得多,可以覆盖那些即使被 webpack 内联了 env 引用的代码路径。
技术启示
1. 容器镜像选型的隐性成本
node:22-alpine 比 node:22-slim 小 200MB 左右,但 musl libc 的兼容性差异可能在特定场景下引爆。对于网络密集型的 Node.js 应用,slim(基于 Debian/glibc)可能是更安全的选择。
2. webpack 的 DefinePlugin 是把双刃剑
production build 时自动替换 process.env.NODE_ENV 是标准做法,但它也会将模块级引用的 process.env.X 全部内联。如果你的代码依赖运行时动态设置的环境变量,请务必使用函数级读取。
3. SSR 架构中的网络假设
Next.js Server Component 的设计假设 SSR 环境和后端之间有稳定的网络连接。当这个假设不成立(如容器内 DNS 问题),数据获取失败不会自动降级------这就是为什么我们需要 Client Component 作为兜底。
4. 防御纵深(Defense in Depth)
这个 bug 的最佳解法不是纠结 DNS,而是让 SSR 根本不做 HTTP 请求。其余的改动(自定义 Agent、动态 env、entrypoint 预解析)都是多一层保险------任何一个生效都可以解决问题,但全部加在一起才能覆盖所有边界情况。
最后
这个 bug 花了一个多小时定位,但修复代码只写了几百行。事后回顾,问题没那么复杂------只是在错误的层层叠加下,指向了一个意想不到的方向。
有趣的是,如果任意一层修复先被应用,开发者在遇到这个 bug 之前就会因为它被解决了而不再深究。这就是多层防御的意义。
写于 2026-06-12,Docker Alpine DNS 问题修复之后