spring boot健康检查无法感知redis故障恢复的问题梳理

问题描述

最近环境上发现redis集群某个节点挂掉之后,应用的健康检查就一直无法正常。由于应用是基于kubernetes部署的,并且采用/actuator/health/readiness作为就绪探针,所以应用一直无法正常访问,直到该redis节点重启才恢复正常。

重现问题现象

因为之前redis集群也出现过类似问题,但是相应的应用也只是短暂影响,很快就恢复正常了。

本地简单跑一下该应用,的确redis节点如果挂掉(无论哪个),健康检查就一直返回DOWN。

差异在哪?

哪里出问题了? 只好先对比一下两个应用在实现上的差异,从业务代码集成redis这块分析是没有问题的,一时没找到具体原因。 突然我想起最近这个应用是基于spring boot 2.3.x的,要么再对比一下健康检查的实现差异!

经过对比发现,2.3.x版本和2.2.x版本在RedisReactiveHealthIndicator没有明显差别。但2.4.x就略有变化。

  • 2.3.x版本
private Mono<Health> doHealthCheck(Health.Builder builder, ReactiveRedisConnection connection) {
	boolean isClusterConnection = connection instanceof ReactiveRedisClusterConnection;
	return connection.serverCommands().info("server").map((info) -> up(builder, info, isClusterConnection))
	.onErrorResume((ex) -> Mono.just(down(builder, ex)))
	.flatMap((health) -> connection.closeLater().thenReturn(health));
}
  • 2.4.x版本
private Mono<Health> doHealthCheck(Health.Builder builder, ReactiveRedisConnection connection) {
    return getHealth(builder, connection).onErrorResume((ex) -> Mono.just(builder.down(ex).build()))
            .flatMap((health) -> connection.closeLater().thenReturn(health));
}

private Mono<Health> getHealth(Health.Builder builder, ReactiveRedisConnection connection) {
    if (connection instanceof ReactiveRedisClusterConnection) {
        return ((ReactiveRedisClusterConnection) connection).clusterGetClusterInfo()
                .map((info) -> up(builder, info));
    }
    return connection.serverCommands().info("server").map((info) -> up(builder, info));
}

2.4.x版本对于集群方式,实现方式有些变化,最终根据提交发现了原因:2.4.0修复了一个BUG

变更描述信息如下:

Prior to Spring Data Redis version 2.2.8, the contents of the
Properties object returned from the
ReactiveRedisConnection.ServerCommands.info API were the same
for clustered and non-clustered Redis configurations, containing a set
of key/value pairs. This allowed ReactiveRedisHealthIndicator to get
a version property using a well-known key. Starting with Spring Data
Redis 2.2.8, the info property keys contain a host:port prefix in a
clustered Redis configuration. This prevented
ReactiveRedisHealthIndicator from getting the version property as
before and resulted in the health always being reported as DOWN.

This commit adjusts ReactiveRedisHealthIndicator to detect the
clustered configuration from Spring Data Redis and find the version
property for one of the reported cluster nodes.

这样我就理解了,spring-data-redis 2.2.8之后就破坏了原来的代码实现。而原来用的应用使用的2.2.8之前版本,所以没有发现问题。既然这样,就只能参考最新代码定制一个健康检查进行修复,等后续升级2.4.x之后再进行调整。

应用确保Redis高可用生效的要点梳理

最终总结一下,如何才能在Spring Boot中确保Redis高可用可以生效。

  1. redis cluster是具备自动恢复能力的,短时间不可用是正常现象,进行主从切换后即可正常。如果是一主一从同时挂掉,那么集群会不正常,需要介入恢复。集群状态可以通过cluster info查看。
  2. spring boot通过sprin-boot-data-redis来实现redis访问,底层有jedis和lettuce两种,目前主要使用lettuce,而lettuce是需要开启自适应拓扑刷新(Adaptive updates)与定时拓扑刷新(Periodic updates),但这个特性默认是不开的,网上相关问题主要也是提供这个解决方案。在spring boot 2.3.x可以通过配置实现,而之前版本需要通过代码实现,详细可以参考这个文章Spring Boot 2.3.0 新特性Redis 拓扑动态感应
  3. spring boot 2.3.x的默认redis健康检查对于cluster模式,实现有问题(正确的说法是基于spring-data-redis 2.2.8之后),不能正确处理异常节点情况,这个问题在2.4.x进行了修复。

http keep-alive无效导致短连接的案例总结

本文基于关于nginx的keep-alive配置的安全性分析,结合实际情况的再说几点。

nginx针对upstream配置keep-alive的特殊性

最近反馈生产nginx的TIME_WAIT状态比较多,其中对接ingress的忙时7000左右,担心更多接口接入后可能存在隐患。

在测试环境验证,的确有疑似大量短连接的情况。经分析,是配置有误导致。

当前的配置示例(高度简化过)如下:

http {
  proxy_http_version 1.1;
  proxy_set_header Connection "Keep-Alive";

  upstream app   {
    sticky expires=1h path=/;
    server x.y.z.k:1094 max_fails=2;
  }

  server {
    location /app {
      proxy_pass http://app
      proxy_set_header Host $proxy_host;
      proxy_set_header X-Real-IP $remote_addr;
   }
}

其中最前面两个是用来修改短连接为长连接用的。

但这里有两个情况需要注意:

1.Upstream的keepalive默认是没有设值的。如果需要启动keepalive,需要在upsteram指令块中进行相应配置。参考http://nginx.org/en/docs/http/ngx_http_upstream_module.html#keepalive

2.这里有个非常特殊的情况,根据官方文档说明http://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_set_header文档中描述,只有当前作用域的配置没有proxy_set_header,才会考虑上级的proxy_set_header指令,否则设置默认(短连接)。所以上面的配置是无效的。需要考虑在location增加指令。

Allows redefining or appending fields to the request header passed to the proxied server. The value can contain text, variables, and their combinations. These directives are inherited from the previous configuration level if and only if there are no proxy_set_header directives defined on the current level. By default, only two fields are redefined:

proxy_set_header Host       $proxy_host;
proxy_set_header Connection close;

jmeter的keep-alive配置如何生效

一般压测机出现端口不足,是由于没有使用keep-alive导致的。如果要使用keep-alive,关注下面三个点:

  1. 在http请求中选中keep-alive。这是必须的。
  2. 修改httpclient4.idletimeout,这个主要在服务端没有返回Keep-Alive来指定超时时间的情况。另外,这个是针对httpclint4(在jmeter http request高级配置中设置)
# Idle connection timeout (Milliseconds) to apply if the server does not send
# Keep-Alive headers (default 0)
# Set this > 0 to compensate for servers that don't send a Keep-Alive header
# If <= 0, idle timeout will only apply if the server sends a Keep-Alive header
httpclient4.idletimeout=60000
  1. 还有个关键的配置,这也是网上很多例子都是无效的主要原因,主要是因为jmeter在5.0做了一个变更
Since JMeter 5.0, when using default HC4 Implementation, JMeter will reset HTTP state (SSL State + Connections) on each thread group iteration. If you don't want this behaviour, set httpclient.reset_state_on_thread_group_iteration=false

所以最关键的地方是要把这个参数调整成false。

httpclient.reset_state_on_thread_group_iteration=false

关于nginx的keep-alive配置的安全性分析

基本概念

最近关注了一下nginx的keep-alive配置,其实看看这块对性能和502 Bad Request的影响。 简单来说,keep-alive主要涉及出入口两块配置,包含三个参数(这个模型基本是通用的):

两块配置是外围调进来的配置、调用出去(upstream)的配置。 三个参数是保留多少长连接(keepalive,针对upstream)、长连接的超时时间(keepalive_timeout)、长连接的最多处理请求数(keepalive_requests)。

关于参数的详细说明,可以参考nginx的官方文档. ngx_http_core_modulengx_http_upstream_module

默认参数值

对于nginx/nginx-ingress来说,这些参数的默认值如下。

对于调用进来的情况:

  • keepalive_timeout: 通过keep-alive参数设置(注意这是设置的keepalive_timeout),表示超时时间,默认75s,我们项目目前设置300s
  • keepalive_requests: 通过keep-alive-requests参数设置,表示最多处理请求数,默认100

对于调用出去的情况:

  • keepalive:通过upstream-keepalive-connections参数设置,表示和upstream保持多少个长连接,默认32
  • keepalive_timeout:通过upstream-keepalive-timeout参数设置,表示超时时间,默认60s
  • keepalive_requests:通过upstream-keepalive-requests参数设置,表示最多处理请求数,默认100

备注:nginx-ingress官网显示keepalive默认320、keepalive_requests默认10000。 是因为后来进行了默认值调整 而我们用的版本是之前的,没有这个修改。

对于tomcat来说,这些参数的默认值如下。具体见tomcat官方文档中的参数配置

  • keepAliveTimeout 默认60s
  • maxKeepAliveRequests 默认100个请求

keep-alive的安全性

这里的安全性不是指安全漏洞,而是说如何避免向一个已关闭的连接发送请求,从而造成502的问题。

下面这个两个文件已经说得比较详细了,可以参考一下。

总体来说,要求对于A调用B的情况:

  • A的idle超时(调用出去)要少于B的idle超时(调用进来)
  • A的最大请求数(调用出去)要少于B的最大请求数(调用进来)

通俗点,举个例子:

如果场景是nginx -> tomcat的情况,如果tomcat保持默认,那么nginx的idle超时要小于60s,最大请求数要小于100。
如果场景是nginx -> ingress -> tomcat的情况,需要确保两块连接都符合上述要求。

按照当前的配置,不少配置还是存在隐患的。

无论是tomcat对接ingress还是nginx-ingress,默认值都可能有问题,需要把调出的超时和最大请求数稍微调小。
还有类似的插件,例如nginx_upstream_check_module有个类似的参数check_keepalive_requests,默认是1,也是要注意配套的,否则可能有探测失败的情况。

nginx-ingress-controller会话保持的踩坑记录

诡异的会话丢失

最近系统上线PAAS之后,IE11上出现一些页面打不开、窗口闪退、提示会话过期的问题,但客户反馈在360浏览器是正常的。

为什么IE11有问题,360浏览器却正常呢?难道IE又有什么神奇的兼容性问题了?最好有请求抓包可以看看。

很快,维护找到客户录制的请求抓包,找了几个反馈有问题的页面,除了cookie比较多之外,还发现有些请求报文比较特殊。

	approute=xxxxxxxxxxxxx
	JSESSIONID=yyyyyyyyyyyyyyyyyyyyyy
	approute=uuuuuuuuuuuuu
	JSESSIONID=vvvvvvvvvvvvvvvvvvvvvv

请求头中的cookie如上,居然有同名的cookie,这个系统是比较老的基于Session的Web应用,除了用户认证,用户的业务信息是保存在Session上面的。 上PAAS之后,这些cookie是用来做会话保持用的。

Set-Cookie: approute=2750fcbd2c7fa1fdd1189d95f63c3cd8; Expires=Sat, 27-Mar-21 01:37:04 GMT; Max-Age=172800; Path=/app-next; HttpOnly

在响应中也通过Set-Cookie对cookie进行重设,这样就没办法维护会话绑定了。如果某个请求用到了上一个请求的Session信息,就会出现”会话丢失”的情况。 而且从请求上看,这种情况在后续的请求上反复出现,给人感觉就是设置了没效果。

会话如何保持?

环境部署

正式的部署环境是有多层代理的,其中外层的nginx主要做静态资源托管,会话保持采用nginx-sticky-module-ng模块, 而ingress这一层是用的nginx-ingress-controller自带的Sticky sessions功能。

对应的ingress配置如下,采用cookie来做会话保持。

apiVersion: extensions/v1beta1
kind: Ingress
metadata:
  name: approute-ingress
  annotations:
    kubernetes.io/ingress.class: "approute"
    nginx.ingress.kubernetes.io/affinity: "cookie"
    nginx.ingress.kubernetes.io/session-cookie-name: "approute"
    nginx.ingress.kubernetes.io/session-cookie-hash: "sha1"
    nginx.ingress.kubernetes.io/affinity-mode: persistent
    nginx.ingress.kubernetes.io/ssl-redirect: "false"
    nginx.ingress.kubernetes.io/session-cookie-expires: "172800"
    nginx.ingress.kubernetes.io/session-cookie-max-age: "172800"
    nginx.ingress.kubernetes.io/proxy-body-size: 300m
spec:
  rules:
  - http:
      paths:
      - backend:
          serviceName: approute-app
          servicePort: 8080
        path: /app
  ...更多ingress rule

按当前的配置,就应该只有一个cookie来对,那这种同名cookie是怎么来的?

同名Cookie从哪里来?

以前没怎么听过同名Cookie,查了一下资料How to handle multiple cookies with the same name?,从中得到的初步结论是:

  • 怎么产生: 同名cookie是通过设置不同的子域名、子PATH等情况产生的。
  • 发送哪个: 如果不是很旧的浏览器,PATH越具体,优先级越高,排序越靠前。如果是其他属性不同,则没有约定。
  • 识别哪个: 服务端没有有明确的约定。

当前我们主要是通过PATH来设置的,而目前的会话保持也是设置在应用的上下文的。 我初步猜想,是不是有个地方设置PATH为根路径”/”了?

运气不错的是,根据会话丢失的判断,测试环境也拉起多个应用实例,发现可以重现问题。

不过IE11不能直接查看cookie文件了,需要通过ie的导出功能(点击浏览器右上角的星星打开收藏夹-添加收藏夹下拉-选择导入和导出…)。

a.b.c.d	TRUE	/app	    FALSE	1617197971	approute 880dffc13701aebc8315c4a881c82de4
a.b.c.d	TRUE	/app-next	FALSE	1648561945	approute 43d24255920374268ed5abff09360163

如上所示,可以发现两个cookie的值刚好对应/app和/app-next两个PATH。

纳尼,不是应该按路径来划分的么?为什么调用/app-next会把/app的cookie也带过去了?

IE对PATH的骚操作

好吧,写段代码测试一下。设置了三个cookie如下:

Set-Cookie: approute=6d7770821d2aa0bce5739b1a27fca753; Path=/app/; HttpOnly
Set-Cookie: sna_cookie=ACB73F8C4EA88A6; Path=/
Set-Cookie: JSESSIONID=0E817AD9FEF46123A2DAB6A9F99B38AC; Path=/app; HttpOnly

对于Chrome来说,访问/app-next发送的cookie是

JSESSIONID=0E817AD9FEF46123A2DAB6A9F99B38AC
sna_cookie=ACB73F8C4EA88A6

对于IE11来说,访问/app-next发送的cookie是

sna_cookie=ACB73F8C4EA88A6

从测试结果上看,IE直接采用prefix来判断Path,IE对cookie的Path处理方式是不同于chrome的,有点反常识,纯属骚操作。

修复方案

看来只能调整一下Path的设置方式,考虑到路径的优先级,尝试使用根路径”/”来设置,发现没有作用,nginx-ingress-controller的路由逻辑是去到不同的Path才来处理的, 由于找到对应的值,返回的时候又重新写回了一个,导致它还是不断的变。

看来只能直接给规则文件中的所有的path结尾都添加一个”/”了。如下所示:

spec:
  rules:
  - http:
      paths:
      - backend:
          serviceName: approute-app
          servicePort: 8080
-       path: /app
+       path: /app/

同时考虑到这种cookie应该会话级的,而且后续方便调整,去掉了cookie的超时控制。

-nginx.ingress.kubernetes.io/session-cookie-expires: "172800"
-nginx.ingress.kubernetes.io/session-cookie-max-age: "172800"

后续有什么可借鉴的?

首先,为什么上线前没有发现?简单的说,还是部署方式,组网不同,很多老应用就是基于Session的,而不是无状态的,但是在环境上只起一个实例, 只有在压测等情况才进行扩容。后续要考虑增加集群资源,让此类应用至少可以保持在2个,提前发现问题。

另外,对cookie的设置要慎重,虽然本次的cookie非业务用,但cookie始终是在客户端控制的,变更、维护都不能随心所欲。 同时,必须使用cookie,也要建议尽量使用会话级cookie

使用第三方软件、库,都通读文档,不要直接”拷贝”配置,即使是所谓官方的示例,像这些软件配置项都很多,几十项到几百项不等,通读一下后续分析会有更多印象。 同时,作为知识沉淀,后续也要开展培训分享,树立技术人的标杆。

FAQ

再记录一下过程中涉及的知识点和资料,用于释疑。

  • 修改Path的可能影响

仔细想想,就可以发现,直接在path后面添加”/”虽然可以绕过IE的骚操作, 但是由于旧cookie的存在(非会话级cookie的锅,要忍耐172800秒,也就是2天才能清除影响),仍然会有”会话丢失”的现象,临时解决办法就是清理IE的cookie并重启IE(或者通过一键IE设置脚本重置)。

考虑之前还有几个系统上线也有类似的隐患,后续考虑先去掉cookie的超时控制,过渡一下再修改Path,可以减少影响。

  • cookie的数量限制

之前有案例和材料表明,IE对于某个域名的cookie数量限制是50个。所以也曾怀疑过cookie数量过多导致异常(抓包显示接近50个),但查阅相关资料和测试发现,IE11差不多能支持180个cookie。 想测试一下浏览器的cookie限制,可以参考这个链接:Browser Cookie Limits

  • 第一层nginx反向代理的会话保持策略

目前配置是类似这样的,针对upstream会设置一个sticky指令。

    upstream test {
         sticky expires=1h path=/;
         server a.b.c.d:10086   max_fails=2;
    }

上面也提到,这是nginx-sticky-module-ng模块实现的(通过nginx -V可以看到加载的模块),从文档上可以看出它也是基于cookie的,默认的cookie名称是route。 但是如果你发现你没看到这个route的cookie,那么说明upstream server只有一个,这种情况下,它是不会增加这个cookie的, 可以参考代码

  • nginx-ingress-controller怎么处理同名cookie

上面提到,同名cookie发过去的时候,越具体的Path,越靠前,但服务端的处理没有约定。

对于nginx-ingress-controller来说,它的会话保持功能不是通过nginx stricky模块或者其他方式实现的,是自己通过lua实现的。

通过sticky.lua#L40 看到cookie是由lua-resty-cookie这个模块实现的。

继续深入,可以发现cookie.lua#L109 就是解析cookie放到一个字典去的。

所以后面的cookie会覆盖前面的cookie,这也符合我们处理cookie的常见做法。另外,有个PR23也提到要支持多Path的情况, 不过似乎lua-resty-cookie已经没人维护了。

关于nginx-ingress-controller中worker参数的差异分析

问题描述

生产上发现ingress有个pod超时,重启pod正常以后过几十秒状态又变为异常,检查容器日志报错项:

2021/03/19 01:51:07 [emerg] 263#263: epoll_create() failed (24: No file descriptors available)

对比POD里边的nginx.conf参数,发现有些worker开头的参数比较奇怪。例如worker_processes很大,但是worker_rlimit_nofile很小。

daemon off;
worker_processes 64;
worker_rlimit_nofile 1024;
worker_shutdown_timeout 240s ;
events {
        multi_accept        on;
        worker_connections  16384;
        use                 epoll;
}

临时将worker_rlimit_nofile的值设置为102400,再reload了nginx发现就正常了。

分析过程

  • 由于对nginx-ingress-controller不是很熟悉,所以查看了部署的chart包,发现是没有任何相关配置的。猜想应该是自动生成的。
  • 根据docker部署的经验,worker_processes应该是根据宿主机的cpu计算出来的,而不是通过POD的资源限制。
  • 查看了POD的部署情况,发现的确是没有设置资源限制。
  • 尝试进行资源限制后,发现就正常了。

细节分析

翻了一下nginx-ingress-controller的源码,查找这些值是怎么计算出来的。其中重要的文件如下:

可以发现一些细节。

worker参数的计算方式

  • worker_processes 等于 cpu core
  • worker_rlimit_nofile 等于 最大打开文件数(ulimit –n)/ worker_processes - 1024(如果计算出来小于1024就设置为1024)
  • worker_connections 默认是16384,如果设置0的话,会被调整为worker_rlimit_nofile*3.0/4.0;

由于当前Ingress controller没有资源限制,看到的资源是看到宿主机的cpu core,一般都挺大的,例如64。根据实现规则,假设系统的open files限制,假设配置102400,那么 1024000/64-1024 =576.小于1024,那么就设置为1024。所以会看到一个很小的nofile设置。

关键参数的获取细节

  • 关于cpu core的获取方式,最初也是用直接用的宿主机的,后面就支持了cgroup了,见PR2990
  • 关于最大打开文件数的获取方式,也是变过几次,可以参考追踪nginx ingress最大打开文件数问题,最新的方式和ulimit -n是一致的,和sysctl没有关系。

当前的nginx-ingress-controller增加了一个init容器来进行最大打开文件数的控制:

sysctl -w fs.file-max=1048576

最终在容器里边看到的情况是

bash-5.0$ ulimit -n
809600
bash-5.0$ sysctl fs.file-max
fs.file-max = 1048576

程序读取的就是809600,这个值是怎么来的呢。宿主机看到的是,也是完全不搭边,如下所示。

dggpsprahi09192:~ # ulimit -n 
1024
dggpsprahi09192:~ # sysctl  fs.file-max
fs.file-max = 1048576

那么肯定是docker还做了一点手脚了。查阅suse、docker的材料。最终发现还有几个地方可以控制。

  • 通过docker服务设置
dggpsprahi09192:~ # grep Limit /usr/lib/systemd/system/docker.service
# Having non-zero Limit*s causes performance problems due to accounting overhead
LimitNOFILE=1048576
LimitNPROC=infinity
LimitCORE=infinity
StartLimitBurst=3
StartLimitInterval=60s
  • 通过docker daemon设置

路径在/etc/docker/daemon.json,不过这个宿主机没有这个文件。

	"default-ulimits": {
		"nofile": {
			"Name": "nofile",
			"Hard": 64000,
			"Soft": 64000
		}
	},
  • 通过docker启动参数设置
dggpsprahi09192:/opt/docker # cat /etc/sysconfig/docker
DOCKER_OPTS=" -b none --icc=false --log-level='info' --iptables=true -s devicemapper --default-ulimit nofile=809600:809600 --default-ulimit nproc=131072:131072 --live-restore --storage-driver=devicemapper --storage-opt dm.basesize=10G --storage-opt dm.mountopt=nodiscard --storage-opt dm.fs=ext4 --storage-opt dm.blkdiscard=false --storage-opt=dm.thinpooldev=/dev/mapper/docker-thinpool --log-opt max-size=10m --log-opt max-file=5 --log-driver=json-file --log-level=info --userland-proxy=false"

注意里边有设置–default-ulimit nofile=809600:809600,就是控制最大打开文件数的,和docker中看到的保持一致。

worker参数的手工配置

可以直接通过configmap来配置,覆盖自动计算的方式。参数如下:

  • WorkerProcesses string json:"worker-processes,omitempty"
  • MaxWorkerConnections int json:"max-worker-connections,omitempty"
  • MaxWorkerOpenFiles int json:"max-worker-open-files,omitempty"

详细可以参考官方文档的ConfigMaps

总结

了解nginx-ingress-controller的参数是如何设置之后,考虑到宿主机上的docker都是标准设置的,所以容器里边看到的ulimit -n是一致,结果是可预计的。 另一方面ingress controller不是独享宿主机资源的,还是要限制资源。最终决定通过限制资源来解决,保持资源是Guaranteed的,其他具体的worker参数根据资源自动计算。