Openresty动态更新(无reload)TCP Upstream的原理和实现

什么是Openresty

引用官网的介绍,OpenResty®是基于NGINX和LuaJIT的动态Web平台。通俗的讲Openresty项目是一个开源且成熟的Web负载均衡器。集成了Nginx的内核并进行了加强,集成了LuaJIT以及一系列Lua库。最关键的是在其生态中具有较多的Nginx模块扩展和外部依赖项。使得开发者对Nginx这块老牌又强大的负载均衡器的使用和扩展都变得更加简单。

更多的Openresty介绍这里就不再累述,有兴趣查看官网即可:https://openresty.org/cn/

什么是动态更新TCP Upstream

使用过Nginx的开发者应该都知道,Nginx对TCP通信的反向代理的支持通过Stream模块支持,如果你自行编译Nginx,从1.9.0版本开始可以添加--with-stream参数使Nginx支持Stream模块,当然,发行版Nginx和今天讲的Openresty都是默认编译支持的。使用时配置可能如下:

stream {
    upstream backend {
        hash $remote_addr consistent;

        server backend1.example.com:12345 weight=5;
        server 127.0.0.1:12345            max_fails=3 fail_timeout=30s;
        server unix:/tmp/backend3;
    }
    upstream dns {
       server 192.168.0.1:53535;
       server dns.example.com:53;
    }
    server {
        listen 12345;
        proxy_connect_timeout 1s;
        proxy_timeout 3s;
        proxy_pass backend;
    }
    server {
        listen 127.0.0.1:53 udp reuseport;
        proxy_timeout 20s;
        proxy_pass dns;
    }
}   

在这个配置中添加了两个Server和其对应的Upstream,Stream模块同时支持TCP协议和UDP协议,上诉的配置中,对于127.0.0.1:53监听的Server就使用的UDP协议,这一段配置处于 nginx主配置文件中。我们需要使其生效时有两种方式:

  • 重启Opnresty或Nginx,当然这种方式仅适用于测试阶段和首次配置阶段。
  • 执行nginx -t 检测一次,配置正常后 nginx -s reload 使其生效。

这里所说的 nginx -s reload 的方式是否就是我们今天要讲的动态更新呢?我们首先来分析一下这个命令执行后nginx会做什么,大致工作流程如下:

  1. 执行命令实际上是发送了HUP signal的信号给nginx主进程,这里需要说明的是nginx的工作方式是多进程模式,一个主进程,负责调度工作进程和处理配置信息等。多个工作进程(worker)负责处理请求。

进程之间的通信方式有两个,进程信号和共享内存

  1. 主进程首先会检测新配置的语法有效性。
  2. 配置有效时开始分配新的工作进程,并通知老的工作进程优雅退出。
  3. 工作进程优雅退出是指不再接受新的请求,等待当前所有的请求处理完成后进程退出。

这个过程看起来是实现了配置的热更改,及不需要重新启动整个nginx服务。但是不可否认的是这个过程依然涉及到工作进程的新建和关闭。看起来优雅的过程实际上在大流量请求时,或频繁的进行更新时这种切换负担依然很重。很显然,这并不是我们今天聊的动态更新效果。我们想要实现的效果是可以不做任何进程变化的基础上将部分配置(这里是指Upstream的配置)动态进行更新并应用,我们试想能否直接修改内存中的数据,且使所有的工作进程同步生效呢?

我们为什么需要做动态更新

当前云原生运行环境已经被大量企业所接受,特别是Kubernetes平台,各类PaaS平台。容器化运行我们的云原生应用已经成为标配。容器与过去的物理机、虚拟机部署应用有一个最大的一个不同就是其”可变化性“,比如在Kubernetes平台,其网络地址(IP)可能变化,运行宿主机可以变化,运行实例数量可以动态变化。这种变化就带来了今天我们分享的话题,如果我们使用nginx作为前置的负载均衡器。我们可能需要频繁的修改配置文件中的upstream部分,从而频繁的reload。首先从操作方式上我们当然很容易做到自动化,自动写入配置文件,自动执行reload。然而回顾上面我们讲的内容,频繁的创建进程、销毁进程对负载均衡机器的压力是可想而知的,在我们过去的经验中,初始化32个工作进程,在进行应用启停、扩充实例、故障转移等过程时nginx这边开始持续的工作进程切换,部分请求开始失败,实例变更后生效的时间越来越大,造成灾难性后果。

对于配置中的另外一部分,server配置相对来说更新频率是有限的,使用reload的方式进行更新即可。

如何实现

想要扩展Nginx,当然首先想到的就是Lua,上文我们已经说到Openresty项目做了较多的扩展,其中就有对stream模块的lua扩展支持。

项目地址 https://github.com/openresty/stream-lua-nginx-module#readme

该项目是Openresty的核心扩展,在Openresty的发行版本中默认支持,其实现了lua扩展的大多数API,比如:init_by_lua init_worker_by_lua access_by_lua balancer_by_lua_block 等,其具体作用和生效流程大致如下:
Openresty动态更新(无reload)TCP Upstream的原理和实现

第一部分是nginx启动阶段,通过init_by_lua等模块加载lua脚本,进行相关变量的初始化。

第二部分是每一个请求的处理,从请求进入到返回的各个阶段都可以使用相应的lua脚本进行处理。

今天我们使用golang+openresty+lua给出一个动态更新tcp upstream的参考实现。

Openersty的初始化配置

stream {
    lua_package_cpath "/run/nginx/lua/vendor/so/?.so;/usr/local/openresty/luajit/lib/?.so;;";
    lua_package_path "/run/nginx/lua/?.lua;;";
    lua_shared_dict tcp_udp_configuration_data {{$h.UpstreamsDict.Num}}{{$h.UpstreamsDict.Unit}};

    init_by_lua_block {
        collectgarbage("collect")

        -- init modules
        local ok, res

        ok, res = pcall(require, "config")
        if not ok then
          error("require failed: " .. tostring(res))
        else
          configuration = res
        end

        ok, res = pcall(require, "tcp_udp_configuration")
        if not ok then
          error("require failed: " .. tostring(res))
        else
          tcp_udp_configuration = res
        end

        ok, res = pcall(require, "tcp_udp_balancer")
        if not ok then
          error("require failed: " .. tostring(res))
        else
          tcp_udp_balancer = res
        end
    }

    init_worker_by_lua_block {
        tcp_udp_balancer.init_worker()
    }

    lua_add_variable $proxy_upstream_name;

    upstream upstream_balancer {
        server 0.0.0.1:1234; # placeholder

        balancer_by_lua_block {
          tcp_udp_balancer.balance()
        }
    }

    server {
        listen 127.0.0.1:{{ $stream.StreamPort }};

        access_log off;

        content_by_lua_block {
          tcp_udp_configuration.call()
        }
    }
    include stream/*/*_servers.conf;
}

这里是nginx stream模块的配置,其中分为几块:

  • 加载lua脚本和第三方模块,涉及上诉配置lua_package_cpath lua_package_path
  • 分配共享内存,用于存储upstream配置。涉及上诉配置 lua_shared_dict
  • 初始化lua类及相关变量。init_by_lua_block 中初始化了处理upstream config的类,处理请求负载均衡的类。
  • worker工作进程初始化,主要是负载均衡配置初始化,状态初始化。init_worker_by_lua_block
  • 定义一个默认的upstream配置,其中server 0.0.0.1:1234;只是占位符,因为nginx要求upstream中必须存在server。实际的处理流程由balancer_by_lua_block处理。
  • 定义一个配置更新的server,由外部进程发起tcp请求更新共享内存中的upstream配置。

如上配置完成后,所有的server即可proxy_pass即可使用upstream_balancer这同一个upstream,由内部逻辑选择正确的upstream。如何选择呢?

server {
    preread_by_lua_block {
        ngx.var.proxy_upstream_name="{{ $udpServer.UpstreamName }}";
    }
    {{ if $udpServer.Listen }}listen {{$udpServer.Listen}} {{ if $udpServer.ProxyProtocol.Decode }} proxy_protocol{{ end }};{{ end }}
    proxy_responses         {{ $udpServer.ProxyStreamResponses }};
    proxy_timeout           {{ $udpServer.ProxyStreamTimeout }};
    proxy_pass              upstream_balancer;
}

这里贴出的是用于golang生成nginx配置的模版,从中我们需要关注两个点:

  • preread_by_lua_block 阶段设置了nginx 变量 proxy_upstream_name,这个变量标记当前的server监听负载的后端upstream名称。对于不同的server,生成不同的后端upstream。
  • proxy_pass 部分使用统一的upstream_balancer,即上文提到的部分。

TCP请求的处理流程

  • 请求首先由nginx工作进程接收,并根据正常的处理流程到达upstream_balancer这个upstream中。开始执行lua代码tcp_udp_balancer.balance()

    function _M.balance()
      local balancer = get_balancer()
      if not balancer then
        local backend_name = ngx.var.proxy_upstream_name
        ngx.log(ngx.ERR, string.format("not balancer %s", backend_name))
        return
      end
    
      local peer = balancer:balance()
      if not peer then
        ngx.log(ngx.WARN, "no peer was returned, balancer: " .. balancer.name)
        return
      end
    
      ngx_balancer.set_more_tries(1)
      ngx.log(ngx.ERR, string.format("select peer %s", peer))
      local ok, err = ngx_balancer.set_current_peer(peer)
      if not ok then
        ngx.log(ngx.ERR, string.format("error while setting current upstream peer %s: %s", peer, err))
      end
    end
  • 在lua tcp_udp_balancer类中为每一个upstream生成一个对应的balancer对象,这里首先根据proxy_upstream_name名称取到对应的balancer对象。
  • 执行balancer对象的balance方法选择一个合适的后端实例,这个过程即复杂均衡选择过程,这个过程可以有多种实现,特别是在http这个高级协议中有更多实现。今天这里讲解的TCP/UDP则会少很多,可以有轮询算法(最常用的算法),最少连接数等。这里我们主要讲轮询算法。

    local balancer_resty = require("balancer.resty")
    local resty_roundrobin = require("resty.roundrobin")
    local util = require("util")
    
    local _M = balancer_resty:new({ factory = resty_roundrobin, name = "round_robin" })
    
    function _M.new(self, backend)
      local nodes = util.get_nodes(backend.endpoints)
      local o = {
        instance = self.factory:new(nodes),
        traffic_shaping_policy = backend.trafficShapingPolicy,
        alternative_backends = backend.alternativeBackends,
      }
      setmetatable(o, self)
      self.__index = self
      return o
    end
    
    function _M.balance(self)
      return self.instance:find()
    end
    
    return _M

这里是一个轮询算法的lua实现例子。

有人可能会问,nginx有可用的负载均衡算法,为什么需要重新实现。需要说明的是在nginx的配置upstream中并没有可用的server,因此不能再使用nginx的负载均衡算法。

  • 将选择好的后端实例告知给nginx工作进程,通过ngx_balancer.set_current_peer(peer)的方式,然后由nginx处理流量转发以及后续工作。

Upstream配置如何更新

既然我们已经实现负载均衡的整个过程,且Upstream的配置由lua控制存储于共享内存中。可能有人会问,为啥需要存储于共享存储中呢?能否存储到lua类变量中。这肯定是不行的,我们上文讲到Nginx的工作模式是多进程,如果共享数据存储于类变量则每一个工作进程无法进行同步从而产生问题。这个问题可以理解为我们业务程序需要使用redis这种第三方缓存来共享数据类似。

我们在上面讲到nginx的配置中看到配置了一个配置更新的server,即可以与此server进行tcp通信来更新upstream数据。我们来看看后端实现:

function _M.call()
  local sock, err = ngx.req.socket(true)
  if not sock then
    ngx.log(ngx.ERR, "failed to get raw req socket: ", err)
    ngx.say("error: ", err)
    return
  end

  local reader = sock:receiveuntil("\r\n")
  local backends, err_read = reader()
  if not backends then
    ngx.log(ngx.ERR, "failed TCP/UDP dynamic-configuration:", err_read)
    ngx.say("error: ", err_read)
    return
  end

  if backends == nil or backends == "" then
    return
  end

  if backends == "GET" then
    sock:send(_M.get_backends_data())
    return
  end

  local success, err_conf = tcp_udp_configuration_data:set("backends", backends)
  if not success then
    ngx.log(ngx.ERR, "dynamic-configuration: error updating configuration: " .. tostring(err_conf))
    ngx.say("error: ", err_conf)
    return
  end
end  

我们这里实现了简要的两种模式,基于TCP的更新和获取。通过ngx.req.socket取得通信sock,解析到发送来的数据。若是“GET”字符串,则写回当前生效的配置内容。若不是则认为是最新的配置内容,将其写入到共享内存中,完成处理。

在控制端,采用Golang语言实现,包括上诉的nginx配置文件生成和这里的调用TCP请求更新Upstream。

func (o *OrService) persistUpstreams(pools []*v1.Pool) error {
    streams := make([]model.Backend, 0)
    for _, pool := range pools {
        var endpoints []model.Endpoint
        for _, node := range pool.Nodes {
            endpoints = append(endpoints, model.Endpoint{
                Address: node.Host,
                Port:    strconv.Itoa(int(node.Port)),
            })
        }
        streams = append(streams, model.Backend{
            Name:      pool.Name,
            Endpoints: endpoints,
        })
    }

    buf, err := json.Marshal(streams)
    if err != nil {
        return err
    }

    conn, err := net.Dial("tcp", fmt.Sprintf("127.0.0.1:%v", o.ocfg.ListenPorts.Stream))
    if err != nil {
        return err
    }
    defer conn.Close()

    _, err = conn.Write(buf)
    if err != nil {
        return err
    }
    _, err = fmt.Fprintf(conn, "\r\n")
    if err != nil {
        return err
    }
    logrus.Debug("dynamically update tcp and udp Upstream success")
    return nil
}

这是一个参考实现,通过生成model.Backend模型并进行TCP请求写入数据。这里的model.Backend即Upstream配置。

// Backend describes one or more remote server/s (endpoints) associated with a service
type Backend struct {
    // Name represents an unique apiv1.Service name formatted as <namespace>-<name>-<port>
    Name string `json:"name"`
    Endpoints []Endpoint `json:"endpoints,omitempty"`
    // LB algorithm configuration per ingress
    LoadBalancing string `json:"load-balance,omitempty"`
}

模型中包含upstream名称,后端实例列表和负载均衡方式。

结语

本文介绍了对Openresty或Nginx的TCP Upstream的动态更新(无需Reload)的一种实现方式,这种实现对于正在尝试做Nginx扩展的开发者是一种参考。文中我们对nginx结合lua对一次请求的处理流程和可扩展方式也进行了说明,重要的是给出了实际代码帮助开发者理解。目前社区中比如Kong、nginx-ingress-controller等基于Nginx扩展的项目都是类似的思路。

本文给出的实现是开源项目Rainbond的应用网关实现的局部代码,需要了解详细的实现可参考项目: https://github.com/goodrain/rainbond


Rainbond 是以企业云原生应用开发、架构、运维、共享、交付为核心的Kubernetes多云赋能平台, 向下结合Kubernetes云原生资源管理模式,对接管理各类基础设施,通过多维度的软件定义屏蔽了底层资源的差异,甚至包括CPU架构差异和操作系统差异,从而对上层提供以应用为中心的基础设施; 向上定义了标准应用模型(RAM,OAM),内置ServiceMesh微服务架构框架, 提供用户基于源码/已有镜像构建服务组件的能力,编排服务组件的能力,发布共享完整应用模型的能力,交付运维业务应用的能力。

上一篇:实现 Kubernetes 动态LocalVolume挂载本地磁盘


下一篇:Rainbond 持续部署Vue、React前端项目