likes
comments
collection
share

如何优雅地部署一个 Next.js 应用

作者站长头像
站长
· 阅读数 16

在上一篇文章中,讲述了基于Next.js搭建多端应用。当完成了编码开发,自然而然就会涉及到应用的部署。跟CSR的部署不太一样,Next.js应用的部署需要Node.js 运行时,所以无法使用纯静态资源服务的方式部署。本文将以一个实际项目为例,讲述如何完成一个Next.js应用的部署及优化。

项目背景

该项目是一个PC端仪表盘,展示一些运营指标,部署的平台选用腾讯云TKE。为了做容灾,采用了异地双活部署。并且前端应用同时支持SSR+SSG。

初始部署方案——直接暴露Node服务

这也是社区通行的部署模式,依托云服务,在腾讯云TKE上申请Workload资源,每个Workload下可以部署多个Pod,Pod与Node服务按照1:1的比例进行配置,Workload作为前端服务的的实际承载。 在部署容灾性上,采用了异地双活模式,在A、B地域分别部署有Workload。通过接入CLB实现了异地多活服务的流量分配,在CLB之上就是运营商的域名解析服务了,这里按照运营商的要求配置就行。 由此得到前端服务的整体部署:

如何优雅地部署一个 Next.js 应用

在这种部署模式下出现了两个问题:

  1. Node服务的访问日志记录不够完整,信息不够全
  2. 静态资源的访问也会经过Node服务的转发,对Node服务压力比较大,压测性能不佳

问题1:访问日志记录困难

虽然在网关层可以记录用户的访问日志,但是仍然希望在前端服务层进行日志记录(最重要是记录异常日志)。考虑到Next.js框架的全栈能力,自然而然地决定用 log4js 这样的日志库进行Node服务日志的收集。 下面的代码定义了一个utils,对外抛出一个logger对象,需要记录日志时只需要调用logger.info()logger.error()方法即可。因为同时配置了 stdoutdateFile类型的 appenders,日志会打印在控制台并持久化存储到 /logs 目录下:

import * as log4js from 'log4js';

/**
 * log4js
 * 官方文档:https://log4js-node.github.io/log4js-node/index.html
 */
const LEVELS = {
  trace: log4js.levels.TRACE,
  debug: log4js.levels.DEBUG,
  info: log4js.levels.INFO,
  warn: log4js.levels.WARN,
  error: log4js.levels.ERROR,
  fatal: log4js.levels.FATAL,
};

const LAYOUT_CONSOLE = {
  type: 'pattern',
  pattern: '%[[%p] %d{yyyy/MM/dd-hh.mm.ss}%] at %x{callStack} %n %m',
  tokens: {
    callStack: (event: any) => `${event.fileName} ${event.lineNumber}:${event.columnNumber}`,
  },
};

log4js.configure({
  appenders: {
    console: {
      type: 'stdout',
      layout: LAYOUT_CONSOLE, // 日志内容格式
    },
    info: {
      type: 'dateFile',
      filename: 'logs/INFO.log',
      encoding: 'utf-8',
      // 配置 layout,此处使用自定义模式 pattern
      layout: {
        type: 'pattern',
        pattern: '{"date":"%d{yyyy-MM-dd hh:mm:ss:SSS}%","level":"%p","category":"%c","host":"%h","pid":"%z", "data":\'%m\'}',
      },
      pattern: '-yyyy-MM-dd',
      // 回滚旧的日志文件时,保证以 .log 结尾 (只有在 alwaysIncludePattern 为 false 生效)
      keepFileExt: true,
      // 输出的日志文件名是都始终包含 pattern 日期结尾
      alwaysIncludePattern: true,
    },
    debug: {
      type: 'dateFile',
      filename: 'logs/DEBUG.log',
      encoding: 'utf-8',
      layout: {
        type: 'pattern',
        pattern: '{"date":"%d{yyyy-MM-dd hh:mm:ss:SSS}%","level":"%p","category":"%c","host":"%h","pid":"%z", "data":\'%m\'}',
      },
      pattern: '-yyyy-MM-dd',
      keepFileExt: true,
      alwaysIncludePattern: true,
    },
  },
  categories: {
    // 设置默认的 categories
    default: { appenders: ['console', 'info'], level: 'info', enableCallStack: true },
  },
});

const logger = log4js.getLogger('default');
// 定义日志级别
logger.level = LEVELS.info;

export { logger };

export default logger;

日志记录效果:

  • 控制台直接输出

如何优雅地部署一个 Next.js 应用

  • 记录到日志文件

如何优雅地部署一个 Next.js 应用

虽然通过 log4js 实现了Node服务的运行日志收集,但对于用户访问日志,log4js 有点力不从心,主要的问题有:

  1. 无法自动记录http/https请求日志;
  2. 日志记录对代码有一定侵入性;
  3. 无法记录请求耗时。

在文章的项目背景中讲到应用需要支持SSG,这就造成Next.js的一些特性无法使用,比如:getServerSideProps,这就意味着难以记录到 reqres 对象里的信息。

其实在Next.js 的SSG模式下也提供一种途径获取请求的reqres,那就是 middleware,但遗憾的是,middleware运行环境部署标准的Node.js runtime,而是Next.js自己内置的 Edge Runtime。很多Node.js的API在 Edge Runtime下都不支持。 简单来讲就是像 log4js 这样的日志库在middleware里无法正常运行。所以想在middleware里记录访问日志的路走不通。

问题2:压测性能不佳

这一点很好理解,Node.js因为线程模型的限制,高并发性能不佳,处理高并发请求会出现响应耗时过长的问题。下面是使用 ab 工具的测试结果(以请求某个css资源为例):

并发级别10

ab -n 100 -c 10 http://localhost:3000/_next/static/css/b2d4c37da20f311d.css

如何优雅地部署一个 Next.js 应用

并发级别20

ab -n 100 -c 20 http://localhost:3000/_next/static/css/b2d4c37da20f311d.css

如何优雅地部署一个 Next.js 应用

并发级别50

ab -n 100 -c 50 http://localhost:3000/_next/static/css/b2d4c37da20f311d.css

如何优雅地部署一个 Next.js 应用

通过压测数据可以发现,当并发级别达到50时,响应耗时急剧增加,达到并发级别为20时的3倍以上耗时。

部署优化-增加Nginx代理层

为了解决直接暴露Node服务部署模式的问题,我们决定引入Nginx中间件进行访问日志记录和静态资源代理。

如何优雅地部署一个 Next.js 应用

构建自己的基础镜像

Q: 为什么要构建自己的基础镜像,公共仓库的镜像不香吗?

A:首先dockerhub镜像仓库没有同时支持Nginx+Node.js运行时的基础镜像(有个人上传的镜像,但因为不透明,不太安全)。

其次,在Nginx基础镜像Node.js基础镜像中通过 RUN 指令动态安装缺少的运行时环境有两个不足:

  1. 会增加流水线执行部署耗时且总是会因为公司防火墙的关系造成安装失败(安装Nginx or Node.js会超时)
  2. 增加部署镜像的体积(我们对比 Nginx基础镜像 vs Nginx+Node.js基础镜像最终构建出的部署镜像体积,两者相差20MB

构建基础镜像的步骤基本是按照官方命令来做,这里不在赘述。我们把镜像托管在dockerhub上:

如何优雅地部署一个 Next.js 应用

该基础镜像主要的特性有:

  1. 安装有Nginx,Nginx version: Nginx/1.23.4
  2. 安装有Node.js,v18.14.2
  3. 时区设置为 Asia/Shanghai(北京时间)

更新dockerfile

旧的dockerfile:

# Install dependencies only when needed
FROM Node:alpine AS deps

WORKDIR /app

COPY . .
EXPOSE 80
ENV PORT 80
ENV NODE_TLS_REJECT_UNAUTHORIZED=0

# set the time zone to Zone 8
ENV TZ Asia/Shanghai
RUN ln -fs /usr/share/zoneinfo/${TZ} /etc/localtime 
    && echo ${TZ} > /etc/timezone

CMD ["node_modules/.bin/next", "start"]

上面的dockerfile表明使用 node:alpine基础镜像,然后设置时区为 Asia/Shanghai

在新的部署模式下,因为新的基础镜像已经设置的时区,所以无需再重复设置时区为东八区。而在启动命令中则需要同时启动 Nginx 和 Next 服务,新的dockerfile如下:

新的dockerfile如下:

# 使用自定义镜像
FROM xxx/xxx/nginx-node:latest AS deps

WORKDIR /app

COPY ./nginx.conf /etc/nginx/conf.d/default.conf
COPY ./nginx_default.conf /etc/nginx/nginx.conf
RUN rm -rf ./nginx.conf ./nginx_default.conf
COPY . .

EXPOSE 80

ENV NODE_TLS_REJECT_UNAUTHORIZED=0

# start docker
CMD ["sh", "-c", "node_modules/.bin/next start & nginx -g 'daemon off;'"]

配置nginx.conf

这里分成两个Nginx配置文件,其一用来配置Nginx日志格式,并设置日志阀,对于不感兴趣的请求不记录日志;其二用来配置静态资源代理,主要是代理 /_next/static 路径和 / 路径下的静态资源请求:

nginx_default.conf

nginx_default.conf是在/etc/nginx/nginx.conf基础上进行修改后用来替换/etc/nginx/nginx.conf的配置文件:

user  nginx;
worker_processes  auto;

error_log  /var/log/nginx/error.log notice;
pid        /var/run/nginx.pid;

events {
    worker_connections  2048;
}

worker_rlimit_nofile 10000;

http {
    include       /etc/nginx/mime.types;
    default_type  application/octet-stream;

    map $request_method $loggable {
        HEAD 0;
        OPTIONS 0;
        default 1; 
    }

    log_format  main  '$remote_addr - $remote_user [$time_local] "$request" '
                    '$status $body_bytes_sent "$http_referer" '
                    '"$http_user_agent" "$http_x_forwarded_for" '
                    '"$proxy_host" "$upstream_addr" '
                    'request_time=$request_time '
                    'upstream_connect_time=$upstream_connect_time '
                    'upstream_header_time=$upstream_header_time '
                    'upstream_response_time=$upstream_response_time ';

    access_log  /var/log/nginx/access.log  main if=$loggable;

    sendfile        on;
    #tcp_nopush     on;

    keepalive_timeout  65;

    gzip  on;

    include /etc/nginx/conf.d/*.conf;
}

修改点主要有:

  1. 设置$loggable条件,对于 HEAD, OPTIONS请求不记录日志
  2. 设置日志格式,增加request_timeupstream_connect_timeupstream_header_timeupstream_response_time时间数据,关于这四个时间的含义可以看下图:

如何优雅地部署一个 Next.js 应用

nginx.conf

nginx.conf是用来替换 /etc/nginx/conf.d/default.conf,主要是配置具体的代理策略。

server {
    listen 80;
    server_name next-app;
    root /app;
    index .next/server/pages/index.html; 
    autoindex off;

    # nginx代理static目录,减小对node服务的压力
    location ~* /_next/static/.*(js|css|png|jpg|jpeg|svg|gif|ico)$ {
        rewrite /_next/(.*) /.next/$1 break;
        try_files $uri $uri/;
        expires 7d;
        add_header Cache-Control "public";
        gzip on;
        gzip_types text/plain text/css image/svg+xml image/png application/javascript text/xml application/xml application/xml+rss text/javascript;
    }

    location ~* /assets/.*(png|jpg|jpeg|svg|gif)$ {
        root /app/public;
        expires 7d;
        add_header Cache-Control "public";
        gzip on;
        gzip_types text/plain text/css image/svg+xml image/png application/javascript text/xml application/xml application/xml+rss text/javascript;
    }

    location /favicon.ico {
        root /app/public;
        expires 7d;
        add_header Cache-Control "public";
        gzip on;
        gzip_types image/x-icon;
    }

    location / {
        proxy_pass http://localhost:3000;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection 'upgrade';
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $remote_addr;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_cache_bypass $http_upgrade;
    }
}

上面配置的关键是对 /_next/static/路径下静态资源的代理。使其可以不通过Node服务,而直接由Nginx返回。

Q:可以不配置对静态资源的代理吗?

A:仅从可访问性角度,直接将所有请求转发到 http://localhost:3000就可以实现服务的正常访问,但是会出现性能瓶颈,其主要是静态资源请求挤占了Node服务的连接数,造成服务拥塞,进而出现静态资源响应慢的问题:

如何优雅地部署一个 Next.js 应用

从上图可以发现一些静态资源虽然体积很小,但却需要花超过1s的时间响应成功,这是完全不可接受的。

最终效果

增加Nginx层不会影响到CLB、域名解析上的配置,因此可以直接更新部署,部署完后后访问站点可以在 Workload 的日志栏看到Nginx的访问日志:

如何优雅地部署一个 Next.js 应用

并且静态资源经过代理后其访问性能也是得到了进一步提升:

如何优雅地部署一个 Next.js 应用

使用ab工具进行压测:

ab -n 500 -c 50 http://localhost:80/_next/static/css/b2d4c37da20f311d.css

如何优雅地部署一个 Next.js 应用

ab -n 500 -c 100 http://localhost:80/_next/static/css/b2d4c37da20f311d.css

如何优雅地部署一个 Next.js 应用 在并发级别为50的条件下,使用Nginx代理后性能远超直接访问Node服务(性能提升近20倍)。

总结

Next.js应用需要Node.js运行时,也就限定了其不能使用COS静态资源部署模式。docker容器化部署也存在直接暴露Node服务和通过Nginx代理Node服务后再进行暴露两种方式。这两种方式没有绝对的好与不好,只有适用与不适用。

在实际访问中,我们发现了直接暴露Node服务存在的问题:

  1. 日志记录不全
  2. 压测性能不佳

所以才有了增加Nginx层转发的思路。进一步的,借助Nginx的代理能力,不经过Node服务,直接由Nginx代理所有静态资源的访问,可以进一步提高访问性能。

参考