http请求响应头缺失Content-Length头信息的问题

对接第三方对象存储的服务最近总是有warning日志,远程上传的对象get请求返回的响应头中缺失content-length信息
go程序中获取到的content-length值为-1

之前被content-length值为-1坑过,具体场景为程序中使用 minio 作为sdk对接第三方对象存储
使用PutObject方法完成远程上传,即通过http get获取源对象,直接上传,不进行本地转存,原本运行挺稳定,直到出现大量响应头信息缺失content-length的资源出现
当时使用的minio版本为github.com/minio/minio-go v6.0.8+incompatible,此版本中当不显式传入大于0的size(对应http请求返回的content-length)时,将采用分块上传的逻辑
但是每块的体积是500M,计算方式是最大对象体积/最大分块数
最开始是串行上传,所以并没有暴露问题,直到为了优化性能利用带宽,改成了并行上传时,OOM了,因为需要同时上传的对象数n*500M的内存,这啥配置顶得住
这个问题的解决方案是升级了minio版本到v7,可以配置默认的chunk大小,不再是500M,然后将有问题的源都改为支持返回content-length,同时对获取不到content-length的请求添加告警,包括chunked
所以再看到这个告警还是要重视的
结果大跌眼镜,是个内部服务提供的远程上传链接,有着不少诡异的清空,初期很是抓头

前提

Content-Length

Content-Length 表示请求/响应实体内容的长度(字节)
一般是自动生成的,并不需要手动指定修改,手动操作反而会引发错误,比如在chrome浏览器中发送ajax时进行操作,可以试试

  • 当内容的实际字节数大于 Content-Length 时
    • 当服务端接收的请求实体长度超过 Content-Length ,服务端会直接关闭连接,从而导致请求失败
    • 当服务端返回的响应实体长度超过 Content-Length,超出 Content-Length 长度的内容将被丢弃
  • 当内容的实际字节数小于 Content-Length 时
    • 当服务端接收的请求实体长度小于 Content-Length,服务端会继续等待后面的数据,从而导致超时
    • 当服务端返回的响应实体长度小于 Content-Length,浏览器会报错,如Chrome 浏览器会报 net::ERR_CONTENT_LENGTH_MISMATCH

可见Content-Length适用于消息体长度明确的情况,Content-Length 无法提前计算出来的情况则使用分段传输

Transfer-Encoding

分块传输编码(Chunked transfer encoding)是超文本传输协议(HTTP)中的一种数据传输机制,允许HTTP由网页服务器发送给客户端应用( 通常是网页浏览器)的数据可以分成多个部分。分块传输编码只在HTTP协议1.1版本(HTTP/1.1)中提供
Transfer-Encoding 和 Content-Length 是互斥的,如果同时出现,浏览器以 Transfer-Encoding 为准,在Go中,会从header中删除两个信息,然后设置TransferEncoding和ContentLength属性
具体可以参考net/http源码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
// srs/net/http/transfer.go
// parseTransferEncoding sets t.Chunked based on the Transfer-Encoding header.
func (t *transferReader) parseTransferEncoding() error {
raw, present := t.Header["Transfer-Encoding"]
if !present {
return nil
}
delete(t.Header, "Transfer-Encoding")

// Issue 12785; ignore Transfer-Encoding on HTTP/1.0 requests.
if !t.protoAtLeast(1, 1) {
return nil
}

// Like nginx, we only support a single Transfer-Encoding header field, and
// only if set to "chunked". This is one of the most security sensitive
// surfaces in HTTP/1.1 due to the risk of request smuggling, so we keep it
// strict and simple.
if len(raw) != 1 {
return &unsupportedTEError{fmt.Sprintf("too many transfer encodings: %q", raw)}
}
if !ascii.EqualFold(textproto.TrimString(raw[0]), "chunked") {
return &unsupportedTEError{fmt.Sprintf("unsupported transfer encoding: %q", raw[0])}
}

// RFC 7230 3.3.2 says "A sender MUST NOT send a Content-Length header field
// in any message that contains a Transfer-Encoding header field."
//
// but also: "If a message is received with both a Transfer-Encoding and a
// Content-Length header field, the Transfer-Encoding overrides the
// Content-Length. Such a message might indicate an attempt to perform
// request smuggling (Section 9.5) or response splitting (Section 9.4) and
// ought to be handled as an error. A sender MUST remove the received
// Content-Length field prior to forwarding such a message downstream."
//
// Reportedly, these appear in the wild.
delete(t.Header, "Content-Length")

t.Chunked = true
return nil
}

...
// Unify output
switch rr := msg.(type) {
case *Request:
rr.Body = t.Body
rr.ContentLength = t.ContentLength
if t.Chunked {
rr.TransferEncoding = []string{"chunked"}
}
rr.Close = t.Close
rr.Trailer = t.Trailer
case *Response:
rr.Body = t.Body
rr.ContentLength = t.ContentLength
if t.Chunked {
rr.TransferEncoding = []string{"chunked"}
}
rr.Close = t.Close
rr.Trailer = t.Trailer
}

问题

  1. 不是100%出现
  2. 同样的接口,Go请求不返回content-length,但curl访问则返回
  3. 同样的代码,其它服务中的文件访问均会返回content-length

原因

1. 并不是每个请求都能复现

通过观察日志,正常返回content-length的请求,其content-length值均不大于255
通过创建特殊文件请求验证,确认只有大于等于256字节的文件被访问时才会出现不返回content-length的情况

1. curl表现不一致的问题

通过排查,定位到 compressed 这个配置上,如果显示的添加配置 curl --compressed,则表现与Go请求一致,即获取到了chunked 响应
compressed是指定请求要压缩的,对接收到的响应也会自动进行解压缩。即默认请求是不进行压缩的,所以会返回content-length头
同理,如果Go请求时指定 Accept-Encoding 为 identity,可以达到同样的效果

2. 同代码不同表现的原因

由于有参照,通过控制变量发的对比发现唯一的不同是其他服务是内网访问,而出问题的服务配置了公网地址访问
那么问题就只能是出在了多余的公网访问这一步。而服务是k8s集群内的,那么就是使用的ingress-nginx配置上有文章了
先找到ingress的pod,然后查看pod的nginx配置

1
2
3
4
5
6
7
8
9
10
11
12
13
$ kubectl get po -n ingress-nginx
NAME READY STATUS RESTARTS AGE
default-http-backend-7c6b4d97f-xx6qx 1/1 Running 36 1y
nginx-ingress-controller-48h52 1/1 Running 1 178d
nginx-ingress-controller-7nwrq 1/1 Running 0 178d
nginx-ingress-controller-fbf8c 1/1 Running 1 178d
nginx-ingress-controller-gtbg2 1/1 Running 1 178d
nginx-ingress-controller-hz8l7 1/1 Running 1 178d
nginx-ingress-controller-kxh2b 1/1 Running 1 178d

# 随便一个pod
$ kubectl exec -it nginx-ingress-controller-48h52 -n ingress-nginx -- cat /etc/nginx/nginx.conf > nginx.conf
# 搜索nginx.conf文件

果然,发现了这些配置

1
2
3
4
5
6
7
gzip on;
gzip_comp_level 5;
gzip_http_version 1.1;
gzip_min_length 256;
gzip_types application/atom+xml application/javascript application/x-javascript application/json application/rss+xml application/vnd.ms-fontobject application/x-font-ttf application/x-web-app-manifest+json application/xhtml+xml application/xml font/opentype image/svg+xml image/x-icon text/css text/plain text/x-component;
gzip_proxied any;
gzip_vary on;

其中gzip_min_length 256;对应了问题1,响应达到256字节的请求才会出现状况的原因
而nginx中chunked_transfer_encoding又是默认开启(on)的,所以导致文件被压缩后通过分块传输的方式对外响应,从而表现出content-length响应头信息丢失的现象

为什么gzip会导致分块传输 Transfer-Encoding: chunked ?

虽然原始的服务提供者的http响应是未分块且响应了正确的头信息 Content-Length 的
由于请求经过了ingress-nginx(或者其它形式的),根据其配置,超过 gzip_min_length 大小的响应body需要被压缩
header 是先于 body 被发送的,但应用 gzip 压缩的过程会导致 body 的体积变化,且压缩完成前是不知道其明确的体积的,所以此场景下只能选择分块传输
这都是因为nginx的gzip默认是动态压缩的,如果又要压缩,又要返回content-length,则需要额外的 gzip_static 模块 ngx_http_gzip_static_module 支持,添加模块后加入配置 gzip_static on; 即可实现

gzip_static 开启需要额外的存储空间,自行权衡

解决方法

  1. 指定不压缩
    有一定的风险,毕竟不压缩会造成带宽的额外开销,如果确定影响不大,那么就可以放心设置

    1
    2
    req, err := http.NewRequest("GET", url, nil)
    req.Header.Set("Accept-Encoding", "identity")
  2. 绕过nginx
    已经分析到原始的服务返回是没有分块传输的,是ingress造成的这个现象,而又有直接内网访问的方案来解决,所以绕过它就好了

参考链接

原文链接
分块传输编码
ngx_http_gzip_static_module