业务全链路灰度发布-方案再优化

683 阅读7分钟

作者:李成熙(heyli),现500强央企数字化技术负责人,前端架构师。2014年加入AlloyTeam,先后负责过QQ群、花样直播等业务; 2019年加入腾讯云云开发团队;同年加入Shopee,担任金融商家业务前端负责人; 2020年-2022年回归腾讯文档,单人文字品类技术负责人

优化背景

书接《业务全链路灰度发布的设计落地与思考》,该文中的前端方案主要采用了 Nginx + OpenResty 的技术,让容器内存在多版本的前端静态资源。但在实践环境中遇到版本扩张的问题。由于受取于打包速度、容器体积等因此,不太好在该方案下提供更多版静态资源的支持,因此该方案后续要扩展为支持测试环境的多泳道是不太可行的。基于此考虑,为了后续同时能支持生产环境多桶发布以及测试环境的多泳道,我们决定对方案进行调整。

后台方案的思路启发

由于K8S和容器部署方式的普及,现在越来越多的公司也是使用这套方式部署后台服务,但部署的方式往往只是一个服务使用一个Deployment (工作负载)。后续我们在引入了灰度能力后,一个服务的一个版本就使用一个Deployment。参考后台的做法,其实前端也可以采用类似的方案,这样就可以突破上面提到的发布多版本的构建性能慢和容器体积膨胀问题。

从新的方案出发,我们要解决的主要是以下两个问题:

  1. 提供部署镜像,可以在部署静态资源的同时,具备在 Nacos 服务发布中心注册服务的能力。
  2. 提供网关镜像,具备服务发现、基于灰度规则转发流量、遇到不健康实例迅速切流等能力。

新前端部署镜像

新前端部署的镜像,主要解决两个问题,第一是Nacos 进行服务的注册,第二是定时向 Nacos 发送服务心跳,告诉 Nacos 服务很健康。考虑到性能的问题,我们依然是使用 OpenResty + Lua 脚本完成该逻辑。如果团队内对此技术栈道不熟悉,使用 Node.js 实现也未尝不可,但使用 Node.js 做静态资源的分发按过往的经验性能并不太好,这种情况强烈建议使用 Node.js 方案的业务一定要将非 html 的资源全部上 CDN。

首先在 nginx.confinit_by_lua_block 生命周期中,进行服务的注册,然后在 init_worker_by_lua_block 生命周期中注册定时程序,定期向 Nacos 发送心跳保活。

# nginx.conf
http {
    # 其它配置

    init_worker_by_lua_block {
        # Nacos 定时心跳
        require("lua/timer").run();

        prometheus = require("prometheus").init("prometheus_metrics")
        metric_requests = prometheus:counter("nginx_http_requests_total", "Number of HTTP requests", {"host", "status"})
        metric_latency = prometheus:histogram("nginx_http_request_duration_seconds", "HTTP request latency", {"host"})
        metric_connections = prometheus:gauge("nginx_http_connections", "Number of HTTP connections", {"state"})
    }

    init_by_lua_block {
        # Nacos服务注册
        require("lua/entry").register();
    }

    # 其它配置
}

以下是核心逻辑的源码,包括 Nacos 服务注册、Nacos 定时心跳、Nacos 定时器。

-- entry.lua
local _service = require("lua/service")

local _entry = {}
-- 注册实例
function _entry.register()
    _service.register()
end


return _entry
-- service.lua
local socket = require("socket")
local _util = require("lua/util")
local _cjson = require("cjson")
local NacosServiceDiscovery = require("api.NacosServiceDiscovery")

local releaseVersion = os.getenv("RELEASE_VER")
local default = os.getenv("IS_DEFAULT")

local config = _util.getConfig()

local _service = {}


-- Nacos 服务注册
function _service.register()
    if default == nil then
        default = 0
    end

    local ip = unpack(_util.getIp(socket.dns.gethostname()))
    local registerReturn = NacosServiceDiscovery.registerInstance(config.nacos_domain, ip, config.port, config.nacos_namespace_id, nil, nil,
        nil, _util.urlEncode(_cjson.encode({
            -- 注册服务版本
            version = releaseVersion,
            -- 服务是否默认版本
            default = default,
        })), nil, config.serviceName, config.nacos_group, config.nacos_ephemeral)
    _util.print(registerReturn)
    return  registerReturn
end

-- Nacso 服务心跳
function _service.beat(queryServiceDetail)
    local serviceDetail = _cjson.decode(queryServiceDetail)
    local beatParam = {
        serviceName = config.nacos_group .. '@@' .. config.serviceName,
        ip = serviceDetail.ip,
        port = serviceDetail.port,
        clusterName = serviceDetail.clusterName,
        cluster = serviceDetail.clusterName,
        weight = serviceDetail.weight,  
        metadata = serviceDetail.metadata,
    }
    local beatReturn = NacosServiceDiscovery.sendBeatWithNamespace(config.nacos_domain, config.serviceName, config.nacos_group,
        config.nacos_namespace_id, config.nacos_ephemeral, _util.urlEncode(_cjson.encode(beatParam)))
    _util.print("beatReturn beatReturn: " .. tostring(beatReturn))
end

-- Nacos 服务详情查询
function _service.queryServiceDetail()
    local ip = unpack(_util.getIp(socket.dns.gethostname()))
    local queryServiceDetail = NacosServiceDiscovery.queryDetail(config.nacos_domain, config.serviceName, config.nacos_group,
        ip, config.port, config.nacos_namespace_id, nil, nil, config.nacos_ephemeral)
    print ('queryServiceDetail: ' .. tostring(queryServiceDetail))

    return queryServiceDetail
end


return _service
-- timer.lua
local socket = require("socket")
local _util = require("lua/util")
local _constant = require("lua/constant")
local _service = require("lua/service")
local NacosServiceDiscovery = require("api.NacosServiceDiscovery")

local config = _util.getConfig()
local _timer = {}
local retryTime = 0
local maxRetryTime = 3

function _timer.heartBeat()
    local queryServiceDetail = _service.queryServiceDetail()

    if queryServiceDetail ~= nil then
        _service.beat(queryServiceDetail)
        retryTime = 0
    else
        if retryTime < maxRetryTime then
            retryTime = retryTime + 1
            _util.print("retryTime: " .. retryTime)
            _service.register()
        else
            _util.print("retryTime: " .. retryTime .. '. >= maxRetryTime: ' .. maxRetryTime .. '. stop retry.')
        end
    
    end
end

function _timer.run()
    local new_timer_every = ngx.timer.every
    local action = function(premature)
        if not premature then
            _timer.heartBeat()
            _util.print("timer success")
        else
            _util.print("timer failed")
        end
    end

    -- 用于调试,平时要注释掉
    -- action(false)

    print("worker id" .. ngx.worker.id())
    if 0 == ngx.worker.id() then
        local ok_every = new_timer_every(_constant.API_TIMER_LENGTH, action)
        if not ok_every then
            _util.print("failed to create new_timer_every")
            return
        end
    end
end

return _timer

这里咱们使用的是 nacos-lua-sdk,源码地址:github.com/nacos-group…

FROM openresty/openresty:1.21.4.3-2-alpine-apk

RUN apk add curl busybox perl libc-dev readline readline-dev gcc make wget git \
    && curl https://raw.githubusercontent.com/openresty/opm/master/bin/opm > /usr/local/openresty/bin/opm \
    && chmod +x /usr/local/openresty/bin/opm \
    && opm get knyar/nginx-lua-prometheus

ARG LUA_VER="5.4.6"
ARG LUA_ROCKS_VER="3.10.0"

# install luarocks
RUN cd /tmp \
    && wget https://luarocks.org/releases/luarocks-${LUA_ROCKS_VER}.tar.gz \
    && tar zxf luarocks-${LUA_ROCKS_VER}.tar.gz \
    && cd luarocks-${LUA_ROCKS_VER} \
    && ./configure \
    && make build \
    && make install \
    && cd /tmp \
    && rm -rf /tmp/*

# install luasocket
RUN luarocks install https://raw.githubusercontent.com/nacos-group/nacos-sdk-lua/main/nacos-sdk-lua-dev-1.rockspec \
    && luarocks install luasocket
RUN luarocks install luasocket

COPY lua /usr/local/openresty/lualib/lua
COPY publish.config.json /usr/local/openresty/lualib/lua

COPY nginx.conf /usr/local/openresty/nginx/conf/nginx.conf
COPY default.conf /usr/local/openresty/nginx/conf/conf.d/default.conf

# 将html资源拷贝到灰度与非灰度目录
COPY ./dist /usr/local/openresty/nginx/html

服务编译与部署

在服务编译与部署的时候,需要带上重要的环境变是,分别是版本号 version 以及 是否默认版本服务 default(如果需要明确指定默认版本,务必带上),另外需要保证每一个版本的服务有自己独占的 Deployment以及充足的 Pods。

当部署成功且服务注册成功后,就会在Nacos中看到对应的服务注册信息:

后文提到的前端网关,会从 Nacos 拉取到该列表,先不健康的实例筛走之后,再基于灰度的规则进行流量的转发,找到匹配的静态资源再返回给用户。

前端网关镜像

前端网关在这个方案里作为非常重要的流量入口,它的稳定性非常关键。因此设计的时候除了满足Nacos 服务的寻找、灰度的匹配、流量转发之外,还需要比较快地识别到不健康的以及需要下线的实例,使得流量不要转发到这些实例中。

以下是初始化入口

async function init() {
  // 上报日志到 Kafka
  await initLogger();
  
  // 初始化 Nacos 服务数据
  await initData();
  
  // 初始化代理服务器
  initServer(server); 
  
  // 灰度规则定时更新
  initRefreshGrayConfig();
}

以下是初始化 Nacos 服务数据,并且创建代理服务器实例(但未启动):

// 初始化数据
async function initData() {
  try {
    // 灰度信息初始化
    await fetchGray();
    // Nacos Client 初始化
    await client.ready();
    // 监听 Client 错误
    client.on('error', err => {
      console.error(err.stack);
    });

    // 服务 Nacos 实例注册信息获取
    config.services.map(async (service) => {
      // 初始化服务实例映射
      serviceMap[service.serviceName] = {
        serviceList: {},
      };

      // 监听服务实例变化,有变化就更新
      client.subscribe(service.serviceName, hosts => {
        // 如果有服务实例信息更新,全部重置
        serviceMap[service.serviceName] = {
          serviceList: {},
        };

        Logger('info', hosts);
        // 初始化服务实例映射
        serviceMap[service.serviceName].serviceList = {};
        
        // 1. 剔除不健康实例
        // 2. 版本旧老的排在前面
        let sortedHosts = hosts.filter((host) => host.healthy).sort((a, b) => {
          return b.metadata.version - a.metadata.version;
        });
        
        sortedHosts.map((host, index) => {
          // 服务实例版本号
          const version = host.metadata.version || 0;
          // 不同版本对应的服务实例列表
          if (!serviceMap[service.serviceName].serviceList.hasOwnProperty(version)) {
            serviceMap[service.serviceName].serviceList[version] = [];
          }
          serviceMap[service.serviceName].serviceList[version].push(`http://${host.ip}:${host.port}`);
          
          // 服务是否默认版本,用于兜底
          const isDefault = +host.metadata.default || 0;
          // 默认版本
          if (isDefault) {
            serviceMap[service.serviceName].defaultVersion = version;
          }

          // 如果是最后一个实例,且没有默认版本,则使用最后一个版本作为默认版本,用于兜底
          if (!serviceMap[service.serviceName].defaultVersion && index === sortedHosts.length - 1) {
            serviceMap[service.serviceName].defaultVersion = version;
          }

          // 如果不开启泳道能力,则使用灰度的能力,这时最多只有两个版本
          if (!config.use_lane && !isDefault) {
            serviceMap[service.serviceName].grayVersion = version;
          }
        });

      });
    });

    // 服务器实例初始化
    server = http.createServer((req, res) => {
      const cookie = getCookie(req);
      const urlObject = new URL(`http://127.0.0.1${req.url}`);
      
      // 基于路径匹配,将请求转发到对应的服务实例
      for (let i = 0; i < config.services.length; i++) {
        const service = config.services[i];
        // 默认版本号
        const defaultVersion = serviceMap[service.serviceName].defaultVersion;
        // 灰度版本号
        const grayVersion = serviceMap[service.serviceName].grayVersion;
        // 需要寻找的版本号
        let version = defaultVersion;
        
        // 用于泳道能力
        if (config.use_lane) {
          version = cookie.version || defaultVersion;
        } else {
          // 如果不使用泳道模式,则通过是否灰度来判断版本
          if (isGray(req)) {
            version = grayVersion || defaultVersion;
          } else {
            version = defaultVersion;
          }
        }

        const serviceList = serviceMap[service.serviceName].serviceList[version] || {};
        const serviceListLength = serviceList.length;
        // 命中了就马上转发,因此在写路径配置的时候,希望优先命中的请放前面
        if (urlObject.pathname.indexOf(service.path) === 0 && Array.isArray(serviceList) && serviceListLength > 0) {
          proxyServer.web(req, res, {
            target: serviceList[(Math.floor(Math.random() * serviceListLength))],
          });

          proxyServer.on('error', (err, req, res) => {
            console.error(err.stack);
            res.writeHead(500, {
              'Content-Type': 'text/plain',
            });
            res.end('服务超时,请稍后重试。');
          });
          return;
        }
      }
    });
  } catch (e) {
    Logger('error', e.stack);
  }
}

拿到代理服务器实例后,进行初始化

// 代理服务器初始化
function initServer(server) {
  server && server.listen(3000, () => {
    console.log('Proxy server is running in port 3000')
  });   
}

以下是定时拉取灰度规则

// 更新灰度配置
function initRefreshGrayConfig() {
  setInterval(() => {
    fetchGray();
  }, refreshInterval);
}

以下是灰度匹配的具体逻辑:

const axios = require('axios');
const { getConfig, getCookie } = require('./index');
const { Logger } = require('./logger');

axios.defaults.timeout = 5000;
// 获取配置
const config = getConfig();
// 灰度配置
let grayConfig = null;

async function fetchGray() {
  try {
    const res = await axios.get(`${config.api}`);
    console.log('grayConfig: ', res?.data?.data);
    grayConfig = res?.data?.data;
  } catch (e) {
    console.error(e.stack);
  }
}

function getGrayConfig() {
  return grayConfig || {};
}

function isGray(req) {
  if (!grayConfig) {
    return false;
  }

  const cookie = getCookie(req);
  const { uid: userUid, projectId: userProjectId } = cookie;
  const { uid, project, uidPercent: percent } = grayConfig;

  if (checkUser(userUid, uid, percent) || checkProject(userProjectId, project)) {
    return true;
  }

  return false;
}

function checkProject(userProjectId, projectId) {
  if (!userProjectId || !projectId) {
    return false;
  }

  if (projectId.includes(userProjectId)) {
    return true;
  }

  return false;
}

function checkUser(userUid, uid, userPercent) {
  // 如果配置100%用户命中,包括不带uid和projectId的用户均命中灰度
  if (userPercent && +userPercent === 100) {
    return true;
  }

  if (!userUid) {
    return false;
  }

  // 命中指定 uid
  if (uid && uid.includes(userUid)) {
    return true;
  }

  // 命中灰度范围
  if (userPercent && distributeGrayscale(userUid, userPercent)) {
    return true;
  }

  return false;
}

function mapCharsToInt(chars) {
  return chars.charCodeAt(0) * 36 + chars.charCodeAt(1) - '0'.charCodeAt(0);
}

// 分配灰度级别
function distributeGrayscale(userId, uidPercent) {
  // 取用户ID最末2位字符
  const lastTwoChars = userId.slice(-2);

  // 将最末2位字符映射为整数
  const mappedValue = mapCharsToInt(lastTwoChars);

  // 计算用户ID的百分比分布
  const userPercentage = (mappedValue - 1728) / 2738.0 * 100;

  // 判断用户是否进入灰度环境
  if (userPercentage <= uidPercent) {
    // 用户进入灰度环境,这里可以根据需要返回具体的灰度级别
    return 1; // 例如,假设灰度级别为1
  } else {
    // 用户进入默认环境
    return 0; // 例如,假设默认环境的灰度级别为0
  }
}

module.exports = {
  fetchGray,
  getGrayConfig,
  isGray,
  checkProject,
  checkUser,
  distributeGrayscale,
};

其实在使用 Node.js 作为开发前端网关之前,第一选择仍然是 Nginx + OpenResty,这是因为 Node.js 作为静态资源的转发中间层,性能是不如 Nginx 的。之前去调研并试验通过 OpenResty 动态修改 Nginx 的 Upstream 的各种方法,但均以失败告终,暂时还没找到非常好的办法,因此退而求其次采用 Node.js。但如果你的业务对性能的要求比较高,建议除了 html 资源以外的其它静态资源,请使用 CDN 进行加速。

不同服务版本的上线与下线

随着灰度过程的完结,需要将默认版本的服务下掉,这时可以依托 Nacos 下线服务的能力,先将默认版本的服务下线,然后将新版本的服务发布为默认服务,即可完成整个新旧版本切换的过程。

将默认服务下线后,当前没有任何服务是默认的,在前端网关我们有设计一个逻辑,将最近发布的版本作为默认版本,这起到了兜底服务的作用。

泳道能力支持

将整个方案向后台的部署靠拢后,使得支持泳道能力成为可能。每增加一个泳道,相当于新加一个版本、一个Deployment工作负载,各泳道也是独立发布与维护,具体的逻辑在上面的 initData 方法里,也有开启泳道能力与开启灰度能力,两者版本匹配逻辑的差异:前者是直接通过在 Cookie 里带上版本信息进行精准的匹配,而后者则与业务绑定,基于项目 id,用户 id 来作进一步的灰度判定来决定命中默认还是灰度版本的服务。

// 用于泳道能力
if (config.use_lane) {
  version = cookie.version || defaultVersion;
} else {
  // 如果不使用泳道模式,则通过是否灰度来判断版本
  if (isGray(req)) {
    version = grayVersion || defaultVersion;
  } else {
    version = defaultVersion;
  }
}