--- 本文原文发布在 https://www.3pjgames.com/archives/229
第一款游戏上线之后,陆续发现了一些诡异的问题。其中一个是通过 360 SDK 登录的用户在进行服务器验证的时候,偶尔会出现 HTTP 客户端没有收到任何回应就返回的情况。怪异的是当尝试手动发送请求的时候,却无法重现,即使是用脚本模拟不停的发送请求。后来陆续发现其它一些渠道,像豌豆荚也出现类似情况。
经过差不多一天的研究和分析,最终发现是这些登录验证服务提供的是 HTTPS 接口,但是可能服务器版本比较老,并没有完全符合 HTTPS 协议中的一些标准。而我们使用的客户端 typhoeus 做了非常多的性能优化,其中对 HTTPS 的一项优化最终导致了 TLS 握手失败,客户端没有收到任何回应就返回了。
TLDR; 问题出在 typhoeus 启用了 SSL session id cache,而这些渠道提供的 HTTPS 服务器未能正确处理该标准 (RFC5246)。解决方案只能是牺牲性能,将连接选项中的 :ssl_sessionid_cache
设置为 false
。Typhoeus 是对 libcurl 的封装,对应的选项是 CURLOPT_SSL_SESSIONID_CACHE
。如果使用了其它基于 libcurl 的 HTTP 客户端启用了这项优化,也需要找到地方关闭这个选项。
我们通过统一的网关来集成各种第三方 SDK 的登录验证,该服务使用 Ruby 实现,使用了 HTTP 客户端框架 Faraday,底层传输使用了 typoeus,而 typoeus 又是对 libcurl 的封装。
问题发生之后,首先想到的还是网络问题,另外线上问题需要快速解决,所以首先加上了自动重试。Faraday 已经提供了 retry 中间件,只需要在出现这种问题的时候抛出特定类型的异常就可以了。因为没返回结果的时候 status code 是 0,加个中间件去检查 status code 就行了。
当 status 为 0 的时候抛出 EmptyResponseError
class EmptyResponseError < HttpSdkError; end
class CheckEmptyResponse < Faraday::Response::Middleware
def on_complete(env)
if env[:status].to_i == 0
raise EmptyResponseError
end
end
end
当抛出 EmptyResponseError
的时候重试两次。注意 response 中间件是以相反的顺序处理,中间件 CheckEmptyResponse
加到最后面让它最先处理返回结果。
conn = Faraday.new('https://openapi.360.cn') do |builder|
builder.request :retry, max: 2, interval: 0.05,
interval_randomness: 0.5, backoff_factor: 2,
exceptions: [EmptyResponseError]
builder.request :json
builder.response :json
builder.use CheckEmptyResponse
builder.adapter :typhoeus
end
加了之后还是会出现连续 3 次都是空结果的情况,但是频率已经大大降低。
加上重试问题的影响大大降低,也赢得时间去彻底解决问题。
我们自架了 Sentry 来收集各种异常,问题最初就是通过 Sentry 的通知发现的。但是因为出错时,HTTP 客户端没有收到返回,所以完全无法得知原因,而手动改送请求或者通过 Sentry 的『重放』都能拿到正确的返回结果,无法重现。
首先想到的是加日志,于是在向 360 发送请求时开启了 Faraday 的中间件 logger
来打印更详细的日志,但是依然未能发现问题。于是只能去看 typoeus 的源代码,发现可以通过全局设置来显示更多的信息:
Typhoeus::Config.verbose = true
但是另外一个问题是没办法手动去重现,只能等着问题发生。虽然这个问题隔一段时间就会出现,可是有时候要等上几个小时。这会导致添加日志和尝试修改之后确认的反馈环节被拉长,可以要几天才能有点眉目,很难排除无效的修复方案。
之前提到过用脚本模拟循环发也没办法重现,但是线上却一定会发生,于是想到通过解析日志,重发所有 360 的请求。这个方法果然有效,终于能比较快速的去获得反馈了,而且可以在出现问题的时候,在脚本中插入 pry 进行检查,大大加快了定位问题的速度。最终通过 Typhoeus 的日志可以确定问题是因为 TLS 握手失败了
Hostname was found in DNS cache
Trying 220.181.132.231...
Connected to openapi.360.cn (220.181.132.231) port 443 (#2)
successfully set certificate verify locations:
CAfile: none
CApath: /etc/ssl/certs
SSL re-using session ID
SSLv3, TLS handshake, Client hello (1):
...
SSLv3, TLS alert, Server hello (2):
Ferror:1408F10B:SSL routines:SSL3_GET_RECORD:wrong version number
Closing connection 2
找到问题原因但是怎么解决还是一头雾水,求助 Google 也基本说得是证书问题。然而关闭 peer verification 并没有什么用。也有提到 openssl 版本问题的,安装了 libcurl4-openssl-dev
,更新了 gems faraday
和 typhoeus
也没有任何用。
不过对比成功和失败请求的 Typoeus 请求,注意到所有空结果都会包含
SSL re-using session ID
一开始以为是 keep alive 导致的问题,关闭之后也没能解决问题。最后定位到 RFC5246,而且在 Mac 下 man curl
也提到
--no-sessionid (SSL) Disable curl's use of SSL session-ID caching. By default all transfers are done using the cache. Note that while nothing should ever get hurt by attempting to reuse SSL session-IDs, there seem to be broken SSL implementations in the wild that may require you to disable this in order for you to succeed. (Added in 7.16.0) Note that this is the negated option name documented. You can thus use --sessionid to enforce session-ID caching.
简要来说就是 sessionid 缓存不会带来任何坏处,但是这个世界上还有很出有问题的 SSL 实现,这时可以通过该选项禁用。
通过这个线索去 typhoeus 源码里去搜索相关选项,最终找到 ssl_sessionid_cache
。不过因为 faraday 没有暴露出该选项的设置,所以采用了 monkey pack 的方式强制将该选项设置为 false
require 'typhoeus/adapters/faraday'
module TyphoeusRequestPatch
def request(env)
req = super(env)
req.options[:ssl_sessionid_cache] = false
req
end
end
Faraday::Adapter::Typhoeus.prepend TyphoeusRequestPatch
测试之后问题解决。
这也说明了为何手动难重现,因为这个问题只会在 keep alive 的连接失效,而 session id 的缓存又还没失效的窗口之间发生。
国内环境中使用 HTTPS 各种坑,这里提到的是服务端的问题。另外在手机客户端上也是一堆问题,比如相当一部份的国产 Android 系统手机上不信任 GoDaddy 颁发的证书,有些低端机上发送 HTTPS 请求会静默失败,没有返回结果也不报任何错。所以不管前端、后端,碰到了 HTTPS,预先考虑各种情况,记录下详细日志方便日后调试。