Traefik 中断 headers 和 Tomcat 保持流活动直到超时
Traefik breaking headers and Tomcat keeping stream alive until timeout
TL;DR
我的 Spring 启动 REST 端点 returns HTTP 状态立即,但有时在 return 获取结果之前等待 60 秒。
这是由于 Feign-proxied 请求的 header 被更改(Transfer-Encoding
被 Content-Length
替换)引起的
调查的背景和结果详述如下。
事实
- A (Spring Boot + Feign + Tomcat) -> Traefik -> B (Rest资源,Spring 启动 + Tomcat)
- Traefik & B总是立即响应,A总是return立即返回200状态码。
- 基于未知的标准,A 使用 KeepAlive 流并且 returns 仅在 精确地 60 秒(最大空闲线程?)。
- B 使用
Transfer-Encoding: chunked
header,但 Traefik 根据未知条件将其替换为 Content-Length
。
- 暂停来自未关闭的 KeepAliveStream。我找到了几个 workarounds/solutions,但我也很想得到解释。
- 删除
Content-Length
header 解决了问题。调试 sun.www.net.HttpClient 确认 Content-Length
header 会触发 KeepAlive 流的使用。
- 用
Connection: close
header 调用 A 似乎可以解决问题(同样的原因:这会阻止使用 KeepAliveStream)。
- 在 A 中用 Jetty 替换 Tomcat 似乎解决了这个问题,因为它似乎依赖于其他 HTTP 库。
- 用OkHttp替换A的Feign的Apache HttpClient解决了问题。
剩余问题
- 为什么 Feign/Tomcat/HttpClient 在整个 body 可用后(即立即)不关闭?
- 奖金问题:Why/When/Based 关于 Traefik 改变 header 的内容,规则是什么?
丢失字节之谜
我们最近的一项测试是将 -v
与 curl 一起使用,我们在 A 暂停时看到了这一点:
$ curl -i -v http://localhost:8100/a/proxied-endpoint
#...
< Content-Length: 1843
<
{ [1793 bytes data]
因此,服务挂起并等待 50 个丢失的字节。
但是,当被打断时,它 return 就是整个响应。
我正在考虑那里的编码问题,但不明白它可能发生在哪里。
将内容长度替换为 1793
(或更短)使端点立即使端点 return。
Content-Length
header 的计算方式与我们的客户在收到 body 时的计算方式存在差异。
详细上下文
情况
我在使用 Spring 引导 REST 控制器 (A) 时遇到问题,它充当另一个服务 (B).端点基本上是这样的:
@GetMapping("/{resource}")
public ResponseEntity<List<Something>> getAll(@PathVariable resource) {
return myFeignProxy.getAll(resource);
}
A 和 B 之间有一个 Traefik 反向代理。
总结:A -> Traefik -> B.
在所有情况下,Feign 代理都会在不到 100 毫秒的时间内做出响应,并且端点 return 会立即返回 HTTP 状态 (200)。然而,在大多数情况下,body 不会立即被 return 编辑。 A 的 Spring 引导等待 精确地 60 秒(这确实是 不是 随机的).
body 是立即 return 编辑还是在 60 秒后编辑似乎取决于 resource
:一些资源总是立即可用,其他资源则需要等待。再一次,这 不是 看起来是随机的。
[编辑]:调查表明,在 A 暂停的情况下,Traefik 替换了 B 的原始 Transfer-Encoding
header 与 Content-Length
。
基于此 header,sun.net.www.HttpClient
将决定使用 KeepAliveStream。
问题是这个流然后没有关闭。
版本
Spring 开机:2.2.6
Tomcat: 9.0.33
Feign:(由 Spring Cloud 2.1.2 决定)
特拉菲克:2.2.11
它不是什么
代理服务 (B) 速度慢不是问题。在所有情况下,myFeignProxy
都会在几毫秒内做出响应,并且端点 return 会立即返回 200 HTTP 状态。
我试过更改 Feign 客户端超时,但没有任何影响。
我还发现暂停、body 的大小和假代理的响应时间之间没有关联。
Resource
Size (KB)
Feign time (ms)
60s pause
1
1.87
34
yes
2
3.29
35
no
3
1.55
34
yes
4
10.05
81
yes
该问题也与 Spring 安全无关,因为完全删除它(配置和依赖项)不会改变症状。
更新调查
导致暂停的技术层
暂停似乎来自 Tomcat。用 Jetty 启动器替换 Tomcat 启动器(在 A 中)消除了问题(所有请求立即响应)。
话虽这么说,但并不能说明问题。
跟踪日志分析
看起来,对于出现暂停的端点,在调用期间日志中有一行。有关示例,请参见下文。 HttpURLCon 的参数似乎也有不同,虽然我不明白为什么。
案例无停顿
DEBUG [nio-8100-exec-9] s.n.www.protocol.http.HttpURLConnection : sun.net.www.MessageHeader@784b4a945 pairs: {GET /xxx HTTP/1.1: null}{Accept: application/json}{User-Agent: Java/11.0.7}{Host: xxx}{Connection: keep-alive}
DEBUG [nio-8100-exec-9] s.n.www.protocol.http.HttpURLConnection : sun.net.www.MessageHeader@2a3818a612 pairs: {null: HTTP/1.1 200 OK}{Cache-Control: no-cache, no-store, max-age=0, must-revalidate}{Content-Type: application/json}{Date: Tue, 20 Apr 2021 07:47:47 GMT}{Expires: 0}{Pragma: no-cache}{Strict-Transport-Security: max-age=31536000 ; includeSubDomains}{Vary: accept-encoding}{X-Content-Type-Options: nosniff}{X-Frame-Options: DENY}{X-Xss-Protection: 1; mode=block}{Transfer-Encoding: chunked}
暂停案例
DEBUG [nio-8100-exec-6] s.n.www.protocol.http.HttpURLConnection : sun.net.www.MessageHeader@7bff99e75 pairs: {GET /xxx HTTP/1.1: null}{Accept: application/json}{User-Agent: Java/11.0.7}{Host: xxx}{Connection: keep-alive}
TRACE [nio-8100-exec-6] s.n.www.protocol.http.HttpURLConnection : KeepAlive stream used: https://xxx/xxx
DEBUG [nio-8100-exec-6] s.n.www.protocol.http.HttpURLConnection : sun.net.www.MessageHeader@5aed6c9312 pairs: {null: HTTP/1.1 200 OK}{Cache-Control: no-cache, no-store, max-age=0, must-revalidate}{Content-Type: application/json}{Date: Tue, 20 Apr 2021 07:57:42 GMT}{Expires: 0}{Pragma: no-cache}{Strict-Transport-Security: max-age=31536000 ; includeSubDomains}{Vary: accept-encoding}{X-Content-Type-Options: nosniff}{X-Frame-Options: DENY}{X-Xss-Protection: 1; mode=block}{Content-Length: 803}
暂停后终于响应时(立即响应时不存在)
DEBUG [nio-8100-exec-7] o.apache.tomcat.util.threads.LimitLatch : Counting down[http-nio-8100-exec-7] latch=1
DEBUG [nio-8100-exec-7] org.apache.tomcat.util.net.NioEndpoint : Calling [org.apache.tomcat.util.net.NioEndpoint@63668501].closeSocket([org.apache.tomcat.util.net.NioEndpoint$NioSocketWrapper@cfdc708:org.apache.tomcat.util.net.NioChannel@6e7c15b6:java.nio.channels.SocketChannel[connected local=/0:0:0:0:0:0:0:1:8100 remote=/0:0:0:0:0:0:0:1:52501]])
附加日志(“使用的 KeepAlive 流”)出现在 sun.net.www.http.HttpClient
中。我似乎理解使用此 KeepAlive 的决定取决于代理响应的 headers.
Headers 分析和 Traefik 干预
Traefik 在 A 和 B.
之间更改 headers
B 总是 return 它的响应是 Transfer-Encoding: chunked
。
Traefik 有时会用 Content-Length
和有效负载的正确大小替换它。
我们的 Traefik 实例中没有关于 headers 的配置。
用来决定 Transfer-Encoding
和 Content-Length
的规则似乎很难掌握:
- 这似乎取决于被调用的端点或其负载。
- 它似乎也取决于呼叫者的某些东西,因为我并不总是得到相同的 header 取决于我是从 A 还是从卷曲。
这解释了为什么当两个应用程序都在本地计算机上时问题无法重现,因为它们之间没有 Traefik。
关于 Traefik 应用的规则,似乎 HTTP 版本发挥了作用。
$ curl -s -o /dev/null -D - --http1.1 https://traefik/b/endpoint
HTTP/1.1 200 OK
# ...
Transfer-Encoding: chunked
$ curl -s -o /dev/null -D - --http2 https://traefik/b/endpoint
HTTP/2 200
# ...
content-length: 2875
Traefik 对于给定的端点总是 returns 相同的 headers,因此我们可以认为 headers 也取决于地址,或者更可能地,取决于有效载荷(给定的端点总是 return 与此服务的相同有效载荷)。
B 的第一个版本不起作用
执行git二等分,我发现代理服务(B)开始在其DTO中使用ZonedDateTime而不是LocalDateTime时出现60秒暂停问题。唯一的变化是日期时间字段现在在响应 body 中有一个偏移量,对 header 没有影响。然而,Feign 客户端对 LocalDateTimes 工作正常,对 ZonedDateTimes 暂停。
强制关闭连接
传递 Connection: close
header 会使 A 中的停顿消失。
响应 body 立即被 return 编辑。
HttpClient 在这种情况下不使用 KeepAliveStream。
尝试用模拟重现 B
我写了一个快速模拟服务 B。
它 return 是 Content-Type
header 和内容。
有趣的是:
- 如果mock-Breturn是
Content-Length
header,则A 有 60 秒的停顿。
- 如果mock-B不return
Content-Length
header,则A return秒。
这与之前的测试一致,表明 Content-Length
header 发挥了作用,但我仍然无法理解是哪个,因为它出现在某些 Traefik 事件中A 仍然 return 立即。
mock-B
const port = 8080;
const http = require('http');
const path = require('path');
const fs = require('fs');
const countBytesInString = str => encodeURI(str).split(/%..|./).length - 1
const requestListener = (req, res) => {
console.log(`\n>> Mock call to endpoint ${req.url}`);
fs.readFile(path.join(__dirname, `endpoints${req.url}.json`), 'utf8' , (err, data) => {
if (err) {
console.error(err)
return;
}
const contentLength = countBytesInString(data);
console.log(`Content-Length: ${contentLength}`);
res.setHeader('Content-Type', 'application/json');
res.setHeader('content-length', contentLength);
res.writeHead(200);
res.end(data);
})
};
const server = http.createServer(requestListener);
server.listen(port);
console.log(`Mock server listening on port ${port}`);
解释原因
我们终于了解了导致该问题的机制。
A -> Traefik -> B
- B returns objects 的列表,其中包含 ZonedDateTime 字段 (
"validFrom":"2021-12-24 23:59:57+01:00"
) 和 header Transfer-Encoding: chunked
.
- Traefik 将
Transfer-Encoding: chunked
替换为 Content-Length
,根据请求的 body 计算得出。
- A 收到响应,反序列化 objects,然后在 UTC 时区(
"validFrom":"2021-12-24 22:59:57Z"
), 但它重用了 Traefik 的 Content-Length
而没有重新计算它。
因此,body 比宣布的 Content-Length
更短(每个 ZonedDateTime 在 A 发送时比在 Traefik 时少占用五个字节计算内容长度)。
然而,客户端已被宣布 Content-Length
并且正在等待丢失的字节。
可能的解决方案
我们现在想到的解决方案是告诉 Feign 及其调用控制器它 return 是 ResponseEntity<String>
而不是 ResponseEntity<List<MyObject>>
。
优点:
- B 的响应是 returned as-is,因此由于内容长度不同,不再有问题。
- A 不花费 CPU-time 反序列化然后立即重新序列化响应。
缺点:
- A 的 OpenApi 文档不会显示 return 的类型(除非 Open API 注释允许指定 return 模型)。这就是我今天晚些时候要测试的内容。
TL;DR
我的 Spring 启动 REST 端点 returns HTTP 状态立即,但有时在 return 获取结果之前等待 60 秒。
这是由于 Feign-proxied 请求的 header 被更改(Transfer-Encoding
被 Content-Length
替换)引起的
调查的背景和结果详述如下。
事实
- A (Spring Boot + Feign + Tomcat) -> Traefik -> B (Rest资源,Spring 启动 + Tomcat)
- Traefik & B总是立即响应,A总是return立即返回200状态码。
- 基于未知的标准,A 使用 KeepAlive 流并且 returns 仅在 精确地 60 秒(最大空闲线程?)。
- B 使用
Transfer-Encoding: chunked
header,但 Traefik 根据未知条件将其替换为Content-Length
。 - 暂停来自未关闭的 KeepAliveStream。我找到了几个 workarounds/solutions,但我也很想得到解释。
- 删除
Content-Length
header 解决了问题。调试 sun.www.net.HttpClient 确认Content-Length
header 会触发 KeepAlive 流的使用。 - 用
Connection: close
header 调用 A 似乎可以解决问题(同样的原因:这会阻止使用 KeepAliveStream)。 - 在 A 中用 Jetty 替换 Tomcat 似乎解决了这个问题,因为它似乎依赖于其他 HTTP 库。
- 用OkHttp替换A的Feign的Apache HttpClient解决了问题。
- 删除
剩余问题
- 为什么 Feign/Tomcat/HttpClient 在整个 body 可用后(即立即)不关闭?
- 奖金问题:Why/When/Based 关于 Traefik 改变 header 的内容,规则是什么?
丢失字节之谜
我们最近的一项测试是将 -v
与 curl 一起使用,我们在 A 暂停时看到了这一点:
$ curl -i -v http://localhost:8100/a/proxied-endpoint
#...
< Content-Length: 1843
<
{ [1793 bytes data]
因此,服务挂起并等待 50 个丢失的字节。 但是,当被打断时,它 return 就是整个响应。 我正在考虑那里的编码问题,但不明白它可能发生在哪里。
将内容长度替换为 1793
(或更短)使端点立即使端点 return。
Content-Length
header 的计算方式与我们的客户在收到 body 时的计算方式存在差异。
详细上下文
情况
我在使用 Spring 引导 REST 控制器 (A) 时遇到问题,它充当另一个服务 (B).端点基本上是这样的:
@GetMapping("/{resource}")
public ResponseEntity<List<Something>> getAll(@PathVariable resource) {
return myFeignProxy.getAll(resource);
}
A 和 B 之间有一个 Traefik 反向代理。 总结:A -> Traefik -> B.
在所有情况下,Feign 代理都会在不到 100 毫秒的时间内做出响应,并且端点 return 会立即返回 HTTP 状态 (200)。然而,在大多数情况下,body 不会立即被 return 编辑。 A 的 Spring 引导等待 精确地 60 秒(这确实是 不是 随机的).
body 是立即 return 编辑还是在 60 秒后编辑似乎取决于 resource
:一些资源总是立即可用,其他资源则需要等待。再一次,这 不是 看起来是随机的。
[编辑]:调查表明,在 A 暂停的情况下,Traefik 替换了 B 的原始 Transfer-Encoding
header 与 Content-Length
。
基于此 header,sun.net.www.HttpClient
将决定使用 KeepAliveStream。
问题是这个流然后没有关闭。
版本
Spring 开机:2.2.6
Tomcat: 9.0.33
Feign:(由 Spring Cloud 2.1.2 决定)
特拉菲克:2.2.11
它不是什么
代理服务 (B) 速度慢不是问题。在所有情况下,myFeignProxy
都会在几毫秒内做出响应,并且端点 return 会立即返回 200 HTTP 状态。
我试过更改 Feign 客户端超时,但没有任何影响。
我还发现暂停、body 的大小和假代理的响应时间之间没有关联。
Resource | Size (KB) | Feign time (ms) | 60s pause |
---|---|---|---|
1 | 1.87 | 34 | yes |
2 | 3.29 | 35 | no |
3 | 1.55 | 34 | yes |
4 | 10.05 | 81 | yes |
该问题也与 Spring 安全无关,因为完全删除它(配置和依赖项)不会改变症状。
更新调查
导致暂停的技术层
暂停似乎来自 Tomcat。用 Jetty 启动器替换 Tomcat 启动器(在 A 中)消除了问题(所有请求立即响应)。 话虽这么说,但并不能说明问题。
跟踪日志分析
看起来,对于出现暂停的端点,在调用期间日志中有一行。有关示例,请参见下文。 HttpURLCon 的参数似乎也有不同,虽然我不明白为什么。
案例无停顿
DEBUG [nio-8100-exec-9] s.n.www.protocol.http.HttpURLConnection : sun.net.www.MessageHeader@784b4a945 pairs: {GET /xxx HTTP/1.1: null}{Accept: application/json}{User-Agent: Java/11.0.7}{Host: xxx}{Connection: keep-alive}
DEBUG [nio-8100-exec-9] s.n.www.protocol.http.HttpURLConnection : sun.net.www.MessageHeader@2a3818a612 pairs: {null: HTTP/1.1 200 OK}{Cache-Control: no-cache, no-store, max-age=0, must-revalidate}{Content-Type: application/json}{Date: Tue, 20 Apr 2021 07:47:47 GMT}{Expires: 0}{Pragma: no-cache}{Strict-Transport-Security: max-age=31536000 ; includeSubDomains}{Vary: accept-encoding}{X-Content-Type-Options: nosniff}{X-Frame-Options: DENY}{X-Xss-Protection: 1; mode=block}{Transfer-Encoding: chunked}
暂停案例
DEBUG [nio-8100-exec-6] s.n.www.protocol.http.HttpURLConnection : sun.net.www.MessageHeader@7bff99e75 pairs: {GET /xxx HTTP/1.1: null}{Accept: application/json}{User-Agent: Java/11.0.7}{Host: xxx}{Connection: keep-alive}
TRACE [nio-8100-exec-6] s.n.www.protocol.http.HttpURLConnection : KeepAlive stream used: https://xxx/xxx
DEBUG [nio-8100-exec-6] s.n.www.protocol.http.HttpURLConnection : sun.net.www.MessageHeader@5aed6c9312 pairs: {null: HTTP/1.1 200 OK}{Cache-Control: no-cache, no-store, max-age=0, must-revalidate}{Content-Type: application/json}{Date: Tue, 20 Apr 2021 07:57:42 GMT}{Expires: 0}{Pragma: no-cache}{Strict-Transport-Security: max-age=31536000 ; includeSubDomains}{Vary: accept-encoding}{X-Content-Type-Options: nosniff}{X-Frame-Options: DENY}{X-Xss-Protection: 1; mode=block}{Content-Length: 803}
暂停后终于响应时(立即响应时不存在)
DEBUG [nio-8100-exec-7] o.apache.tomcat.util.threads.LimitLatch : Counting down[http-nio-8100-exec-7] latch=1
DEBUG [nio-8100-exec-7] org.apache.tomcat.util.net.NioEndpoint : Calling [org.apache.tomcat.util.net.NioEndpoint@63668501].closeSocket([org.apache.tomcat.util.net.NioEndpoint$NioSocketWrapper@cfdc708:org.apache.tomcat.util.net.NioChannel@6e7c15b6:java.nio.channels.SocketChannel[connected local=/0:0:0:0:0:0:0:1:8100 remote=/0:0:0:0:0:0:0:1:52501]])
附加日志(“使用的 KeepAlive 流”)出现在 sun.net.www.http.HttpClient
中。我似乎理解使用此 KeepAlive 的决定取决于代理响应的 headers.
Headers 分析和 Traefik 干预
Traefik 在 A 和 B.
之间更改 headersB 总是 return 它的响应是 Transfer-Encoding: chunked
。
Traefik 有时会用 Content-Length
和有效负载的正确大小替换它。
我们的 Traefik 实例中没有关于 headers 的配置。
用来决定 Transfer-Encoding
和 Content-Length
的规则似乎很难掌握:
- 这似乎取决于被调用的端点或其负载。
- 它似乎也取决于呼叫者的某些东西,因为我并不总是得到相同的 header 取决于我是从 A 还是从卷曲。
这解释了为什么当两个应用程序都在本地计算机上时问题无法重现,因为它们之间没有 Traefik。
关于 Traefik 应用的规则,似乎 HTTP 版本发挥了作用。
$ curl -s -o /dev/null -D - --http1.1 https://traefik/b/endpoint
HTTP/1.1 200 OK
# ...
Transfer-Encoding: chunked
$ curl -s -o /dev/null -D - --http2 https://traefik/b/endpoint
HTTP/2 200
# ...
content-length: 2875
Traefik 对于给定的端点总是 returns 相同的 headers,因此我们可以认为 headers 也取决于地址,或者更可能地,取决于有效载荷(给定的端点总是 return 与此服务的相同有效载荷)。
B 的第一个版本不起作用
执行git二等分,我发现代理服务(B)开始在其DTO中使用ZonedDateTime而不是LocalDateTime时出现60秒暂停问题。唯一的变化是日期时间字段现在在响应 body 中有一个偏移量,对 header 没有影响。然而,Feign 客户端对 LocalDateTimes 工作正常,对 ZonedDateTimes 暂停。
强制关闭连接
传递 Connection: close
header 会使 A 中的停顿消失。
响应 body 立即被 return 编辑。
HttpClient 在这种情况下不使用 KeepAliveStream。
尝试用模拟重现 B
我写了一个快速模拟服务 B。
它 return 是 Content-Type
header 和内容。
有趣的是:
- 如果mock-Breturn是
Content-Length
header,则A 有 60 秒的停顿。 - 如果mock-B不return
Content-Length
header,则A return秒。
这与之前的测试一致,表明 Content-Length
header 发挥了作用,但我仍然无法理解是哪个,因为它出现在某些 Traefik 事件中A 仍然 return 立即。
mock-B
const port = 8080;
const http = require('http');
const path = require('path');
const fs = require('fs');
const countBytesInString = str => encodeURI(str).split(/%..|./).length - 1
const requestListener = (req, res) => {
console.log(`\n>> Mock call to endpoint ${req.url}`);
fs.readFile(path.join(__dirname, `endpoints${req.url}.json`), 'utf8' , (err, data) => {
if (err) {
console.error(err)
return;
}
const contentLength = countBytesInString(data);
console.log(`Content-Length: ${contentLength}`);
res.setHeader('Content-Type', 'application/json');
res.setHeader('content-length', contentLength);
res.writeHead(200);
res.end(data);
})
};
const server = http.createServer(requestListener);
server.listen(port);
console.log(`Mock server listening on port ${port}`);
解释原因
我们终于了解了导致该问题的机制。
A -> Traefik -> B
- B returns objects 的列表,其中包含 ZonedDateTime 字段 (
"validFrom":"2021-12-24 23:59:57+01:00"
) 和 headerTransfer-Encoding: chunked
. - Traefik 将
Transfer-Encoding: chunked
替换为Content-Length
,根据请求的 body 计算得出。 - A 收到响应,反序列化 objects,然后在 UTC 时区(
"validFrom":"2021-12-24 22:59:57Z"
), 但它重用了 Traefik 的Content-Length
而没有重新计算它。
因此,body 比宣布的 Content-Length
更短(每个 ZonedDateTime 在 A 发送时比在 Traefik 时少占用五个字节计算内容长度)。
然而,客户端已被宣布 Content-Length
并且正在等待丢失的字节。
可能的解决方案
我们现在想到的解决方案是告诉 Feign 及其调用控制器它 return 是 ResponseEntity<String>
而不是 ResponseEntity<List<MyObject>>
。
优点:
- B 的响应是 returned as-is,因此由于内容长度不同,不再有问题。
- A 不花费 CPU-time 反序列化然后立即重新序列化响应。
缺点:
- A 的 OpenApi 文档不会显示 return 的类型(除非 Open API 注释允许指定 return 模型)。这就是我今天晚些时候要测试的内容。