Docker 基础体系 · 第 39/70 篇。示例以现代 Docker Engine、BuildKit 与 Compose 规范为基础,并明确 Linux 容器边界。
Vue 与 React 静态站点镜像:构建、Nginx、缓存、路由和运行时配置
Vue 和 React 项目经过生产构建后,通常不再需要 Node.js 运行时。构建工具把源码、依赖和配置转换为 HTML、CSS、JavaScript、字体和图片等静态文件,运行阶段只需要一个能够返回这些文件的 HTTP 服务器。
因此,一个典型的生产交付链路是:
Vue/React 源码
│
│ npm ci + npm run build
▼
静态构建产物 dist/ 或 build/
│
│ COPY --from=build
▼
Nginx 镜像
│
│ HTTP 请求
▼
浏览器
这里有一个必须先明确的边界:
- Node.js 负责构建,例如执行
npm ci和npm run build。 - Nginx 负责运行,例如返回
index.html、JavaScript 和 CSS。 - 如果项目依赖服务端渲染、Node.js API、Next.js Server Runtime 或 Nuxt Server Runtime,那么它就不是单纯的静态站点,不能直接套用本文的 Nginx-only 镜像。
- 本文示例以 Linux 容器、现代 Docker Engine、BuildKit 和 Compose 规范为基础。Windows 容器的文件系统、路径和进程行为不在本文范围内。
一、静态站点到底构建出了什么
1.1 构建阶段和运行阶段是两个不同系统
以常见的 Vite 项目为例:
npm ci
npm run build
构建工具会完成一系列工作:
- 读取
package.json和锁文件; - 安装依赖;
- 解析 Vue 或 React 组件;
- 编译 JSX、TypeScript、单文件组件等源码;
- 进行 Tree Shaking、压缩和代码分割;
- 生成浏览器可以加载的静态文件。
构建完成后,目录可能类似于:
dist/
├── index.html
└── assets/
├── index-B7f3k2.js
├── index-A91d0c.css
└── logo-2c81e4.svg
React 项目也可能输出:
build/
├── index.html
├── asset-manifest.json
└── static/
├── js/
└── css/
dist 和 build 只是不同工具的默认目录名,不是 Docker 或 Nginx 的特殊约定。真正重要的是:运行阶段只需要把构建产物放入 HTTP 文档根目录。
1.2 Vue 和 React 的差异主要发生在构建工具层
静态部署时,Vue 和 React 的核心机制相同:
浏览器请求文件
→ Nginx 查找文件
→ 返回 HTML/CSS/JavaScript
→ 浏览器执行 JavaScript
→ 前端路由接管后续页面切换
差异通常来自项目工具链:
| 工具链 | 常见环境变量前缀 | 常见输出目录 |
|---|---|---|
| Vite + Vue | VITE_ |
dist/ |
| Vite + React | VITE_ |
dist/ |
| Vue CLI | VUE_APP_ |
dist/ |
| Create React App | REACT_APP_ |
build/ |
这些前缀是构建工具的约定,不是 Vue、React 或 Docker 的统一规范。使用前应检查项目实际配置。
例如,Vite 只会把符合前缀规则的变量暴露给前端代码:
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL;
如果变量名是:
API_BASE_URL=https://api.example.com
它通常不会自动进入浏览器代码。改为:
VITE_API_BASE_URL=https://api.example.com
之后,Vite 才会在构建时替换 import.meta.env.VITE_API_BASE_URL。
1.3 不是所有 React 或 Vue 项目都是静态站点
以下项目可能需要 Node.js 运行时:
- Next.js 未执行静态导出;
- Nuxt 使用服务端渲染;
- 自定义 Node.js 服务端渲染;
- 构建后仍需要服务端读取请求头、Cookie 或数据库;
- 使用 WebSocket、服务端 API 或动态页面生成。
只有当构建结果是可以由 HTTP 服务器直接返回的静态文件时,才适合使用本文的 Nginx 运行阶段。
二、为什么要使用多阶段 Dockerfile
多阶段构建的核心是:构建环境和运行环境使用不同的镜像阶段。
构建阶段需要:
- Node.js;
- npm、pnpm 或 yarn;
- 编译器和依赖包;
- 源代码;
- 可能存在的原生模块构建工具。
运行阶段通常只需要:
- Nginx;
- 静态文件;
- Nginx 配置。
一个基础 Dockerfile 如下:
# syntax=docker/dockerfile:1
FROM node:22-bookworm-slim AS build
WORKDIR /app
# 先复制依赖清单,便于复用依赖安装层
COPY package.json package-lock.json ./
RUN npm ci
# 再复制源码
COPY . .
# Vite 项目通常输出 dist/
RUN npm run build
FROM nginx:1.27-alpine AS runtime
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
构建并运行:
docker build -t frontend:local .
docker run --rm -p 8080:80 frontend:local
然后访问:
http://localhost:8080
在 Linux 主机上,也可以检查容器内部的文件:
docker run --rm frontend:local \
find /usr/share/nginx/html -maxdepth 2 -type f -print
预期能看到 index.html 和静态资源。
2.1 每个阶段为什么这样写
FROM node:22-bookworm-slim AS build
这一步选择一个带 Node.js 的 Linux 构建环境,并将阶段命名为 build。AS build 使后续可以通过 COPY --from=build 复制构建结果。
COPY package.json package-lock.json ./
RUN npm ci
先复制依赖清单的原因是 Docker 的层缓存。
如果只修改业务源码:
package.json 未变化
package-lock.json 未变化
那么 npm ci 这一层可以复用。反过来,如果一开始执行:
COPY . .
RUN npm ci
任何源码变化都会使整个 COPY 层失效,之后的依赖安装也需要重新执行。
npm ci 适合 CI 和镜像构建,因为它要求锁文件存在,并按照锁文件安装,避免根据新的 semver 范围重新解析依赖。它通常会删除已有的 node_modules,因此不应把宿主机的 node_modules 复制进构建上下文。
COPY --from=build /app/dist /usr/share/nginx/html
--from=build 只复制构建产物。Node.js、npm、源码、测试文件和开发依赖不会进入最终镜像。
这就是多阶段构建减少运行时内容的根本原因,而不是简单地“把 Node.js 卸载掉”。
2.2 .dockerignore 不是可选的细节
建议至少包含:
node_modules
dist
build
.git
.gitignore
Dockerfile*
docker-compose*.yml
npm-debug.log*
.env
.env.*
coverage
.vscode
.idea
Docker 构建首先会把构建上下文发送给 Docker Engine 或 BuildKit。即使 Dockerfile 最终没有复制某个文件,该文件仍可能影响上下文传输、构建速度和信息暴露。
尤其是:
.env
.env.production
不应因为“最终没有 COPY”就认为安全。更重要的秘密不应进入构建上下文。
2.3 使用 BuildKit 的 npm 缓存挂载
BuildKit 支持在构建步骤中挂载缓存目录。对于 npm,可以写成:
# syntax=docker/dockerfile:1
FROM node:22-bookworm-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci
COPY . .
RUN npm run build
这里的缓存目录不是最终镜像的一部分。它只用于重复构建时减少包下载。
构建:
DOCKER_BUILDKIT=1 docker build -t frontend:local .
现代 Docker Engine 默认通常已经启用 BuildKit,但在 CI 中仍应确认构建器配置,不要假设所有旧环境都具备相同能力。
缓存挂载只能改善下载速度,不能替代锁文件。没有 package-lock.json,依赖解析仍可能漂移。
2.4 镜像标签不能代替供应链固定
示例中的:
FROM node:22-bookworm-slim
FROM nginx:1.27-alpine
是可读性较好的标签,但标签对应的内容可能随时间更新。生产构建如果要求可重复性,可以固定到镜像摘要:
FROM nginx:1.27-alpine@sha256:<经过验证的摘要>
摘要必须从可信的镜像仓库和经过验证的发布流程中获得,不能随意编造。固定摘要提升可重复性,但也意味着基础镜像安全更新不会自动进入构建,需要由依赖更新流程主动变更摘要。
三、构建期配置和运行时配置不是一回事
这是静态前端部署中最容易出现的概念混淆。
3.1 构建期配置的状态变化
假设 Dockerfile 中有:
ARG VITE_API_BASE_URL
ENV VITE_API_BASE_URL=${VITE_API_BASE_URL}
RUN npm run build
构建命令:
docker build \
--build-arg VITE_API_BASE_URL=https://api.example.com \
-t frontend:prod .
构建过程相当于:
VITE_API_BASE_URL
│
│ npm run build 时读取
▼
JavaScript 文件中的字符串常量
│
▼
镜像中的静态文件
构建结束后,浏览器只会看到已经被替换后的字符串。容器启动时再执行:
docker run -e VITE_API_BASE_URL=https://other.example.com frontend:prod
通常不会改变已经生成的 JavaScript,因为构建已经完成,Nginx 也不会重新执行 Vite。
因此,以下两种做法的语义完全不同:
--build-arg 影响构建结果
docker run -e 影响容器启动时的环境
3.2 构建参数不是秘密存储
不要这样传递前端秘密:
docker build \
--build-arg API_SECRET=top-secret \
.
原因包括:
- 前端代码最终会发送给浏览器;
ARG可能出现在构建历史、日志或元数据中;- 即使不直接输出,构建步骤也可能把它写入静态文件;
- Docker 构建参数不等于秘密管理系统。
前端可以公开的配置包括:
- API 公共地址;
- 公共 OAuth Client ID;
- 域名;
- 功能开关。
不能放入前端的内容包括:
- API Secret;
- 数据库密码;
- 私钥;
- 服务端签名密钥;
- 仅应由服务端使用的访问令牌。
3.3 运行时配置的目标
运行时配置希望实现:
同一个前端镜像
├── 容器 A:API_BASE_URL=https://api-a.example.com
└── 容器 B:API_BASE_URL=https://api-b.example.com
这要求配置在容器启动时生成,不能在构建阶段写死。
一种简单方法是让 HTML 加载独立的配置文件:
<script src="/config.js"></script>
<script type="module" src="/assets/index.js"></script>
config.js:
window.__APP_CONFIG__ = {
API_BASE_URL: "https://api.example.com"
};
应用代码读取:
const apiBaseUrl = window.__APP_CONFIG__?.API_BASE_URL;
if (!apiBaseUrl) {
throw new Error("API_BASE_URL is not configured");
}
这里的 window.__APP_CONFIG__ 是浏览器全局变量,不是 Vue 或 React 的内置 API。它只是一种在静态文件和应用初始化之间传递配置的约定。
3.4 一个可运行的运行时配置镜像
目录结构:
.
├── Dockerfile
├── nginx.conf
├── docker-entrypoint.d/
│ └── 40-runtime-config.sh
└── public/
└── config.template.js
public/config.template.js:
window.__APP_CONFIG__ = {
API_BASE_URL: "${API_BASE_URL}"
};
前端构建时,public/ 下的文件通常会被复制到输出目录。构建结果中需要存在:
dist/config.template.js
Dockerfile:
# syntax=docker/dockerfile:1
FROM node:22-bookworm-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci
COPY . .
RUN npm run build
FROM nginx:1.27-alpine AS runtime
RUN apk add --no-cache gettext
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
COPY docker-entrypoint.d/40-runtime-config.sh \
/docker-entrypoint.d/40-runtime-config.sh
RUN chmod 0755 /docker-entrypoint.d/40-runtime-config.sh
EXPOSE 80
脚本:
#!/bin/sh
set -eu
: "${API_BASE_URL:=}"
# 模板内容会被放入 JavaScript 字符串中。
# 拒绝明显可能破坏字符串边界的字符。
case "$API_BASE_URL" in
*\"*|*\'*|*'`'*|*'$('*|*')'*|*';'*)
echo "API_BASE_URL contains unsafe characters" >&2
exit 1
;;
esac
envsubst '${API_BASE_URL}' \
< /usr/share/nginx/html/config.template.js \
> /usr/share/nginx/html/config.js
rm -f /usr/share/nginx/html/config.template.js
这个脚本利用官方 Nginx 镜像常见的 /docker-entrypoint.d/ 启动脚本机制,在 Nginx 主进程启动前生成 config.js。由于使用了 envsubst,镜像中安装了 gettext。
启动:
docker run --rm \
-p 8080:80 \
-e API_BASE_URL=https://api.example.com \
frontend:runtime
验证:
curl http://localhost:8080/config.js
预期结果:
window.__APP_CONFIG__ = {
API_BASE_URL: "https://api.example.com"
};
这个示例只适用于受控配置值。将任意用户输入直接插入 JavaScript 字符串是不安全的。更严格的实现应使用能够正确进行 JSON 编码的配置生成器,并对 URL 的协议、主机名、端口和路径进行校验。
运行时配置本身也不是秘密。config.js 会被浏览器下载,任何访问站点的用户都可以查看它。
四、Nginx 如何处理静态文件和 SPA 路由
4.1 普通静态文件请求
对于请求:
GET /assets/index-B7f3k2.js
Nginx 通常执行:
文档根目录 + URI
/usr/share/nginx/html/assets/index-B7f3k2.js
如果文件存在,直接返回文件;如果不存在,返回 404。
这和应用服务器不同。Nginx 不会理解 Vue 组件、React 组件或前端路由配置,它只理解 HTTP 请求和文件系统。
4.2 什么是 SPA 历史路由
单页应用通常有两种路由模式。
Hash 路由
URL 类似:
https://example.com/#/orders/123
# 后面的部分不会发送给服务器。服务器只收到:
GET /
因此 Nginx 不需要理解前端路由。
History 路由
URL 类似:
https://example.com/orders/123
浏览器第一次直接访问该地址时,会向服务器发送:
GET /orders/123
如果服务器只按文件查找,Nginx 会寻找:
/usr/share/nginx/html/orders/123
该文件通常不存在,于是返回 404。但在前端应用看来,/orders/123 可能是一个合法路由。
所以 History 路由需要服务器做回退:
如果请求对应的静态文件存在 → 返回该文件
否则 → 返回 index.html
这个规则只负责把应用启动起来,真正的 /orders/123 页面判断仍由 Vue Router 或 React Router 完成。
4.3 try_files 的逐步语义
配置:
try_files $uri $uri/ /index.html;
可以理解为:
- 使用当前 URI 查找文件;
- 如果没有文件,尝试查找目录;
- 如果仍不存在,内部回退到
/index.html。
例如请求:
GET /assets/index-B7f3k2.js
如果文件存在,返回 JavaScript。
请求:
GET /orders/123
如果没有对应文件,则返回 index.html。浏览器加载应用后,前端路由读取当前 URL,并渲染订单页面。
4.4 一个适合 SPA 的 Nginx 配置
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# 运行时配置不能被长时间缓存
location = /config.js {
try_files $uri =404;
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
add_header Pragma "no-cache" always;
expires 0;
}
# Vite 默认资源目录。
# 不存在的资源不能回退到 index.html,否则浏览器会收到 HTML。
location ^~ /assets/ {
try_files $uri =404;
add_header Cache-Control "public, max-age=31536000, immutable" always;
}
# 如果 React 项目使用 /static/,可以使用类似规则:
# location ^~ /static/ {
# try_files $uri =404;
# add_header Cache-Control "public, max-age=31536000, immutable" always;
# }
# index.html 不应被长时间缓存
location = /index.html {
add_header Cache-Control "no-cache, must-revalidate" always;
}
# SPA History 路由回退
location / {
try_files $uri $uri/ /index.html;
}
}
这里特意将资源目录和应用路由分开。
如果只写:
location / {
try_files $uri $uri/ /index.html;
}
请求一个拼写错误的 JavaScript 文件,例如:
/assets/index-not-found.js
也可能得到 index.html。浏览器随后会尝试把 HTML 当作 JavaScript 解析,常见错误是:
Refused to execute script ... because its MIME type ('text/html') is not executable
或者出现 Unexpected token '<'。资源目录使用 =404 可以让真正的静态资源错误保持为 404。
4.5 API 请求不能无条件回退到前端
如果同一个 Nginx 同时代理 API,应将 API 路径单独配置:
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
location /api/ {
proxy_pass http://backend:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
try_files $uri $uri/ /index.html;
}
}
proxy_pass http://backend:8080; 的路径重写行为与尾部斜线有关,配置前应明确后端期望收到的 URI。不能只凭“看起来能转发”判断路径一定正确。
如果 /api/users 被错误地回退成 index.html,前端收到的是 HTTP 200 和 HTML,而不是 API 的 JSON 错误。这会让故障表现变得隐蔽,因此 API location 应优先于通用 SPA 回退规则。
五、前端路由、服务器路由和基础路径必须一致
5.1 Vue Router
Vue Router 使用 History 模式时通常类似:
import { createRouter, createWebHistory } from "vue-router";
export const router = createRouter({
history: createWebHistory("/"),
routes: [
{ path: "/", component: () => import("./views/Home.vue") },
{ path: "/orders/:id", component: () => import("./views/Order.vue") }
]
});
对应 Nginx 必须对 /orders/123 回退到 index.html。
如果站点部署在子路径:
https://example.com/portal/
则 Vue Router 的基础路径和构建工具的 base 都应设置为 /portal/,Nginx 也要以 /portal/ 为根处理请求。否则会出现:
- HTML 能打开;
- JavaScript 仍请求
/assets/...; - 实际资源位于
/portal/assets/...; - 浏览器得到 404。
5.2 React Router
React Router 使用浏览器历史 API 时,同样需要服务器回退:
import { BrowserRouter, Routes, Route } from "react-router-dom";
export default function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/orders/:id" element={<Order />} />
</Routes>
</BrowserRouter>
);
}
直接访问 /orders/123 时,Nginx 必须返回 index.html。
如果不希望配置服务器回退,也可以使用:
import { HashRouter } from "react-router-dom";
但 URL 会包含 #,这会影响 URL 规范、服务端日志、搜索引擎处理和某些外部回调场景。Hash 路由不是“更正确”,只是减少了服务器配置要求。
六、缓存:为什么 HTML 和带哈希资源必须区别处理
6.1 内容哈希提供了什么保证
假设资源 URL 使用内容哈希:
/assets/index-B7f3k2.js
可以把 URL 抽象为:
u = name + hash(content)
当文件内容从 C1 变为 C2,且:
hash(C1) != hash(C2)
则:
u1 != u2
因此,只要哈希计算可靠,并且构建不会复用错误内容,就可以对资源设置:
Cache-Control: public, max-age=31536000, immutable
浏览器和 CDN 可以长期缓存旧资源,因为新版本会使用新的 URL。
这不是“所有静态文件都可以永久缓存”。安全条件是:
- 资源文件名确实包含可靠的内容哈希;
- 内容变化必然导致 URL 变化;
- HTML 能及时指向新的资源 URL;
- 部署期间旧版本引用的资源不会过早消失。
6.2 为什么 index.html 不能同样永久缓存
index.html 通常包含当前版本资源的引用:
<script type="module" src="/assets/index-B7f3k2.js"></script>
新版本可能变为:
<script type="module" src="/assets/index-C91a20.js"></script>
如果浏览器永久缓存旧的 index.html,它会继续请求旧资源。旧资源是否存在,取决于部署方式。
常见策略是:
Cache-Control: no-cache, must-revalidate
no-cache 的含义不是“绝对不存储”,而是使用缓存内容前必须向服务器验证是否仍然有效。若希望明确禁止存储,可以使用:
Cache-Control: no-store
两者语义不同:
no-cache:可以缓存,但使用前验证;no-store:不要存储响应。
对于普通 index.html,no-cache 通常足够;对于运行时配置、包含敏感或强实时内容的文件,可以使用 no-store。
6.3 发布竞态:新 HTML 和旧资源的组合
考虑一次发布:
旧版本:
index.html → app-A.js
新版本:
index.html → app-B.js
如果部署动作直接删除旧目录、再复制新目录,可能出现这样的时间窗口:
请求 1:拿到旧 index.html
请求 2:发布删除了 app-A.js
请求 1:浏览器请求 app-A.js,得到 404
所以“资源使用哈希”仍不足以解决所有部署问题。生产系统还需要保证以下条件之一:
- 新旧资源同时保留一段时间;
- CDN 或对象存储保留历史版本资源;
- 使用不可变发布目录,并通过符号链接或上游路由切换;
- 滚动更新期间,所有版本镜像都包含兼容的资源集合;
- 通过原子切换避免半更新目录。
在 Kubernetes、Compose 多副本或 CDN 场景中,多个版本同时服务请求是常态,而不是异常情况。
6.4 使用浏览器验证缓存头
启动容器后:
curl -I http://localhost:8080/
curl -I http://localhost:8080/index.html
curl -I http://localhost:8080/assets/index-B7f3k2.js
curl -I http://localhost:8080/config.js
预期应分别看到近似结果:
HTTP/1.1 200 OK
Cache-Control: no-cache, must-revalidate
资源文件应看到:
HTTP/1.1 200 OK
Cache-Control: public, max-age=31536000, immutable
运行时配置应看到:
HTTP/1.1 200 OK
Cache-Control: no-cache, no-store, must-revalidate
具体响应头可能还包含 Nginx 版本、Content-Type、ETag 等。验证时应关注缓存策略是否符合意图,而不是要求响应头字面上完全一致。
七、用 Compose 区分构建参数和运行环境变量
一个静态站点服务可以这样定义:
services:
web:
build:
context: .
args:
VITE_API_BASE_URL: https://api.example.com
image: example-frontend:local
ports:
- "8080:80"
environment:
API_BASE_URL: https://api.example.com
这段配置同时展示了两种不同机制:
build:
args:
VITE_API_BASE_URL: ...
它只在 docker compose build 时使用。
environment:
API_BASE_URL: ...
它在容器启动时注入,只有运行时配置脚本读取它时才会产生效果。
命令:
docker compose build
docker compose up -d
查看容器状态:
docker compose ps
查看日志:
docker compose logs web
如果运行时配置脚本检测到非法值并退出,Nginx 不会正常启动,日志中应能看到类似:
API_BASE_URL contains unsafe characters
这比让容器启动后返回错误的 config.js 更容易诊断,因为配置错误在启动阶段就被拒绝了。
7.1 Compose 的端口映射不是 Nginx 监听端口改变
配置:
ports:
- "8080:80"
表示:
宿主机 TCP 8080 → 容器 TCP 80
Nginx 仍然监听容器内部的 80 端口。访问地址是:
http://localhost:8080
如果写成:
ports:
- "80:80"
则宿主机的 80 端口必须没有被其他进程占用,并且 Linux 上绑定低端口通常需要相应权限。
八、构建和运行的端到端检查
假设项目是 Vite,并且 npm run build 输出 dist/。
8.1 检查构建产物
先在宿主机直接构建:
npm ci
npm run build
find dist -maxdepth 2 -type f -print
应至少存在:
dist/index.html
如果 Dockerfile 复制的是:
COPY --from=build /app/dist /usr/share/nginx/html
但项目实际输出 build/,镜像构建会失败,或者复制不到预期文件。不要根据框架名称猜输出目录,应以构建工具配置和实际结果为准。
8.2 检查镜像中的入口文件
docker run --rm frontend:local \
test -f /usr/share/nginx/html/index.html
命令成功时没有输出,退出码为 0。检查退出码:
echo $?
如果输出不是 0,说明最终镜像中没有入口文件,可能原因包括:
- 构建输出目录写错;
npm run build没有执行成功;.dockerignore排除了需要的文件;COPY --from路径错误;- 构建产物被后续步骤覆盖。
8.3 检查直接访问和深层链接
docker run -d --name frontend-test -p 8080:80 frontend:local
curl -i http://localhost:8080/
curl -i http://localhost:8080/orders/123
curl -i http://localhost:8080/assets/not-exist.js
预期:
/返回index.html;/orders/123在 History 路由下也返回index.html;/assets/not-exist.js返回 404,而不是返回index.html。
停止并删除测试容器:
docker rm -f frontend-test
8.4 使用浏览器开发者工具定位问题
静态站点故障通常可以从 Network 面板快速分类:
| 表现 | 常见原因 |
|---|---|
/ 是 404 |
Nginx root 或文件复制路径错误 |
| 深层链接刷新 404 | 缺少 SPA fallback |
| JS 返回 HTML | 资源路径错误或资源 location 错误回退 |
| JS/CSS 全部 404 | Vite base、React public path 或部署子路径不一致 |
| 页面正常但 API 失败 | API 地址、代理、CORS 或运行时配置问题 |
| 修改环境变量无效 | 实际使用的是构建期配置 |
| 配置修改后浏览器仍使用旧值 | config.js 被缓存 |
九、运行阶段的进程和信号边界
Nginx 在容器中通常以前台方式运行:
nginx -g "daemon off;"
这样 Nginx 主进程保持在前台,并成为容器中的主服务进程。Docker 向容器发送停止信号时,信号应能到达 Nginx 主进程,使其有机会优雅退出。
不要在生产运行阶段使用:
CMD ["sh", "-c", "nginx -g 'daemon off;'"]
无必要的 shell 包装会增加进程树和信号转发的复杂性。官方 Nginx 镜像已经提供了启动入口,通常会最终执行 Nginx。可以验证:
docker run -d --name frontend-test frontend:local
docker top frontend-test
常见结果会包含 Nginx master 和 worker 进程。docker stop frontend-test 后,再查看:
docker inspect -f '{{.State.Status}} {{.State.ExitCode}}' frontend-test
容器应进入 exited 状态。具体退出码取决于停止方式和镜像行为,不应仅凭一个退出码判断所有问题;关键是确认 Nginx 能响应停止信号而不是被强制杀死。
如果使用运行时配置脚本,应让脚本在完成生成后把控制权交给官方入口或 Nginx,而不是启动一个脱离 Docker 管理的后台进程。脚本的配置校验失败则应直接退出,让容器保持失败状态,便于编排系统重启或报警。
十、权限、安全和文件系统问题
10.1 前端镜像中的“配置”都是公开数据
以下配置即使通过运行时注入,也不是秘密:
window.__APP_CONFIG__ = {
API_BASE_URL: "...",
OAUTH_CLIENT_ID: "..."
};
因为浏览器必须读取它。真正的秘密必须留在后端,并通过后端完成需要授权的操作。
10.2 文件权限和非 root Nginx
示例使用官方 Nginx Alpine 镜像,默认配置在常见场景下可以正常运行。若要求非 root 运行,需要同时处理:
- Nginx 监听端口改为大于 1024;
- PID 文件路径可写;
- 临时目录可写;
- 日志输出到标准输出和标准错误;
- 静态文件和配置文件对运行用户可读;
- 入口脚本不能依赖 root 权限。
不能只把 Dockerfile 加上一行:
USER nginx
然后认为镜像就完成了非 root 改造。Nginx 的默认 PID、缓存和临时文件路径可能仍需要写权限。
10.3 不要把宿主机目录随意挂载到文档根目录
如果运行:
docker run --rm \
-v "$PWD:/usr/share/nginx/html" \
-p 8080:80 \
frontend:local
挂载点会遮蔽镜像中原本的 /usr/share/nginx/html 内容。结果可能是:
- 镜像里已经有构建产物;
- 但容器运行时看到的是宿主机目录;
- 宿主机没有
dist内容; - Nginx 返回 404。
这不是镜像复制失败,而是挂载覆盖了镜像文件系统视图。
10.4 Linux 文件名大小写
Linux 文件系统通常区分大小写:
Logo.svg
logo.svg
是两个不同文件。开发者在大小写不敏感的宿主环境中测试通过,进入 Linux 容器后可能出现资源 404。因此,源码 import 路径、构建产物引用和真实文件名必须严格一致。
十一、发布策略对静态站点的影响
静态站点镜像本身通常是不可变的:
镜像版本 frontend:2025-01-01
镜像版本 frontend:2025-01-02
每个镜像包含一套确定的 HTML 和资源。真正的发布风险主要来自请求在不同版本之间流动:
sequenceDiagram
participant B as 浏览器
participant N as Nginx/CDN
participant V1 as 旧版本
participant V2 as 新版本
B->>N: 请求 /index.html
N->>V1: 路由到旧版本
V1-->>B: index.html 引用 app-A.js
Note over V1,V2: 发布切换
B->>N: 请求 /assets/app-A.js
N->>V2: 可能路由到新版本
V2-->>B: 如果已删除旧资源则 404
解决方案不是简单地“把缓存时间调小”,而是让发布过程满足资源可用性条件:
旧 index.html 引用的旧资源
在发布切换后仍然可访问
常见实现包括:
- 新镜像保留旧资源;
- CDN 保存多个版本目录;
- 以版本目录部署,例如:
/releases/2025-01-01/ /releases/2025-01-02/ /index.html通过原子指针切换到新版本;- 灰度期间同时提供新旧版本的资源集合。
这也是静态站点与“只复制一份最新文件”的简单文件服务器之间的重要区别:浏览器请求不是一个原子事务,HTML 和其依赖资源可能在不同时间、甚至不同节点上返回。
十二、常见失败方式与诊断逻辑
12.1 把 Node.js 放进最终镜像
失败方式:
FROM node:22
COPY . .
RUN npm ci
CMD ["npm", "run", "start"]
这不一定错误。如果项目确实需要 Node.js 服务端运行,它可能是正确方案。但对于纯静态站点,它会带来不必要的内容:
- 开发依赖可能留在镜像中;
- 运行时启动逻辑更复杂;
- HTTP 静态文件服务职责重复;
- 镜像体积和攻击面通常更大。
判断标准不是“Node.js 镜像不好”,而是项目运行阶段是否真的需要 Node.js。
12.2 把容器环境变量当成前端运行时变量
失败方式:
docker run -e API_BASE_URL=https://api.example.com frontend:local
但前端代码实际使用:
import.meta.env.VITE_API_BASE_URL
如果没有启动脚本生成 config.js,容器环境变量不会自动进入浏览器。Docker 环境变量只存在于容器进程环境中,浏览器无法直接读取容器环境。
诊断方法:
docker exec frontend-test env | grep API_BASE_URL
如果能看到变量,只能证明容器收到了变量,不能证明前端 JavaScript 使用了它。还要检查:
curl http://localhost:8080/config.js
以及浏览器加载的 JavaScript 内容。
12.3 深层链接刷新 404
诊断步骤:
curl -i http://localhost:8080/orders/123
如果返回 404,检查:
- 是否使用了 History 路由;
- Nginx 是否加载了预期配置;
try_files是否位于正确的server块;- 容器中的
index.html是否存在; - 是否有更具体的
location提前返回了 404。
可以进入容器查看最终配置:
docker exec frontend-test nginx -T
nginx -T 会输出 Nginx 解析后的完整配置,适合确认实际生效的文件,而不是只检查宿主机上的配置源文件。
12.4 所有请求都返回 200,但页面仍然坏
这经常是通用 SPA fallback 造成的:
location / {
try_files $uri /index.html;
}
对于不存在的资源,Nginx 也返回 200 的 index.html。应将资源目录设置为严格 404:
location ^~ /assets/ {
try_files $uri =404;
}
然后重新请求:
curl -i http://localhost:8080/assets/not-exist.js
如果仍然返回 200,需要检查是否存在更优先的 location、缓存层或上游代理。
12.5 构建失败但本地可以构建
容器构建环境和宿主环境可能不同,常见原因包括:
- 锁文件没有提交;
- 宿主机使用了未声明的全局工具;
- 路径大小写不一致;
- 使用了依赖 postinstall 脚本但容器缺少系统库;
- Node.js 版本不同;
.dockerignore排除了构建必需文件。
应优先比较:
node --version
npm --version
npm ci
npm run build
以及 Dockerfile 中的 Node.js 版本和工作目录,而不是先修改 Nginx 配置。
十三、一个可复用的生产镜像基线
对于纯静态 Vue 或 React 项目,可以将基线压缩为以下结构:
# syntax=docker/dockerfile:1
FROM node:22-bookworm-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci
COPY . .
RUN npm run build
FROM nginx:1.27-alpine AS runtime
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
配套 Nginx:
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location = /index.html {
add_header Cache-Control "no-cache, must-revalidate" always;
}
location ^~ /assets/ {
try_files $uri =404;
add_header Cache-Control "public, max-age=31536000, immutable" always;
}
location / {
try_files $uri $uri/ /index.html;
}
}
构建:
docker build -t frontend:1.0.0 .
运行:
docker run -d \
--name frontend \
--restart unless-stopped \
-p 8080:80 \
frontend:1.0.0
这个基线建立了几个清晰的因果关系:
锁文件
→ 可重复依赖安装
多阶段构建
→ Node.js 只存在于构建阶段
Nginx 文档根目录
→ 静态文件可被 HTTP 返回
SPA fallback
→ History 路由刷新不再 404
资源哈希 + 长缓存
→ 资源可被长期复用
HTML 短缓存
→ 浏览器能较快发现新版本
当项目需要不同环境使用同一个镜像时,再增加运行时 config.js 生成机制;当项目需要服务端渲染或动态 API 时,则应回到 Node.js 应用镜像和服务端进程模型,而不是继续把所有需求强行放进 Nginx 静态站点模型。
系列导航与关联阅读
- 系列入口:Docker 完整学习路线:从镜像与容器到安全、可观测和生产交付
- 上一篇:Node.js 应用 Docker 镜像:依赖、构建产物、PID 1、信号和安全
- 下一篇:Docker Buildx Bake:多目标、矩阵、缓存、平台和 CI 编排
- 延伸:Docker 生产交付体系:CI、灰度、回滚、容量和运行手册
官方资料
本文依据 Docker、OCI 与 CNCF 官方文档重新梳理;正文与生产检查清单由 WR BLOG 编写。

评论
0 条讨论