开头先交代一个场景:我自己有一台装k3s的小服务器,跑着几个内部服务,平时流量不算大,但偶尔会有某个接口被定时任务或者爬虫扫到,CPU直接飙满。一开始图省事,手动kubectl scale deployment临时把副本数顶上去,但半夜出问题的时候,爬起来扩副本真的不是人干的事。所以我决定给k3s配上HPA,让副本数自己跟着负载走。
在动手之前,我对HPA的理解还停留在“Kubernetes自带功能,应该开箱即用”的程度。真正开始配置才发现,k3s作为轻量级发行版,在这条路上少了很重要的一环:它默认不装metrics-server。没有它,HPA控制器连Pod当前的CPU占用都读不到,扩缩容就无从谈起。这篇文章把我从“装metrics-server”到“HPA真正跑起来”的完整过程、踩过的坑、以及最后怎么调优都记录下来。内容适合在k3s、K3d、MiniKube这类轻量集群上折腾自动扩缩容的人参考,也适合刚接触HPA、不清楚它到底怎么工作的人对照理解。
1. 为什么k3s上的HPA要先过“缺一个组件”这道坎
1.1 HPA的工作链路:它到底从哪里拿数据
很多人第一次配HPA的时候,直觉是“我创建一个HorizontalPodAutoscaler对象,Kubernetes就会自动帮我扩容”。这句话对,但它忽略了中间的数据链路。HPA不是直接去读容器运行时或者kubelet的原始数据的,它通过Kubernetes的metrics API获取指标,这个API的实现者就是metrics-server。
整条链路是这样的:kubelet内置的cAdvisor负责采集节点上每个容器的CPU和内存使用量,然后metrics-server通过kubelet的摘要接口把这些数据取走,聚合成标准指标,再暴露成一个叫metrics.k8s.io的API。到了这一步,HPA控制器才能通过kubectl top pod同源的那个接口拿到数据,计算期望副本数,最后去修改Deployment的replicas字段。
如果你对这两个概念还不太熟,可以把metrics-server理解成“仪表盘的中枢”:kubelet是各个传感器,负责感知数据;HPA是自动控制系统,负责根据仪表盘的读数决定要不要加机器。传感器有了,中枢没装,自动控制系统就只能看到一片空白。
1.2 k3s和标准Kubernetes在这件事上的差异
标准Kubernetes集群,尤其是云厂商托管的那种,往往会替你装好metrics-server,或者至少给你一个一键开启的选项。k3s出于轻量化的考虑,默认没有内置metrics-server,所以你在k3s上创建HPA时,如果没做任何前置操作,大概率会看到HPA的状态一直是Unable to fetch metrics,事件里反复出现类似failed to get cpu utilization: unable to fetch metrics from resource metrics API的报错。
另外一个k3s特有的情况是:k3s默认自带Traefik作为Ingress Controller,还会有svclb(Service Load Balancer)组件。在小内存节点上,这些系统组件的资源占用是实实在在的。资源紧张的时候,它们会和业务Pod抢CPU,直接影响HPA观察到的业务Pod负载,这一点后面讲排错的时候会再展开。
给k3s装HPA,第一件事不是写YAML,而是先把metrics.k8s.io这条数据链路打通。否则后边所有操作都是空中楼阁。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 装好metrics-server才算拿到扩缩容的“仪表盘”
2.1 安装方式:直接kubectl apply还是塞进k3s的manifests目录
安装metrics-server本身不难,官方仓库提供了现成的components.yaml,一条命令就能部署:
bash复制kubectl apply -f https://github.com/kubernetes-sigs/metrics-server/releases/latest/download/components.yaml
不过我实际建议你把它放到k3s的自动manifest目录里:
bash复制cp metrics-server.yaml /var/lib/rancher/k3s/server/manifests/
k3s会监控这个目录,自动应用里面的YAML文件。这种方式的几个好处是:节点重启或者k3s服务重新拉起之后,metrics-server会被自动补回来;同时在离线环境下也更方便,你把YAML文件准备好就行,不需要依赖外网。
2.2 最容易踩的坑:kubelet TLS自签名证书校验失败
我第一遍装完metrics-server,等了半天,kubectl top nodes始终没有任何输出。查日志才发现,metrics-server连kubelet时报错了:
code复制E0412 10:21:33.123456 1 scraper.go:140] "Failed to scrape node" err="Get \"https://127.0.0.1:10250/stats/summary?only_cpu_and_memory=true\": x509: cannot validate certificate for 127.0.0.1 because it doesn't contain any IP SANs"
原因是k3s的kubelet证书是自签名的,metrics-server默认不会信任它。解决方法很直接:在metrics-server的Deployment启动参数里加上--kubelet-insecure-tls。如果你用的是官方components.yaml,需要先下载文件,再修改Deployment的args,加一行:
yaml复制containers:
- args:
- --cert-dir=/tmp
- --secure-port=10250
- --kubelet-preferred-address-types=InternalIP,ExternalIP,Hostname
- --kubelet-use-node-status-port
- --metric-resolution=15s
- --kubelet-insecure-tls
这里还有个细节:--metric-resolution=15s是默认值,意思是metrics-server每15秒从kubelet拉一次数据。这个值不用动,它决定了HPA的感知延迟至少是秒级的,不是实时的。
2.3 验证指标链路真的通了
装完不是直接看HPA,而是先确认两件事。
第一,查看API服务是否注册成功:
bash复制kubectl get apiservices.apiregistration.k8s.io | grep metrics
正常情况下能看到v1beta1.metrics.k8s.io处于Available状态。
第二,用kubectl top直接测链路:
bash复制kubectl top nodes
kubectl top pods -A
如果能看到节点和Pod的CPU、内存数据,说明指标链路已经通了。如果kubectl top nodes空转没输出,优先检查metrics-server的日志和Pod状态。这个环节多花五分钟,能省下后边排查HPA不工作的两个小时。
顺便提一句,要在配置HPA之前确认autoscaling/v2这个API版本是可用的:
bash复制kubectl api-resources | grep horizontalpodautoscaler
kubectl get --raw /apis/autoscaling/v2 | head
新版本的k3s都支持autoscaling/v2,但如果你的k3s版本比较老,可能还在用v2beta2。后面写YAML的时候要看清楚自己集群支持哪个版本,否则会报no matches for kind "HorizontalPodAutoscaler",这是很常见的配置失败原因。
3. 第一个HPA:先用最简单的CPU伸缩把它跑起来
3.1 准备一个“会喘气”的示例Deployment
HPA要工作,目标Deployment必须满足一个条件:Pod模板里必须声明resources.requests,而且requests.cpu或requests.memory不能为空。原因是HPA计算CPU使用率时,用的是“实际使用量除以requests声明的值”,而不是除以节点的物理CPU核数。这个逻辑特别容易误解,很多人把requests当成“下限”随便填,结果发现HPA怎么都不扩容,问题往往就出在这。
我用的示例Deployment很简单,跑一个busybox死循环,专门消耗CPU:
yaml复制apiVersion: apps/v1
kind: Deployment
metadata:
name: cpu-demo
namespace: demo
spec:
replicas: 1
selector:
matchLabels:
app: cpu-demo
template:
metadata:
labels:
app: cpu-demo
spec:
containers:
- name: cpu-demo
image: busybox
command: ["/bin/sh", "-c", "while true; do echo 'computing...'; done"]
resources:
requests:
cpu: 100m
memory: 128Mi
requests.cpu: 100m表示这个Pod“声称”至少要占用0.1个CPU核心。HPA会把目标百分比换算成实际毫核来用,比如目标50%,对应的实际目标值就是50m。如果Pod实际用了200m,HPA就认为使用率是200%,远超目标,触发扩容。
3.2 创建HPA:YAML写法和关键字段
创建HPA有两种方式。快速验证的时候,可以直接用命令:
bash复制kubectl autoscale deployment cpu-demo --cpu-percent=50 --min=1 --max=5 -n demo
但如果想保留配置、方便后续多次调整,建议还是用YAML。这是我实际使用的版本:
yaml复制apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: cpu-demo-hpa
namespace: demo
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: cpu-demo
minReplicas: 1
maxReplicas: 5
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 50
scaleTargetRef指向要扩缩容的Deployment;minReplicas和maxReplicas是副本数的上下限,也是HPA的“安全边界”;metrics数组中定义的averageUtilization: 50意思是所有Pod的平均CPU使用率达到50%时就开始扩容。
这里要重点解释一下averageUtilization的算法。HPA控制器每15秒左右从metrics API拿一次数据,然后套用这么个公式:
text复制desiredReplicas = ceil[currentReplicas * (currentMetricValue / desiredMetricValue)]
放到我刚才的例子里就是:
text复制desiredReplicas = ceil[1 * (200m / 50m)] = 4
也就是说一个副本实际用了200m CPU,HPA会直接把副本数扩到4,因为一个正忙的Pod被分摊成4个之后,平均负载才降到50m,也就是目标值。这就是HPA“一次性大步扩容”的行为来源。
3.3 压测观察扩容,再等缩容
Deployment和HPA都创建好之后,观察HPA状态:
bash复制kubectl get hpa -n demo -w
一开始HPA的状态列大概率是<unknown>,因为metrics-server还没采到第一轮数据。等10到30秒,状态会变成具体的百分比。
这时候给Pod制造压力:
bash复制kubectl exec -it deploy/cpu-demo -n demo -- /bin/sh
# 进入容器后,再起几个死循环进程
while true; do :; done &
一个busybox容器默认的command已经在死循环了,其实它本身就在空转CPU,你再叠加几个子shell,很快就能把CPU打上去。实测下来,从CPU飙高到HPA触发扩容,大概需要20到40秒,因为要等metrics-server采集、HPA控制器拉取、再执行扩容,三个步骤各有一段延迟。
扩容期间可以看到:
bash复制kubectl get pods -n demo -w
新Pod会被调度出来,HPA的REPLICAS列从1变成2、3甚至4。等CPU降下来之后,缩容不会立刻发生,HPA默认有一个5分钟的缩容稳定窗口(--horizontal-pod-autoscaler-downscale-stabilization),防止负载一有波动就盲目缩容。所以压测完想等缩容,至少要有耐心等5分钟以上。
4. 跑起来不难,难的是不抖动:HPA日常排错与调优
4.1 状态列是unknown:先查这四层
HPA跑起来之后,最常遇到的情况就是某个时间点开始状态列变成<unknown>。我排错的时候基本按下面这个顺序查:
第一层,看HPA自身的事件和状态条件:
bash复制kubectl describe hpa cpu-demo-hpa -n demo
重点看Events和Conditions部分。如果出现FailedGetResourceMetric,说明HPA拿不到指标,问题在数据链路;如果出现FailedRescale,说明Deployment那边有问题。
第二层,看metrics-server是否还活着:
bash复制kubectl logs -n kube-system deploy/metrics-server --tail=50
如果日志里出现大量Failed to scrape node,基本可以确定是采集侧出问题了。自签名证书的报错、节点IP变化、kubelet端口不通,都是常见原因。
第三层,检查Deployment的Pod模板有没有设置requests。HPA按Utilization类型计算时,如果某个Pod的容器没写requests,这个Pod会被直接跳过,导致取不到有效的平均值。所以生产环境最好用准入控制或者规范约束一下,保证被纳管的Deployment都有resources字段。
第四层,看看是不是API版本的问题。如果是老集群上用了v2beta2的YAML,kubectl apply的时候不一定报错,但HPA的行为可能不符合预期。最好的做法是先确认集群支持的版本,再写YAML。
4.2 扩了又缩、缩了又扩:怎么治“抖动”
HPA能正常工作之后,另一个常见的烦恼是“反复横跳”:流量稍微上来一点,扩容;流量一回落,缩容;然后又上来,又扩容。这种抖动在k3s单节点上尤其明显,因为丧事喜办,资源本来就少,一次扩容就可能导致新Pod被调度不出来。
造成抖动的原因有几类。一是目标阈值设置得太紧,比如CPU目标设在20%或30%,日常负载稍微波动一下就会超过目标。二是metrics-server默认15秒采样一次,HPA控制器默认15秒同步一次,相当于整个反馈链路有30秒左右的延迟,这会让策略对短时突发负载特别敏感。三是缩容稳定窗口虽然是5分钟,但在持续低压但偶发尖峰的场景下,5分钟根本不够,副本数会一直跟着尖峰走。
我自己的经验是先从目标值上做文章。一般CPU密集型业务,averageUtilization设在60到80之间比较合理,不要低于50。如果确实需要低延迟扩容,可以把behavior里的scaleUp策略放开一点,同时把scaleDown策略收紧一点。
下面这段是我在一台8核节点上用的HPA策略:
yaml复制behavior:
scaleUp:
stabilizationWindowSeconds: 0
policies:
- type: Percent
value: 100
periodSeconds: 15
scaleDown:
stabilizationWindowSeconds: 300
policies:
- type: Percent
value: 10
periodSeconds: 60
scaleUp的stabilizationWindowSeconds: 0表示负载一超标立刻扩,不在“等确认”上浪费时间;scaleDown的stabilizationWindowSeconds: 300表示缩容前必须持续低压5分钟,而且每次最多缩当前副本数的10%。这样既能快速响应突增,又能避免缩得太快导致再次扩容。
4.3 单节点k3s的资源竞争:监控和业务抢CPU
k3s最常见的部署方式是单节点,有些甚至跑在树莓派或者2核4G的小主机上。这种环境里,HPA扩容出来的Pod很可能因为节点资源不足而一直Pending。所以配置HPA之前,一定要先看节点容量:
bash复制kubectl describe node <node-name>
重点看Allocatable和当前的Allocated resources。如果业务Deployment的requests总量已经把节点申请满了,HPA扩出的Pod就算数量达标,也调度不上去,反而让Deployment一直处于部分副本Ready的状态。
另一个容易被忽略的点是metrics-server自身。当集群里的Pod数量多了以后,metrics-server的采集压力也会上升。如果它自己没有设置resources,它的QoS等级就是BestEffort,节点内存紧张时会被第一个驱逐。我踩过一次坑,Pod数从几十涨到上百之后,metrics-server频繁重启,HPA全部变unknown。后来我给metrics-server的Deployment加上了resources限制,比如requests.cpu: 50m、requests.memory: 100Mi,才稳定下来。
4.4 HPA计算里的“隐藏规则”值得再念叨一遍
HPA并不是“一超过目标就扩容”,它有一个容忍区间。按官方算法,只有当currentMetricValue / desiredMetricValue超过1.1倍或者低于0.9倍时,才会触发扩缩容。也就是说目标50%的时候,实际使用率在45%到55%之间,HPA是不会动的。
这个规则对排查“为什么还不扩容”很有用。我见过有人把requests.cpu设成4核,业务进程实际跑了5个核心,HPA一看使用率125%,确实触发了扩容;但另一个人把requests.cpu设成8核,同样的5核负载,HPA认为使用率只有62.5%,没到触发线,自然就不动。所以requests设置得是否合理,直接影响HPA的敏感度。这个讨论不是让你把requests无脑调低,而是提醒你:requests是HPA的计算基础,可不能随手一填。
5. 从CPU指标到业务指标:k3s上HPA的进阶玩法
5.1 CPU扩缩容的局限:很多服务不是CPU瓶颈
CPU利用率是HPA最常用、最好理解的指标,但它不是万能的。举个例子,一个Java应用堆内存吃紧,GC频繁,CPU反而可能不高;一个消息消费者,瓶颈在队列堆积长度,CPU却一直闲得很。这些场景下,基于CPU的HPA要么反应迟钝,要么干脆不动作。
想做到“按业务指标伸缩”,跳不过去的一步是引入自定义指标API(custom.metrics.k8s.io)。社区里最常见的方案是Prometheus加Prometheus Adapter:Prometheus负责采集指标,Adapter负责把Prometheus里的指标转换成Kubernetes的自定义metrics API,HPA再去读它。
如果k3s上已经用Helm装了kube-prometheus-stack,Prometheus Adapter可以直接通过Helm方式安装并配置。假设业务服务暴露了一个http_requests_total指标,想按QPS扩容,Adapter的配置大致是这样的:
yaml复制rules:
- seriesQuery: 'http_requests_total{namespace!="",pod!=""}'
resources:
overrides:
namespace: {resource: "namespace"}
pod: {resource: "pod"}
name:
matches: "^(.*)_total"
as: "${1}_qps"
metricsQuery: |
sum(rate(<<.Series>>{<<.LabelMatchers>>}[1m])) by (<<.GroupBy>>)
对应的HPA就可以这样写:
yaml复制metrics:
- type: Pods
pods:
metric:
name: http_requests_qps
target:
type: AverageValue
averageValue: 100
type: Pods表示按每个Pod的平均QPS来做扩缩容,当单Pod QPS超过100时触发扩容。这种方式比CPU指标更贴近业务真实情况。
不过要提醒一句:自定义指标链路比metrics-server长得多,涉及Prometheus采集、Adapter查询、HPA拉取三层,任何一个环节慢半拍,都会放大延迟。我实际测过,从QPS上涨到HPA扩容,大概有1到2分钟延迟,比CPU指标那种三十秒级别的速度要慢。设计扩容策略的时候要预留好这个缓冲。
5.2 KEDA是另一个值得关注的方案
如果你不想维护Prometheus Adapter,KEDA(Kubernetes Event-driven Autoscaling)是另一个很流行的选择。它可以对接几十种事件源,比如消息队列积压数量、Redis列表长度、HTTP请求量等。KEDA的用法是用ScaledObject这个CRD来描述伸缩规则,它背后会替你去创建一个HPA。比如按RabbitMQ队列长度扩容:
yaml复制apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: consumer-scaledobject
spec:
scaleTargetRef:
name: consumer-deployment
minReplicaCount: 1
maxReplicaCount: 10
triggers:
- type: rabbitmq
metadata:
queueName: jobs
queueLength: "20"
KEDA的好处是对业务侵入小、触发器丰富,文档也相对友好。缺点是它本身又引入了一个控制面组件,你需要权衡“省下的运维时间”和“新增的组件复杂度”哪个更重要。
5.3 behavior策略的深入理解:给扩容和缩容立规矩
Kubernetes从1.18开始支持behavior字段,这让HPA不再是“一个公式走天下”。除了上面示例里的稳定窗口,policies里还能控制每一次实际扩容/缩容的最大幅度。
因为默认的扩容策略是“立即翻倍直到副本上限”,在某些业务场景下太激进。比如你的服务依赖数据库连接池,瞬间从2个副本扩到10个副本,数据库连接可能直接被冲垮。这时候可以限制单次扩容最大幅度:
yaml复制behavior:
scaleUp:
policies:
- type: Percent
value: 50
periodSeconds: 15
- type: Pods
value: 2
periodSeconds: 15
Percent和Pods同时存在时,HPA会挑出周期内计算结果最大的那个作为生效策略,也就是说既允许按50%扩,也允许按“最多2个Pod”扩,两个都算一遍,取大值。如果想要绝对安全,可以只留一个Pods策略:
yaml复制scaleUp:
policies:
- type: Pods
value: 1
periodSeconds: 15
这样每15秒最多只能多1个Pod,扩容过程会平滑很多,代价是应对突发流量时扩容速度慢。生产环境怎么选,完全取决于你的服务能接受多大的突增冲击,没有标准答案,只能实测调参。
5.4 HPA和节点扩容的关系:别把两件事混在一起
相当一部分人以为配置了HPA,整个集群就能“按需自动变大”,其实HPA只负责调整Deployment的副本数,它不会给集群加节点。如果你的k3s集群只有一台节点,HPA扩容出来的Pod没地方调度,那就等于白扩。
HPA管的是“水平Pod自动扩缩容”,集群节点的扩容是cluster-autoscaler的事。但这俩在小型k3s集群上往往很难同时用起来,因为cluster-autoscaler通常依赖云厂商接口来添加虚拟机节点,自建的物理机或边缘节点不一定有这种机制。
所以对于单节点k3s,我的建议很简单:不要指望HPA解决一切问题。合理设置maxReplicas,别让副本数超过节点能承受的数量,然后优先保证requests的声明准确。如果服务确实需要跨节点伸缩,要么组一个多节点k3s集群,要么考虑把重业务迁到支持自动扩容的托管Kubernetes上。轻量集群有它的适用范围,弄清楚边界才能把它用顺手。
最后再说说我实际用下来的几个感受
第一,k3s上配HPA这件事,最大的门槛不是HPA本身,而是把前置指标链路搞清楚。只要metrics-server数据通了,后面的HPA配置跟标准Kubernetes几乎没差别。第二,HPA跑起来之后不要一劳永逸,要观察一周左右的HPA事件和Pod日志,根据实际流量曲线调整averageUtilization和behavior。第三,单节点k3s的HPA更像是一个“保护机制”,用来防止某个服务被突发流量打挂,而不是用来做弹性的。把它定位成“安全网”,心态就会平和很多。
如果你也正在k3s上折腾HPA,建议先把文章里的metrics-server验证部分跑通,再创建HPA。一旦链路通了,剩下的都是参数调优问题,慢慢试总能找到适合自己的配置。
