使用Kubernetes中的Nginx来改善第三方服务的可靠性和延迟
译自:How we improved third-party availability and latency with Nginx in Kubernetes
本文讨论了如何在Kubernetes中通过配置Nginx缓存来提升第三方服务访问的性能和稳定性。这种方式基于Nginx来实现,优点是不需要进行代码开发即可实现缓存第三方服务的访问,但同时也缺少一些定制化扩展。不支持缓存写操作,多个pod之间由于使用了集中式共享方式,因而缓存缺乏高可用。
使用Nginx作为网关来缓存到第三方服务的访问
第三方依赖
技术公司越来越依赖第三方服务作为其应用栈的一部分。对外部服务的依赖是一种快速拓展并让内部开发者将精力集中在业务上的一种方式,但部分软件的失控可能会导致可靠性和延迟降级。
在Back Market,我们已经将部分产品目录划给了一个第三方服务,我们的团队需要确保能够在自己的Kubernetes集群中快速可靠地访问该产品目录数据。为此,我们使用Nginx作为网关代理来缓存所有第三方API的内部访问。
多集群环境中使用Nginx作为网关来缓存第三方API的访问
使用结果
在我们的场景下,使用网关来缓存第三方的效果很好。在运行几天之后,发现内部服务只有1%的读请求才需要等待第三方的响应。下面是使用网关一周以上的服务请求响应缓存状态分布图:
HIT:缓存中的有效响应 ->使用缓存
STALE:缓存中过期的响应 ->使用缓存,后台调用第三方
UPDATING:缓存中过期的响应(后台已经更新) ->使用缓存
MISS:缓存中没有响应 ->同步调用第三方
即使在第三方下线12小时的情况下,也能够通过缓存保证96%的请求能够得到响应,即保证大部分终端用户不受影响。
内部网关的响应要远快于直接调用第三方API的方式(第三方位于Europe,调用方位于US)。
以 ms 为单位的缓存路径的请求持续时间的 P90(1e3为1秒)
下面看下如何配置和部署Nginx。
Nginx 缓存配置
可以参见官方文档、Nginx缓存配置指南以及完整的配置示例
proxy_cache_path ... max_size=1g inactive=1w;
proxy_ignore_headers Cache-Control Expires Set-Cookie;
proxy_cache_valid 1m;
proxy_cache_use_stale error timeout updating
http_500 http_502 http_503 http_504; proxy_cache_background_update on;
配置的目标是最小化第三方服务的读请求(如HTTP GET)。
如果缓存中不存在响应,则需要等待第三方响应,这也是我们需要尽可能避免的情况,这种现象可能发生在从未请求一个给定的URL或由于响应过期一周而被清除(inactive=1w
),或由于该响应是最新最少使用的,且达到了全局的缓存大小的上限(max_size=1g
)而被清除。
如果响应位于缓存中,当设置proxy_cache_background_update on
时,即使缓存的响应超过1分钟,也会将其直接返回给客户端。如果缓存的响应超过1分钟(proxy_cache_valid 1m
),则后台会调用第三方来刷新缓存。这意味着缓存内容可能并不与第三方同步,但最终是一致的。
当第三方在线且经常使用URLs时,可以认为缓存的TTL是1分钟(加上后台缓存刷新时间)。这种方式非常适用于不经常变更的产品数据。
假设全局缓存大小没有达到上限,如果一周内第三方不可达或出现错误,此时就可以使用缓存的响应。当一周内某个URL完全没有被调用时也会发生这种情况。
为了进一步降低第三方的负载,取消了URL的后台并行刷新功能:
proxy_cache_lock on;
第三方API可能会在其响应中返回自引用绝对链接(如分页链接),因此必须重写URLs来保证这些链接指向正确的网关:
sub_filter 'https:\/\/$proxy_host' '$scheme://$http_host'; sub_filter_last_modified on;
sub_filter_once off;
sub_filter_types application/json;
由于sub_filter
不支持gzip
响应,因此在重写URLs的时候需要禁用gzip
响应:
# Required because sub_filter is incompatible with gzip reponses: proxy_set_header Accept-Encoding "";
回到一开始的配置,可以看到启用了proxy_cache_background_update
,该标志会启用后台更新缓存功能,这种方式听起来不错,但也存在一些限制。
当一个客户端请求触发后台缓存更新(由于缓存状态为STALE
)时,无需等待后台更新响应就会返回缓存的响应(设置proxy_cache_use_stale updating
),但当Nginx后续接收到来自相同客户端连接上的请求时,需要在后台更新响应之后才会处理这些请求(参见ticket)。下面配置可以保证为每个请求都创建一条客户端连接,以此保证所有的请求都可以接收到过期缓存中的响应,不必再等待后台完成缓存更新。
# Required to ensure no request waits for background cache updates:
keepalive_timeout 0;
缺电是客户端需要为每个请求创建一个新的连接。在我们的场景中,成本要低得多,而且这种行为也比让一些客户端随机等待缓存刷新要可预测得多。
Kubernetes部署
上述Nginx配置被打包在了Nginx的非特权容器镜像中,并跟其他web应用一样部署在了Kubernetes集群中。Nginx配置中硬编码的值会通过Nginx容器镜像中的环境变量进行替换(参见Nginx容器镜像文档)。
集群中的网关通过Kubernetes Service进行访问,网关pod的数量是可变的。由于Nginx 缓存依赖本地文件系统,这给缓存持久化带来了问题。
非固定pod的缓存持久化
正如上面的配置中看到的,我们使用了一个非常长的缓存保留时间和一个非常短的缓存有效期来刷新数据(第三方可用的情况下),同时能够在第三方关闭或返回错误时继续使用旧数据提供服务。
我们需要不丢失缓存数据,并在Kubernetes pod扩容启动时能够使用缓存的数据。下面介绍了一种在所有Nginx实例之间共享持久化缓存的方式--通过在pod的本地缓存目录和S3 bucket之间进行同步来实现该功能。每个Nginx pod上除Nginx容器外还部署了两个容器,这两个容器共享了挂载在/mnt/cache
路径下的本地卷emptyDir,两个容器都使用了AWS CLI容器镜像,并依赖内部Vault来获得与AWS通信的凭据。
init容器会在Nginx启动前启动,负责在启动时将S3 bucket中保存的缓存拉取到本地。
aws s3 sync s3://thirdparty-gateway-cache /mnt/cache/complete
除此之外还会启动一个sidecar容器,用于将本地存储中的缓存数据保存到S3 bucket:
while true
do
sleep 600
aws s3 sync /mnt/cache/complete s3://thirdparty-gateway-cache
done
为了避免上传部分写缓存条目到bucket,使用了Nginx的use_temp_path 选项(使用该选项可以将):
proxy_cache_path /mnt/cache/complete ... use_temp_path=on;
proxy_temp_path /mnt/cache/tmp;
默认的aws s3 sync
不会清理bucket中的数据,可以配置bucket回收策略:
<LifecycleConfiguration>
<Rule>
<ID>delete-old-entries</ID>
<Status>Enabled</Status>
<Expiration>
<Days>8</Days>
</Expiration>
</Rule>
</LifecycleConfiguration>
限制
如果可以接受最终一致性且请求是读密集的,那么这种解决方式是一个不错的选项。但它无法为很少访问的后端提供同等的价值,也不支持写请求(POST、DELETE等)。
鉴于使用了纯代理方式,因此它不支持在第三方的基础上提供抽象或自定义。
除非某种类型的客户端服务认证(如通过服务网格头)作为缓存密钥的一部分,否则会在所有客户端服务之间共享缓存结果。这种方式可以提高性能,但也会给需要多级认证来访问第三方数据的内部服务带来问题。我们的场景中不存在这种问题,因为生产数据对内部服务是公开的,且缓存带来的"认证共享"只会影响读请求。
在安全方面,还需要注意,任何可以访问bucket的人都可以读取甚至修改网关的响应。因此需要确保bucket是私有的,只有特定人员才能访问。
集中式的缓存存储会导致缓存共享(即所有pod会共享S3 bucket中的缓存,并在网关扩展时将缓存复制到pod中),因此这不是Nginx推荐的高可用共享缓存。未来我们会尝试实现Nginx缓存的主/备架构。