1. 线程池打满但 CPU 只有 30%,HPA 为什么不扩容
线上有个 Java 服务,接口偶尔从 80ms 抖到 2s,翻监控发现 CPU 一直趴在 30% 上下,HPA 死活不扩。登到 Pod 里jstack一看,Tomcat 的maxThreads=200全被占满,请求全堵在队列里。这就是典型的「CPU 没到阈值,但线程已经不够用」——默认 HPA 只看 CPU/内存,而线程池打满这件事,CPU 指标根本反映不出来。
你可能会想,那我把 CPU 阈值调低点不就行了?问题是调低之后,正常流量下也会疯狂扩容,成本直接起飞。真正该扩缩容的依据是「活跃线程数」和「队列积压」,而不是 CPU。这篇就讲清楚怎么把 Java 的线程指标通过 Prometheus 采集出来,再用 prometheus-adapter 转成 K8s 的 custom metrics,最后写一个基于线程数的 HPA。
适合谁看:已经在 K8s 上跑 Java 服务、装了 kube-prometheus 或 Prometheus Operator、想用业务指标替代 CPU 做扩缩容的同学。前置条件是你得有一个能访问的 Prometheus,以及kubectl权限。整个链路是:Java 暴露/metrics→ Service → ServiceMonitor → Prometheus → prometheus-adapter → custom.metrics.k8s.io → HPA。
我试过把这套跑通之后,线程数超过 400 就扩容,压测时扩容触发比 CPU 方案早了将近 40 秒,响应时间抖动明显收敛。下面按步骤来,每一步都给可复制的配置。
2. TaoToken 前置:先把模型和 Key 准备好,方便调试指标查询
在动手配 adapter 之前,有个容易被忽略的环节:写 PromQL 查询语句、调 adapter 的metricsQuery模板时,经常需要反复试错。这时候如果有个能直接对话、帮你解释 PromQL 和 K8s 配置的模型入口,效率会高很多。TaoToken 就是干这个的——它是一个聚合多家大模型的 API 平台,你拿一个 Key 就能调不同模型,用来辅助排查配置问题挺顺手。
先说清楚它是什么、能做什么:TaoToken 提供统一的 API 入口,兼容 OpenAI 风格的调用方式,你可以用它来问「这条 PromQL 为什么返回空」「adapter 的 seriesQuery 正则怎么写」这类问题。适合谁:手头没有多个模型账号、又想在排障时快速问一句的开发者。
拿 Key 的路径很简单,打开控制台 https://taotoken.net/api-keys ,登录后创建一个 API Key,复制出来存好。注意这个 Key 只在创建时完整显示一次,丢了就得重建。
拿到 Key 之后,你可以用模型对话页面 https://taotoken.net/models 直接测试,不用写代码就能验证 Key 是否可用。如果你打算长期用它辅助编码和排障,可以看下 Coding Plan https://taotoken.net/coding-plan ,按套餐走比单次调用划算。
这里要强调一点:TaoToken 是帮你调模型、问问题的工具,不是让你拿它去替代 Prometheus 或 adapter。指标采集和 HPA 逻辑还是得老老实实配。它的价值在于,当你卡在seriesQuery正则或者metricsQuery模板上时,能快速得到解释和示例。
配置方式上,如果你用 OpenAI SDK,把 Base URL 指向https://taotoken.net/api,Key 填刚才创建的,Model ID 填你选的模型名即可。比如用 curl 测一下:
curl https://taotoken.net/api/v1/chat/completions \ -H "Content-Type: application/json" \ -H "Authorization: Bearer $TAOTOKEN_API_KEY" \ -d '{ "model": "gpt-4o-mini", "messages": [{"role": "user", "content": "解释一下 prometheus-adapter 的 seriesQuery 里 __name__ 的作用"}] }'返回正常就说明 Key 和网络都没问题。这一步不是必须的,但排障阶段有个能问的模型,确实省事。接下来进入正题,先让 Java 把线程指标暴露出来。
3. 可复制配置:从 Java 暴露线程指标到 adapter 的 values.yaml
这一节是核心,全部给可复制的片段。链路分四段:Java 依赖与/metrics、Service、ServiceMonitor、adapter 的 values.yaml。
3.1 Java 侧暴露线程指标
用simpleclient系列依赖,simpleclient_hotspot会自动带上 JVM 线程相关指标,其中jvm_threads_current就是当前活跃线程数。在pom.xml里加:
<dependency> <groupId>io.prometheus</groupId> <artifactId>simpleclient</artifactId> <version>0.16.0</version> </dependency> <dependency> <groupId>io.prometheus</groupId> <artifactId>simpleclient_hotspot</artifactId> <version>0.16.0</version> </dependency> <dependency> <groupId>io.prometheus</groupId> <artifactId>simpleclient_servlet</artifactId> <version>0.16.0</version> </dependency>然后注册一个 Servlet 暴露/metrics:
@Bean public ServletRegistrationBean<MetricsServlet> metricsServlet() { ServletRegistrationBean<MetricsServlet> bean = new ServletRegistrationBean<>(new MetricsServlet()); bean.addUrlMappings("/metrics"); return bean; }启动后访问http://pod-ip:端口/metrics,能看到jvm_threads_current就对了。如果你用的是 Spring Boot Actuator,也可以直接开management.endpoints.web.exposure.include=prometheus,效果类似。
3.2 Service 暴露指标端口
给指标单独开一个 Service,端口名要记住,后面 ServiceMonitor 靠它匹配:
apiVersion: v1 kind: Service metadata: name: user-service-metrics namespace: wit-parking-dev labels: app: user-service spec: ports: - name: http-metrics protocol: TCP port: 30002 targetPort: 30002 selector: app: user-service type: ClusterIP注意port的name: http-metrics,ServiceMonitor 里endpoints.port要写这个名字。
3.3 ServiceMonitor 让 Prometheus 抓取
apiVersion: monitoring.coreos.com/v1 kind: ServiceMonitor metadata: name: prometheus-user-service namespace: monitoring-system labels: prometheus: kube-prometheus spec: endpoints: - honorLabels: true interval: 30s port: http-metrics namespaceSelector: any: true selector: matchLabels: app: user-servicehonorLabels: true是为了避免 Prometheus 后端 label 和抓取 label 冲突时把原始 label 改名。配好后去 Prometheus 的 Targets 页面确认这个 job 是 UP。
3.4 prometheus-adapter 的 values.yaml
这是最关键的一段。adapter 的作用是把 Prometheus 里的指标「翻译」成 K8s 的 custom metrics API。先装 adapter:
helm repo add prometheus-community https://prometheus-community.github.io/helm-charts helm repo update kubectl create ns prometheus-adapter然后写values.yaml:
rules: default: false custom: - seriesQuery: '{__name__=~"^jvm_threads_current.*",container="",pod!=""}' seriesFilters: - isNot: "jvm_threads_current_not" resources: overrides: namespace: { resource: "namespace" } pod: { resource: "pod" } name: matches: "(.*)" as: "jvm_threads_current" metricsQuery: <<.Series>>{<<.LabelMatchers>>} prometheus: url: http://prometheus-k8s.kubesphere-monitoring-system.svc.cluster.local port: 9090几个点解释一下。seriesQuery用正则筛出jvm_threads_current开头的指标,container=""和pod!=""是为了只保留 Pod 维度的序列。resources.overrides把指标里的namespace和podlabel 映射到 K8s 的 namespace 和 pod 资源,这样 HPA 才能按 Pod 聚合。name.as把指标重命名成jvm_threads_current,HPA 里就用这个名字。metricsQuery里的<<.Series>>和<<.LabelMatchers>>是 adapter 的模板占位符,会替换成实际指标名和 label 匹配条件。
安装:
helm install prometheus-adapter prometheus-community/prometheus-adapter \ -n prometheus-adapter --values values.yaml注意prometheus.url要换成你自己的 Prometheus 地址,端口默认 9090,如果 Prometheus 在别的 namespace,把 FQDN 改对。这一步配错的话,后面kubectl get --raw会返回空或者报错。
4. 验证请求:确认 custom metrics 能查到线程数
配完 adapter 别急着写 HPA,先验证指标能不能通过 custom metrics API 查到。这是最容易翻车的地方,分两步。
第一步,看 API 列表里有没有你的指标:
kubectl get --raw /apis/custom.metrics.k8s.io/v1beta1 | jq .在返回的 JSON 里找jvm_threads_current,如果能看到,说明 adapter 已经把它注册进去了。如果返回404或者列表里没有,多半是seriesQuery正则没匹配上,或者 Prometheus 里压根没这个指标。
第二步,查具体 Pod 的指标值:
kubectl get --raw \ '/apis/custom.metrics.k8s.io/v1beta1/namespaces/wit-parking-dev/pods/*/jvm_threads_current' | jq .正常会返回类似这样的结构:
{ "kind": "MetricValueList", "apiVersion": "custom.metrics.k8s.io/v1beta1", "items": [ { "describedObject": { "kind": "Pod", "name": "user-service-v1-7d9f8b6c5-abcde", "apiVersion": "/v1" }, "metricName": "jvm_threads_current", "value": "312" } ] }看到value有具体数字,就说明整条链路通了。如果items是空数组,去 Prometheus 里直接查jvm_threads_current确认有没有数据;如果 Prometheus 有但 adapter 查不到,检查resources.overrides里的 label 映射对不对。
验证通过后,写 HPA。用autoscaling/v2,基于 Pods 类型的 AverageValue:
apiVersion: autoscaling/v2 kind: HorizontalPodAutoscaler metadata: name: user-service-v1 namespace: wit-parking-dev spec: scaleTargetRef: apiVersion: apps/v1 kind: Deployment name: user-service-v1 minReplicas: 1 maxReplicas: 4 metrics: - type: Pods pods: metric: name: jvm_threads_current target: type: AverageValue averageValue: "400"这里averageValue: "400"的意思是:所有 Pod 的jvm_threads_current平均值超过 400 就扩容。注意type: Pods和type: AverageValue的搭配,前者表示按 Pod 聚合,后者表示取平均值。如果你想让每个 Pod 的线程数都不超过某个值,也可以用AverageValue配合Pods,效果一样。
应用后看 HPA 状态:
kubectl get hpa user-service-v1 -n wit-parking-dev -w正常会显示TARGETS列是312/400这种格式,说明 HPA 已经读到指标了。
5. 本篇常见错排查:401、local proxy failed、reading choices 报错
配这套链路,报错基本集中在几个地方。下面按真实报错对照排查。
报错一:kubectl get --raw返回401 Unauthorized或the server could not find the requested resource。这通常是 adapter 没起来,或者 custom metrics API 没注册。先kubectl get pods -n prometheus-adapter看 Pod 状态,如果是CrashLoopBackOff,kubectl logs看日志。常见原因是values.yaml里prometheus.url写错,adapter 连不上 Prometheus 就起不来。另外确认kubectl get apiservice | grep custom.metrics里v1beta1.custom.metrics.k8s.io是True。
报错二:local proxy failed或error: unable to upgrade connection。这个多半是你在用kubectl proxy或者某些面板的代理访问 API 时出的,和 adapter 本身无关。直接用kubectl get --raw走 kubeconfig 认证就不会有这个问题。如果你在容器里跑,确认 ServiceAccount 有权限读 custom metrics。
报错三:reading choices相关报错,比如error reading choices: ...。这类报错通常出现在你用某些客户端或脚本解析 API 返回时,返回体不是预期的 JSON 结构。先kubectl get --raw ... | jq .看原始返回,如果返回的是{"kind":"Status","message":"..."},说明请求路径或资源名写错了。检查 namespace、pod 名、指标名是否拼对。
报错四:HPA 显示<unknown>/400。说明 HPA 读不到指标。先确认kubectl get --raw能查到值,再检查 HPA 的metric.name是否和 adapter 里name.as一致。还有一个坑:adapter 的seriesQuery里container=""这个条件,如果你的指标带了containerlabel 且不为空,就会被过滤掉,导致查不到。去掉这个条件试试。
报错五:adapter 日志里no metrics matches query。这是seriesQuery正则没匹配上。去 Prometheus 的 Graph 页面直接查jvm_threads_current,确认指标名和 label。如果指标名带了前缀或后缀,调整正则。另外seriesFilters里的isNot是排除,别写成is把想要的排除了。
关于 OAuth 报错:如果你在调 TaoToken 的 API 时遇到401或 OAuth 相关错误,检查 Key 是否过期、Base URL 是否写成https://taotoken.net/api(注意不要多加/v1之外的路径)。模型对话页面可以直接测 Key 是否有效。
排查顺序建议:Prometheus 有没有数据 → adapter 能不能查到 → HPA 能不能读到。一层层往下,别跳步。
6. 语义一致 CTA:把线程指标扩缩容真正跑起来
到这里,整条链路应该已经通了:Java 暴露jvm_threads_current,Prometheus 抓取,adapter 转成 custom metrics,HPA 按 400 的阈值扩缩容。压测验证的时候,用wrk或jmeter打流量,观察kubectl get hpa -w的 TARGETS 变化,线程数上去之后 15 秒左右(受interval和 HPA 同步周期影响)就能看到副本数增加。
如果你在写 PromQL 或者调 adapter 配置时卡住,可以用 TaoToken 的模型对话快速问一下,省得翻文档。接入文档在 https://taotoken.net/doc ,里面有 API 调用的详细说明。需要创建 Key 就去 https://taotoken.net/api-keys ,长期做编码和 Agent 相关的话可以看 Coding Plan https://taotoken.net/coding-plan 。
最后提醒几个实战细节:averageValue的阈值别拍脑袋定,先观察正常流量下的线程数基线,留 30% 余量;minReplicas别设 0,Java 冷启动慢,缩到 0 再拉起会有明显抖动;adapter 的interval和 HPA 的同步周期叠加,扩容有延迟,对延迟敏感的服务可以把interval调到 15s。这套跑顺之后,你会发现线程指标比 CPU 更能反映 Java 服务的真实压力。