线上偶发 502 排查:用 Netty 成功复现 KeepAlive 时间窗口案例实战(附完整源码)

线上偶发 502 排查:用 Netty 成功复现 KeepAlive 时间窗口案例实战(附完整源码)

起因

前段时间生产环境偶发 502 错误,不是那种大面积的,就是偶尔冒出来几个。客户端重试一下基本都能成功,所以一开始没太当回事。

我们的架构是这样的:

scss 复制代码
Java 应用 → Ingress (Nginx) → Higress Gateway → 外部服务

问题就出在 Ingress 到 Higress 这一段。

线上排查过程

当时排查了挺久的,日志里也没啥明显的异常。应用层的监控看着都正常,CPU、内存、连接数都没问题。Higress 的监控也没异常。

后来有同事建议抓包看看,这一抓还真抓到了东西。

发现了这样的情况:

  • Ingress 向 Higress 发送请求
  • Higress 返回了 RST 包(连接重置)
  • Ingress 收到 RST 后返回 502 给客户端

当时就奇怪了,为啥 Higress 要发 RST?是不是哪里配置有问题?

翻了半天配置,发现了一个可疑的点:

  • Higress 的 keepalive_timeout 配置是 120 秒
  • Ingress 的 upstream-keepalive-timeout 配置是 180 秒

问题就出在这!

问题的本质

这个问题的核心是时间窗口差异导致的连接复用问题。

sequenceDiagram participant Ingress participant Higress Note over Ingress,Higress: 第一次请求建立连接 Ingress->>Higress: GET /api/xxx Higress->>Ingress: 200 OK Note over Ingress,Higress: 连接保持(Keep-Alive) Note over Ingress: 认为连接可用 (180s 未到) Note over Higress: 空闲 120s Higress->>Higress: 超时,关闭连接 Note over Ingress,Higress: 130 秒后,第二次请求 Ingress->>Higress: GET /api/yyy (复用连接) Higress->>Ingress: RST (连接已关闭) Ingress->>Ingress: 生成 502 响应 Ingress->>Client: 502 Bad Gateway

时间线:

  1. Ingress 和 Higress 建立连接,发送第一次请求,成功
  2. 连接进入 Keep-Alive 状态,双方都保持连接
  3. 120 秒后,Higress 认为连接空闲,主动关闭
  4. 但 Ingress 不知道!它的 keepalive 是 180 秒,还认为连接活着
  5. 130 秒时(120 < 130 < 180),Ingress 收到新请求
  6. Ingress 从连接池拿出这条"已关闭"的连接,尝试复用
  7. 在已关闭的连接上写数据 → 收到 RST
  8. Ingress 返回 502 给客户端

这就是为什么 502 是偶发的:

  • 只有在 120-180 秒这个时间窗口内的请求才会触发
  • 如果请求很频繁,连接一直在用,不会空闲 120 秒
  • 如果请求很稀疏,可能早就超过 180 秒,Ingress 也关了连接

本地复现

光看抓包和日志还是不够直观,我决定在本地搭个环境把这个问题复现出来。

架构设计

用 Netty 搭了个简化版:

scss 复制代码
Client (测试程序) → Proxy A (模拟 Ingress) → Proxy B (模拟 Higress)

Proxy B (Higress 模拟):

  • 监听 8081
  • IdleStateHandler(120, 0, 0) - 120 秒空闲后关闭连接
  • 收到请求返回固定的 200 OK

Proxy A (Ingress 模拟):

  • 监听 8080
  • 维护到 8081 的连接池,复用连接
  • IdleStateHandler(180, 0, 0) - 180 秒空闲后才关闭
  • 转发请求到 Proxy B,如果失败返回 502

Client (测试程序):

  • 发送第一次请求(建立连接)
  • 等待 130 秒(进入时间窗口)
  • 并发发送多个请求(触发复用)

Proxy B 的实现

这个比较简单,就是个 HTTP Server,加上空闲检测:

java 复制代码
// Pipeline 配置
ch.pipeline()
  .addLast(new HttpServerCodec())
  .addLast(new HttpObjectAggregator(10 * 1024 * 1024))
  .addLast(new IdleStateHandler(120, 0, 0, TimeUnit.SECONDS))  // 120秒
  .addLast(new ProxyBHandler());

// Handler 实现
public class ProxyBHandler extends SimpleChannelInboundHandler<FullHttpRequest> {
    @Override
    protected void channelRead0(ChannelHandlerContext ctx, FullHttpRequest req) {
        System.out.println("[ProxyB] 接收请求: " + req.method() + " " + req.uri());
        
        // 返回 200 OK
        FullHttpResponse response = new DefaultFullHttpResponse(
            HTTP_1_1, OK,
            Unpooled.copiedBuffer("OK", CharsetUtil.UTF_8)
        );
        response.headers().set(CONNECTION, HttpHeaderValues.KEEP_ALIVE);
        response.headers().set(CONTENT_TYPE, "text/plain; charset=utf-8");
        response.headers().setInt(CONTENT_LENGTH, response.content().readableBytes());
        
        ctx.writeAndFlush(response);
    }
    
    @Override
    public void userEventTriggered(ChannelHandlerContext ctx, Object evt) {
        if (evt instanceof IdleStateEvent) {
            IdleStateEvent event = (IdleStateEvent) evt;
            if (event.state() == IdleState.READER_IDLE) {
                // 120秒空闲,关闭连接
                System.out.println("[ProxyB] 连接空闲 120s,关闭连接");
                ctx.close();
            }
        }
    }
}

Proxy A 的实现

这个稍微复杂一点,要维护连接池:

java 复制代码
// 连接池(刻意不检查 isActive,增加复用概率)
class ConnectionPool {
    private Map<String, Connection> connections = new ConcurrentHashMap<>();
    
    public Connection get(String upstream) {
        Connection conn = connections.get(upstream);
        if (conn != null) {
            System.out.println("[ProxyA] 复用已有连接 ✓ " + upstream);
            return conn;
        }
        
        System.out.println("[ProxyA] 创建新连接到 " + upstream);
        conn = createConnection(upstream);
        connections.put(upstream, conn);
        return conn;
    }
}

// 转发逻辑
public class ProxyAHandler extends SimpleChannelInboundHandler<FullHttpRequest> {
    @Override
    protected void channelRead0(ChannelHandlerContext ctx, FullHttpRequest req) {
        System.out.println("[ProxyA] 接收请求: " + req.method() + " " + req.uri());
        
        try {
            // 从连接池获取连接
            Connection conn = connectionPool.get("localhost:8081");
            
            // 发送请求到 Proxy B
            CompletableFuture<FullHttpResponse> future = new CompletableFuture<>();
            conn.handler.setPending(future);
            
            FullHttpRequest upstreamReq = new DefaultFullHttpRequest(
                HTTP_1_1, GET, "/"
            );
            upstreamReq.headers().set(HOST, "localhost");
            upstreamReq.headers().set(CONNECTION, HttpHeaderValues.KEEP_ALIVE);
            
            conn.channel.writeAndFlush(upstreamReq).addListener(f -> {
                if (!f.isSuccess()) {
                    future.completeExceptionally(f.cause());
                }
            });
            
            // 等待响应
            FullHttpResponse upstreamResp = future.join();
            ctx.writeAndFlush(upstreamResp);
            
        } catch (Exception e) {
            // 连接复用失败,返回 502
            System.out.println("[ProxyA] 转发失败: " + e.getMessage());
            
            FullHttpResponse response = new DefaultFullHttpResponse(
                HTTP_1_1,
                HttpResponseStatus.BAD_GATEWAY,
                Unpooled.copiedBuffer("502 Bad Gateway", CharsetUtil.UTF_8)
            );
            response.headers().set(CONNECTION, HttpHeaderValues.KEEP_ALIVE);
            response.headers().set(CONTENT_TYPE, "text/plain; charset=utf-8");
            response.headers().setInt(CONTENT_LENGTH, response.content().readableBytes());
            
            ctx.writeAndFlush(response);
        }
    }
}

这里有个关键点:

我故意没有在连接池里检查 channel.isActive()。为什么?因为要模拟 Ingress 的"天真"行为 - 它不知道连接已经被 Higress 关了,还傻傻地去复用。

这样能更容易触发问题。

测试客户端

java 复制代码
public class TestClient {
    public static void main(String[] args) throws Exception {
        HttpClient client = HttpClient.newHttpClient();
        String url = "http://localhost:8080/";
        
        // 第一次请求(建立连接)
        System.out.println("[Client] 发送第一次请求");
        HttpRequest request = HttpRequest.newBuilder()
            .uri(URI.create(url))
            .GET()
            .build();
        HttpResponse<String> resp1 = client.send(request, HttpResponse.BodyHandlers.ofString());
        System.out.println("[Client] 收到响应: " + resp1.statusCode());
        
        // 等待 130 秒(进入时间窗口)
        System.out.println("[Client] 等待 130 秒...");
        Thread.sleep(130 * 1000);
        
        // 并发发送请求
        System.out.println("[Client] 开始并发请求,线程数: 10");
        ExecutorService executor = Executors.newFixedThreadPool(10);
        AtomicBoolean found502 = new AtomicBoolean(false);
        
        for (int i = 0; i < 10; i++) {
            executor.submit(() -> {
                while (!found502.get()) {
                    try {
                        HttpRequest req = HttpRequest.newBuilder()
                            .uri(URI.create(url))
                            .GET()
                            .build();
                        HttpResponse<String> resp = client.send(req, HttpResponse.BodyHandlers.ofString());
                        
                        if (resp.statusCode() == 502) {
                            System.out.println("[Client] 收到响应: 502 Bad Gateway ✓");
                            found502.set(true);
                        }
                        
                        Thread.sleep(100);
                    } catch (Exception e) {
                        // ignore
                    }
                }
            });
        }
        
        Thread.sleep(5000);
        executor.shutdownNow();
        
        if (found502.get()) {
            System.out.println("[Client] ✓ 成功复现 502 错误!");
        }
    }
}

为了加快测试,我把 Proxy B 的空闲时间改成了 5 秒(通过 -Dproxyb.idle=5 参数),Client 等待 6 秒。这样十几秒就能看到效果,不用真等 130 秒。

复现结果

Proxy A 日志

csharp 复制代码
[ProxyA] 启动,监听端口 8080,keepalive: 180s
[ProxyA] 接收请求: GET /
[ProxyA] 创建新连接到 localhost:8081
[ProxyA] 接收请求: GET /
[ProxyA] 复用已有连接 ✓ localhost:8081
[ProxyA] 转发失败: io.netty.channel.StacklessClosedChannelException
[ProxyA] 接收请求: GET /
[ProxyA] 复用已有连接 ✓ localhost:8081
[ProxyA] 转发失败: io.netty.channel.StacklessClosedChannelException
[ProxyA] 接收请求: GET /
[ProxyA] 复用已有连接 ✓ localhost:8081
[ProxyA] 转发失败: io.netty.channel.StacklessClosedChannelException
...

关键信息:

  • "复用已有连接 ✓" - 说明从连接池拿了旧连接
  • "StacklessClosedChannelException" - 连接已经关了,写入失败
  • 然后 Proxy A 会返回 502

Proxy B 日志

csharp 复制代码
[ProxyB] 启动,监听端口 8081,keepalive: 5s
[ProxyB] 接收请求: GET /
[ProxyB] 连接空闲 120s,关闭连接
[ProxyB] 接收请求: GET /
...

可以看到 Proxy B 在空闲后主动关闭了连接。

Client 日志

csharp 复制代码
[Client] 发送第一次请求
[Client] 收到响应: 200
[Client] 等待 6 秒...
[Client] 开始并发请求,线程数: 10
[Client] 收到响应: 502 Bad Gateway ✓
[Client] 收到响应: 502 Bad Gateway ✓
[Client] 收到响应: 502 Bad Gateway ✓
[Client] 收到响应: 502 Bad Gateway ✓
[Client] 收到响应: 502 Bad Gateway ✓
[Client] ✓ 成功复现 502 错误!

完美复现!

抓包验证

用 tcpdump 抓了包:

bash 复制代码
sudo tcpdump -i lo0 -s 0 port 8081 -w keepalive-502.pcap

用 Wireshark 打开,过滤 RST 包:

ini 复制代码
tcp.flags.reset == 1

可以清楚地看到:

  • 8081 → 8080: [RST, ACK]
  • 这就是 Proxy B 关闭连接时发的 RST 包

证据确凿!

为什么是 Proxy A 生成 502?

有人可能会问:为什么不是 Proxy B 返回 502,而是 Proxy A 自己生成的?

这里要区分两种情况:

情况1:上游返回 502(应用层)

sequenceDiagram participant Client participant Ingress participant Higress participant Backend Client->>Ingress: GET /api/xxx Ingress->>Higress: GET /api/xxx Higress->>Backend: GET /api/xxx Backend->>Backend: 应用挂了 Backend->>Higress: 超时/无响应 Higress->>Ingress: HTTP 502 Bad Gateway Ingress->>Client: HTTP 502 Bad Gateway

这种情况下,Higress 主动返回 HTTP/1.1 502 Bad Gateway 响应,Ingress 只是转发。

情况2:网关生成 502(连接层)

sequenceDiagram participant Client participant Ingress participant Higress Note over Ingress,Higress: 连接已建立 Higress->>Higress: 空闲 120s,关闭连接 Note over Higress: 发送 FIN/RST Client->>Ingress: GET /api/xxx Ingress->>Ingress: 从连接池取连接 Ingress->>Higress: 写请求 (连接已关) Note over Ingress: ClosedChannelException Ingress->>Ingress: 生成 502 响应 Ingress->>Client: HTTP 502 Bad Gateway

这种情况下,Higress 没有返回任何 HTTP 响应,只是在 TCP 层关闭了连接(FIN 或 RST)。

Ingress 在尝试写数据时发现连接已关,只能自己生成 502 返回给客户端。

我们复现的就是情况2!

真实网关的行为

Nginx(Ingress 常用):

当上游连接失败时,Nginx 会在错误日志里记录:

csharp 复制代码
[error] upstream prematurely closed connection while reading response header from upstream

然后返回 502 Bad Gateway 给客户端。

Envoy(Higress 基于 Envoy):

Envoy 的错误码映射:

  • Connection reset by peer → 502 Bad Gateway
  • Connection timeout → 504 Gateway Timeout
  • Connection refused → 503 Service Unavailable

所以网关生成 502 是标准行为,不是我瞎写的!

解决方案

既然问题找到了,解决起来就简单了。

核心原则:上游的 keepalive 要小于下游的 keepalive

在我们的场景里:

  • Ingress 是上游
  • Higress 是下游
  • Ingress 的 keepalive 要小于 Higress 的 keepalive

方案1:调小 Ingress 的 keepalive

yaml 复制代码
# Ingress ConfigMap
apiVersion: v1
kind: ConfigMap
metadata:
  name: nginx-configuration
  namespace: ingress-nginx
data:
  upstream-keepalive-timeout: "90"  # 改成 90 秒,小于 Higress 的 120 秒
  keepalive-timeout: "90"

这样 Ingress 会在 90 秒后主动关闭连接,不会出现"Higress 关了但 Ingress 不知道"的情况。

方案2:调大 Higress 的 keepalive

yaml 复制代码
# Higress 配置
keepalive_timeout: 200s  # 改成 200 秒,大于 Ingress 的 180 秒

这样 Higress 会等更久才关闭连接。

我们选了方案1,因为:

  1. Ingress 是我们自己管的,改起来方便
  2. Higress 可能有全局配置,不想动
  3. 90 秒够用了,没必要保持太长

验证修复

改完配置后,观察了几天,502 完全消失了。

我也在本地验证了一下,把 Proxy A 的 keepalive 改成 90 秒(小于 Proxy B 的 120 秒),再跑测试,确实不会出现 502 了。

几个要注意的地方

1. KeepAlive 的层次

KeepAlive 有好几个层次,容易搞混:

TCP 层(操作系统):

bash 复制代码
# Linux
net.ipv4.tcp_keepalive_time = 7200

这是 TCP 协议的 keep-alive,和 HTTP 的 Keep-Alive 不是一回事。

HTTP 层(应用):

nginx 复制代码
# Nginx
keepalive_timeout 60s;

这是 HTTP/1.1 的 Keep-Alive,用于复用 HTTP 连接。

Upstream 层(反向代理):

nginx 复制代码
# Nginx upstream
upstream backend {
    server 127.0.0.1:8080;
    keepalive 32;
    keepalive_timeout 90s;
}

这是反向代理到上游的连接保持。

我们遇到的问题是 HTTP 层的 Keep-Alive。

2. 为什么是偶发的?

这个问题只有在特定时间窗口才会触发:

复制代码
请求间隔 < 120s → 连接一直在用,不会触发
120s < 请求间隔 < 180s → 触发 502
请求间隔 > 180s → Ingress 也关了连接,会重新建立,不会触发

所以如果你的流量很大,可能永远遇不到这个问题。如果流量很小,也可能遇不到。

只有不大不小的流量,才容易踩坑。

3. 如何抓包

在 K8s 环境里抓包有点麻烦,这里记录一下:

方法1:在 Pod 所在节点抓包

bash 复制代码
# 找到 Pod 所在节点
kubectl get pod -n ingress-nginx -o wide

# SSH 到节点
ssh node-xxx

# 找到容器的网络命名空间
docker inspect <container-id> | grep Pid

# 进入命名空间抓包
nsenter -t <pid> -n tcpdump -i any -w /tmp/capture.pcap

方法2:用 ksniff(推荐)

bash 复制代码
# 安装 krew 插件管理器
kubectl krew install sniff

# 直接抓包
kubectl sniff <pod-name> -n <namespace>

过滤 RST 包:

bash 复制代码
# Wireshark 过滤器
tcp.flags.reset == 1

# tcpdump 过滤器
tcpdump -r capture.pcap 'tcp[tcpflags] & tcp-rst != 0'

4. 连接池的复用策略

不同的 HTTP 客户端对连接池的处理不一样:

OkHttp:

java 复制代码
// 默认会检查连接是否可用
if (!connection.isHealthy(false)) {
    // 丢弃,重新建立
}

Apache HttpClient:

java 复制代码
// 有专门的连接验证器
connectionManager.setValidateAfterInactivity(2000);  // 2秒

Nginx:

nginx 复制代码
# Nginx 比较"天真",不会主动检查
# 直接复用,如果失败了再重试

我们的 Netty 实现故意不检查 isActive(),就是为了模拟这种"天真"的行为。

5. 生产环境的监控

如果要在生产环境发现这类问题,可以监控:

Ingress 侧:

  • 502 错误数量和比例
  • upstream 连接失败次数
  • connection reset by peer 错误

Higress 侧:

  • 连接关闭原因(idle timeout)
  • 新建连接数

如果发现:

  • 502 偶发
  • upstream 连接失败和连接关闭时间相关
  • 抓包看到 RST

那多半就是 keepalive 配置的问题了。

写在最后

这个问题排查了挺久的,一开始完全没往 keepalive 配置上想。毕竟这种基础配置,一般都是默认值,谁会想到会有问题呢?

但网络问题就是这样,往往是一些很基础的配置导致的。抓包真的很重要,很多时候日志看不出来的问题,抓包一看就明白了。

本地复现也很有价值。虽然搭环境花了点时间,但能稳定复现问题,对理解原理帮助很大。而且后面验证修复方案也方便。

如果你也遇到过类似的 502 问题,或者有更好的排查方法,欢迎在评论区交流。网络问题千奇百怪,多交流总能学到新东西。


项目代码已开源: gateway502-demo

包含完整的 Proxy A、Proxy B、Client 实现,可以直接运行复现问题。

如果觉得这篇文章有帮助,欢迎点赞、收藏、转发。你的支持是我继续写下去的动力。

下一篇打算写什么?有几个想法:

  • SimpleDynamicTp:手写一个动态线程池
  • 继续手写SimpleRaft

你们想看哪个?评论区告诉我。

周末愉快!

相关推荐
北京耐用通信2 小时前
告别“牵一发而动全身”:耐达讯自动化Profibus PA分线器为石化流量计网络构筑安全屏障
人工智能·网络协议·安全·自动化·信息与通信
Sinowintop2 小时前
易连EDI-EasyLink无缝集成之消息队列Kafka
分布式·网络协议·kafka·集成·国产化·as2·国产edi
大柏怎么被偷了3 小时前
【Linux】进程替换
linux·运维·服务器
EAIReport3 小时前
企业级报表自动化:基于Docker的部署实践
运维·docker·自动化
行初心4 小时前
uos基础 sys-kernel-debug.mount 查看mount文件
运维
1***y1784 小时前
DevOps在云中的Rancher
运维·rancher·devops
tianyuanwo5 小时前
多平台容器化RPM构建流水线全指南:Fedora、CentOS与Anolis OS
linux·运维·容器·centos·rpm
wasp5206 小时前
做了技术管理后,我发现技术和管理其实可以兼得
java·运维·网络
云和数据.ChenGuang6 小时前
mysqld.service is not a native service问题解决!
运维·nginx·运维技术·运维工程师技术