K8s HPA自动扩缩容:CPU/内存/自定义指标的配置与预热策略
K8s HPA自动扩缩容:CPU/内存/自定义指标的配置与预热策略
适读人群:需要弹性伸缩的Java服务运维工程师 | 阅读时长:约22分钟 | 适用版本:K8s 1.23+
开篇故事
我们有个电商平台的订单服务,日常流量很平稳,但每逢大促(618、双十一)流量会在几分钟内飙升到平时的10倍甚至更高。之前的做法是大促前手动扩容,大促后手动缩容,每次都要运维同学盯着,搞得很紧张。
第一次配HPA之后,我们以为万事大吉了,结果大促当天差点出事:流量爆发时,HPA确实触发了扩容,但新Pod的Spring Boot启动需要60秒,这60秒内新Pod还没就绪,流量全压在原来的Pod上,CPU直接打满,部分请求超时。
那次经历让我深刻认识到:HPA只解决了"什么时候扩"的问题,没解决"扩出来的Pod多快能接流量"的问题。Java服务因为启动慢,需要专门处理这个预热问题。
今天把HPA的完整配置,加上针对Java慢启动的预热策略,系统地写出来。
一、核心问题分析
HPA的扩缩容决策机制
HPA(Horizontal Pod Autoscaler)的核心算法是:
期望副本数 = ceil[当前副本数 × (当前指标值 / 目标指标值)]举例:当前3个Pod,CPU利用率80%,目标50%,则: 期望副本数 = ceil[3 × (80/50)] = ceil[4.8] = 5
HPA默认的扩缩容等待时间:
- 扩容冷却:无(立即扩容,默认值)
- 缩容稳定窗口:5分钟(避免抖动)
K8s 1.18引入的behavior字段可以精细控制扩缩容速率。
Java服务的特殊挑战
普通无状态服务(Node.js、Go等)启动通常在1~3秒,新扩容的Pod几乎立刻能接流量。但Java的Spring Boot服务启动通常需要30~120秒,这段时间新Pod处于NotReady状态,无法接收流量。
这带来两个问题:HPA触发扩容后有一段空白期,原有Pod继续承压;如果原有Pod因为高压被OOMKill重启,重启期间没有可用Pod,服务中断。
解决思路是:预热(提前扩容)+ 保底副本数(确保容量下限)+ 缩容保守(避免过度缩容后再扩容)。
二、原理深度解析
HPA v2的多指标支持
当配置了多个指标时,HPA会对每个指标独立计算期望副本数,然后取所有结果中的最大值作为最终的扩容目标。这保证了任何一个指标触发扩容,都能满足需求。
三、完整配置实现
基础HPA配置(CPU + 内存)
# hpa-order-service.yaml
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: order-service-hpa
namespace: production
spec:
# 关联的目标对象
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: order-service
# 副本数范围
minReplicas: 3 # 最小副本数,保证基础容量
maxReplicas: 20 # 最大副本数,防止无限扩容
# 指标配置
metrics:
# CPU使用率(基于Request的百分比)
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 60 # CPU使用率超过60%时扩容
# 内存使用率
- type: Resource
resource:
name: memory
target:
type: Utilization
averageUtilization: 70 # 内存超过70%时扩容
# 扩缩容行为控制(K8s 1.18+)
behavior:
scaleUp:
# 扩容时:稳定窗口0秒(立即响应)
stabilizationWindowSeconds: 0
policies:
# 每15秒最多扩容50%的Pod
- type: Percent
value: 50
periodSeconds: 15
# 或者每15秒最多扩容4个Pod(取两个策略的较大值)
- type: Pods
value: 4
periodSeconds: 15
# 取最大值策略(快速扩容)
selectPolicy: Max
scaleDown:
# 缩容时:稳定窗口5分钟(防止抖动)
stabilizationWindowSeconds: 300
policies:
# 每分钟最多缩容10%的Pod
- type: Percent
value: 10
periodSeconds: 60
selectPolicy: Max自定义指标HPA(Prometheus + Prometheus Adapter)
首先安装Prometheus Adapter并配置指标映射:
# prometheus-adapter-config.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: adapter-config
namespace: monitoring
data:
config.yaml: |
rules:
# 将Prometheus指标映射为K8s Custom Metrics
- seriesQuery: 'http_requests_per_second{namespace!="",pod!=""}'
resources:
overrides:
namespace:
resource: namespace
pod:
resource: pod
name:
matches: "^(.*)$"
as: "http_requests_per_second"
metricsQuery: 'sum(rate(http_server_requests_seconds_count{<<.LabelMatchers>>}[2m])) by (<<.GroupBy>>)'
# 消息队列积压指标
- seriesQuery: 'rabbitmq_queue_messages{namespace!="",pod!=""}'
resources:
overrides:
namespace:
resource: namespace
pod:
resource: pod
name:
matches: "^(.*)$"
as: "rabbitmq_queue_depth"
metricsQuery: 'rabbitmq_queue_messages{queue="order-queue",<<.LabelMatchers>>}'使用自定义指标的HPA:
# hpa-custom-metrics.yaml
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: order-service-hpa-custom
namespace: production
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: order-service
minReplicas: 3
maxReplicas: 30
metrics:
# 基于HTTP请求速率扩容
- type: Pods
pods:
metric:
name: http_requests_per_second
target:
type: AverageValue
# 每个Pod平均不超过500请求/秒
averageValue: "500"
# 基于消息队列积压扩容(外部指标)
- type: External
external:
metric:
name: rabbitmq_queue_depth
selector:
matchLabels:
queue: order-queue
target:
type: AverageValue
# 队列积压超过1000条时扩容
averageValue: "1000"
# 同时保留CPU指标作为兜底
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70Java服务预热策略配置
针对Spring Boot慢启动的预热方案,结合HPA和PodDisruptionBudget:
# deployment-with-warmup.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: order-service
namespace: production
spec:
replicas: 3
selector:
matchLabels:
app: order-service
strategy:
type: RollingUpdate
rollingUpdate:
# 扩容时每次最多增加2个Pod
maxSurge: 2
# 缩容时保持所有Pod可用
maxUnavailable: 0
template:
metadata:
labels:
app: order-service
spec:
# 给足时间:优雅终止时间要比preStop时间长
terminationGracePeriodSeconds: 90
containers:
- name: order-service
image: registry.company.com/order-service:1.2.3
resources:
requests:
cpu: "500m"
memory: "1Gi"
limits:
cpu: "2000m"
memory: "2Gi"
# startupProbe给足启动时间
startupProbe:
httpGet:
path: /actuator/health/liveness
port: 8080
periodSeconds: 10
failureThreshold: 18 # 最多等3分钟
timeoutSeconds: 5
# readinessProbe确保真正就绪才接流量
readinessProbe:
httpGet:
path: /actuator/health/readiness
port: 8080
periodSeconds: 5
failureThreshold: 3
successThreshold: 2 # 连续成功2次才标为就绪
timeoutSeconds: 5
# preStop:接到终止信号后,先睡30秒
# 让负载均衡把当前Pod从列表摘除,然后再关闭
lifecycle:
preStop:
exec:
command:
- sh
- -c
- "sleep 30"
---
# 防止HPA大幅缩容导致服务中断
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: order-service-pdb
namespace: production
spec:
# 任何时候都要保证至少2个Pod可用
minAvailable: 2
selector:
matchLabels:
app: order-service大促预热方案(提前手动扩容)
#!/bin/bash
# pre-scale.sh - 大促前的预热扩容脚本
SERVICE="order-service"
NAMESPACE="production"
TARGET_REPLICAS=15 # 预期峰值需要的副本数
WARMUP_TIME=120 # 等待Pod就绪的时间(秒)
echo "=== 大促预热扩容开始 ==="
echo "目标副本数: ${TARGET_REPLICAS}"
# 暂时提高HPA的最小副本数
kubectl patch hpa ${SERVICE}-hpa -n ${NAMESPACE} \
-p '{"spec":{"minReplicas":'${TARGET_REPLICAS}'}}'
echo "等待Pod扩容并就绪..."
kubectl rollout status deployment/${SERVICE} -n ${NAMESPACE} --timeout=300s
# 验证就绪Pod数量
READY=$(kubectl get deployment ${SERVICE} -n ${NAMESPACE} \
-o jsonpath='{.status.readyReplicas}')
echo "当前就绪Pod数: ${READY}"
if [ "${READY}" -ge "${TARGET_REPLICAS}" ]; then
echo "预热完成!当前就绪Pod数: ${READY}"
else
echo "警告:预热可能未完成,请手动检查"
kubectl get pods -n ${NAMESPACE} -l app=${SERVICE}
fi四、生产最佳实践
HPA目标值的设定原则
CPU目标值不要设太高,60%70%是合理区间,留出足够的应对突发的空间。设成80%90%,扩容决策延迟太大,很容易在扩容完成前就崩了。
对于Java服务,内存目标值要特别谨慎。JVM有GC机制,内存使用会有周期性的波动,设成70%左右,避免GC回收后内存下降又触发缩容,然后流量回升又扩容,形成抖动。
与VPA的组合使用
HPA负责水平扩缩(调整Pod数量),VPA(Vertical Pod Autoscaler)负责垂直扩缩(调整单个Pod的资源配额)。两者可以组合使用,但有一个前提:不能对同一个指标(如CPU)同时配置HPA和VPA的自动模式,会产生冲突。
推荐做法:VPA用"Off"模式,只提供建议不自动修改;定期查看VPA建议,手动优化资源配置;HPA负责实际的自动扩缩容。
五、踩坑实录
坑一:资源Request没设导致HPA失效
部署了HPA但一直不工作,kubectl describe hpa显示:
unable to fetch metrics from resource metrics API:
the server could not find the requested resource以为是metrics-server的问题,折腾了半天,最后发现原因更简单:Pod的resources.requests.cpu没有设置。HPA的CPU利用率是基于CPU Request计算的,没有Request,就没法计算利用率,HPA无法工作。
所有要用HPA的Deployment,必须设置resources.requests,这是硬性前提。
坑二:缩容太快导致大促间歇期服务抖动
大促结束后,HPA快速缩容,把副本数从20降到了3。但大促后通常还有一波"买完之后看物流"的流量,缩容太快导致这波流量又把剩余的3个Pod打挂了。
后来把缩容稳定窗口从5分钟改成了30分钟,并且把缩容速率限制为每5分钟最多缩容10%:
behavior:
scaleDown:
stabilizationWindowSeconds: 1800 # 30分钟稳定窗口
policies:
- type: Percent
value: 10
periodSeconds: 300 # 每5分钟最多缩10%坑三:自定义指标HPA与Prometheus采集间隔的配合
用Prometheus的rate(http_requests_seconds_count[2m])作为HPA指标,发现扩容决策有时候延迟3~4分钟,感觉HPA反应迟钝。
原因是:Prometheus默认采集间隔30秒,rate([2m])函数需要2分钟的窗口,再加上Prometheus Adapter的数据同步间隔,HPA拿到的数据可能已经是3~4分钟前的了。
解决方案:对于需要快速响应的场景,把Prometheus的采集间隔缩短到10秒,rate窗口缩短到30s;接受一定的误差,这是时间序列数据的固有特性。
六、总结
HPA的价值在于把弹性伸缩自动化,但不是配上就完事了。Java服务特有的慢启动问题,需要通过合理的探针配置、足够的startupProbe时间、结合PDB的最小可用保证来弥补。
在大促这类可预期的流量爆发场景下,推荐提前手动把HPA的minReplicas调高,完成预热后再恢复。自动扩容是应对不可预期的流量变化的,对于已知的流量峰值,提前准备比等HPA触发更可靠。
