CI/CD时代的前端工程部署实践

大家好,我是老纪。

本文简要总结下我们的前端工程部署实践。

前端类型

前端工程通常分为两种:

  1. 纯前端应用:
    • 这种类型的前端应用在客户端(通常是浏览器)上运行,但仍可能需要与服务器进行数据交互,例如通过API获取数据或与后端进行通信。
    • 构建产物通常是由 HTML、CSS 和 JavaScript 组成的静态文件,可以直接部署到任何支持静态文件托管的服务器或云服务上。
    • 典型的纯前端应用包括静态网站、SPA(单页应用)、移动应用的前端部分等。
  2. 需要服务端渲染(SSR)的应用:
    • 这种类型的前端应用需要与服务器进行实时交互,并且可能需要在服务器上进行一些数据处理或模板渲染,然后将结果返回给客户端。
    • 构建产物通常包括前端代码以及服务器端代码,前端代码用于与用户交互,服务器端代码用于处理请求、生成页面内容等。
    • 典型的需要服务端渲染的应用包括MPA(多页应用)、部分渲染的应用(如Next.jsNuxt.js等框架支持的应用)、需要SEO优化的应用等。

对我们而言,大部分前端工程都是SPA,仅有官网相关的项目,涉及到SEO优化,选择了SSR框架(如Node.jsNext.jsNuxt.jsDenoFresh)等。

部署方式

传统的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容器即可,目前通用的静态资源服务器有NginxApache等,我们以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这一步里用到的产物,也是从前端步骤得来的:

与纯前端镜像稍微有些不同的是,除了buildnpm 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.jsondevDependencies的包都移除掉,因为这些是生产环境不需要的:

这也要求开发者务必养成良好的习惯,安装依赖包时想清楚应该添加到哪里。

配置文件

纯前端项目

对于纯前端项目,每个工程需要对应修改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开发中,灵活的部署方式至关重要。我们采用了DockerKubernetes技术,实现了前端工程的容器化部署,为项目提供了弹性伸缩和高可用性的保障。

在镜像制作和配置优化方面,我们对纯前端镜像和SSR镜像进行了详细的讨论和示例展示。同时,我们也深入探讨了容器化部署可能出现的问题,并提出了解决方案,如白屏问题的处理和版本更新的通知策略。

希望这些经验能给读者朋友们提供一些参考和启示。

相关推荐
Fan_web15 分钟前
jQuery——事件委托
开发语言·前端·javascript·css·jquery
安冬的码畜日常16 分钟前
【CSS in Depth 2 精译_044】第七章 响应式设计概述
前端·css·css3·html5·响应式设计·响应式
福大大架构师每日一题1 小时前
22.1 k8s不同role级别的服务发现
容器·kubernetes·服务发现
莹雨潇潇1 小时前
Docker 快速入门(Ubuntu版)
java·前端·docker·容器
Jiaberrr1 小时前
Element UI教程:如何将Radio单选框的圆框改为方框
前端·javascript·vue.js·ui·elementui
weixin_453965002 小时前
[单master节点k8s部署]30.ceph分布式存储(一)
分布式·ceph·kubernetes
weixin_453965002 小时前
[单master节点k8s部署]32.ceph分布式存储(三)
分布式·ceph·kubernetes
Tiffany_Ho2 小时前
【TypeScript】知识点梳理(三)
前端·typescript
tangdou3690986552 小时前
1分钟搞懂K8S中的NodeSelector
云原生·容器·kubernetes
安冬的码畜日常3 小时前
【D3.js in Action 3 精译_029】3.5 给 D3 条形图加注图表标签(上)
开发语言·前端·javascript·信息可视化·数据可视化·d3.js