Prometheus HTTP SD 框架

Prometheus 是现在比较流行的监控系统,它的工作模式是拉的模式:要监控的目标要负责把 metris 数据暴露出来,格式是普通的文本格式,协议是 HTTP,就像开放了一个普通的 HTTP 服务一样,然后 Prometheus 定时来这些 HTTP 接口收集 metrics,保存起来。

那么如何告诉 Prometheus 应该去哪里抓取这些 metrics 呢?

你可以直接把要抓取的目标写在 Prometheus 的配置文件里面去。但是这样一些经常变动的目标就不是很友好。

Prometheus 本身支持很多服务发现,比如 consul_sd, 配置一个 consul 的地址,Prometheus 会通过 Consul 发现抓取的目标。还有其他的一些支持云上的发现方式,比如 aws 的 ec2_sd 等。

但是也有很多是不支持的,比如 Etcd,Redis 等就不支持。

更重要的是,很多公司都会有自己内部的一些 cmdb 管理系统,机器资源管理系统,服务治理等,如果要让 Prometheus 自动根据这些信息去抓取 metrics,一种方法是定期向 Prometheus 支持的一种 sd 去同步数据。另一种方法是使用 Prometheus 的 file_sd 或者 http_sd, 即将自己的信息转换成某种 Prometheus 能够直接使用的格式。

prometheus-http-sd 就是这样一个框架,它支持用户自定义编程,通过实现一个 generate_targets() 函数,来返回 Prometheus 的 targets 格式。

然后,启动 prometheus-http-sd,它会 listen 一个端口,通过 HTTP 的服务暴露出来抓取的目标,给 Prometheus 做服务发现。每次 Prometheus 发送来 HTTP 请求的时候,会向后端去调用用户定义的 generate_targets() 函数,然后将函数的返回结果通过 HTTP Response 返回给 Prometheus。

这个项目的想法和 prometheus 的本职一样,所有的东西都通过 http 暴露出来 (metrics),如果你有一个接口,那么你就可以将其转换成 http 格式的 metrics;如果你有一个数据源,那么你就可以将其转换成 HTTP 格式的 targets。

https://github.com/laixintao/prometheus-http-sd

它以树状的目录组织 targets,即你可以将同一级不同目录的 targets 分配给不同的 Prometheus 实例来抓取。这样,也支持同一组目标用两个 job 来抓不同的 metrics 路径。

支持热加载,如果修改了 target generator 不需要重启应用。推荐用户使用 git 维护 target generator,然后只要更新这个文件夹就可以了。

支持 Dry run 模式,这样就可以将 prometheus-http-sd 集成到 CI 里面,每次修改 target generator 就可以在 CI 里面自动检查目标生成是否符合 Prometheus 的规范,Python 代码是否有问题。

除了支持用 Python 文件定义抓取目标,还支持静态的文件定义目标,支持 json,和 Yaml(Prometheus 原生的 file_sd 不支持 Yaml,不知道为啥)。

另外 prometheus-http-sd 本身也支持暴露 metrics,可以监控自身,比如 latency,generator 被调用的次数,生成的目标数量等等。

支持 admin 页面,可以用来 debug。

……

其他的一些功能大家可以直接看 Github 的项目主页,一些新加的 feature 都会写在 readme 上面。

目前使用下来,就是我们内部的一些系统比较慢,Prometheus 每 60s 一次来做服务发现的话,会导致有的时候会超时,虽然这大部分时候不是因为一个问题,因为 Prometheus 在 http_sd 失败的时候,会继续使用上一次的结果,但是为了保护后端的系统,我打算给 http_sd 加一个 cache 的功能。

 

程序 Hot reload config 的实现方式

有时候一些进程必可避免的有重启代价太大的问题。比如有一些长连接的进程,重启会断开连接,然后所有的客户端都需要重连,或者进程的内存中已经缓存了很多内容,如果启动的话缓存就凉了,要重新预热。

但是又有一些配置,我们希望修改进程的配置,而不需要重启进程。

在 像设计 UI 一样去设计配置项 中聊过有一种方式是做一个配置中心,然后如果有配置修改的时候,就推送一个请求给应用程序,这样程序那边收到配置的更新的时候做一个 callback 来执行配置的变更。

这个方案很好,但是依赖一些其他的组件,中心化的配置中心,以及程序内需要有 SDK 依赖去接收和解析配置更新的内容。

本来讨论不依赖其他组件的实现方式,即更新文件,不需要重启进程来让文件生效。

File Watch

这是一种比较自然想到的实现方式,也是最糟糕的方式。

它的原理是,进程在启动之后,就开一个线程负责 watch 这个文件所有的变化,一旦发现变化,就执行配置更新的 callback。

这种实现糟糕的地方在于:

  1. 不同系统的文件变更的 API 是不同的,比如 Linux 的 inotify,Mac/BSD 的 kqueue,和 Windows 的 ReadDirectoryChangesW. 为了兼容这些不同系统的 API,一般需要引入一个专业的 SDK 来封装,比如 watchdog
  2. 占用系统的 thread,虽然这个也有可能做成异步的
  3. 占用系统的 fd,这个看起来不严重,但是有的时候在系统的 fd 用完了的时候,我们想要用 hotreload 去修改一个参数来控制程序的行为,就麻烦了;

好处在于 import 一个 SDK 之后,不需要写很多代码就可以实现。

每一次都重新 load

这种方法就是,每次在使用配置项的时候,不使用全局变量,而每次都去解析配置文件。这样也就不存在说要 hot reload 的问题了,因为每一次使用都是一次 reload,所有用到配置的情况,都是最新的。

很多人乍一看觉得这种实现非常蠢,但是你仔细想一下,很多需要 hot reload 的配置的都不是读写很高的。

比如 lobbyboy 作为一个个人用的 ssh server,每次在有连接进来的时候,花几 ms 去 load 一下最新的 ssh key 配置,是完全可以接受的。

最近在写的 prometheus-http-sd 里面的加载 script 和文件的时候,也是对于每一个请求都是即时 load 的,即时这样,也能在 5ms 内完成请求。

这样节省了非常大的工作量:

  1. 不需要写更新配置的 callback 了,直接复用 load 的代码
  2. 不需要引入额外的依赖了
  3. 非常简单,每个人都能理解

这两种方式有一个问题,就是一旦文件发生更改,就立即生效了,没有时间让你去验证文件对不对,而且这是一个单向的 fire and forget 的操作,操作的时候不知道自己的动作对不对,必须要通过旁路去检查,比如看一下日志或者监控。虽然可以有其他 work around,比如先放到其他 location 验证。

SIGHUP

这是最好的方案。程序注册一个 signal handler,在收到 SIGHUP 信号的时候,reload config。

这样的好处是:

  1. reload 是明确的操作人的意图,而不是单纯文件修改了就 reload,语义没有歧义
  2. 实现成本很小

而且,我们一般可以控制 reload 的行为。比如说使用 systemctl reload service 的时候,我们可以自定义 reload 命令:

  1. 先检查配置文件是否合法,如果不合法,放弃 reload;
  2. 给程序发送 reload 信号;

以下是一个例子,在 systemd 的 unit 文件里面可以定义 ExecReload,即定义执行 systemctl reload serivce 的时候所做的事情。

这里有两个技巧:

  1. Systemd 里面会给你一个 $MAINPID 让你用,即主进程的 PID,直接往这里发送信号即可;
  2. 使用 ; 可以定义多个命令,一个启动成功才会执行下一个。要注意这不是 shell 的 ;

这样,我们在 reload 的时候,如果第一个检查不通过,systemctl reload 的 return code 就不是0,我们就知道 reload 失败了。

在操作的时候,我们可以直接在 Ansible 的操作结果中看到动作出错,而不是需要去 ssh 到机器上,查看执行结果是否正确。

 

Coredns 源码解析:启动流程

Coredns 的启动流程不是很好阅读,因为它本质上是一个 caddy server, 是基于 caddy 开发的。也就是说,它是将自己的逻辑注入到 caddy 里面去,相当于把 caddy 当做一个框架来用,实际的启动流程其实是 caddy 的启动流程,Coredns 里面不会看到很明确的准备启动,Start server 之类的代码,而大部分都是注册逻辑到 caddy 代码。类似于 Openresty 和 Nginx 的关系。这就导致一开始阅读源码不是很好上手,需要搞清楚哪些东西是 Coredns 的,哪些接口是 caddy 的。而 caddy 的文档又不是很多,而且 Coredns 所使用的 caddy 已经不是官方的了,而是自己维护的一个版本,也加大了阅读的难度。所以我将启动流程梳理了这篇博客,希望能理清它的逻辑。

在开始分析源代码之前,读者需要具备的准备工作是:

  1. Golang 的基本语法,但是我发现 Golang 很简单,看完 https://gobyexample.com/ 足矣;
  2. 知道 DNS 的基本工作原理,知道 DNS Resolver, DNS Root Server, DNS TLS Server 的区别,能区分 Cloudflare 1.1.1.1 服务和 AWS 的 Route53 分别是什么角色,知道 Coredns 是哪一种 Server(其实 Coredns 哪一个都能做);
  3. 需要先看完 Coredns 的使用文档,知道它怎么配置;

在完成了这些之后,就基本可以知道 Coredns 是怎么工作的了。在开始进入源码之前,可以通过已知的文档来思考一下:如果 Coredns 需要完成已知的这些功能,需要做哪些事情呢?

猜想的实现模块

因为在之前的博客中已经介绍过,Coredns 其实是基于一个 caddy 的 server,所以我们可以猜想 Coredns 必须要完成以下事情:

  1. 需要能够解析 Corefile,这是 Coredns 的配置文件;
  2. 需要维护一个 Plugin Chain,因为 Coredns 工作方式的本质是一个一个的 Plugin 调用;
  3. Plugin 需要初始化;
  4. Coredns 是基于 caddy 的,那么 Coredns 必须有地方告诉 caddy 自己处理请求的逻辑,监听的端口等等;

你可能还想到了其他的功能,这样,在接下来的源码阅读中,就可以尝试在源代码中找到这些逻辑,看看 Coredns 是怎么实现的。

Coredns 的入口程序是 coredns.go,里面只有两部分,第一部分是 import,第二部分是 main

Golang 的 import 并不是简单地引入了 package 的名字,如果 package 里面有 init() 的话,会执行这个 init()。所以这里的 import 其实做了很多事情。

注册

import package 的时候,imported package 还会 import 其他的 package,最终,这些 package 的 init() 都会被执行。

import 的链路和最终执行过的 init 如下,其中一些不重要的,比如 caddy.init() 在图中忽略了。

重要的注册主要有两部分,第一部分是将所有的 Plugins 都 import 了一遍,并且执行了这些 Plugin 里面的 init()。这个 Plugin 的 import 列表其实是生成的,在之前的博客中提到过。

因为 Golang 是静态编译的语言,所以要修改支持的 Plugin 列表,必须要重新编译 Coredns:

  1. 修改 plugin.cfg 文件
  2. 使用仓库中的 directives_generate.go 生成 zplugin.go 源代码中文,这时 import 列表更新
  3. 重新编译 Coredns

Plugin 中的 init() 做的事情很简单,就是调用 coredns.plugin.Register 函数,将 Plugin 注册到 caddy 中去,告诉 caddy 两个事情:

  1. 这个 Plugin 所支持的 ServerType 是 DNS
  2. Plugin 的 Action,即初始化函数。这里只是注册,并没有运行过。

第二部分是 register 中的 init 函数,主要的动作是使用 caddy 的接口 caddy.RegisterServerType 注册上了一个新的 Server 类型。

注册的时候,要按照 caddy 的接口告诉 caddy:

  1. Directives: 新的 ServerType 支持的 Directives 是什么;
  2. DefaultInput: 在没有配置文件输入的时候,默认的配置文件是什么,这个选项其实不重要;
  3. NewContext: 这个是最重要的,如何生成对应这个 ServerType 的 Context,Context 是后面管理 Config 实例的主要入口;

coremain 里面也有一个 init() 主要是处理了 Coredns 启动时候的命令行参数,然后注册了 caddyFileLoader,即读取(注意还没有解析)配置文件的函数。

到这里,import 阶段就结束了,目前为止所做的工作大部分都是将函数注册到 caddy,告诉 caddy 应该做什么,函数并没有运行。

然后回到 coredns.go 文件的第二部分:coremain.Run()

启动

数据结构

启动的大致流程是,初始化好各种 Instance, Context, 和 Config, 然后启动 Server. 不同的阶段所初始化的数据结构不同,要理解这个过程,最好先明白这些数据结构之间的关系。主要的数据结构以及它们之间的互相应用如下图。黄色的表示 caddy 中的数据结构,绿色的表示 Coredns 中的数据结构。

其中,caddy.Controllercaddy.Instance 是 caddy 中定义的结构,主要是 caddy server 在使用,Coredns 中并没有看到很多用到的地方。

dnsContext 是实现了 caddy.context,内部保存了和 Config 之间的关系,实现了 caddy 中定义的 InspectServerBlocks 和 MakeServers 接口,是一个主要的数据结构。对应 caddy.Instanse 全局只有一个,由 caddy 创建。

Config 就完全是 Coredns 内部的结构了,是最重要的一个结构,里面保存了 Plugin 列表,在处理 DNS 请求的时候,主要通过 Config 去调用 Plugin. 对于每一个 Corefile 配置文件中的 ServerBlock 和 Zone 都会有一个 Config 实例。

启动流程

Coredns 的启动流程之所以复杂,一个原因是真正的流程在 caddy 中而不是在 Coredns 中,另一个原因是随之而来的各种逻辑,本质上是 Coredns 定义的,然后注册到 caddy 中,caddy 执行的代码实际上是 Coredns 写的。

所以为了说明白这个启动的流程,我先画了一个图。启动流程的本职是初始化好上文中描述的各种数据结构。下图中,上面是数据结构,下面是代码的执行流程。在下图中,实线表示实际调用关系,虚线表示这段代码初始化了数据机构实例。

从 coremain.Run 开始,这里逻辑很简单,先是执行了上文提到过的注册的 caddyFileLoader 。然后调用了 caddy.Start,由 caddy 负责主要的启动流程。

下面我们找深度优先描述这个启动过程。

Caddy 先创建了一个 Instnace ,然后调用 ValidateAndExecuteDirectives。

ValidateAndExecuteDirectives 中,根据我们之前 load 出来的 caddy file 中的 ServerType string,拿到 DNS Server Type,就是上文提到的我们在 init 的过程中注册进去的。

然后执行 loadServerBlocks,这是 caddy 内置的函数,根据我们之前注册的 ServerType.Directives 返回的 Directives 去解析 caddy file,这时候是作为一个普通的 caddy 文件解析的,没有 coredns 的解析逻辑。这时候原来的 caddy file 被解析成 Tokens。由此也可以看出,Coredns 的配置文件和 Corefile 和 caddy 的格式必须是一样的,遵循一样的语法规范,因为解析器都是用的 caddy 中的(如果 Coredns fork 的版本没有修改的话)。

下一步是调用 ServerType 注册的第二个重要方法:NewContext 创建出来一个 context,实质的类型是 dnsContext

然后调用 context.InspectServerBlocks,这个逻辑也是 Coredns 中实现的,是 dnsContext 实现的接口。主要做了两件事,一是检查 Corefile 是否合法,有无重复定义等。然后是创建 dnsserver.Config,Config 主要是和 dnsContext 关联,我们后面拿到这个 Config 主要也是通过 Context。比如在 config.go 中通过 Controller 拿到对应 Config 的方法实现

实际上是通过 Controller 找到 Instance, 然后找到 Instance 上的 Context (c.Context() 逻辑)。然后通过一个 Utils 去从 Context 上找到对应的 Config。这个实现和上图也是完全符合的。

上文提到过,每一个 Server Block 中的每一个 Key 都会有一个对应的 Config,那么这么多的 Config,我们怎么找到对应的呢?

其实就是 keyForConfig 的逻辑,context 中记录了 Server Block Index + Server Block Key Index 组合,和 Config 的一一对应关系。

回到 ValidateAndExecuteDirectives 的逻辑,调用完 InspectServerBlocks 之后,就是针对每一个 Directive 去拿到 Action 然后去执行。即,初始化每一个 Plugin。

完成之后,逻辑回到 caddy.Start中,会调用 dnsContext 中实现的第二个重要的接口:MakeServers. 初始化好 Coredns 中的 Server.

最后一步,就是 startServers 了,这里实际的逻辑又回到了 Coredns 实现的接口上。感觉比较清晰,没有什么难以理解的地方。主要是实现了两个接口,一个是 Listen, 一个是 Serve

然后就可以开始处理请求了。

开始处理请求

最后 Server 实现了 caddy.Server 的 Serve 方法, 里面做的事情主要就是根据 DNS 查询请求里面的 zone 匹配到对应的 Config,然后 PluginChain 保存在 Config 里面,通过调用 h.pluginChain.ServeDNS 来完成请求的处理

本文的代码解析就到这里,至此启动流程就完成了。本文尽量少贴代码,试图缕清启动的流程,具体的代码如果对照本文的图片和解释应该都找到的,并且剩下的部分应该不难看懂。如果发现错误或者有疑问,欢迎在评论区交流。

 

本文基于 Coredns 源码 Commit:3288b111b24fbe01094d5e380bbd2214427e00a4

对应最近的 tag 是:v1.8.6-35-gf35ab931

 

Side Project 成本最小化运行

有时候,人们会忘了今天的计算机资源已经如此强大,一台 $5/月 的机器可以干多少事情。

之前有人在讨论 redis.io 这个官网上,访问量一定巨大,而且可以实时运行 Redis 命令,一定用了很多机器,是一个分布式的系统。但其实就是跑在一个 $5/月的 VPS 单机上。

原文,后来替换成 4G 内存的机器了。

我在工作之余会做一些出于兴趣的 Hobby Project,这些 Project 可以分成两种:

  1. 工具和库之类的,只要下载就可以使用,用户量再大也不会增加我的成本;
  2. 提供服务的网站类型,需要花钱购买域名和服务器,并且要付出维护成本;

对于第二种类型,只有减少成本,项目才可能持续:

  1. 每一个项目刚开始的时候可能都没有什么人,如果不节省成本,很可能在一开始就没什么用户,以及高昂的运行成本而做不下去
  2. 成本低可以长时间积累用户

这篇博客写一些如何降低运行成本的方法。

运行平台

如果是静态网站,选择就很多了,cloudflare, Vercel, netlify, 都可以。只需要把前端的文件上传上去就可以了。

如果是动态网站,近几年也有很多不错的 SaaS 平台可以选择:Sass 部署可以选择 fly.io, heroku, serverless 可以选择 cloudflare, aws 等等。这些平台的免费额度基本都可以覆盖很多场景了。

但是我做的小东西基本上都需要运行用户提交的代码,所以我一般用 DigitalOcean 的虚拟机。好处是便宜,自己完全可以用控制 VM,而且账单透明。不会有 vendor lock, 随时可以换另一家的 VM 用。

部署

如果选择 SaaS 的话,就需要根据使用的 SaaS 平台写部署描述文件。

如果使用虚拟机来部署的话,我一般使用 ansible 来管理部署:

  • 将代码放在一个 repository, 可以公开,也可以私有
  • 使用一个 私有的 repository, 存放 ansible 配置文件,包括一些 secret 文件,host inventory 都放在这里面
  • 每次发布版本的时候,在代码 repository 提交 tag,build binary
  • 然后在 ansible repository 修改部署的 tag,提交一次部署(这一步其实可以集成到 CI 里面去,每次改动都去自动运行一下 Ansible)
  • 保持一个原则:就是给你一组新的 IP,能够在几分钟之内搭建好一模一样的集群(这样可以不用担心原来的集群坏掉,没有运维负担)

Logs 和 Metrics

Logs 也有一些 Saas 平台可以使用,比如 Datadog, sematext, 但是我都没有用过,一个是会提高管理成本,也会提高运行成本,即使 Saas 是免费的,你也需要流量把 log 发出去,而且我比较习惯使用命令行的工具看日志。

关于 Metrics, 其实可以不必关心,小型的服务一开始用 jvns 介绍的方法完全足够了:Monitoring tiny web services,即使用黑盒监控探测,以及定时 curl 网站的方式,从小时级别确保网站在正常运行。这样已经可以检测足够多的东西了,比如 SSL 证书过期,数据库挂了等情况。

如果要更精确的监控,一个比较好的方法是部署 Prometheus + Grafana, 两个组件一个是收集 metrics 的一个是用来展示 metrics 的,都可以用 docker 来启动,然后用 Ansible 管理配置文件,做监控代码化,也能做到随时迁移。

如果要运行用户代码的话……

之前做的一个项目叫 clock.sh, 是一个定时执行用户代码的服务,可以理解成 serverless crontab. 最近还在跑的一个项目叫 xbin.io, 提供一些命令行工具,不用安装就可以运行。这两个项目都需要运行用户代码。

Jvns 在她的博客写过很多解决类似需求的工具分析,基本上和我想要的一样,非常推荐阅读一下:

我现在使用的方案是:Docker + runsc, 使用最新版的 docker,避免 0day 攻击。然后用 runsc 只能允许有限的 syscall,就可以满足大部分的安全需要了(暂时还没出过问题)。

开发和测试

前面说了这么多有关运维部署的事情,其实写代码也有一些技巧。就是尽量不要自己从头自己实现一个功能或者模块,你的代码应该只去实现核心模块。(除非你要写的东西很好玩)。因为业余的时间有限,要把一件事情做成,就要把时间用在关键的地方。几个原则:

  1. 如果一个功能需要很复杂的实现,就用现成的库。比如登录功能,涉及反垃圾,验证邮箱,管理 session,那么直接用 Oauth 库 + Github 账号登录,几行配置就解决了;
  2. 如果一个功能不算复杂,但是现有的库的实现都比较复杂,那么自己写。比如你的一个服务去调用另一个服务,只有一个 API,用 protobuf + gRPC 太复杂了,不如自己设计一个 TCP 协议,或者直接用 HTTP;
  3. 实现的时候写的代码越少越好。代码越少,bug 越少,1年之后你在看自己的代码也看的懂。

有关测试:

  1. 可以只写关键地方的测试,以及复杂的地方的单元测试,核心目的是避免自己以后修改代码的时候不注意改错了复杂的地方;
  2. 可以不写集成测试,因为集成测试都很复杂,即使通过了集成测试,你也不会很有信心一定没问题。所以集成测试即使通过了,一般你也会手动再去验证一下,所以意义不大;
  3. 不是一定需要 CI 自动运行测试,你可以在 merge 之前手动在本地跑一下测试。因为 CI 的调试和维护也很花时间,只有自己一个人的话,makefile 反而更好用。

最后一点

对于业余的项目,应该让自己的精力放在比较重要的(快乐)事情上。自己维护的东西要越少越好。

比如要实现一个功能的话,现成的 SaaS > 基于库实现 > 自己从软件方面实现 > 使用新的开源项目额外部署系统来实现。问题尽量从软件自身解决。举个例子,比如每小时要清理一下不在运行的容器,一个方法是写一个脚本做成 crontab 运行,但是一个更好的方法是在软件本身开一个后台线程,每隔一个小时就检查一遍。这样,就不需要维护 crontab 了(实际上是由 crond 运行的)。

 

长连接负载均衡的问题

在分布式的系统中,如果服务 A 要调用服务 B,并且两个服务都部署了多个实例的话,就要解决负载均衡的问题。即,我们希望到达 B 的 QPS 在 B 的所有实例中都是均衡的。

以前的类似 HTTP/1.1 的实现中,服务 A 每发起一次请求,都需要跟 B 建立一个 TCP 连接。所以负载均衡的实现方式一般都是基于连接数的。但是每次都建立一个新的连接,性能会很低。所以就有了后来长连接的实现方式:建立一个 TCP 连接,在这上面发送很多请求,复用这个 TCP 连接。gRPC 就是基于 HTTP/2 实现的,用了这种长连接的方式。

用长连接会提高性能,因为不用每次都去重新建立一个 TCP 连接。但是也有一些问题。

第一个问题就是负载均衡。Kubernetes 这篇博客讲了为什么 gRPC 需要特殊的负载均衡。很显然,HTTP/1.1 的方式,每次随机选择一个实例去调用,负载是均衡的。但是 HTTP/2 这种一直用一个连接的方式,一旦连接上了就会一直用,使用哪一个实例就取决于最开始选择的谁。

即使是一开始有办法让它连接均衡,但是有一些情况会打破这种均衡。比如说一台一台重启 service instance。

在每重启了一个 instance 之后,原本连接这个 instance 的 client 就会与其断开连接,转而去连接其他的可用 instance。所以,第一台被重启的 instance 重启完成之后是不会有连接的。其他的 instance 会增加:(1/n)/(n-1) * total connections 的连接。n 是总实例数。

因为每一个 instance 重启之后都会增加其他 instance 的连接数,就有两个问题:

  1. 第一个重启的 instance 到头来会有最多的连接数,最后一个重启的,不会有连接,非常不均衡
  2. 最后一个重启的 instance 在重启的时候会造成大量的 client 去重连

第二个问题就是增加服务端 instance 的时候,不会有 client 去连接它。即服务端迁移/上线下线的问题。因为所有的 client 都使用原来已经建立好的连接,不会知道有新的 instance 可用了。其实说到底和第一个问题差不多。

解决的方法,想到 3 个。

第一个就是如同上文博客中提到的那样,在 client 和 server 之间加一个 load balancer,来维护到后端的连接。可以完美解决上面两个问题。缺点是资源会比较高,架构增加复杂性。

第二个方法是从服务端解决:服务端可以不定时给客户端发送 GOAWAY 指令,示意客户端去连接别的 server instance。api-server 有一个选项是可以指定用多少的概率去给 client 发送这个指令:–goaway-chance float.

To prevent HTTP/2 clients from getting stuck on a single apiserver, randomly close a connection (GOAWAY). The client’s other in-flight requests won’t be affected, and the client will reconnect, likely landing on a different apiserver after going through the load balancer again. This argument sets the fraction of requests that will be sent a GOAWAY. Clusters with single apiservers, or which don’t use a load balancer, should NOT enable this. Min is 0 (off), Max is .02 (1/50 requests); .001 (1/1000) is a recommended starting point.

这样还有一个好处,就是下线的时候,不是粗暴地退出,而是可以对自己当前所有的连接都发送 GOAWAY 指令。然后无损地退出。

第三个方法就是从客户端解决:客户端不使用单一连接去连接服务端,而是使用一个连接池:

  1. 客户端每次要发送请求的时候,需要先向自己的连接池请求一个可用连接:
    1. 这时候,如果有,就返回一个连接
    2. 如果没有,就发起建立连接
  2. 使用完成之后,将连接放回连接池
  3. 连接池支持设置一些参数,比如:
    1. 如果 idle 一定的时间,就关闭连接
    2. 一个连接 serve 了多少个 request 之后,或者被使用了多少次之后,就关闭它,不再使用。

这样,一来可以解决一个连接被无限使用的问题,而来关闭连接也是无损的,因为连接池里面的连接没有给任何人使用,由连接池自己管理。其实,像数据库客户端,比如 jdbc,以及 Redis 客户端,都是这么实现的。