线上偶发 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

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

周末愉快!

相关推荐
大树886 小时前
金刚石散热越强,管路越先见顶
大数据·运维·服务器·人工智能·ai
摇滚侠6 小时前
Linux CentOS7 rpm 安装 MySQL 5.7
linux·运维·mysql
霸道流氓气质6 小时前
领域驱动设计(DDD)在 Spring Boot 微服务中的实践指南
运维·spring boot·微服务
Inhand陈工7 小时前
基于台达PLC与映翰通IG502的智慧水产养殖精准投喂与远程运维解决方案
运维·人工智能·物联网·阿里云·信息与通信
酣大智7 小时前
ARP代理--工作原理
运维·网络·arp·arp代理
shushangyun_8 小时前
2026年快消品B2B系统推荐:支持终端门店订货、促销政策自动化的工具?
java·运维·网络·数据库·人工智能·spring·自动化
程序员mine8 小时前
HTTPS-TLS加密与证书完全指南(中)
网络协议·https·ssl
施努卡机器视觉8 小时前
SNK施努卡侧滑门锁上滑轮总成自动化装配线,从零件到组件,全流程精密制造方案
运维·自动化·制造
之歆9 小时前
现代 HTTP 客户端深度解析:Fetch 与 Axios
chrome·网络协议·http
AC赳赳老秦9 小时前
用 OpenClaw 搭建服务器故障应急响应系统,自动处理 80% 常见运维故障
android·运维·服务器·python·rxjava·deepseek·openclaw