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-EncodingContent-Length 替换)引起的 调查的背景和结果详述如下。

事实

剩余问题

丢失字节之谜

我们最近的一项测试是将 -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);
}

AB 之间有一个 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 在 AB.

之间更改 headers

B 总是 return 它的响应是 Transfer-Encoding: chunked。 Traefik 有时会用 Content-Length 和有效负载的正确大小替换它。

我们的 Traefik 实例中没有关于 headers 的配置。 用来决定 Transfer-EncodingContent-Length 的规则似乎很难掌握:

这解释了为什么当两个应用程序都在本地计算机上时问题无法重现,因为它们之间没有 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 和内容。 有趣的是:

这与之前的测试一致,表明 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

  1. B returns objects 的列表,其中包含 ZonedDateTime 字段 ("validFrom":"2021-12-24 23:59:57+01:00") 和 header Transfer-Encoding: chunked.
  2. Traefik 将 Transfer-Encoding: chunked 替换为 Content-Length,根据请求的 body 计算得出。
  3. 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 模型)。这就是我今天晚些时候要测试的内容。