BFF架构WEB项目的持续交付部署优化与实践-下

 

前面的文章介绍了我主导负责的公司内部研发管理平台(使用人数约为210人)前端项目的技术选型以及架构体系,其采用了BFF架构,有 Node 服务作为中间层以及普遍意义上的 Web 层两个微应用。也介绍了该项目常规情况下的交付部署。

但是常规的交付部署过程繁琐,所以下面的文章介绍了在生产实践中如何便捷持续地交付项目给运维部署升级,基本概念是使用 Docker 镜像以及 Docker Compose 编排镜像,达到一键升级与部署的效果,希望对你有所帮助或启发。

常规交付部署方式的痛点

Nestjs交付部署流程

上面的图是前文的常规交付部署流程,可以看出运维人员要操作的步骤比较多,另外运维人员还需要提前在服务器机器上配置 Node 环境以及 安装 pm2,另外为了实现统一 BFF 层与 Web 层的端口,解决 Web 层访问 BFF 层的跨域问题,还需要运维同事配置 nginx 反向代理 BFF 层的 Node 服务。

那么有没有一个技术方案是解决配置环境、同时还可以解决为解决跨域问题需要配置外部 nginx 反向代理问题的呢,是有的,这个解决方案就是鼎鼎大名的 Docker , Docker 利用计算虚拟化,让使用者可以把自己的应用打包构建成一个 image (镜像), 镜像已经配置好运行应用所必备的环境,每个镜像运行后的实例我们称之为容器,容器之间是环境隔离的,因此镜像是可移植的,保证了在不同的宿主机中运行的效果一致。

因此我们可以利用 Docker 来完成便捷交付镜像给予运维完成部署。

Docker 镜像交付

前面提到该项目分为了 BFF 层,为基于 Nest.js 的 Node 服务, Web 层,基于 Create React App 脚手架 的 web 应用。因此我们可以拆解为两个镜像,一个 React SPA 镜像,一个 Nest.js 镜像。

执行 docker build 命令, Docker 会读取 Dockerfile 文件来自动构建镜像,其中包含了一系列操作基于已存在的镜像(一般是官方的镜像)组装镜像,具体可以参考 Docker 官方参考文档 。 那么下面我们来讲解一下两个镜像如何组装构建,以及其中的诀窍。

先了解一下项目的增加 Docker 构建相关文件后的基本目录结构,有一个基本的认识。

含docker构建文件的代码结构

构建 Web 层镜像

Web 层是一个 React SPA 应用,因此我们使用官方的 nginx 镜像来代理访问其中的 index.html 文件以及 css 、 js 文件。

# web层 create-react-app 镜像打包
# 构建阶段,基于官方 node 镜像
FROM node:16.13.2-buster-slim As builder

# 镜像中创建文件夹
RUN mkdir -p /usr/src/devops-app-web

# 镜像工作目录
WORKDIR /usr/src/devops-app-web
# 复制本地上下文目录下的 package.json 进入镜像
COPY ./package.json ./
# 安装依赖
RUN npm install --production
# 复制本地上下文的所有文件进入镜像
COPY . ./
# 编译
RUN npm run build

# 生产阶段,生成部署镜像,基于官方 nginx 镜像
FROM nginx:1.21.6-alpine

# 复制构建阶段的编译文件夹,以便减小最后镜像的大小
COPY --from=builder /usr/src/devops-app-web/build /usr/share/nginx/html

# 必须重写默认的 nginx 镜像配置文件,不然在非首页的路由页面刷新就会报404错误,原因是 nginx 默认配置没有 try_files , 找不到 spa 的资源直接返回404,不会走默认路由,我们添加 try_files , 当找不到资源时候访问 index.html
COPY ./default.conf /etc/nginx/conf.d/default.conf

# 声明容器开放端口,仅作声明,运维人员可以调整
EXPOSE 80

CMD ["nginx", "-g", "daemon off;"]

构建 Web 层镜像的 Dockerfile 文件内容

基本流程就是分两个编译阶段以及生产阶段,主要是为了减小生产的镜像大小,生产阶段并不需要诸如 node_modules 等文件与文件夹。另外还有一点需要注意的是默认的 nginx 镜像的配置文件并没有配置 try_files,这样会导致我们的 web 应用路由失效,因此我们需要更换镜像中的 nginx 默认配置。

# web docker 镜像中的 nginx 配置
server {
    listen       80;
    listen  [::]:80;
    server_name  localhost;

    root   /usr/share/nginx/html;

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

    error_page   500 502 503 504  /50x.html;
    location = /50x.html {
        root   /usr/share/nginx/html;
    }
}

Web 层镜像的 nginx 配置文件内容

然后我们在项目根目录下执行构建镜像命令 docker build -t web-app:0.0.1 ./frontend/ ,会在本地把 React SPA 应用构建成一个 Docker 镜像。需要注意的是构建命令其中的 ./frontend/ ,这个是告诉构建引擎构建的上下文在 frontend 文件夹,从该文件夹读取 Dockerfile 文件、 .dockerignore 文件等。

构建完成后使用 docker images 可以看到本地已经有构建完成的镜像。

docker构建镜像

可以在本地运行镜像,自测一下是否有问题,执行运行镜像命令 docker run -d -p 3000:80 web-app:0.0.1-d 表示在后台持续运行, -p 3000:80 表示宿主机的3000端口映射容器实例的80端口,我们的 web 镜像开放的是80端口,因此你运行容器后访问 localhost:3000 就是访问容器实例的80端口,即访问 web 应用。

构建 BFF 层镜像

同样是分为构建与生产两个阶段,运行 Nest.js 应用需要有编译后的 dist 文件夹以及依赖的第三方库 node_modules 文件夹,因此我们在生产阶段我们复制构建阶段的这两个文件夹,然后使用 node dist/main.js 运行 Nest.js 应用。

# Bff层 nestjs 镜像打包
# 构建阶段
FROM node:16.13.2-buster-slim As builder

# 镜像中创建文件夹
RUN mkdir -p /usr/src/devops-app

# 镜像工作目录
WORKDIR /usr/src/devops-app

# 复制依赖包信息到镜像中
COPY ./package.json ./

# 因为 Dockerfile 中的每个语句都会创建一个新层。该层被缓存以供将来构建。
RUN npm install --production

# 把当前父目录下的所有文件拷贝到 镜像 的 /usr/src/devops-app/ 目录下
COPY . .

# 构建生成 dist 文件夹
RUN npm run buildBff

# 生产阶段,生成部署镜像
FROM node:16.13.2-buster-slim As production

# 设置环境变量 
ENV NODE_ENV production

# 环境变量把时区设置成中国时区,否则 node 中的 date 会快8小时
ENV TZ=Asia/Shanghai

WORKDIR /usr/src/devops-app

COPY --from=builder /usr/src/devops-app/node_modules ./node_modules

COPY --from=builder /usr/src/devops-app/dist ./dist

# 声明开放端口
EXPOSE 5000

# 使用 node 用户运行 Node 应用,否则会使用 root 用户运行,违背了最小特权原则,容易受到攻击
USER node

CMD ["node", "dist/main"]

构建 BFF 层镜像的 Dockerfile 文件内容

然后我们在项目根目录下执行构建镜像命令 docker build -t bff-app:0.0.1 . ,注意的是构建命令其中的 . ,这个是告诉构建引擎构建的上下文在根文件夹,从根文件夹下读取构建所需的 Dockerfile 文件、 .dockerignore 文件等,不要读取到构建 Web 层镜像的声明文件。

构建完成后使用 docker images 可以看到本地已经有构建完成的镜像。

docker构建镜像2

同样的可以在本地运行镜像,自测一下是否有问题,执行运行镜像命令 docker run --init -d -p 5000:5000 bff-app:0.0.1-d 表示在后台持续运行, -p 5000:5000 表示宿主机的5000端口映射容器实例的5000端口,我们的 Nest.js 运行实例开放的是5000端口,因此你运行容器后访问 localhost:5000 就是访问容器实例的5000端口,即访问 Nest.js 应用。

这里解释一下 --init 参数的意义,使用 Docker 运行实例的时候默认会将 CMD 运行的应用进程设为进程号PID 1,而Linux 内核对 PID 1 是另眼相看的,缺省情况下收到 SIGTERM 或者 SIGINT 信号不会杀死进程。这导致我们的 Node 应用无法收到被关闭的信号,无法处理垃圾进程以及无法在关闭前进行收尾工作,如应该要把对数据库的写操作完成后再退出应用等。所以官方提供了 --init 参数,会调用 Tini 运行在 PID 1,转发信号给其他应用,这样我们的 Node 应用就能收到关闭进程信号作出相对应的操作,可阅读 Docker 关于 –init 的官方参考文档

这里可以给出 Nest.js 应用如何处理 SIGTERM 或者 SIGINT 信号的常规代码,可以参考着修改 main.ts 文件。

import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';

let server: { close: (arg0: (err: any) => void) => void };

async function bootstrap() {
  const app = await NestFactory.create(AppModule, {
    bufferLogs: true,
  });
  // 给所有接口添加前缀 api
  app.setGlobalPrefix('api');
  server = await app.listen(process.env.PORT || 5000);
  // 处理进程终止信号
  process.on('SIGINT', shutdown);
  process.on('SIGTERM', shutdown);
}

function shutdown() {
  // 优雅地关闭所有对该 node 服务还在进行中的 HTTP 连接
  server.close((err) => {
    if (err) {
      console.error('关闭服务时发生错误。预测已关闭');
      console.error(err);
      process.exit(1);
    }
    console.log('Http 服务正常关闭.');

    // 在这里关闭数据连接,例如数据库池连接

    // 清理资源并退出
    process.exit(0);
  });
}

bootstrap();

Docker Compose 编排镜像

构建镜像后可以直接把两个镜像交付给运维部署,但是还记得我们的目的吗,就是便捷持续地交付,因此让运维同事一键部署才是最终目的,另外分别部署 Web 层镜像与 BFF 层镜像会导致两个应用的端口不一致,会出现 Web 层镜像的 React SPA 应用访问 BFF 层镜像的 Nest.js 应用出现跨域问题。

Docker 能成为一个成熟稳健的交付系统,已经有解决这个问题的方案了,那就是使用 Docker Compose 编排一个组成系统的若干个镜像,解决多个容器相互配合来完成某项任务的情况。

它允许用户通过一个单独的 docker-compose.yml 模板文件(YAML 格式)来定义一组相关联的应用容器为一个项目(project)。

# 声明使用到的 Docker Compose 的版本,适配 Docker 版本需要 >= 18.06
version: '3.7'
services:
  bff:
    # 这里写你构建镜像时候的名字,执行这个编排文件时候若执行机器本地没有该镜像,会从相对应的远程仓库拉取
    image: bff-app:0.0.1
    init: true
    # 这里表示暴露该容器在编排容器项目内的端口,可供该项目内的应用访问
    expose:
     - "5000"
  web:
    # 这里写你构建镜像时候的名字,执行这个编排文件时候若执行机器本地没有该镜像,会从相对应的远程仓库拉取
    image: web-app:0.0.1
    # 这里表示宿主机映射端口
    ports:
     - "80:80"

docker-compose.yml 文件内容

Docker Compose 编排文件重点讲解

在有 docker-compose.yml 文件的目录下执行 docker-compose up -d 来根据目录下的docker-compose.yml 文件启动镜像。值得注意的是 docker-compose.yml 文件中的 bff 应用并没有映射宿主机端口,因此外部是无法访问 bff 的 Nest.js 应用的。但我们必须要在外部能访问 bff 的 Nest.js 应用,所以我们在 web 应用中的 nginx 反向代理了 bff 。

这里讲解一下 Docker Compose 中的网络,一个 docker-compose.yml 文件定义了一个项目, Docker 会为在这个项目中的所有容器实例设置一个独立的局域网络,默认会随机为项目中的所有容器实例分配 IP ,我们在项目中的容器实例可以使用其应用名来访问彼此,如在我们的 Docker Compose 中 http://bff:5000 就是 web 容器实例内访问 bff 的 Nest.js 应用的域名。

因此我们修改打包 web 镜像时候的 nginx 配置文件如下:

# web docker 镜像中的 nginx 配置
server {
    listen       80;
    listen  [::]:80;
    server_name  localhost;

    root   /usr/share/nginx/html;

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

    # 新增该反向代理,当接收到后缀为 api 的访问就反向代理到 Nest.js 应用
    location /api/ {
		proxy_pass http://bff:5000;
	}

    error_page   500 502 503 504  /50x.html;
    location = /50x.html {
        root   /usr/share/nginx/html;
    }
}

增加反向代理后的 Web 层镜像的 nginx 配置文件内容

修改后记得重新执行构建命令,打包新镜像, docker build -t web-app:0.0.2 ./frontend/

同步修改 docker-compose.yml 文件中的 web 镜像版本。

修改后执行 docker-compose up -d 就同步跑起来两个应用,也使用了统一的端口访问了。

总结

前面的文章总结了如何使用 Docker 交付 BFF 架构应用,以及如何分别构建镜像,现在的交付流程可以总结如下 docker交付部署流程

注意到有一步是上传镜像到仓库的,一般是私建的 Docker 镜像仓库,不然你的代码可能会泄露。

希望能对你有所启发与帮助,也欢迎交流,感谢。

参考文档

Docker 官方参考文档
Docker Compose 官方参考文档之网络部分
Node 最佳实践之 Docker 最佳实践