Skip to main content

Docker 部署前端应用

前端容器化的关键不是「能跑起来」,而是镜像够小、构建可复现、运行时配置可注入。

一、构建与运行分离​

第一个阶段用完整的 Node 镜像装依赖并构建,第二个阶段只把 dist 拷进一个精简镜像。收益不只是体积小——构建镜像里有源码、node_modules、缓存与调试工具,把它们留在生产镜像里等于扩大攻击面,别人拿到镜像就能读到你的源码和构建缓存。

FROM node:24-alpine AS build
WORKDIR /app
COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfile
COPY . . && RUN yarn build

FROM nginx:1.31-alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf

写法上要注意先拷贝依赖清单再安装:package.json / yarn.lock 没变时,这一层能命中缓存,改一行业务代码不用重装依赖。把 COPY . .(源码)放在 RUN yarn install 之后,正是为了让依赖层尽量靠前、命中率最高。生产镜像不该有源码与调试工具,这是多阶段构建存在的理由。

build 阶段base:node:24-alpinedeps:先拷 package.json 装依赖src:拷源码(改代码只失效这一层)run build → 产出 distrun 阶段nginx:stable-alpine只拷 dist,不带走源码与依赖镜像从上百 MB 降到几十 MB补 .dockerignore、非 root、HEALTHCHECKCOPY --from=build
图:多阶段构建——构建工具留在 build 阶段,最终镜像只装产物

二、镜像选型与体积​

运行时镜像常选 nginx:alpine 或 distroless 这类精简镜像,体积与安全面都更小,但「越小越好」是误区。alpine 用的是 musl libc,部分原生依赖(尤其是带预编译二进制的 npm 包)可能因 glibc / musl 差异在运行时报找不到符号;alpine 也可能缺 CA 证书,导致 HTTPS 请求失败。选基础镜像要看实际依赖,而不是只看体积数字。

另一个铁律是固定版本号。截至 2026 年 10 月,Node 24 是当前活跃 LTS、Node 26 于当月进入 LTS,主流做法是用 nginx:1.31-alpine、node:24-alpine 这种带明确版本号的 tag,而不是 latest。latest 让构建不可复现——同一份 Dockerfile 今天和一个月后构建出来的镜像可能完全不同,出了问题无法回放。

三、让依赖层命中缓存​

镜像由层叠加而成,每一层是否命中缓存取决于它和它之前的所有层有没有变化。所以层顺序直接决定构建速度:COPY 依赖清单并 install 放前面,源码放后面,源码频繁变动也不会让依赖层失效。反过来,如果把整个目录先 COPY 进去再装依赖,任何源码改动都会让依赖层重装。

不要把 node_modules 打进最终镜像:运行时只需要静态产物(dist),node_modules 只在构建阶段用。把它留在最终镜像里只会白白增加体积——前面多阶段构建的 COPY --from=build /app/dist 已经天然做到了这一点。改一行代码不该重装依赖,也该避免把依赖带进运行时。

四、配置怎么注入​

这里有个几乎必踩的坑:客户端代码里的环境变量是构建期内联进去的,镜像构建完再改环境变量没有任何效果。想在运行时切换接口地址,只能走另外两条路——把配置写进一个不被打包的 JS 文件由页面动态加载,或者在容器启动时用脚本替换产物里的占位符。

// 方案 A:页面动态加载一个不被打包的 config.js
// public/config.js(构建期原样拷贝,运行时可由 ConfigMap / 启动脚本生成)
window.__APP_CONFIG__ = { apiBase: 'https://api.example.com' }

// 业务代码读取运行时配置,而非写死的构建期常量
const apiBase = window.__APP_CONFIG__?.apiBase ?? ''

方案 B 是入口脚本替换:构建时把 API_BASE 写成占位符,容器启动时用 sed 或 Node 脚本把占位符替换成环境变量值再启动 Nginx。两条路都解决同一件事——配置不该被烤进不可变的镜像里。另外,密钥绝不能打进前端镜像:前端产物任何人都能下载,秘密只能走后端或运行时注入到服务端配置。构建完再改环境变量没有效果,这是前端容器化最常被忽略的一点。

单页应用的路由回退就这几行,漏了就是刷新 404:

location / {
try_files $uri $uri/ /index.html;
}

五、静态托管与回退​

SPA 的核心是 try_files:未命中的路由都回退到 index.html,否则刷新子路由会 404。带 hash 的文件名(main.ab12cd.js)内容不可变,可以一年强缓存;而 index.html 必须 no-cache,保证发版后用户能拿到新入口。

server {
root /usr/share/nginx/html;
# SPA:未命中静态资源时回退到 index.html
location / {
try_files $uri $uri/ /index.html;
}
# 带 hash 的文件名长期缓存,HTML 不缓存
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
location = /index.html {
add_header Cache-Control "no-cache";
}
gzip on;
}

再补上压缩(gzip / brotli)与安全响应头(X-Content-Type-Options、X-Frame-Options 之类),这一套四件套基本够用。反向代理 /api 到后端、配好 TLS,就是一个能上线的静态服务配置。

缓存策略要分开设:带哈希的资源可以长期缓存,入口文件不行:

# 带内容哈希的产物:一年不变
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
}

# 入口文件每次都要校验,否则发版后用户拿到的还是旧 index
location = /index.html {
add_header Cache-Control "no-cache";
}

六、上线前要补的几件事​

镜像不是越小越好。alpine 这类精简镜像可能缺 CA 证书,或遇到 musl 兼容问题,要按实际依赖选。

latest 标签方便,但它让构建不可复现,应该固定版本。

构建完再注入环境变量也是常见误解——客户端代码是构建期内联的,这条路走不通,要另想办法(运行时配置接口或构建期多产物)。

容器化部署最容易翻车的一处,是以为环境变量能在运行时注入客户端代码——它不能。

单页应用的路由回退最终落在 nginx 配置上,指令含义查 ngx_http_core_module 最准。