如何在Nginx配置SSR的降级策略

924 阅读13分钟

前言

  相信大家多多少少也会接触到服务器渲染的项目的业务,在社区中热度比较高当属于:NuxtNext 分别以vuereact两个框架为核心开发,这篇文章不会在如何开发服务器渲染项目的细节,重点是在如何在Nginx配置上,那么话不多说,让我们直接开撸!

如何降级

  完成降级的前提是在当前的服务项目中同时打包出两个包分别是SSR、CSR;在日常投放的过程中实现的SSR渲染这样的好处不言而喻,但是由于Nodejs单线程的模型导致QPS的承载量不会很高,所以在用户量并发比较大的场景中难免会出现宕机或者超时,这个时候就需要判断Node服务器是否健康,然后通过合理化的方式进行服务的降级来支撑整个业务的平稳运行,毕竟服务器的可用性才是考验整个应用的唯一标准

常用的Nginx配置方式

从目前的调研和搜集的资料来看的话,可以分为以下4种方式:

  1. 直接捕获错误code
  2. 使用lua脚本实现拦截
  3. upstream被动检查
  4. upstream主动检查

直接捕获错误code降级策略

  以下就是捕获了当前错误类型进行了CSR的降级,这样的代码策略虽然可以降级,但是大家可以试想一个场景,如果当前的SSR的服务发生了短暂的网络波动,就会触发5xx,那么只要返回error_page对应的错误码 就会降级到SPA的模式,这样的方式虽然可以实现但是灵活性比较低。

upstream static_env {
  server x.x.x.x:port1; //html静态文件服务器
}
upstream nodejs_env {
  server x.x.x.x:port2; //node渲染服务器
}

server {
  listen 80;
  server_name xxx;
  ... // 其他配置
  location ^~ /demo/ {
    proxy_pass http://static_env; //将非ssr目录的请求转发到静态HTML文件服务器
  }
  location ^~ /demo/ssr/ { 
    proxy_pass http://nodejs_env; //将ssr目录的请求转发node渲染服务器
    proxy_intercept_errors on; // 开启拦截响应状态码
    error_page 403 404 408 500 501 502 503 504 = 200 @static_page; // 若响应异常,将这些异常状态码改为200响应,并指向下面的新规则@static_page
  }
  location @static_page {
    rewrite_log on;
    error_log logs/rewrite.log notice;
    rewrite /demo/ssr/(.*)$ /demo/$1 last; // 去掉ssr目录后重新定向地址,将请求Node渲染服务器转发到静态HTML文件服务器
  }

使用lua脚本实现拦截降级策略

  依赖于nginx的三方模块的能力,我们可以在nginx上集成lua的脚本模板,在不同的阶段进行请求状态的拦截,通过init_by_lua_file初始一个lua_shared_dict共享内存地址,实现在nginx请求的各个阶段都能获取当存储的信息状态,通过维护公共的lua_shared_dict在不同阶段进行筛选

init_by_lua_file

初始化lua_shared_dict

-- 记录错误的个数
ngx.shared.nuxt_errors:set("count", 0)
-- 记录每次进入nginx的请求时间
ngx.shared.nuxt_errors:set("nowtime", 0)

access_by_lua_file

每个请求执行当前的生命周期

function intercept()
    local timeout_count = ngx.shared.nuxt_errors:get("count")
    local max_timeout_count = 3

    -- 创建请求前的时间戳  
    -- 获取的时间戳是 以秒为单位,小数点为毫秒
    local now = math.floor(ngx.now())
    ngx.shared.nuxt_errors:set("nowtime", now)

    if(timeout_count >= max_timeout_count) then
        return ngx.redirect("http://www.baidu.com")
    end
end

intercept()

log_by_lua_file

在每个请求完成落入日志前的操作,这个时候可以拿到反向代码的node服务的状态信息:请求头、响应的状态码等


local _M =  {
}
function _M.init()
    local headers = ngx.resp.get_headers()
    local contentType = headers["content-type"] or nil
    local statusCode = tonumber(ngx.var.status)
    local pattern = "text/html"
    local start_time = math.floor(ngx.req.start_time())
    -- 最大超时时间,单位s
    local max_timeout = 2
    local nowtime = ngx.shared.nuxt_errors:get("nowtime")
    local request_time = nowtime - start_time

    if statusCode >= 500 or request_time >= max_timeout then
        -- 为了避免请求多次的资源
        if string.find(contentType, pattern) then 
            ngx.shared.nuxt_errors:incr("count", 1)
        end
    else
        ngx.shared.nuxt_errors:set("count", 0)
    end

end

_M.init()

实现方式


worker_processes  1;

# 配置log
error_log  /usr/local/etc/openresty/logs/error.log;
error_log  /usr/local/etc/openresty/logs/info.log  info;

worker_rlimit_nofile 1024;  # 设置每个Worker进程可以打开的文件描述符的数量上限「本地调试打开哦」

events {
    worker_connections  1024;
}

http {
    include       mime.types;
    default_type  application/octet-stream;
    sendfile        on;
    keepalive_timeout  65;

    lua_shared_dict nuxt_errors 10m;

    # 初始化
    init_by_lua_file /usr/local/etc/openresty/testluas/init.lua;

    # 每次请求前
    access_by_lua_file /usr/local/etc/openresty/testluas/access.lua;

    # 每次请求后,落入日志前
    log_by_lua_file /usr/local/etc/openresty/testluas/log.lua;

    upstream nuxt_upstream {
        server 10.167.233.159:3000;
    }

    server {
        listen       80;
        server_name  api;

         location / {
            proxy_pass http://nuxt_upstream;
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;

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

}

总结

  从上述的代码可以看到我们在Nginx初始化定义了nginx的内存地址,通过在请求结束后获取的上游服务(SSR服务)捕获当前服务的状态,是否超时和http的status状态码进行重定向,同样可以达到降级的效果,不过原生的nginx并不支持需要集成lua-nginx-module的模板插件进行编译,或者使用三方开源的openresty也可以,总起来说有 lua 的学习成本感兴趣的老铁可以尝试哈~

upstream被动检查 降级策略

  关于上述的lua脚本上手比较困难的选手来说,使用nginx自带的upstream的负载轮询是个不错的选择,通过ngnx原生自带的upstream的负载均衡的方式可以被动的检查每个server的状态,然后配置响应的时间和错误码等等, 但是缺点是只能用户访问当前服务才能得到结果,显然在高并发的场景中不适用,不过整体来说可以满足大部分的场景了


worker_processes  1;

# 配置log
error_log  /usr/local/etc/openresty/logs/error.log;
error_log  /usr/local/etc/openresty/logs/info.log  info;

worker_rlimit_nofile 1024;  # 设置每个Worker进程可以打开的文件描述符的数量上限「本地调试打开哦」

events {
    worker_connections  1024;
}

http {
    include       mime.types;
    default_type  application/octet-stream;
    sendfile        on;
    keepalive_timeout  65;

    upstream nuxt_upstream {
        server 10.167.233.159:3000 max_fails=3 fail_timeout=10s;
        server 10.167.189.192:8080 backup;
    }

    server {
        listen       80;
        server_name  api;

         location / {
            
            # 针对一次请求的配置
            proxy_next_upstream error timeout invalid_header http_500 http_502 http_503 http_504;  # 配置响应服务器的错误类型
            proxy_next_upstream_timeout 15s;  # 重试总共的时间
            proxy_next_upstream_tries 5; # 重试几次,包含第一次

            proxy_pass http://nuxt_upstream;
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
            
            # 配置相应服务器的超时时间
            # proxy_connect_timeout 5s; # 连接后台服务器的超时时间
            # proxy_read_timeout 10s; # 从后台服务器读取数据的超时时间
            # proxy_send_timeout 10s; # 向后台服务器发送数据的超时时间
        }

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

}

Tips:以上的配置出现了几个关键字对于不经常配置的伙伴可能会有一些陌生,下面让我简单的说明一下,当然比较熟悉的伙伴直接进入下一部分哈

负载均衡

  以上重点说几个负载常用的关键字、和轮询的规则的算法对于调试整个负载有很大的帮助,有感兴趣的小伙伴可以自行尝试

  1. 轮询分发(默认算法) :即请求依次分发给不同的节点,第一次给第一个节点,第二次给第二个节点,轮流请求,如果不显示声明,将会默认该种算法
  upstream nuxt_upstream {
        server 10.167.233.159:3000;
        server 10.167.189.192:8080;
    }
  1. 按权重分发:按照设置的权重配比,将请求转发给节点,比如权重为1:2,就会将1/3的请求分发给节点1,2/3的请求分发给节点2。该方式适用于服务器配置有显著差别的场景
    upstream nuxt_upstream {
        server 10.167.233.159:3000 weight=8;
        server 10.167.189.192:8080 weight=2;
    }
  1. 源地址ip哈希法分发:根据请求的发起方的ip计算出来的hash值进行分发,这样可以将相同ip的请求分发到固定节点 以此实现会话保持(如:同一客户的session请求到同一个后台服务)、A/B 测试等应用场景、分布式缓存 但如果客户服务器有多个出口ip的话,这种方式就不再适用
    upstream nuxt_upstream {
        ip_hash;
        server 10.167.233.159:3000;
        server 10.167.189.192:8080;
    }
  1. 源地址url哈希法分发:根据其ing求的url的hash值来分发到后端的服务器为不同业务做分布式缓存的场景下比较适用(三方)
 upstream nuxt_upstream {
        url_hash;
        server 10.167.233.159:3000;
        server 10.167.189.192:8080;
    }
  1. 最小连接数法分发:根据后端服务当前的连接情况,动态的选取其中连接数最少的服务器来处理当前请求,简单来说就是哪台服务器处理的越快且资源越空闲就给谁处理。这样的做法是可以尽可能的提高后台服务器的利用率,合理的分配流量给对应的服务器这也是几种负载均衡算法中的动态负载算法
 upstream nuxt_upstream {
        least_conn;
        server 10.167.233.159:3000;
        server 10.167.189.192:8080;
    }
  1. 根据服务器的响应时间来分配请求,响应时间短的优先分配;
 upstream nuxt_upstream {
        fair;;
        server 10.167.233.159:3000;
        server 10.167.189.192:8080;
    }

关键字

max_conns : 限制分配给某台Server处理的最大连接数量,默认为0(不限制),表示超过这个数量后,将不会分配新的连接给它

   upstream nuxt_upstream {
        server 10.167.233.159:3000 max_conns=200;
        server 10.167.189.192:8080 weight=2;
    }

down : 不参与负载均衡

   upstream nuxt_upstream {
        server 10.167.233.159:3000 weight=8 down;
        server 10.167.189.192:8080 weight=2;
    }

backup : 没有负载均衡的机器可以使用的时候

   upstream nuxt_upstream {
        server 10.167.233.159:3000 weight=8 down;
        server 10.167.189.192:8080 weight=2 backup;
    }

upstream主动检查 降级策略

  说完了刚才被动检查的方式,其实目前这个才是主要的,依赖于nginx_upstream_check_module的模块形式进行检查,好处很明显:配置难度低,能够主动检查当前upstream的状态,缺点就是需要集成这个模块,不过对于使用docker的伙伴来说这个显然不是个问题,(楼主就是~)下面主要是这个版本进行讲解

  说起nginx_upstream_check_module这个大家可能不知道,但是Tengine 的话大家应该知道吧,那可是支撑淘宝和天猫千万级并发的nginx开源框架,nginx_upstream_check_module就是Tengine里面比较核心的模块之一,目前具备主动检查上有服务器的配置中社区大概有两个: nginx plus 版本(收费)、Tengine 版(免费开源);很多使用tengine的有很大的原因都是使用自带的nginx_upstream_check_module功能。

Dockerfile

// https://hub.docker.com/r/joshm1/nginx
FROM joshm1/nginx
COPY nginx.conf /etc/nginx/nginx.conf

实现方式

worker_processes  1;
events {
    worker_connections  1024;
}

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

    sendfile        on;
    keepalive_timeout  65;

    upstream nuxt_upstream {

        server 10.167.155.190:3000;
        server 10.167.17.172:8888 backup;

        # https://github.com/yaoweibin/nginx_upstream_check_module
        # interval 每隔3s检查一次
        # rise 请求1次成功标记
        # fall 请求3次失败标记
        # timeout 超时时间
        # type 协议类型
        
        check interval=3000 rise=1 fall=3 timeout=4000 type=http;
        check_http_send "HEAD / HTTP/1.0\r\n\r\n";
        check_http_expect_alive http_2xx http_3xx;
    }

    server {
        listen       8888;
        server_name  localhost;
        location / {
            proxy_pass http://nuxt_upstream;
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;

        }
        location /status {
            check_status;
            access_log  off;
        }

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

完整流程

  以下的配置是根据 NuxtJs2.x的版本进行配置,虽然版本有些老旧,但是其他的版本或者Nextjs都会打包配置,这里不做赘述

目录结构

.
├── Dockerfile
├── README.md
├── app.html
├── components
│   ├── NuxtLogo.vue
│   └── Tutorial.vue
├── config
│   └── abtest.mjs
├── csr-dist   // 打包的CSR目录
│   ├── 200.html
│   ├── _nuxt
│   └── favicon.ico
├── csr-dockerfile  // csr的dockerfile
│   └── Dockerfile
├── ecosystem.config.js
├── middleware
│   └── customRouter.js
├── modules
│   └── customHooks.js
├── nginx.conf
├── nuxt.config.js  // 配置文件
├── package.json
├── pages
│   ├── about
│   ├── grayscale
│   ├── home
│   ├── index.vue
│   └── user
├── plugins
│   └── hooks.js
├── router
│   └── index.js
├── scripts
│   └── server.js
├── serverMiddleware
│   └── cuoInfo.js
├── ssr-dockerfile // SSR的dockerfile
│   └── Dockerfile
├── static
│   └── favicon.ico
├── store
│   └── README.md
├── utils
│   ├── ABtests
│   └── index.mjs
└── yarn.lock
└── .nuxt  // 打包的ssr 默认即可

配置文件

// package.json
{
  "name": "ssr-demo",
  "version": "1.0.0",
  "private": true,
  "scripts": {
    ...
    "build-all": "nuxt build --spa &&  nuxt build",  // 全部打包
  },
  "dependencies": {
    "axios": "0.24.0",
    "cookie": "^0.6.0",
    "core-js": "^3.25.3",
    "express": "^4.18.2",
    "nuxt": "^2.15.8",
    "vue": "^2.7.10",
    "vue-server-renderer": "^2.7.10",
    "vue-template-compiler": "^2.7.10"
  },
  "devDependencies": {
    "@nuxtjs/router": "^1.7.0"
  }
}
// nuxt.config.js
export default {
  ... 
  generate: {
    dir: "csr-dist",  // 打包的CSR目录
  },

  server: {
    port: 3000, // default: 3000
    host: "0.0.0.0", // default: localhost
  },
  hooks: {},
};

构建流程(CICD)

整体流程

下图充分展示出了整个从上线打包到降级的流程

基于以上的前提是前端在本地build出两个包:SSR、CSR上传到执行的服务地址中

  1. 部署一个Nginx(降级服务)
  2. 通过Docker方式集成 nginx_upstream_check_module
  3. 配置upstream 服务器组,设置backup为CSR服务
  4. 通过监听使用 nginx_upstream_check_module 监听整个upstream抛出接口状态
  5. 启动另外一个服务器进行监听upstream的服务器状态
  6. 检测失败状态下发通知

飞书通知

  目前楼主采用的是在启动一个服务器Node来定时扫描当前的服务是否宕机,其实在推荐的方式中 可以通过当前配置的status进行接口调用:http://localhost:18888/status?format=json&status=down 拿到当前服务器是否宕机,下方是伪代码:

目录文件

.
├── Dockerfile // docker镜像地址
├── index.js // cron
├── node_modules
├── package.json
├── storage.json   // 模拟mysql写入操作
└── yarn.lock

index.js

const axios = require("axios");
const path = require("path");
const fs = require("fs");
const { CronJob } = require("cron");

// 参考配置:https://github.com/yaoweibin/nginx_upstream_check_module
const ssrUrl = "http://localhost:18888/status?format=json&status=down";
const fsurl = `https://飞书通知机器人`;

const failmsg = "服务器报错,正在降级CSR模式!";
const successMsg = "服务器已经恢复!正在访问SSR模式";

const datafile = path.resolve(__dirname, "./storage.json");

// 写文件
const writeFileSyncJson = (file, data) => {
  try {
    fs.writeFileSync(file, JSON.stringify(data, null, 2), "utf8");
  } catch (error) {
    throw Error(`写入 ${file} 文件报错:\n ${error}`);
  }
};

// 读文件
const readFileSyncJson = (path) => {
  try {
    return JSON.parse(fs.readFileSync(path, "utf8"));
  } catch (error) {
    return null;
  }
};

//   飞书通知
const sendNotification = async (url, title) => {
  try {
    const userData = {
      msg_type: "post",
      content: {
        post: {
          zh_cn: {
            //   title: "SSR服务器已经恢复,已经切换到SSR",
            title,
            content: [
              [
                {
                  tag: "text",
                  text: "请相关同学周知!",
                },
                {
                  tag: "at",
                  user_id: "all",
                },
              ],
            ],
          },
        },
      },
    };
    await axios.post(url, userData);
  } catch (error) {
    console.log("下发通知失败", error);
  }
};

//   请求地址
const getServerPage = async (url) => {
  try {
    const { data = {} } = await axios.get(url);
    const result = readFileSyncJson(datafile);

    // 如果返回报错的的serverlist、那么就不是对象的形式
    if (data && typeof data === "string" && data.indexOf("down") !== -1) {
      if (result && result.ssrmode) return;
      await sendNotification(fsurl, failmsg);
      writeFileSyncJson(datafile, { ssrmode: true });
    } else {
      if (result && result.ssrmode) {
        await sendNotification(fsurl, successMsg);
        writeFileSyncJson(datafile, {});
      }
    }
  } catch (error) {
    console.log(`接口参数报错:`, error);
  }
};

// 每5秒钟进行巡查一次
const job = new CronJob("*/5 * * * * *", function () {
  const d = new Date();
  console.log("正在加载定时器:", d);
  getServerPage(ssrUrl);
});
job.start();

通过每次通知手动的写入文件来模拟写库的操作,多频次通知一次的效果

Dockerfile

FROM node:18-alpine // 根据业务选择版本

WORKDIR /home/work/alarm-push

COPY . ./

CMD ["/usr/local/bin/node", "/home/work/alarm-push/index.js"]

如何宕机

  其实在很多的公司都有测开的同学,可以找他们对服务器进行压测,前提是申请一个“巨小”的服务,当然如果没有的话也可以本地启动一个for循环直接干,或者更加优雅的自己压测,比如社区比较火的autocannon,具体是自己怎么方便怎么来,达到目的即可。

最后

  好了,跟大家聊了这么多,我相信上面的方案一定不是最优解,但是如果能帮助大家在日常开发中解决业务的难题,那么真是荣幸之至,如果大家有更好的方案的话欢迎在下方留言,欢迎大家点赞收藏哈~,下期见~