CDN故障案例content-encoding深入分析

2022-06-14 14:20:44 浏览数 (1)

故障现象:

同事反映在AWS的s3增加自定义header: Content-Encoding:gzip后,通过AWS 的cdn(cloudfront)加速后,chrome浏览器发现无法打开。

于是一起查看,打开chrome浏览器的debug模式,发现chrome浏览器和cloudfront CDN节点是通过H2(HTTP2) over TLS 协议建连的,由于之前碰到多次HTTP2的故障(因为基于http2 over TLS要求的加密套件cipher中算法强度更高,会导致客户端,服务器协商失败,导致http2访问异常), 先让同事禁止掉cloudfront CDN的http2, 禁止后,再次使用chrome访问,确定使用的http1.1, 但仍然报错。 仔细查看报错的字符:content_decoded_fail, 初步判断是由于gzip压缩导致的问题,在S3 资源文件中去掉Content-Encoding:gzip自定义header后,cloudfront加速的静态资源可以正常访问。考虑到AWS技术询问流程过于复杂,没有国内便捷。

便将这个配置在国内公有云的CDN/公有桶上配置一次。chrome浏览器访问报出同样的错误,虽然不知道2家cdn底层的具体实现, 从这2个现象看,cdn的行为是一致的。(多家CDN的表现行为一致,说明此行为大概率就是按照RFC规定实现的), 咨询国内公有云技术支持(主要是询问国内技术团队的技术参数很快就会有结果),得知CDN节点开启了gzip,但是没有开启gzip_vary,公有桶也没有开启压缩策略。

既然两家CDN在配置相同配置情况下,都表现出相同的行为,主流版本nginx极有可能表现出同样行为(目前看像是共性问题)

二. 故障复现:

以nginx1.13.6作为实例例子,在开发环境搭建环境,复现,: 搭建2个节点,一个cdn节点,一个源站

一: CDN节点配置: 1.nginx版本:1.13.6 边缘nginx节点主配置: 开启gzip, 关闭gzip_vary【和公有云CDN保持一致】

代码语言:javascript复制
# gzip upstream回源的时候,启用压缩请求头回源,即带上Accept-Encoding:gzip
upstream npsdk_shot_com_admin_gzip {
server 192.168.94.39:4000; 
check interval=3000 fall=5 rise=2 timeout=3000 default_down=true type=tcp;
keepalive 300;
}
#no gzip, upstream回源的时候,启用非压缩请求头回源
upstream npsdk_shot_com_admin_nogzip {
server 192.168.94.39:3000; 
check interval=3000 fall=5 rise=2 timeout=3000 default_down=true type=tcp;
keepalive 300;
}
#default, upstream回源的时候,客户端的请求头不作任何改变
upstream npsdk_shot_com_admin {
server 192.168.94.39:5000;
check interval=3000 fall=5 rise=2 timeout=3000 default_down=true type=tcp;
keepalive 300;
}
server {
listen 8000; 
server_name repo.ops.ot.ease.com localhost;
access_log /data/logs/openresty/repo.ops.ot.netease.com.log nginxjson;
set $upstream 'npsdk_shot_com_admin';
location /default {
##增加cache,缓存时间24小时
proxy_cache ops;
proxy_cache_valid 200 304 24h;
proxy_cache_key $host$uri$is_args$args;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_http_version 1.1;
proxy_set_header X-real-ip $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for,$server_addr;
#默认行为,回源行为不改变客户端的请求头
#proxy_set_header Accept-Encoding "";
#proxy_ignore_headers Vary;
proxy_pass http://$upstream;
}
location /rs_noogzip {
##增加cache,缓存时间24小时
proxy_cache ops;
proxy_cache_valid 200 304 24h;
proxy_cache_key $host$uri$is_args$args;
set $upstream 'npsdk_shot_com_admin_nogzip';
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_http_version 1.1;
proxy_set_header X-real-ip $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for,$server_addr;
##回源时候,不带上压缩请求头
proxy_set_header Accept-Encoding "";
proxy_ignore_headers Vary;
proxy_pass http://$upstream;
}
location /rs_gzip {
##增加cache,缓存时间24小时
proxy_cache ops;
proxy_cache_valid 200 304 24h;
proxy_cache_key $host$uri$is_args$args;
set $upstream 'npsdk_shot_com_admin_gzip';
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_http_version 1.1;
proxy_set_header X-real-ip $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for,$server_addr;
##回源时候,带上压缩请求头
proxy_set_header Accept-Encoding "gzip";
proxy_ignore_headers Vary;
proxy_pass http://$upstream;
}
}

二 源站节点配置 源站nginx版本:1.13.6 源站主配置:关闭gzip压缩,关闭gzip_vary 测试Server配置增加自定义头:add_header Content-Encoding gzip;

代码语言:javascript复制
server {
listen 3000;
##no zip
server_name _;
access_log /data/logs/openresty/3000.log nginxjson;
set $upstream 'rpgmkt_nodejs';

location / {
#return 200 "test 3000 ok";
root /opt/nginxtest;
}
}
server {
listen 4000;
## gzip
server_name _;
access_log /data/logs/openresty/4000.log nginxjson;
set $upstream 'rpgmkt_nodejs';

location / {
#return 200 "test 4000 ok";
root /opt/nginxtest;
}
} 
server {
listen 5000;
##default
server_name _;
access_log /data/logs/openresty/5000.log nginxjson;

location / {
add_header Content-Encoding gzip;
root /opt/nginxtest;
#return 200 "test 4000 ok";
}
}

通过带压缩头请求

代码语言:javascript复制
#curl 'http://192.168.94.21:8000/default/test.html' -H 'Host:repo.ops.ot.ease.com' -H 'Accept-Encoding: gzip,deflate' 
出现故障(命令行请求居然出现文本内容,应该出现gzip压缩的内容才是正常)

#不带压缩头请求

代码语言:javascript复制
#curl 'http://192.168.94.21:8000/default/test.html' -H 'Host:repo.ops.ot.ease.com' 
正常

通过chrome 浏览器访问,故障可以重现,故障现象和AWS cloudfront , 公有云CDN报的错误一样:CONTENT_DECODED_FAIL, 完成了重现环境的搭建。

三. 故障定位

由于先前有一定的nginx基础,所以很快就找到相应的代码文件,代码段。由于gzip的压缩处理,全部都是由http_gzip_filter_module处理,那么查看http/modules/ngx_http_gzip_filter_module.c 的大体逻辑即可。

代码语言:javascript复制
if (!conf->enable
|| (r->headers_out.status != NGX_HTTP_OK
&& r->headers_out.status != NGX_HTTP_FORBIDDEN
&& r->headers_out.status != NGX_HTTP_NOT_FOUND)
|| (r->headers_out.content_encoding
&& r->headers_out.content_encoding->value.len)
|| (r->headers_out.content_length_n != -1
&& r->headers_out.content_length_n < conf->min_length)
|| ngx_http_test_content_type(r, &conf->types) == NULL
|| r->header_only)
{
return ngx_http_next_header_filter(r);
}
r->gzip_vary = 1;
/* http/modules/ngx_http_gzip_filter_module.c: 261 */
if (!r->gzip_tested) {
if (ngx_http_gzip_ok(r) != NGX_OK) {
return ngx_http_next_header_filter(r);
}
} else if (!r->gzip_ok) {
return ngx_http_next_header_filter(r);
}
/* http/ngx_http_core_module.c: 1915 */
if (r->http_version < clcf->gzip_http_version) {
return NGX_DECLINED;
}

由于upstream response 处理完成后会处理一系列的filter handle, 其中gzip是其中的一个filter handle,其中upstream的响应包头读入 ngx_http_request_t::headers_out结构体中, 即r->headers中。如果upstream返回的数据的包 头字段中必定含有: "Content-Encoding: gzip" 字段, 那么上面的

r→headers_out.content_encoding判断为真,nginx直接跳过gzip_module的处理, 也就是说在源站自定义header: Content-Encoding: gzip 会导致cdn节点不进行压缩,直接跳过return ngx_http_next_header_filter(r); 另外的2个条件分支(本质就是2个条件) 也会导致http_gzip_filter_module不压缩,1. http_version 版本小于 gzip_http_version; 2. 已经存在gzip压缩过的资源(gzip_ok/gzip_testd的值由ngx_http_core_module.c处理,该模块会处理cache文件的内容,读取cache文件中的response header,读取cache文件中的response header, 如果已经存在了gzip的cache赋值r->gzip_ok=1) .

到此,自定义header "Content-Encoding: gzip" 导致chrome报错的原因算是定位到了:由于源站没有开启gzip, cdn回源的时候返回的是非压缩的数据,但是添加了自定义了content-enconding: gzip的头, 该响应头也一并被cdn节点cache到文件, 等chrome浏览器发起压缩请求的时候, cdn节点发现cache文件中response的header中已经存在content-encoding:gzip了,就跳过了gzip压缩过程, chrome浏览器接收到了非压缩的数据(但带上了content-encoding:gzip的头), 于是使用gzip去解压未压缩的内容,报: CONTENT_DECODED_FAIL。

四. 问题扩展以及修复方案

需要彻底fix这个问题,需要了解http vary这个机制了,http vary的机制简单来说: 服务端根据客户端发送的请求头中某些字段自动发送最合适的版本, 例如: 以gzip_vary为例,客户端发起压缩请求(带Accept-Encoding:gzip,br,deflate),客户端发起的非压缩请求(不带该header), 服务器端根据请求不同分发给客户端gzip压缩内容,非gzip压缩版本的内容。 由于我们只是看了部分的源代码,不排除有其他的入口,所以不排除有其他的入口, 所以仍然需要调试和小心求证.

观察以下技术指标点:

  1. CDN缓存的文件名,大小,cache缓存文件的个数
  2. CDN缓存文件中的response header变化
  3. 非压缩请求response的Etag,content-encoding变化
  4. 压缩请求response的Etag,content-encoding变化

CDN的缓存规则: proxy_cache_key hosturiis_argsargs, CDN完全透传请求头到源站(不会proxy_set_header修改客户端请求头)

以下是考察的场景:

1. 源站和cdn节点都不开启gzip_vary, 源站和cdn节点都开启gzip 2. 源站和cdn节点开启gzip_vary,源站和cdn节点都开启gzip 3. cdn节点开启gzip_vary,源站不开启gzip_vary, cdn节点开启gzip, 源站开启gzip, 4. cdn节点不开启gzip_vary,cdn节点开启gzip, 源站不开启gzip 5. cdn节点不开启压缩,源站也不开启压缩,前后两者都不开启gzip_vary

场景1中: 1.1 如果客户端是第一次发起非压缩请求,那么cdn会透传请求头到源站,以非压缩请求发送到源站,源站返回非压缩的内容给cdn节点, cdn节点缓存非压缩内容,接着客户端第二次发起带压缩的请求, 由于cdn没有开启gzip_vary, 带压缩头的请求和非带压缩头的请求都会命中proxy_cache_key hosturiis_argsargs, 所以CDN节点将非压缩的内容发送给客户端,刚才在http_gzip_filter_module代码中观察到,cdn节点读取cache文件的response头部,此时的response头部没有content-encoding:gzip, 所以r->gzip_ok非真,仍然要走压缩过程,所以cdn节点取出非压缩版本内容然后压缩再发送给用户端。

1.2 如果客户端是第一次发起压缩请求,那么cdn会透传请求头到源站,以压缩请求发送到源站,源站返回压缩的内容给cdn节点, cdn节点缓存压缩内容,接着客户端第二次发起带压缩的请求,由于cdn没有开启gzip_vary, 带压缩头的请求和非带压缩头的请求都会命中proxy_cache_key hosturiis_argsargs, 所以CDN节点将压缩的内容发送给客户端,刚才在http_gzip_filter_module代码中观察到,cdn节点读取cache文件的response头部,此时的response头部存在content-encoding:gzip, 所以r->gzip_ok真,不需要走压缩过程,所以cdn节点取出压缩版本内容然后压缩再发送给用户端。再接着用户端发起一个非压缩的请求,那就出现灾难了,因为带压缩的请求和非带压缩的请求都命中同一个cache文件,cdn直接将cache文件发送给客户端,结果就出现问题,客户端请求非压缩内容,结果得到了压缩的内容。一般这种情况下,服务器端会出现,浏览器类型的客户端很少出现,因为一般的浏览器都是发送要求的请求头。

场景2,3,4,5类似

0 人点赞