大家好,我是老纪。
本文简要总结下我们的前端工程部署实践。
前端类型
前端工程通常分为两种:
- 纯前端应用:
- 这种类型的前端应用在客户端(通常是浏览器)上运行,但仍可能需要与服务器进行数据交互,例如通过API获取数据或与后端进行通信。
- 构建产物通常是由 HTML、CSS 和 JavaScript 组成的静态文件,可以直接部署到任何支持静态文件托管的服务器或云服务上。
- 典型的纯前端应用包括静态网站、
SPA
(单页应用)、移动应用的前端部分等。
- 需要服务端渲染(
SSR
)的应用:- 这种类型的前端应用需要与服务器进行实时交互,并且可能需要在服务器上进行一些数据处理或模板渲染,然后将结果返回给客户端。
- 构建产物通常包括前端代码以及服务器端代码,前端代码用于与用户交互,服务器端代码用于处理请求、生成页面内容等。
- 典型的需要服务端渲染的应用包括
MPA
(多页应用)、部分渲染的应用(如Next.js
或Nuxt.js
等框架支持的应用)、需要SEO优化的应用等。
对我们而言,大部分前端工程都是SPA
,仅有官网相关的项目,涉及到SEO优化,选择了SSR
框架(如Node.js
的Next.js
或Nuxt.js
、Deno
的Fresh
)等。
部署方式
传统的Java Web项目在早期以传统交付方式为主,这意味着前端和后端的代码在部署时会被打包成一个WAR(Web Application Archive)文件,然后部署到Java Web容器中(如Tomcat、Jetty等)。这种方式的优点是部署简单,只需将WAR文件放入指定目录即可,但缺点也很明显,比如前后端代码耦合度高、部署不够灵活等。
随着前端技术的发展和前后端分离的趋势,现代的Web项目采用了更为灵活的部署方式,例如使用前端构建工具(如Webpack、Gulp、Vite等)来构建前端代码,并将前端代码和后端代码分别部署到不同的服务器或服务上。
我们团队使用Docker
将前端产物打包成一个容器,然后通过容器编排工具K8S(Kubernetes
)进行部署和管理,实现弹性伸缩和高可用性,比如我们通常会部署3个节点。
由于我司内部使用GitLab进行代码管理,所以CI/CD自然而然地使用了GitLab自带的GitLab CI来实现自动化构建、测试、部署。
对GitLab CI有兴趣的同学,可以参考我之前的两篇文章《持续集成之.gitlab-ci.yml篇(上)》、《持续集成之.gitlab-ci.yml篇(下)》。
制作镜像
纯前端镜像
纯前端项目的部署非常简单,它只需要一个Web容器即可,目前通用的静态资源服务器有Nginx
、Apache
等,我们以Nginx
为主。
我们选用一个Nginx
镜像,以下是个Dockerfile
示例:
dockerfile
FROM nginx:brotli
COPY ./ /usr/share/nginx/html
EXPOSE 80
nginx:brotli
是我们重新编译过的一个带有br
压缩模块的镜像。如果不用br
压缩,也可以考虑其它alpine
的镜像。
比如完整版本的镜像体积可能有60M+:
而alpine
的则是15M+,更小的甚至是5M左右:
纯前端工程,只需要将HTML等前端产物复制到/usr/share/nginx/html
这个目录下,这个镜像就算制作完成了。
我们的Dockerfile
之所以如此简单,是因为通常将构建步骤(npm run build
)拆分到GitLab CI的前置任务中:
在当前步骤(docker),只需要从缓存中拉取到构建的产物就可以了。
如果没有前置步骤,Dockerfile
里就要考虑二级构建,以下是个示例:
dockerfile
# 第一阶段:构建阶段
FROM node:18 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
# 第二阶段:运行阶段
FROM nginx:1.19.2-alpine
COPY --from=builder /app/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
SSR镜像
服务端渲染的应用,就只能选择对应的服务器语言的镜像,它与只提供REST API的纯服务器的镜像没有区别。
以Node.js
的项目为例,它的镜像大体是这样的:
dockerfile
FROM node:18.16.0-alpine
ADD ./ /app/
WORKDIR /app
ENV NODE_ENV production
ENV HOST 0.0.0.0
EXPOSE 80
CMD ["node", "dist/src/main.js"]
它在docker
这一步里用到的产物,也是从前端步骤得来的:
与纯前端镜像稍微有些不同的是,除了build
(npm run build
)外,还有一步是移除不必要的node_modules
文件,目的是为了减少镜像体积。其实就是执行了以下脚本:
bash
if [ "$NPM_TYPE" = "yarn" ]; then
yarn install --production;
elif [ "$NPM_TYPE" = "pnpm" ]; then
pnpm install --production;
else
npm install --omit=dev --legacy-peer-deps;
fi
该脚本将package.json
中devDependencies
的包都移除掉,因为这些是生产环境不需要的:
这也要求开发者务必养成良好的习惯,安装依赖包时想清楚应该添加到哪里。
配置文件
纯前端项目
对于纯前端项目,每个工程需要对应修改Nginx
的配置文件。
try_files
最常见的是使用了history
路由的SPA项目,需要在nginx.conf
添加一句try_files
:
diff
location / {
root /usr/share/nginx/html;
+ try_files $uri $uri/ /index.html;
}
这段代码的意思是,当用户请求资源时,会到/usr/share/nginx/html
下查找,首先尝试使用请求的URI($uri
)作为文件路径,如果找不到文件,那么尝试将URI作为目录路径($uri/
),如果还是找不到,那么最后尝试返回/index.html
文件。
比如:/a.html
会优先响应/usr/share/nginx/html/a.html
,但如果没有找到,则响应/usr/share/nginx/html/index.html
。
这个配置是history
路由所必需的。
静态资源缓存
对于hash后的静态资源(JS、CSS)文件,我们完全可以设置为强缓存为一年甚至更长:
nginx
location ~* \.(?:css|js)$ {
root /usr/share/nginx/html;
try_files $uri =404;
expires 1y;
access_log off;
add_header Cache-Control "public";
}
需要注意的是,这条配置需要加到上面的location /
之上。原因是为了避免这些静态资源被响应为index.html
页面。
API
只要不是纯静态的Web页面,必然要与后端进行交互,如果不想让后端配置CORS
跨域,则通常要在Nginx
里配置API
代理:
nginx
server {
listen 80;
location /api/ {
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_pass http://server-svc/api/;
}
}
这里的server-svc
是K8S中的Service名称,对应到具体的后端服务器的容器。
通常来说,你在开发阶段使用Node.js
配置了多少代理,这里就得添加多少个API
。
SSR项目
对于SSR项目,推荐使用YAML作为后端的配置文件,仍是在K8S的deployment.yaml
中配置为具体的数据库连接、端口号等信息:
以下是一段挂载/app/config.yaml
为上面配置的示例:
yaml
spec:
replicas: 1
selector:
matchLabels:
app: web
template:
metadata:
labels:
app: web
spec:
containers:
- name: web
image: xxx/spacex/xx-web:7.0.9
ports:
- containerPort: 80
volumeMounts:
- name: config
mountPath: /app/config.yaml
subPath: server.yaml
白屏问题
使用容器化部署,一个最大的坑是白屏问题。其复现的条件为SPA项目,使用了路由懒加载,用户在旧页面停留时点击路由跳转,这时请求到旧路由的hash资源(JS或CSS,此时已不存在了),导致代码错误。我推荐的方案是在代码中捕获该错误,重新刷新页面。详情可参见我以前的文章《SPA项目频繁上线导致白屏?轻松帮你搞定!》。
这又引出一个常见的优化思路,我们的网站版本更新后,要不要通知用户,怎么通知用户刷新页面?
自动化使得我们的部署变得频繁,我常推荐我的团队在HTML注入相应的package.json
的版本号,以便确认线上环境的版本,因为容器可能部署失败,也可能回滚,也可能Web页面的上层有CDN缓存,问题可能会很复杂。
而在考虑网站版本更新的问题后,我意识到处理可以更简单些。我在docker
构建的步骤中,统一注入了一个环境变量VERSION
,也就是当前CI
流水线的tag
标签,那么在Dockerfile
里添加一行代码,写入一个携带版本号的文件:
dockerfile
RUN echo $VERSION > /usr/share/nginx/html/version.txt
前端定时请求/version.txt
,就能判断是否要通知用户刷新页面了。这种方式,是上述白屏解决方案的一个积极的补充。
总结
本文我们深入探讨了前端工程部署的实践经验。在现代Web开发中,灵活的部署方式至关重要。我们采用了Docker
和Kubernetes
技术,实现了前端工程的容器化部署,为项目提供了弹性伸缩和高可用性的保障。
在镜像制作和配置优化方面,我们对纯前端镜像和SSR镜像进行了详细的讨论和示例展示。同时,我们也深入探讨了容器化部署可能出现的问题,并提出了解决方案,如白屏问题的处理和版本更新的通知策略。
希望这些经验能给读者朋友们提供一些参考和启示。