0%

Nginx怎样隐藏上游错误

当上游出错时,作为负载均衡的Nginx可以实时更换Server,在客户端无感知的情况下重新转发HTTP请求。这一功能在Nginx指令中称为next upstream,本文将详细介绍其用法及实现原理。

在OSI网络模型中,传输层的TCP协议通过内核提供的系统调用向Nginx反馈错误,表示层的TLS/SSL协议通过openssl库向Nginx返回错误,而应用层的HTTP协议(或者uwsgi、gRPC、CGI、memcached等协议)通过Response的Decode解码流程返回错误。当Nginx能够通过重试解决这些错误时,我们可以使用next upstream机制对客户端隐藏个别上游Server由于宕机、网络异常产生的错误,这可以极大的提升整个分布式系统的可用性

如果我们不清楚它处理协议错误及重试转发的原理,就很容易在实际场景中发现next upstream没有发挥作用,比如:

  • proxy_request_buffering功能关闭后,一旦Nginx转发了请求包体,它就会释放掉内存中缓存的内容,从而失去了next upstream的重试能力。
  • 从上游接收到完整的HTTP头部后Nginx就会向下游客户端转发,由于TCP协议是有序字符流,一经发出就无法更改,此时从HTTP语法层面上也会失去next upstream能力。
  • POST方法属于idempotent非幂等方法,所以从HTTP语义层面上next upstream功能也不会开启(默认配置下)。
    等等。可见,next upstream是否能够按预期工作(遵照proxy_next_upstream_tries、proxy_next_upstream_timeout等指令),需要我们对它有深入的理解。

本文将介绍Nginx作为代理服务器转发请求时,next upstream机制检测错误并重新转发给上游的执行流程。虽然本文例子中的指令属于HTTP/1模块,但在最后我会将官方提供的6个代理模块放在一起做个比较,在对比中你会更深入的了解upstream机制。同时,本文也是Nginx开源社区基础培训系列课程第二季,即7月23日晚第3次直播课的文字总结。

TCP传输层的错误处理

作为负载均衡,Nginx可以在OSI网络模型的多个层级中检测、处理错误,我们首先来看Nginx在TCP传输层是如何应用next upstram机制的。

TCP层的错误主要体现在三次握手与数据传输中,是否能够及时接收到对方返回的ACK确认帧。由于TCP协议实现了有序字节流的可靠传输,所以HTTP、gRPC、CGI、memcached等协议都是基于TCP实现的。因此,Nginx向上游转发请求前,需要先通过三次握手建立TCP连接。关于3次握手的流程,你可以参见下图,这里不再详述。

当Nginx作为客户端发起三次握手时,它会向上游Server监听的端口上发送SYN报文。在以下2种情况下,Nginx会认为3次握手建立失败:
接收到对方返回的RST重置报文。通常,这发生在上游对应的应用程序未启动,或者进程没有监听相应的端口;
在proxy_connect_timeout时间内(默认60秒),没有接收到对方返回的SYN+ACK报文。

在以上场景中,Nginx默认会开启next upstream功能。这是因为,XXX_next_upstream指令拥有默认值error和timeout,其中error对应了协议层错误,而timeout则将Nginx指令定义的超时错误单独拎了出来。所有基于TCP的应用层协议都有这一特性,下面以HTTP/1代理模块为例,探究各指令的用法:

1
2
3
4
5
Syntax:	proxy_next_upstream error | timeout | invalid_header | 
http_500 | http_502 | http_503 | http_504 | http_403 | http_404 | http_429 |
non_idempotent | off ...;
Default: proxy_next_upstream error timeout;
Context: http, server, location

一旦连接建立成功,Nginx就会基于代理模块适配的应用层协议转发请求。TCP协议要求对于发送的每个字节,接收端都要通过ACK报文中的累计确认序列号进行反馈,一旦在RTO(TCP Retransmission Timeout)时间内未收到ACK确认,操作系统的内核就会重发TCP报文,如下图所示:

内核会为每个socket建立发送、接收缓冲区。如果大量发送报文得不到确认,那么发送缓冲区(它是动态调整的,可通过tcp_wmem修改范围)就没有空闲位置,这样一旦Nginx中的epoll_wait函数在proxy_send_timeout秒内都没有返回写事件,就会触发timeout错误

1
2
3
Syntax:	proxy_send_timeout time;
Default: proxy_send_timeout 60s;
Context: http, server, location

当转发完请求,接收响应的过程中,如果epoll_wait两次返回读事件的间隔超过了proxy_read_timeout秒,也会触发timeout错误

1
2
3
Syntax:	proxy_read_timeout time;
Default: proxy_read_timeout 60s;
Context: http, server, location

当Nginx未完成完整的转发流程时,服务器接收到的RST或者FIN报文会试图关闭TCP连接,此时都会通过epoll_wait函数触发error错误。只要proxy_next_upstream指令后加入了error和timeout选项,且Nginx还拥有转发完整请求的能力,next upstream机制就会生效,Nginx会基于负载均衡规则,重新挑选1个可用Server转发请求。

Nginx还提供了proxy_next_upstream_tries指令,用于限制重试次数(默认值是0,表示不加限制):

1
2
3
Syntax:	proxy_next_upstream_tries number;
Default: proxy_next_upstream_tries 0;
Context: http, server, location

通过proxy_next_upstream_timeout指令还可以限制更换上游Server转发请求的总时长(默认不加限制)。注意,该时长的起始时间是从首次转发请求算起(而不是每次更换上游Server时重新计算),而截止时间则是最后1次检测next upstream是否允许使用的时刻

1
2
3
Syntax:	proxy_next_upstream_timeout time;
Default: proxy_next_upstream_timeout 0;
Context: http, server, location

任何时候Nginx与下游的TCP连接出错时,next upstream机制都会失效,因为Nginx失去了转发HTTP响应的能力。

TLS表示层的错误处理

再来看Nginx如何处理表示层TLS/SSL协议的错误。TLS会话的建立需要通过握手完成,如下所示:

TLS握手需要完成密钥协商和证书验证工作,通常需要2个RTT的时延(TLS1.3需要1个RTT),这一过程会复用proxy_connect_timeout指令标识的超时时间。一旦TLS握手超时,同样遵循timeout错误的处理方式。

在TLS握手过程中,Nginx还可以核验上游Server返回的证书链,以及SNI(Server Name Indication)插件中的域名(参见RFC6066)。你可以在proxy_ssl_verify指令中打开这一功能:

1
2
3
Syntax:	proxy_ssl_verify on | off;
Default: proxy_ssl_verify off;
Context: http, server, location

Nginx会将服务器发来证书中SNI插件中的域名,与proxy_ssl_name指令中的变量做比较:

1
2
3
Syntax:	proxy_ssl_name name;
Default: proxy_ssl_name $proxy_host;
Context: http, server, location

proxy_ssl_name的默认值是proxy_host变量,它等于proxy_pass指令后的域名。需要注意,一旦证书链或者SNI域名验证失败,next upstream机制将按error错误处理

应用层错误处理

一旦应用层在协议层面返回了正确的Response响应,但从语义上却是错误的,Nginx同样可以启用next upstream机制。下图是TCP层、TLS层与应用层结合在一起后,next upstream的工作流程示意图:

我们先以HTTP/1协议为例介绍应用层错误的处理方式,再通过它来对比其他应用层协议。对于符合REST规范的HTTP消息,响应码应当能够准确地描述应用层错误,比如,2xx错误码通常表示成功,4xx错误码表示请求参数有问题,而5xx错误码表示服务器出现故障。基于RFC中对各错误码的定义,Nginx允许对以下7种可以进行重试的错误码启用next upstream功能:

响应码 字符串描述 含义
403 Forbidden 服务器理解请求的含义,但没有权限执行此请求
404 Not Found 服务器没有找到对应的资源
429 Too Many Requests 客户端发送请求的速率过快(Nginx版本 >= 1.11.13时提供)。
500 Internal Server Error 服务器内部错误,且不属于其他5xx错误类型
502 Bad Gateway 代理服务器无法获取到合法响应
503 Server Unavailable 服务器资源尚未准备好处理当前请求
504 Gateway Timeout 代理服务器无法及时的从上游获得响应

当然, Nginx默认会将以上错误响应码及包体转发给客户端。有些时候,你可能只是想转换这些错误码,以另一种方式向用户体现业务的处理结果,而不是换一个上游Server重新转发请求。比如,当上游返回404错误时,改为通过200返回一张找不到资源的图片。此时,可以通过proxy_intercept_errors指令完成这一功能:

1
2
3
Syntax:	proxy_intercept_errors on | off;
Default: proxy_intercept_errors off;
Context: http, server, location

当proxy_intercept_errors开启后,对于上游返回的大于等于300响应码的请求,都可以基于error_page指令继续处理:
Syntax: error_page code … [=[response] uri;
Default: -
Context: http, server, location, if in location

比如,对于上游返回的404错误码,以200的方式返回一个本地文件404_not_found.html,就可以做如下配置:

1
2
3
4
5
6
7
8
location /ih {
proxy_pass http://ihBackend;
proxy_intercept_errors on;
error_page 404 = /404.html;
}
location = /404.html {
alias html/404_not_found.html;
}

如果你希望对这7个错误码启用next upstream机制,可以在proxy_next_upstream指令的选项中添加相应的错误码,比如http_500就表示上游Server返回的500错误码:

1
2
3
4
5
Syntax:	proxy_next_upstream error | timeout | invalid_header | 
http_500 | http_502 | http_503 | http_504 | http_403 | http_404 | http_429 |
non_idempotent | off ...;
Default: proxy_next_upstream error timeout;
Context: http, server, location

由于HTTP协议分为头部和包体两部分,对于头部有特定的格式要求,如果上游返回的HTTP头部不符合规范(在error.log日志中可以看到“upstream sent invalid header”字样的log),你可以通过invalid_header选项开启next upstream功能。另外,服务器需要在内存中缓存完整的HTTP头部,才能决定包体的处理方式,如果上游返回的HTTP头部体积超过了proxy_buffer_size指令设置的值(在error.log日志中可以看到“upstream sent too big header”字样的log),也会遵照invalid_header选项的处理方式。

对于HTTP请求方法而言,如果严格遵照REST架构,那么如GET/HEAD这样获取资源的方法是具备幂等性idempotent(参见RFC7231)的,即无论执行多少次,都会获得相同的结果。PUT方法会整体覆盖资源,DELETE是删除资源,这两个方法也具有幂等性。对于在语义上具备幂等性的请求,Nginx默认会启动next upstream功能。

然而,POST方法通过FORM表单修改资源属性,PATCH方法以补丁方式修改资源的部分内容,LOCK方法基于WebDAV规范对资源加锁,这3个方法都不具备幂等性,所以Nginx默认并不会对这3个方法启用next upstream机制。你可以通过proxy_next_upstream中的non_idempotent选项对非幂等方法启用该功能。

官方提供的七层反向代理除了HTTP/1协议外,还有fastcgi、scgi、uwsgi、memcached、grpc这5个模块,它们都使用了TCP协议,且可以与HTTP协议互相转换,因此它们的next upstream与上述HTTP协议的指令极为相似。唯一的差别在于_next_upstream指令后的选项,我把它们的差别列在下表中:

_next_upstream http fastcgi scgi uwsgi memcached grpc
error
timeout
invalid_header √/invalid_response
http_500 x
http_502 x x x x
http_503 x
http_504 x x x x
http_403 x
http_404 √/not_found
http_429 x
non_idempotent x

这里√表示纵轴对应的协议具有proxy_next_upstream选项中的功能,而x则因为协议做过转换后,不再提供这一选项。其中,memcached由于不存在HTTP头部,所以通过invalid_response选项表示invalide_header错误,并以not_found表示了与HTTP_404同样的含义。而fastcgi、scgi、uwsgi通常是与本机进程通讯,所以没有502、504这两种与网络密切相关的错误码。

小结

最后对本文内容做个总结。

当Nginx检测到系统调用返回的传输层错误、openssl返回的表示层错误或者协议解码返回的应用层错误时,在逻辑上允许重试的前提下,可以通过next upstream机制更换上游Server,在客户端无感知的情况下完成请求的转发,大大提升了系统可用性。

HTTP/1、gRPC、scgi、fastcgi、uwsgi、memcached等所有Nginx代理模块,都支持next upstream机制,但由于它们基于不同的通讯协议,所以在语法、语义上有不同的表现,这些会反映在_next_upstream指令的选项上。当你熟悉了1种协议的next upstream工作原理,可以触类旁通地理解其他协议。

下一篇,我们将讨论如何在应用层实时控制Nginx代理的行为。