三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

网关崩了?先抓个 OOM 再谈动态路由安全,这招保命!

网关崩了?先抓个 OOM 再谈动态路由安全,这招保命!

网关崩了?先抓个 OOM 再谈动态路由安全,这招保命!

前言

Spring Cloud Gateway 的动态路由能力如果缺少准入校验、容量限制和内存保护,路由规则膨胀会直接推高堆内存占用,最终引发频繁 GC、请求阻塞甚至 OOM。动态配置越灵活,越需要配套安全边界。

本文围绕网关 OOM 排查和动态路由安全治理,分析路由规则如何进入内存、为什么会失控,以及如何通过限制、审计和熔断保护网关稳定性。

一、底层原理

1.1 核心机制

Spring Cloud Gateway 基于 Netty 构建。

它不是传统的 Servlet 容器,它是异步非阻塞的。

这意味着,它的所有路由规则,都加载在 JVM 的堆内存里。

当请求进来,Gateway 会先匹配路由,再转发。

如果路由规则无限增长,内存迟早要炸。

咱们画个图,看看数据是怎么流进内存的。

graph TD A["用户请求 (Request)"] --> B["Netty EventLoop"] B --> C["路由匹配器 (Route Definition)"] C --> D["内存缓存区 (Heap Memory)"] D --> E["下游服务 (Downstream Service)"] F["管理后台 (Admin API)"] -->|动态加载 | C C -->|规则堆积 | D D -->|溢出风险 | G["OOM 异常"]

核心就在CD这一段。

每一条动态路由,都是一个RouteDefinition对象。

如果缺乏限制,恶意调用管理接口,瞬间就能撑爆D区。

设计优势在于高性能,但劣势就是内存敏感。

1.2 与同类方案的对比

咱们拿它和传统的 Nginx 做个对比,你就明白区别在哪了。

特性Spring Cloud GatewayNginx传统 Servlet (Tomcat)
内存模型JVM 堆内存进程内存 (C 语言)JVM 堆内存
路由加载运行时动态加载需重载配置或热加载部署时确定
OOM 风险高 (对象创建频繁)低 (配置解析开销小)中 (线程模型阻塞)
排查难度高 (需 JVM 工具)中 (日志 + 配置)中 (线程 dump)

看到没?动态加载是双刃剑。

灵活是灵活,但内存风险也是实打实的。

二、快速上手

别急着看代码,先看看怎么给网关“系安全带”。

启动参数里,必须限制堆内存大小。

别给默认值,默认值在容器里会坑死人。

# 启动命令示例 java -Xms512m -Xmx1g -XX:+UseG1GC -jar gateway-service.jar

这就好比开车,油箱别加太满,留点空间给刹车片。

接着,写个最简单的 Hello World 路由配置。

// 这是一个极简的路由配置类 @Configuration public class GatewayConfig { // 定义一个基础的路由 Bean @Bean public RouteLocator customRouteLocator(RouteLocatorBuilder builder) { return builder.routes() .route("test_route", r -> r.path("/hello") .uri("http://localhost:8081")) .build(); } }

这代码跑起来,访问/hello就能通。

但这只是静态路由,动态路由才是内存杀手。

三、核心 API / 深水区

3.1 核心方法速查

排查 OOM,光靠猜不行,得用工具。

工具名称适用场景核心命令
jmap导出堆转储文件jmap -dump:format=b,file=heap.hprof <pid>
jcmd轻量级诊断jcmd <pid> GC.heap_info
Arthas在线热诊断heapdump,thread,dashboard
VisualVM图形化分析本地连接远程 JVM

生产环境推荐 Arthas。

它不用重启服务,直接连上去看。

3.2 生产级配置

光有工具不够,得从配置上防住。

首先,路由刷新频率要限流。

别让用户随便调接口刷新路由。

# application.yml 片段 spring: cloud: gateway: discovery: locator: enabled: true routes: - id: user-service uri: lb://user-service predicates: - Path=/user/**

其次,JVM 参数要调优。

G1 收集器在高并发下表现更好。

-XX:MaxGCPauseMillis=200 -XX:InitiatingHeapOccupancyPercent=45

这两行配置,能减少 STW 时间,防止网关假死。

3.3 高级定制

我们要写一个 Filter,拦截非法的路由更新。

@Component public class SecurityRouteFilter implements GlobalFilter, Ordered { private static final Logger log = LoggerFactory.getLogger(SecurityRouteFilter.class); @Override public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { // 获取请求路径 String path = exchange.getRequest().getPath().value(); // 如果是管理接口,进行二次校验 if (path.startsWith("/admin/routes")) { // 校验请求头里的 Token String token = exchange.getRequest().getHeaders().getFirst("X-Auth-Token"); if (!"super-secret-key".equals(token)) { log.warn("非法路由更新尝试,来源 IP: {}", exchange.getRequest().getRemoteAddress().getAddress().getHostAddress()); exchange.getResponse().setStatusCode(HttpStatus.FORBIDDEN); return exchange.getResponse().setComplete(); } } return chain.filter(exchange); } @Override public int getOrder() { return -100; // 优先级最高,先于路由匹配执行 } }

这个 Filter 就像门口的保安。

不认识的人,想改路由表?没门。

四、实战演练

场景来了。

假设攻击者通过漏洞,循环调用路由创建接口。

我们要模拟这个过程,并观察内存变化。

@RestController @RequestMapping("/admin/routes") public class RouteAdminController { @Autowired private RouteDefinitionWriter routeDefinitionWriter; @PostMapping("/add") public ResponseEntity<String> addRoute(@RequestBody Map<String, String> routeInfo) { try { // 构造路由定义 RouteDefinition definition = new RouteDefinition(); definition.setId(routeInfo.get("id")); // 设置断言 PathRoutePredicateFactory.Config config = new PathRoutePredicateFactory.Config(); config.setPatterns(Collections.singletonList(routeInfo.get("pattern"))); definition.setPredicate(new PathRoutePredicateFactory().apply(config)); // 设置目标 URI definition.setUri(UriUtils.createUri(routeInfo.get("uri"))); // 写入内存 routeDefinitionWriter.save(Mono.just(definition)).subscribe(); return ResponseEntity.ok("路由添加成功"); } catch (Exception e) { // 记录异常日志,防止静默失败 log.error("路由添加失败,堆栈信息:", e); return ResponseEntity.status(500).body("内部错误"); } } }

现在,写个脚本疯狂调用这个接口。

import requests url = "http://gateway:8080/admin/routes/add" headers = {"Content-Type": "application/json"} for i in range(10000): payload = { "id": f"route_{i}", "pattern": f"/api/{i}/**", "uri": "http://localhost:8081" } try: requests.post(url, json=payload, headers=headers, timeout=1) except: break

脚本跑完,监控一看。

内存曲线直线上升,GC 根本回收不掉。

因为RouteDefinition对象被缓存引用着,无法释放。

这时候,Arthas 上线。

# 查看堆内存使用 jvm # 查看哪个类占用最多 sc -d java.util.HashMap

你会发现,RouteDefinition相关的对象数量异常。

这就是内存泄漏的铁证。

五、避坑指南与最佳实践

踩了这么多坑,总结几条血泪经验。

💡技巧 1:路由缓存要有上限
不要无限制保存动态路由。
RouteDefinitionWriter的实现里,加上容量限制。
超过 1000 条,拒绝新增,或者淘汰旧规则。

⚠️警告 2:不要直接暴露管理接口
网关的管理接口,必须放在内网。
或者加上 IP 白名单。
千万别让公网能调/admin/routes

推荐 3:定期 Dump 堆内存
在 K8s 里,配置 CronJob。

每周自动 dump 一次堆内存。

存到 S3 上,没事分析一下。

防患于未然,比救火强。

💡技巧 4:使用引用计数
对于动态路由,记录引用次数。
如果某个路由长时间没流量,可以考虑自动下线。
释放内存空间。

六、综合实战演示

最后,咱们把上面说的东西,整合成一个闭环。

一个带安全校验、带内存监控的路由管理模块。

@Component public class SecureRouteManager { private final RouteDefinitionWriter writer; // 限制最大路由数量 private static final int MAX_ROUTE_COUNT = 2000; private final AtomicLong routeCounter = new AtomicLong(0); public SecureRouteManager(RouteDefinitionWriter writer) { this.writer = writer; } public Mono<Void> createSecureRoute(RouteDefinition definition) { // 1. 检查数量限制 if (routeCounter.get() >= MAX_ROUTE_COUNT) { return Mono.error(new IllegalStateException("路由数量达到上限,禁止新增")); } // 2. 校验 URI 合法性,防止 SSRF 攻击 String uriString = definition.getUri().toString(); if (!uriString.startsWith("lb://") && !uriString.startsWith("http://")) { return Mono.error(new IllegalArgumentException("非法的 URI 协议")); } // 3. 写入并计数 return writer.save(Mono.just(definition)) .doOnSuccess(v -> routeCounter.incrementAndGet()) .doOnError(e -> log.error("路由创建失败", e)); } // 获取当前路由数量,用于监控 public long getRouteCount() { return routeCounter.get(); } }

这段代码,把安全、限流、监控全加上了。

部署到生产环境,心里踏实多了。

监控面板上,route_count指标如果飙升,立马报警。

七、总结

网关是微服务的咽喉。

咽喉堵了,全身都得瘫痪。

OOM 不是玄学,是资源管理的疏忽。

动态路由不是玩具,是内存的重灾区。

把限制加上,把权限收好,把监控建起来。

别等电话响了,才想起来看内存。

今晚能睡个安稳觉,比啥都强。

← 返回列表