一次ETag不规范引发的下载错误

问题背景与现象

之前我们在CDN源站为部分项目做过一个优化,也就是安卓多渠道安装包在CDN场景下的差分打包、存储、分发,具体项目内容在这里不做过多解释,随着优化方案的上线,陆陆续续有几个运营同学找过来说有些安装包无法正常下载,具体现象为 用浏览器下载到百分之九十九之后,会提示发生网络错误,点击重试或者继续按钮后,会重新开始下载

PS: 我们CDN节点的关键进程可以参考下图:

Nginx用于处理业务相关的HTTP请求,ATS用于文件的缓存与回源。

排查过程

  1. 利用curl将请求强制解析到有问题的CDN节点,下载对应的安装包,发现确实是会卡在最后的一个分片,但是如果构造range请求,发现可以正确下载到文件的最后一个分片,并且文件分片正确。

  2. 由于在CDN节点上,我们做了文件的分片存储,每个分片大小为1MB,所以可以利用二分法,定位有问题的分片内容,最终找到是中间的某个分片存在异常,其异常点主要表现为 HTTP的响应头content-length 并不是正常的1048576,而是0 , 但是content-range却是正确的,如下:

    HTTP/1.1 206 Partial Content
    Server: xxxxx
    Date: Fri, 20 Aug 2021 11:33:39 GMT
    Content-Type: application/vnd.android.package-archive
    Content-Disposition: attachment; filename="parent.apk"
    X-Split-Point: 2118123520
    Last-Modified: Fri, 20 Aug 2021 06:30:59 GMT
    ETag: 5a62842782890237774b5f54e801edca
    Access-Control-Allow-Origin: *
    X-Parent-File: parent.apk
    X-Real-Length: 1476358
    Expires: Sat, 20 Aug 2022 11:02:56 GMT
    Cache-Control: max-age=31536000
    X-Cache: hit-fresh
    Accept-Ranges: bytes
    Content-Range: bytes 1819279360-1820327935/2119599878
    Content-Length: 0
    Age: 231253
    Connection: keep-alive
    
    • 由于该分片在ATS的错误缓存,如果直接对ATS进行Range请求的话,结果如上面所示,但是如果从用户的角度,通过Nginx向上游ATS请求该分片的话,由于ATS响应了正确的Content-Range,Nginx忽略ATS响应头的Content-Length,并根据Content-Range重新计算Content-Length,并且把这个正确的Content-Length响应给客户端,最终就导致了文件的蹿位缺失,客户会一直等待Nginx给返回body,造成背景中提到的现象,直到连接超时。
    • 这里其实还有一个非常不容易被发现的问题,即ETag的值不符合规范,根据 RFC7232 中对ETag的格式规范,无论是强ETag还是弱ETag,ETag的值都需要被放在引号中。而这里的值没有引号。
  3. 下面通过ATS的回源逻辑来排查定位为什么会造成这种现象。我们 ATS 分片回源插件是根据 https://github.com/oxwangfeng/ats_slice_range 进行二次开发的,其关键逻辑可以参考这里: http://blog.chinaunix.net/uid-13776576-id-5749765.html

  4. 既然是ATS的回源问题,那么顺着CDN回源链路往上游的CDN二级节点找,发现了当时故障现场的一条日志,如下(去掉了部分敏感信息):

    12.xxx.xx.xx - - [20/Aug/2021:19:28:49 +0800] "GET https://foo.com/bar.apk HTTP/1.1" 200 2119600580 "-" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3770.142 Safari/537.36" 56.914 1048576 "1049344" "bytes=1819279360-1820327935" 206 hit-fresh 
    

    CDN节点的ATS在回源二级节点时,做了分片回源,请求的是1819279360-1820327935范围大小的文件,但是二级节点却给边缘节点的nginx ATS响应了整个包体,即响应http 状态码200,并且是完整的2119600580字节。

  5. 看样子问题点出在二级节点的nginx中两个关于处理range请求的模块,分别为ngx_http_slice_filter_modulengx_http_range_filter_module,最终确认是如果客户端在下载某个文件时,如果中间发生了意外的网络问题,某些浏览器如chrome在重试时会携带If-Range头部,If-Range的值包含两种( https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-Range ),一种是last-modified,另外一种是etag,nginx的range模块如果对If-Range的值校验不通过,则会响应200,否则响应206。所以正是由于客户端携带了该头部,并且源站给响应的分片的HTTP响应头中,ETag格式是不符合rfc规范的,导致二级节点nginx处理请求时作为非range请求处理返回200。问题原因找到。

    以下为nginx ngx_http_range_filter_module 相关的处理逻辑:

    if (r->headers_in.if_range) {
    
        if_range = &r->headers_in.if_range->value;
    
        if (if_range->len >= 2 && if_range->data[if_range->len - 1] == '"') {
    
            if (r->headers_out.etag == NULL) {
                goto next_filter;
            }
    
            etag = &r->headers_out.etag->value;
    
            ngx_log_debug2(NGX_LOG_DEBUG_HTTP, r->connection->log, 0,
                            "http ir:%V etag:%V", if_range, etag);
    
            if (if_range->len != etag->len
                || ngx_strncmp(if_range->data, etag->data, etag->len) != 0)
            {
                goto next_filter;
            }
    
            goto parse;
        }
    
        if (r->headers_out.last_modified_time == (time_t) -1) {
            goto next_filter;
        }
    
        if_range_time = ngx_parse_http_time(if_range->data, if_range->len);
    
        ngx_log_debug2(NGX_LOG_DEBUG_HTTP, r->connection->log, 0,
                        "http ir:%T lm:%T",
                        if_range_time, r->headers_out.last_modified_time);
    
        if (if_range_time != r->headers_out.last_modified_time) {
            goto next_filter;
        }
    }
    

    对于客户端带了If-Range的http请求头的处理,nginx的行为如下:

    1. 如果If-Range的值的最后一个字符为 双引号,则认为其为ETag,会把该值当作ETag进行校验,如果成功,返回206
    2. 如果If-Range的值的最后一个字符不是 双引号,则认为其为Last-Modified,与http上游响应头的Last-Modified进行对比,如果成功,返回206,否则返回200 。

扩展学习

HTTP conditional requests

在http中,有一个条件请求的概念,它主要用于客户端和服务端对http请求/响应的缓存内容的校验,以保证内容的完整性。例如,在客户端恢复下载时,或者在上传、修复服务器上的文档时,防止丢失更新。

HTTP条件请求定义了如下的一系列相关头部,这些头部会作为一种前提条件,最终能否匹配会影响到服务端给客户端的http响应结果。HTTP规范中,认为GET请求是安全的,客户端/服务端可以附带上条件请求头,从而可以节省客户端、服务端的传输带宽。而对于PUT请求,是非安全的,条件请求头只可以用在上传内容到服务端的情况,并且修改的是服务端已存在的内容。

  • If-Match: 属于强校验,用于Range请求的GET/HEAD方法中,值为一个ETag或者多个ETag的列表,服务端只有在强ETag匹配到时(不校验弱ETag),才会响应206,否则响应416 。
  • If-None-Match:属于强校验,值同样为一个ETag或者多个ETag的列表,对于 GET/HEAD 方法来说,当验证失败的时候,服务器端必须返回响应码 304 (Not Modified)。对于能够引发服务器状态改变的方法,则返回 412 (Precondition Failed)。需要注意的是,服务器端在生成状态码为 304 的响应的时候,必须同时生成以下会存在于对应的 200 响应中的首部:Cache-Control、Content-Location、Date、ETag、Expires 和 Vary 。当与If-Modified-Since一起使用时,优先级更高。
  • If-Modified-Since: 值为一个GMT的日期时间,代表只有当请求的内容在给定的日期之后发生了修改,才会响应200状态,否则响应304(Not Modified)。If-Modified-Since只能与GET或HEAD一起使用。
  • If-Unmodified-Since: 值为一个GMT的日期时间,只有当资源在指定的时间之后没有进行过修改的情况下,服务器才会返回请求的资源,或是接受 POST 或其他 非安全 方法的请求。如果所请求的资源在指定的时间之后发生了修改,那么会返回 412 (Precondition Failed) 。除了应用在非安全的POST请求中,还可以与含有 If-Range 消息头的范围请求搭配使用,用来确保新的请求片段来自于未经修改的文档。
  • If-Range:与 If-Match 和 If-Unmodified-Since 类似,它的值可以是ETag,也可以是一个GMT的日期时间,它主要应用于GET/HEAD的Range请求中,如果服务端匹配的条件失败,则响应HTTP 200,并将完整的内容发送给客户端,如果匹配成功,则响应206(Partial Content)

参考

原文地址:https://www.cnblogs.com/webber1992/p/15617121.html