1. 资源控制不搞明白,集群早晚要出事
在我接触过的所有 Kubernetes 集群问题里,由于资源控制没做好而引发的故障占比相当高。不是应用代码写的多烂,也不是集群网络又有什么古怪升级,而是最朴素的CPU和内存不够用了、被误配置了、分配不合理了,导致Pod被杀、节点被压垮、整个业务雪崩。
先看一个很典型的场景:某天线上有个服务突然大量报错,查看Pod状态发现全是OOMKilled,再往下一查,发现这个服务的内存limit设得很随意,有人把它调成了512Mi,但实际业务在高峰要跑到1Gi以上。另一个情况更常见——某个没有设置任何requests和limits的Pod,在流量高峰把节点内存打满了,kubelet被迫触发节点级驱逐,把同一节点上的其他服务全部拖下水。
这就是资源控制的意义所在:它决定了一个Pod能拿到多少资源、能用到多少资源、以及在资源紧张时谁先被牺牲。说白了,Kubernetes的调度、伸缩、驱逐、QoS这些核心机制,通通建立在资源请求(requests)和资源限制(limits)的基础上。如果你没有把这套东西管明白,集群就像一栋没有楼道消防门的大楼,一家着火,整层遭殃。
这篇内容不是讲Kubernetes入门,而是针对已经上生产、想真正把资源控制这件事做扎实的团队。适合运维、平台工程师、负责Kubernetes集群稳定性的同学参考。我会从资源模型本身讲起,再给出一套可以直接抄的配置方案,最后把常见的坑和排查思路理一遍。看完之后,你应该能回答这几个问题:requests和limits到底怎么设?LimitRange和ResourceQuota怎么配合用?HPA怎么配置才靠谱?Pod被杀、节点告警时,第一反应应该查什么?
2. 先把资源模型的地基打牢:Requests、Limits和QoS
2.1 参数拆解:requests是下限,limits是上限
很多刚接触Kubernetes的人最容易犯的错,是混淆requests和limits的含义。简单记一句话:requests是“我保证至少要给这么多”,limits是“我最多只能用到这么多”。
CPU和内存是两套完全不同的机制。
CPU是可压缩资源,单位是核(core)或毫核(millicore),1核等于1000m。Pod里的进程在CPU上是可以被挤占的,所以当一个Pod超过了它申请的CPU requests时,它并不会被杀掉,而是会被限流,也就是慢下来。CPU的limits则是一个硬性上限,超过之后内核会通过CPU调度机制强制限制。但这里有个容易被忽视的细节:cpu: "1"这样的写法等于1000m,如果你的节点是2核,Pod申请了requests.cpu: "1",那么调度器会认为这个Pod需要占满50%的节点CPU。实际跑起来后,如果它只用了200m,那剩下的800m调度器也无法再塞进其他Pod,因为调度依据是requests,不是实时用量。
内存是不可压缩资源,单位是字节(Mi/Gi或者M/G)。内存一旦超出limit,kubelet会直接杀掉容器,触发OOMKilled。内存的requests同样影响调度,但它还有一个关键作用:当节点内存压力比较大时,kubelet会参考Pod的requests来决定驱逐顺序。假如一个Pod的requests很小但实际占用很大,node压力一到,它就会是被优先清理的对象。
所以我们在设计YAML的时候,必须想清楚一件事:**requests是给调度器和驱逐机制看的,limits是给运行时资源上限看的。**真正常见的行为是,people把一堆服务全部塞到一个节点里,以为只要CPU/内存的sum没超过节点总量就安全,但没考虑到节点本身还有系统进程、kubelet、容器运行时开销。结果节点Allocatable资源被占满,新的Pod一直Pending,老的Pod时不时被驱逐。
2.2 QoS等级:被牺牲的顺序,一开始就要定好
每个Pod根据requests和limits的配置方式,会被归入三类QoS(Quality of Service)等级之一:Guaranteed、Burstable、BestEffort。
- 如果Pod里所有容器都同时设置了requests和limits,并且两者相等,这个Pod就是Guaranteed。它会得到最高的保障优先级,在节点资源紧张时最晚被驱逐。
- 如果至少有一个容器设置了requests,但limits和requests不相等,或者有的容器没设置limits,那这个Pod就是Burstable。这是生产环境里最常见的一类。
- 如果容器完全没有设置requests和limits,那就是BestEffort。这类Pod的优先级最低,节点一紧张,最先被清理的就是它们。
我见过太多团队上线服务时不加任何资源配置,导致默认生成一堆BestEffort Pod。平时没事,流量一冲上来,节点压力升高,最先挂掉的恰恰是那些没设资源的服务。更麻烦的是,因为它们是BestEffort,驱逐时不会给你任何预警,直接就被杀掉了,业务方根本来不及反应。
所以在生产环境里,最低标准应该是所有Pod都至少设置requests。如果想让核心服务更稳,最好把limits和requests设成相等,让它进Guaranteed档位。不要觉得这样浪费——Guaranteed Pod更稳定,调试成本更低,后面我会详细聊这里面的取舍。
3. 从零配置一套可落地的资源管控
3.1 给工作负载设置资源请求与限制
第一步,是所有Deployment、StatefulSet、Job、DaemonSet里的Pod模板都写成带resources的版本。下面是一个比较保守的模板:
apiVersion: apps/v1 kind: Deployment metadata: name: web-demo namespace: dev spec: replicas: 3 selector: matchLabels: app: web-demo template: metadata: labels: app: web-demo spec: containers: - name: app image: registry.example.com/web-demo:1.2.3 resources: requests: cpu: 200m memory: 256Mi limits: cpu: 500m memory: 512Mi ports: - containerPort: 8080怎么看这几个数值?通常做法是,先用没有requests/limits的方式在测试环境跑一段压力测试,观察实际使用情况,再根据P99或者平均峰值来设定。比如,通过kubectl top pod看到这个服务平时CPU在150m左右,高峰能冲到400m,内存稳定在300Mi左右,那么requests可以设为cpu 200m、memory 256Mi,limits设置为cpu 500m、memory 512Mi。这样既给足缓冲,又不至于让limits高到失控。
有人会问,既然limits设成512Mi,内存会不会早早被杀掉?这里要提醒一句:内存limit的设定切忌拍脑袋。设小了服务容易OOM,设大了又失去限制意义。我的经验是,初始可以按照实际峰值的1.5~2倍给limit,等线上监控数据跑上两周之后再收敛。
3.2 用LimitRange约束单个Pod的资源配置
有了基础模板,下一步要解决的是“有人忘了配置”的问题。团队越大,YAML越难统一,总有人写了个复杂Job或者临时调试Pod,漏掉resources字段。这时候需要引入LimitRange。
LimitRange做的事情是在Namespace级别约束单个Pod或容器的资源使用范围。它能在创建时强制填上默认值,也可以规定一个Pod的最小/最大资源范围。比如下面这个配置:
apiVersion: v1 kind: LimitRange metadata: name: dev-limitrange namespace: dev spec: limits: - max: cpu: "4" memory: 8Gi min: cpu: 100m memory: 64Mi default: cpu: 500m memory: 512Mi defaultRequest: cpu: 200m memory: 256Mi maxLimitRequestRatio: cpu: 3 - type: PersistentVolumeClaim max: storage: 100Gi这段配置的实际含义是,在dev命名空间里,单个Pod最大能申请4核CPU和8Gi内存,最小不能低于100m CPU和64Mi内存。如果Pod没写资源配置,LimitRange会自动给它的容器填上defaultRequest(200m/256Mi)和default(500m/512Mi)。maxLimitRequestRatio: cpu: 3用来控制limit和request的比例,防止有人把requests写得很低、limits写得极高,导致Pod分数上看起来很小,实际却可能把节点冲爆。
需要注意的是,LimitRange是立即生效的,但它不会修改已经存在的Pod。如果你想通过它给所有老Pod补上配置,需要滚动重建工作负载。
3.3 用ResourceQuota做命名空间总盘子控制
LimitRange管单个Pod,ResourceQuota则是管整个命名空间的总量。有了它,你可以限制一个团队或一个项目在一个命名空间里最多能消耗多少资源。典型配置:
apiVersion: v1 kind: ResourceQuota metadata: name: dev-quota namespace: dev spec: hard: requests.cpu: "8" requests.memory: 16Gi limits.cpu: "16" limits.memory: 32Gi pods: "50" services: "20" persistentvolumeclaims: "30"这个Quota限定了dev环境最多能创建50个Pod,CPU requests总和不超过8核,内存requests总和不超过16Gi,CPU limits总和不超过16核,内存limits总和不超过32Gi。一旦达到上限,新Pod创建会报exceeded quota错误,Event里能明确看到是哪项资源和数量的配额被耗尽。
把ResourceQuota和LimitRange组合使用,是生产环境中非常有用的组合拳。ResourceQuota负责总量控制,LimitRange负责个体约束。举个实际例子:团队A在dev命名空间申请了8核16Gi额度,他们内部有人配置内存requests为4Gi的Pod,那最多只能同时跑4个,再多就创建不了。这种“硬约束”会逼着团队去优化自己的资源配置,而不是无限制地堆资源。
另外,ResourceQuota对存储也有效,比如限定requests.storage总容量或PVC数量上限。如果要在多团队共用集群时避免某团队把存储空间撑爆,这个配置必须加上。
3.4 配套实践:给默认命名空间设置标准
在多个团队共用集群的场景下,光有开发团队自己写YAML显然不够,平台团队应该在生产Namespace级别建立统一规范。我的建议是搭建一个简单的准入控制机制,例如使用ValidatingAdmissionPolicy或者更传统的PodSecurity类工具。不过这些配置比较多,如果不想引入额外组件,建议至少统一以下三件事:
- 所有Pod模板必须包含resources字段,平台通过扫描脚本检查,发现缺失就拦截或告警。
- 核心生产服务的requests和limits必须相等,也就是Guaranteed QoS。
- 每个Namespace必须存在对应的ResourceQuota和LimitRange,否则不允许创建部署。
这三条看着简单,但对稳定性的提升是立竿见影的。因为一旦每个Pod的资源配置和总量控制都被强制约束,“某个Node被某一个无限制Pod打爆”这种事故的概率会大幅下降。
4. 动态伸缩与资源治理:HPA和VPA的正确打开方式
4.1 HPA怎么配,才不容易一直抖动
资源控制的另一个重要方向是自动伸缩。HorizontalPodAutoscaler(简称HPA)通过监控指标动态调整Pod副本数。上了Kubernetes自动伸缩之后,资源控制就不只是“写死一个值”了,而是让副本数和资源请求形成闭环。
先看一个基于CPU指标的HPA配置:
apiVersion: autoscaling/v2 kind: HorizontalPodAutoscaler metadata: name: web-demo-hpa namespace: dev spec: scaleTargetRef: apiVersion: apps/v1 kind: Deployment name: web-demo minReplicas: 2 maxReplicas: 10 metrics: - type: Resource resource: name: cpu target: type: Utilization averageUtilization: 70 behavior: scaleDown: stabilizationWindowSeconds: 300这个HPA的指标是“所有Pod的CPU平均使用率相对于requests的比例”。如果requests是200m,实际使用达到平均70%利用率(即140m)时,HPA会尝试扩容。实际的控制算法是:期望副本数 = 当前副本数 * (当前指标值 / 期望指标值)。比如当前3个副本,CPU使用率平均是140%(相对requests),目标是70%,那么期望副本数就是3 * (140/70) = 6。
这里面最容易踩的坑,是HPA的扩容指标只认requests,不认limits。如果你把requests设得非常低,而应用实际占用很高,那么HPA可能在你意想不到的时机疯狂扩容,直到把maxReplicas打到顶。反过来,requests设得太高,那么CPU利用率再怎么压都超不过目标值,HPA迟迟不扩容,导致服务在高峰期被压垮。
我的建议是:HPA所依赖的CPU requests,必须刻意设成和应用常态工作的基线一致,别为了“调低一点更好扩容”而故意把requests设得很小。HPA调优的关键是让扩容速度和业务增长曲线匹配,而不是让它频繁跳变。上面配置里的stabilizationWindowSeconds就是在缩容时加上一个稳定窗口,默认300秒,避免流量一波动副本数就忽上忽下。
4.2 VPA的使用时机和那些隐蔽的坑
相比HPA的副本扩缩,VerticalPodAutoscaler(简称VPA)解决的是“单个Pod的requests和limits该设多大”的问题。它会根据历史使用数据,给出recommendation,然后可以自动更新Pod的资源配置。听起来很美好,但实际落地时要非常谨慎。
VPA最关键的坑是:它调整资源时需要重启Pod。因为Kubernetes的requests和limits在容器创建之后是不能动态修改的。也就是说,VPA每次调整配置,都意味着你的Pod会被重建一次,连接会被断开。对于无状态服务还好,对于有状态的数据库、消息队列这类服务,VPA的自动更新模式基本不可用。
所以在生产环境里,我见过比较稳的做法是:把VPA设成mode: Off,只用它的Recommendation功能,定期查看它建议的requests/limits值,然后由人来决定要不要改。相当于给它加了一个“参谋”角色,而不是让它直接操控掌舵。
apiVersion: autoscaling.k8s.io/v1 kind: VerticalPodAutoscaler metadata: name: web-demo-vpa namespace: dev spec: targetRef: apiVersion: apps/v1 kind: Deployment name: web-demo updatePolicy: updateMode: "Off" resourcePolicy: containerPolicies: - containerName: app minAllowed: cpu: 100m memory: 128Mi maxAllowed: cpu: "2" memory: 4Gi这里通过minAllowed和maxAllowed限定了recommendation的边界,不让VPA建议出离谱的值。每天让VPA在测试环境运行,收集一周数据后,把它的建议值人工应用到生产。这样既利用了VPA的预测能力,又避开了它自动重启Pod的风险。
5. 线上问题排查与避坑实录
5.1 Pod一直Evicted,为什么节点上明明有空间
Evicted是最常见的资源控制问题之一。它的核心原因是kubelet检测到节点层面存在资源压力,然后主动清理了一些Pod,被清理的Pod状态就会变成Evicted。
很多人不理解:我明明看节点还有空闲CPU和内存,怎么Pod就被Evicted了?原因往往是,kubelet判断的不是节点总资源,而是节点Allocatable资源减去系统保留资源后的可用量。还有一点,被驱逐的未必是资源占用最高的那个Pod,而是QoS优先级最低的那个。换句话说,如果你的Pod是BestEffort,只要节点上任何其他工作负载造成压力,你的Pod都有可能被殃及。
排查Evicted Pod的标准流程是:
- 先看节点状态:
kubectl describe node <node-name>,关注Conditions里的MemoryPressure、DiskPressure、PIDPressure。 - 查看被驱逐的Pod的事件:
kubectl describe pod <pod-name>,Event里通常会写清楚是哪类压力导致的驱逐。 - 检查节点上是否有非Kubernetes管理的进程在吃资源,比如被侵入的挖矿进程或者写死循环的脚本。
- 检查这节点的Pod密度和requests总量。如果requests总量超过节点Allocatable,那么新Pod根本调度不上来;如果节点本身超卖,但没有足够的Pids或者磁盘inode,同样会触发Pressure。
我给团队的实战建议是:给每个节点预留一定的“驱逐头寸”。比如一个16核32Gi的节点,Allocatable大概会扣掉系统组件和kubelet保留的一部分,实际上才能调度约15核28Gi。在这个基础上,不要把requests总量压到95%以上,留出至少5%~10%的缓冲,能极大降低节点进入MemoryPressure的概率。
5.2 容器OOMKilled,可能是limit设小了
OOMKilled和Evicted不一样,它是容器本身超过了memory limit,被内核OOM Killer干掉了。排查时通常用这两条命令:
kubectl get pod <pod-name> -o json | jq .status.containerStatuses kubectl logs <pod-name> --previousEXIT CODE: 137代表容器被SIGKILL杀掉,绝大多数是OOM;如果要更精确,看内核日志:
journalctl -k -f | grep -i oomDmesg里会显示Out of memory: Killed process X (java)之类的记录。知道了是OOM之后,不要急着把limit调大,先看监控里的内存使用曲线。很多Java应用默认的Heap大小和容器内存limit没有做联动,比如容器limit是1Gi,但JVM觉得自己有4Gi可用,于是把Heap扩到2.7Gi,直接触发OOM。这种时候,解决问题的关键不是加limit,而是设置JVM的-XX:MaxRAMPercentage,让它按容器limit比例分配堆内存。
还有一类隐蔽问题:limit设置了,但requests没设置,或者requests和limits差距过大。这种情况会让Pod变成Burstable。在内存压力下,超过requests的部分会被优先回收。所以如果你的服务对延迟敏感,最好把requests和limits设成一致,直接进Guaranteed。
5.3 调度失败:不是资源不够,是碎片问题
Pod一直Pending,kubectl describe里出现Insufficient cpu或Insufficient memory,但不代表整个集群没资源。这很可能是资源碎片化。什么意思?举个例子:集群里有三台节点,每台都有2Gi空闲内存,但你的Pod需要3Gi,调度器找不到一台能满足requests的节点,Pod就一直Pending。
这个问题没有银弹,几个现实的做法:
第一,调整Deployment的副本数和requests尺度。如果Pod声明内存requests是2Gi,而Pod本身常态只用几百Mi,可以考虑把requests降下来,让调度器更容易找到位置。
第二,如果业务确实需要大内存Pod,可以给这个Deployment单独打上节点亲和,把它调度到大内存节点上。但这一步需要区分“大内存节点”和“通用节点”,否则会造成资源倾斜。
第三,开启集群的调度器配置优化,比如使用NodeResourcesFit评分策略,或者在集群规模足够大时引入descheduler这样的组件定期整理碎片。后者比较复杂,非必要不建议一上来就折腾。
最后提醒一个非常常见的坑:不要为了让Pod能调度进去,就把requests降到特别低、limits维持高位。这会让调度器认为节点很空,然后把几十个这种Pod全部调度到一台节点上。结果就是,节点上requests看起来只用了70%,实际内存峰值却快顶到Allocatable上限,最后触发节点驱逐。这种配置比不配还危险,属于典型的“用调度层的宽松换运行层的爆雷”。
6. 资源控制的一些实操习惯和心得
管理Kubernetes资源这件事,说到底不是一次性配置完就结束的,它更像一个持续运营的过程。我在实际项目里积累了几个习惯,不一定适合所有团队,但希望能给后来人一点参考。
第一个习惯是,每周固定看一次资源水线。用kubectl top nodes和kubectl top pods结合,观察节点的资源分配率(requests/Allocatable)和实际使用率。通常只看两个指标:节点请求占用率超过85%要留意,超过90%必须处理;节点实际CPU使用率长期超过70%,说明可能要考虑扩容或者业务拆分。
第二个习惯是,给核心服务做资源审计。审计的结果不是“谁的Pod资源设大了要砍掉”,而是从稳定性角度确认:所有生产服务都进Guaranteed档位、内存limit和真实峰值有足够的余量、CPU requests不会因为设得太低导致HPA错乱。我见过有的团队为了追求高密度部署,把CPU requests压得很低,结果HPA隔三差五扩容,节点被Pod数量打满,调度出了大量Pending,反而更不稳定。
第三个习惯是,把ResourceQuota和LimitRange变成准入模板的一部分。我们内部推行过一套“配额先行”的制度:每个新命名空间上线的第一天,平台就给它建好默认的Quota和LimitRange,不给后补的机会。这样从制度上杜绝了“先用起来再说”的侥幸心理。配合CICD里的检查脚本,凡是提交的YAML非白名单容器没有resources字段,直接构建失败,从源头拦住。
最后再分享一个调试小技巧。很多人排查资源问题时,只盯着kubectl describe和kubectl get events,但有时候事件被刷新太快,或者Pod已经被重新调度,看不到当时的现场。建议提前给集群接入事件持久化,把Kubernetes事件统一收集存储起来。真出问题的时候,根据时间轴去翻当时的事件记录,再配合节点监控,绝大多数资源问题都能找到明确线索。
资源控制这件事,做好了看不出什么功劳,做不好就是事故频发。希望这篇作业指导书能让你的集群少一点Evicted,少一点OOM,少一点凌晨三点被叫起来修容器的经历。