之前的文章里提到,由于Unicorn自身的设计定位,导致Unicorn不建议直接面向客户端收发请求。按照Unicorn的推荐,建议在其前端加上nginx作为客户端请求的反向代理器。由于我们的所有服务都运行在Docker集群中。于是便顺利成章的有了下面的配置流程:
- 首先在docker中创建一个自定义overlay网络,以便于nginx找到后端的服务。
- 其次,创建一个运行unicorn的服务,并加入到第1步中创建的overlay网络。由于该服务并不直接对外提供服务,因此不需要publish端口。这里假定该服务name为backend,unicorn在里面监听3000端口。
- 最后创建一个运行nginx的服务,也加入第1步创建的overlay网络。并将该服务里nginx监听的80端口对外publish。并设置nginx的配置如下:
server {
listen 80 default_server;
location / {
proxy_pass http://backend:3000;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $http_host;
}
}
一开始,启动两个服务后,通过nginx来访问unicorn,没有什么问题。但是当服务部署多了之后就会发现问题。在单独更新backend服务而不更新nginx,或者nginx更新时backend还没更新完成时,会发生两种奇怪的现象:
- 通过nginx代理访问unicorn服务,会出现后端不可达的5XX错误,比如502。
- 通过nginx代理访问unicorn服务,会出现串访的情况。这种情况更让人摸不着头脑。
什么叫“串访”呢?比如,启动100个nginx服务frontend1~frontend100,分别作为后端unicorn服务backend1~backend100的反响代理。正常情况下,访问frontendN得到的就是backendN的响应。但实际上会出现,访问frontendN得到backendM的结果。
当然从现在我解决了问题后,开了上帝视角说起来很简单。但当时出现这种情况时,排查和定位过程很繁杂,也没有什么头绪。感觉就像是撞鬼了一样。那么从结果来看,看看我们如何确认问题的。在出现问题后:
- 首先,我们让原本不对外publish端口的backend服务全部对外publish。然后直接访问backend服务publish的端口。这样可以从源头上检查backend服务本身有没有问题。结果发现可以正常访问,不会出现串访。
- 其次,我们进入到nginx容器内,手动访问
proxy_pass
指定的后端服务。这样我们可以检查docker集群内,overlay网络中nginx和backend服务通信之间有没有问题。结果发现也可以正常访问,不会出现串访; - 再次,我们继续在nginx容器内,通过localhost手动访问nginx监听的80端口。这样可以检查nginx本身是否工作正常。结果发现有问题。
- 最后,我们也顺带在客户端通过nginx服务publish的端口访问。结果现象和第3步一样,说明docker swarm的负载均衡器没有问题。
问题就确定在nginx自身了。
由于我们在nginx的proxy_pass
后端中指定后端时,是通过backendN这样的域名(别名)来访问的。根据经验,nginx最后肯定要解析成IP地址进行访问。那么会不会是这个地方的问题?于是我们通过以下步骤来验证(这也是简单的复现该问题的办法):
- 创建一个新的overlay网络,通过
--ip-range
参数配置网络中容器的IP段。比如:配置为192.168.0.0/29
限制可用地址数量为6个。 - 启动一个nginx服务frontend1,用于反向代理后端的backend1。
- 启动多个replicas为1的unicorn服务backend1~backendN,数量足以覆盖绝大多数overlay的可用地址。
- 测试访问frontend1,确保正常返回backend1的响应。
- 在nginx容器中,通过
nslookup tasks.backend1
,检查后端的IP地址,并记录。 - 尝试多次全部重启所有的unicorn容器。直到测试访问异常,最好是测试到串访的情况。比如正常返回结果应该是backend1,但是返回backendX。
- 此时进入nginx容器,再通过
nslookup tasks.backend1
,检查后端的IP地址。会发现和前面第5步的IP地址(完全)不同。并且,此时通过docker network inspect检查我们创建的overlay网络。会发现,原来第5步的IP地址,这个时候被分配到backendX的服务容器上了。
问题验证结束。说明问题的根源是什么?根源就是nginx服务frontend1启动的时候,会缓存后端backend1的IP地址。此后就算是backend1的IP地址变了(通过第二次nslookup可以确认),但是nginx仍然熟视无睹。
通过Google搜索nginx dns cache,会发现Nginx的确是这么干的,而且干得很坑爹。我截取其中的内容(参考文档DNS for Service Discovery with NGINX)如下:
- If the domain name can’t be resolved, NGINX fails to start or reload its configuration.
- NGINX caches the DNS records until the next restart or configuration reload, ignoring the records’ TTL values.
- We can’t specify another load‑balancing algorithm, nor can we configure passive health checks or other features defined by parameters to the server directive, which we’ll describe in the next section. 看到没有,Nginx大爷说了,在nginx启动的时候他就会去解析IP地址。解析完后就缓存起来,并且对解析结果的TTL不管不顾。坑了个大爹啊!
当然,这段文本说的通过proxy_pass直接指定后端的方式。通过proxy_pass指定后端组也是类似的。
而如果不想出现这种情况,而是每次都解析,那么Nginx也给出了办法,即使用变量设置proxy_pass的后端,并且配合resolver指令设置DNS服务器。配置参考如下:
server {
...
location / {
resolver 8.8.8.8;
set $backend http://backend1:3000;
proxy_pass $backend;
}
}
这个方案里,通过变量指定这个很容易。关键是通过resolver指定DNS服务器地址这个很坑爹。因为在单独的服务器,我们直接使用ISP或者DNS服务商的DNS服务器就可以了。但是docker overlay网络里,这个DNS服务器必须是docker内置的才行,并且没有对外提供服务。
好在Docker可能早就意识到这个问题了,把所有容器内的DNS服务器地址固定为127.0.0.11(参考文档:Embedded DNS server in user-defined networks)。所以,对应到我们这里,就可以把原来的nginx配置文件改为下面的格式:
server {
listen 80 default_server;
location / {
resolver 127.0.0.11;
set $backend http://backend1:3000;
proxy_pass $backend;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $http_host;
}
}
然后重复前面的验证过程,会发现问题就得到了解决。
参考文档: