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 cinpm 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

构建工具会完成一系列工作:

  1. 读取 package.json 和锁文件;
  2. 安装依赖;
  3. 解析 Vue 或 React 组件;
  4. 编译 JSX、TypeScript、单文件组件等源码;
  5. 进行 Tree Shaking、压缩和代码分割;
  6. 生成浏览器可以加载的静态文件。

构建完成后,目录可能类似于:

dist/
├── index.html
└── assets/
    ├── index-B7f3k2.js
    ├── index-A91d0c.css
    └── logo-2c81e4.svg

React 项目也可能输出:

build/
├── index.html
├── asset-manifest.json
└── static/
    ├── js/
    └── css/

distbuild 只是不同工具的默认目录名,不是 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 构建环境,并将阶段命名为 buildAS 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;

可以理解为:

  1. 使用当前 URI 查找文件;
  2. 如果没有文件,尝试查找目录;
  3. 如果仍不存在,内部回退到 /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。

这不是“所有静态文件都可以永久缓存”。安全条件是:

  1. 资源文件名确实包含可靠的内容哈希;
  2. 内容变化必然导致 URL 变化;
  3. HTML 能及时指向新的资源 URL;
  4. 部署期间旧版本引用的资源不会过早消失。

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.htmlno-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-TypeETag 等。验证时应关注缓存策略是否符合意图,而不是要求响应头字面上完全一致。


七、用 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 引用的旧资源
    在发布切换后仍然可访问

常见实现包括:

  1. 新镜像保留旧资源;
  2. CDN 保存多个版本目录;
  3. 以版本目录部署,例如:
    /releases/2025-01-01/
    /releases/2025-01-02/
    
  4. /index.html 通过原子指针切换到新版本;
  5. 灰度期间同时提供新旧版本的资源集合。

这也是静态站点与“只复制一份最新文件”的简单文件服务器之间的重要区别:浏览器请求不是一个原子事务,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,检查:

  1. 是否使用了 History 路由;
  2. Nginx 是否加载了预期配置;
  3. try_files 是否位于正确的 server 块;
  4. 容器中的 index.html 是否存在;
  5. 是否有更具体的 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、OCI 与 CNCF 官方文档重新梳理;正文与生产检查清单由 WR BLOG 编写。